// learn-api.mjs — Headless (no-LLM) network client for the Microsoft Learn Platform API. // Zero dependencies (native global `fetch`, Node 18+). Writes NO files — it only // fetches: token via Entra client-credentials, then paginates the per-type // endpoints. See c3-course-detection-plan.md §6 + the verified contract in // c3-course-detection-spike.md. // // Robustness contract (§6, decision §8 (a)) — what separates "works" from // "professional", since raw fetch has two traps: // 1. fetch does NOT reject on HTTP 4xx/5xx → we check response.ok explicitly. // 2. fetch has NO default timeout → every request gets AbortSignal.timeout(). // Retry: 429 + 5xx only, honouring Retry-After; 4xx≠429 fails immediately; a // timeout propagates fail-closed (never retried, never swallowed). const API_BASE = 'https://learn.microsoft.com/api/v1'; const API_VERSION = '2023-11-01-preview'; const SCOPE = 'https://learn.microsoft.com/.default'; const TOKEN_ENDPOINT = (tenantId) => `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; const VALID_ENDPOINTS = new Set(['modules', 'learning-paths']); const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_ATTEMPTS = 3; const BASE_BACKOFF_MS = 1_000; /** * Serialize a currency cursor for the `updatedAt.gt` filter. * Gotcha #1 (verified empirically): the API needs a FULL ISO 8601 datetime — * a date-only value (e.g. "2026-01-01") fails closed server-side (0 hits). We * reject date-only input outright and canonicalize to the UTC "Z" form. * @param {string} iso — a full ISO 8601 datetime * @returns {string} canonical full ISO datetime (…Z) */ export function buildUpdatedAtGt(iso) { // Require a time component with an explicit zone (Z or ±hh:mm). Date-only is rejected. if (typeof iso !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(iso)) { throw new Error( `updatedAt.gt needs a full ISO 8601 datetime (e.g. 2026-01-01T00:00:00Z); got: ${iso}`, ); } return new Date(iso).toISOString(); } /** * Build a Platform API URL with the api-version pinned and params serialized. * Null/empty params are omitted. Comma-separated values pass through (OR semantics). * @param {'modules'|'learning-paths'} endpoint * @param {Record} [params] * @returns {string} */ export function buildApiUrl(endpoint, params = {}) { if (!VALID_ENDPOINTS.has(endpoint)) { throw new Error(`Unknown endpoint: ${endpoint} (expected 'modules' | 'learning-paths')`); } const url = new URL(`${API_BASE}/${endpoint}`); url.searchParams.set('api-version', API_VERSION); for (const [k, v] of Object.entries(params)) { if (v != null && v !== '') url.searchParams.set(k, String(v)); } return url.toString(); } /** * Compute the retry delay for a retryable response. Honours Retry-After * (delta-seconds form, the one Microsoft's rate-limiter uses); otherwise a * linear backoff scaled by attempt number. * @param {{ headers: { get(name: string): string|null } }} response * @param {number} attempt — 1-based * @returns {number} delay in ms */ function retryDelayMs(response, attempt) { const retryAfter = response.headers?.get?.('retry-after'); if (retryAfter != null && retryAfter !== '') { const secs = Number(retryAfter); if (Number.isFinite(secs)) return secs * 1000; } return BASE_BACKOFF_MS * attempt; } /** * Perform a request with the robustness contract and return parsed JSON. * @param {string} url * @param {RequestInit} [requestOptions] * @param {{ fetchImpl?, sleep?, timeoutMs?, maxAttempts? }} [opts] * @returns {Promise} */ async function requestJson(url, requestOptions = {}, opts = {}) { const { fetchImpl = globalThis.fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), timeoutMs = DEFAULT_TIMEOUT_MS, maxAttempts = DEFAULT_MAX_ATTEMPTS, } = opts; let attempt = 0; while (true) { attempt++; // Trap #2: no default timeout — abort each attempt after timeoutMs. A timeout // (or any network error) rejects here and propagates fail-closed; it is NOT // retried (only HTTP 429/5xx are). const response = await fetchImpl(url, { ...requestOptions, signal: AbortSignal.timeout(timeoutMs), }); // Trap #1: fetch does not reject on 4xx/5xx — check explicitly. if (response.ok) return response.json(); const status = response.status; const retryable = status === 429 || (status >= 500 && status < 600); if (retryable && attempt < maxAttempts) { await sleep(retryDelayMs(response, attempt)); continue; } throw new Error(`Learn Platform API HTTP ${status} for ${url}`); } } /** * Acquire a bearer token via Entra client-credentials. * @param {{ tenantId: string, clientId: string, clientSecret: string }} creds * @param {object} [opts] — passed through to requestJson (fetchImpl/timeoutMs/…) * @returns {Promise} the access token */ export async function getToken({ tenantId, clientId, clientSecret }, opts = {}) { const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, scope: SCOPE, }); const json = await requestJson( TOKEN_ENDPOINT(tenantId), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body, }, opts, ); if (!json.access_token) throw new Error('Token response had no access_token'); return json.access_token; } /** * Paginate a Platform API endpoint, yielding each item across all pages. * The token is acquired once by the caller and reused on every request. * @param {'modules'|'learning-paths'} endpoint * @param {Record} params — products / updatedAt.gt / maxpagesize / … * @param {string} token — bearer token * @param {object} [opts] — passed through to requestJson * @yields {object} an item from `value` */ export async function* paginate(endpoint, params, token, opts = {}) { let url = buildApiUrl(endpoint, params); while (url) { const json = await requestJson( url, { headers: { authorization: `Bearer ${token}` } }, opts, ); for (const item of json.value ?? []) yield item; url = json.nextLink || null; } }