C3 kurs-deteksjon, fase C3.1 (spec docs/c3-course-detection-plan.md §6/§7). Claude-frie, fil-frie infrastruktur-lag for kurs-detektoren (C3.4): - lib/keychain.mjs: readSecret() leser én Keychain-item via `security`-CLI, fail-soft (manglende item / ikke-macOS → null). execImpl-DI for test. - lib/learn-api.mjs: getToken (Entra client-credentials), paginate (async-gen, følger nextLink), buildApiUrl (pinner api-version), buildUpdatedAtGt (kaster på dato-only, canon Z — gotcha #1). Robusthetskontrakt §6: response.ok-sjekk (fetch kaster ikke på 4xx/5xx), AbortSignal.timeout, 429/5xx-retry som respekterer Retry-After, 4xx≠429 ikke-retry, fail-closed. fetchImpl/sleep-DI. Tester (24, alle grønne): test-learn-api (token-body, buildUpdatedAtGt, nextLink-paginering, produkt-param, alle robusthets-asserts), test-keychain, test-courses-invariant (Claude-fri + skriver-ingen-filer). Live-probe (efemer) grønn mot ekte Platform API m/ ekte creds: token + full-enum + inkrementell. Intern infrastruktur (ingen brukervendt kommando/hook/atferd endret ennå — surfacing + docs lander i C3.6). kb-update 213→237 · validate 239 · null regresjon. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
166 lines
6.2 KiB
JavaScript
166 lines
6.2 KiB
JavaScript
// 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<string, string|null|undefined>} [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<any>}
|
|
*/
|
|
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<string>} 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<string, string>} 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;
|
|
}
|
|
}
|