Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
95 lines
4.5 KiB
TypeScript
95 lines
4.5 KiB
TypeScript
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 '@shared/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<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
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<Response> {
|
|
const method = (init?.method ?? 'GET').toUpperCase();
|
|
const headers: Record<string, string> = {
|
|
...((init?.headers ?? {}) as Record<string, string>),
|
|
'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 };
|