import { Provider } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { firstValueFrom, retry, timeout, TimeoutError } from 'rxjs'; import { ApiClient, ProblemDetails } from './api-client'; import { environment } from '../../../environments/environment'; /** Single place every API call passes through: the seam for cross-cutting concerns. */ const REQUEST_TIMEOUT_MS = 10_000; /** * A stable Idempotency-Key threaded down from the command layer (one per logical * submit — see `runSubmit`) rather than minted per HTTP attempt, so a retried * submit dedupes on the backend instead of double-submitting. The NSwag-generated * `ApiClient` has no per-call header hook, so `withIdempotencyKey` bridges it here: * every non-GET call made synchronously inside `fn` picks up the same key. * ponytail: a module-level variable, not a proper async-context primitive — holds * up because every submit command calls its adapter synchronously (no await * before reaching this file); swap for `AsyncLocal`-equivalent if concurrent * submits ever become possible. */ let pendingIdempotencyKey: string | undefined; export function withIdempotencyKey(key: string, fn: () => Promise): Promise { pendingIdempotencyKey = key; return fn().finally(() => (pendingIdempotencyKey = undefined)); } export function currentIdempotencyKey(): string { return pendingIdempotencyKey ?? crypto.randomUUID(); } /** * Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated * client expects, so every API call flows through HttpClient interceptors (the * `?scenario=` toggle) and the cross-cutting concerns below. The generated client * is the only place HTTP shapes are known; this is the only place it meets * Angular's HTTP stack — i.e. the one seam to add: * - timeout (done — REQUEST_TIMEOUT_MS), * - correlation id (done — X-Correlation-Id, echoed in backend logs), * - idempotency key for writes (done — Idempotency-Key, stable per logical * submit via `withIdempotencyKey`/`runSubmit`, so a retry dedupes), * - auth: attach `Authorization: Bearer …` here (one line) when real DigiD lands, * - retry/backoff (done — GET only, `retry({ count: 2, delay: 500 })`; writes are * never auto-retried, which is exactly what makes the idempotency key above * matter only for a future/manual retry, not routine traffic). */ export function httpClientFetch(http: HttpClient) { return { async fetch(url: RequestInfo, init?: RequestInit): Promise { const method = (init?.method ?? 'GET').toUpperCase(); const headers: Record = { ...((init?.headers ?? {}) as Record), 'X-Correlation-Id': crypto.randomUUID(), }; if (method !== 'GET') headers['Idempotency-Key'] = currentIdempotencyKey(); try { const request$ = http .request(method, url as string, { body: init?.body as string | undefined, headers, observe: 'response', responseType: 'text', }) .pipe(timeout(REQUEST_TIMEOUT_MS)); const res = await firstValueFrom( method === 'GET' ? request$.pipe(retry({ count: 2, delay: 500 })) : request$, ); // 204/205/304 are null-body statuses — new Response(body, …) throws for any non-null body. const nullBody = res.status === 204 || res.status === 205 || res.status === 304; return new Response(nullBody ? null : (res.body ?? ''), { status: res.status || 200 }); } catch (e) { if (e instanceof TimeoutError) return new Response('', { status: 504 }); const err = e as HttpErrorResponse; const body = typeof err.error === 'string' ? err.error : JSON.stringify(err.error ?? {}); // ponytail: clamp to a Response-constructible status (an aborted/interceptor // request reports status 0, which `new Response` rejects). const status = err.status >= 200 && err.status <= 599 ? err.status : 500; return new Response(body, { status }); } }, }; } /** Provide a root ApiClient that talks through HttpClient. Base URL comes from the * environment (relative '' in dev → proxy; configurable per deployment). */ export function provideApiClient(): Provider { return { provide: ApiClient, useFactory: (http: HttpClient) => new ApiClient(environment.apiBaseUrl, httpClientFetch(http)), deps: [HttpClient], }; } export type { ProblemDetails };