/** * HTTP-Client fuer die Syncova-API. * * Der Client kapselt die Antworthuelle des Backends und liefert Fehler stets als * ApiError. Aufrufer muessen sich damit nicht mit HTTP-Details befassen und es * kann keine Fehlerantwort versehentlich als Nutzlast interpretiert werden * (PROMPT.md §140: keine stillen Fehler). */ import type { ErrorResponse, SuccessResponse } from '../types/api'; /** Basis-Pfad aller fachlichen Endpunkte (SYNCOVA_API.md). */ const API_BASE_PATH = '/api/v1'; /** Header, ueber den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */ const CORRELATION_ID_HEADER = 'X-Correlation-ID'; /** * Fehler einer API-Anfrage. * * Er traegt den maschinenlesbaren Code und die Request-ID, damit ein Anwender * einen Vorfall gegenueber dem Betreiber eindeutig benennen kann. */ export class ApiError extends Error { /** Stabiler maschinenlesbarer Fehlercode. */ public readonly code: string; /** HTTP-Statuscode der Antwort. */ public readonly statusCode: number; /** Kennung des Requests zur Zuordnung im Serverlog. */ public readonly requestId: string; /** Optionale unbedenkliche Zusatzinformationen. */ public readonly details: Record | undefined; constructor(parameters: { code: string; message: string; statusCode: number; requestId: string; details?: Record; }) { super(parameters.message); this.name = 'ApiError'; this.code = parameters.code; this.statusCode = parameters.statusCode; this.requestId = parameters.requestId; this.details = parameters.details; } } /** * Liefert das aktuelle Zugriffstoken, sofern eine Sitzung besteht. * * Der Client kennt die Anmeldelogik bewusst nicht, sondern erhaelt sie ueber * diese Funktion. Andernfalls entstuende ein Zirkelbezug zwischen dem Client und * dem Anmeldemodul, das seinerseits den Client verwendet. */ let accessTokenProvider: () => string | null = () => null; /** Hinterlegt die Funktion, die das aktuelle Zugriffstoken liefert. */ export function setAccessTokenProvider(tokenProvider: () => string | null): void { accessTokenProvider = tokenProvider; } /** Fehlercode fuer eine nicht erreichbare API. */ export const NETWORK_ERROR_CODE = 'NETWORK_UNREACHABLE'; /** Fehlercode fuer eine unverstaendliche Antwort. */ export const MALFORMED_RESPONSE_CODE = 'MALFORMED_RESPONSE'; /** * Erzeugt eine Correlation ID fuer einen Request. * * crypto.randomUUID ist in allen unterstuetzten Browsern verfuegbar; der * Rueckfall deckt aeltere Testumgebungen ab. */ function createCorrelationId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } // Rueckfall ohne kryptografische Garantie - die Correlation ID dient allein // der Nachvollziehbarkeit, nicht der Sicherheit. return `00000000-0000-4000-8000-${Date.now().toString(16).padStart(12, '0').slice(-12)}`; } /** Optionen einer API-Anfrage. */ export interface RequestOptions { /** HTTP-Methode; Standard ist GET. */ method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; /** Optionaler Anfragekoerper, der als JSON gesendet wird. */ body?: unknown; /** Signal zum Abbrechen der Anfrage. */ signal?: AbortSignal; } /** * Fuehrt eine Anfrage gegen die Syncova-API aus. * * @param endpointPath Pfad unterhalb von /api/v1, z. B. "/health". * @returns Die Nutzlast der Antwort. * @throws ApiError bei jedem Fehlerfall. */ export async function requestApi( endpointPath: string, requestOptions: RequestOptions = {}, ): Promise { const correlationId = createCorrelationId(); const requestMethod = requestOptions.method ?? 'GET'; const requestHeaders: Record = { Accept: 'application/json', [CORRELATION_ID_HEADER]: correlationId, }; if (requestOptions.body !== undefined) { requestHeaders['Content-Type'] = 'application/json'; } // Besteht eine Sitzung, wird sie mitgesendet. Ohne Token laufen die Anfragen // unauthentifiziert - der Server entscheidet dann ueber den Zugriff. const accessToken = accessTokenProvider(); if (accessToken !== null) { requestHeaders['Authorization'] = `Bearer ${accessToken}`; } let httpResponse: Response; try { httpResponse = await fetch(`${API_BASE_PATH}${endpointPath}`, { method: requestMethod, headers: requestHeaders, body: requestOptions.body === undefined ? null : JSON.stringify(requestOptions.body), // Die Sitzung laeuft ueber ein Cookie bzw. einen Token desselben Ursprungs. credentials: 'same-origin', ...(requestOptions.signal ? { signal: requestOptions.signal } : {}), }); } catch (networkError) { // Ein Abbruch durch den Aufrufer ist kein Fehlerfall und wird durchgereicht. if (networkError instanceof DOMException && networkError.name === 'AbortError') { throw networkError; } throw new ApiError({ code: NETWORK_ERROR_CODE, message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.', statusCode: 0, requestId: correlationId, }); } // 204 traegt per Definition keinen Koerper. if (httpResponse.status === 204) { return undefined as TPayload; } let parsedBody: unknown; try { parsedBody = await httpResponse.json(); } catch { throw new ApiError({ code: MALFORMED_RESPONSE_CODE, message: 'Die Antwort des Servers war unverstaendlich.', statusCode: httpResponse.status, requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId, }); } // Massgeblich ist die Antworthuelle, nicht allein der HTTP-Status. // // Beide Angaben tragen unterschiedliche Aussagen: der Status beschreibt den // Betriebszustand, die Huelle den Inhalt. GET /api/v1/health nutzt genau diese // Trennung und meldet einen kritischen Systemzustand mit 503, liefert dabei // aber einen vollstaendigen Bericht als Nutzlast. Wuerde der Client jeden // Status ausserhalb von 2xx als inhaltsleeren Fehler behandeln, ginge // ausgerechnet die Diagnose verloren, die der Anwender jetzt braucht. const errorResponse = parsedBody as Partial; if (errorResponse.error) { throw new ApiError({ code: errorResponse.error.code, message: errorResponse.error.message, statusCode: httpResponse.status, requestId: errorResponse.error.request_id, ...(errorResponse.error.details ? { details: errorResponse.error.details } : {}), }); } const successResponse = parsedBody as Partial>; if (successResponse.data === undefined) { throw new ApiError({ code: MALFORMED_RESPONSE_CODE, message: httpResponse.ok ? 'Die Antwort des Servers enthielt keine Daten.' : 'Der Server meldete einen Fehler ohne verwertbare Beschreibung.', statusCode: httpResponse.status, requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId, }); } return successResponse.data; } /** * Laedt eine Datei von der API herunter. * * Sie steht neben requestApi und nicht darin: Eine Datei traegt **keine** * Antworthuelle, sondern ist der Inhalt selbst. Wuerde man sie durch requestApi * schicken, versuchte dieser, ein PDF als JSON zu lesen, und meldete eine * unverstaendliche Antwort — obwohl alles in Ordnung ist. * * Der Fehlerfall geht dagegen sehr wohl durch die Huelle: Scheitert die Anfrage, * antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprueft, bevor die * Antwort als Datei behandelt wird — sonst landete eine Fehlermeldung als * „bericht.pdf" im Download-Ordner, und der Anwender saehe statt einer Meldung * eine kaputte Datei. */ export async function downloadApiFile( endpointPath: string, requestOptions: RequestOptions = {}, ): Promise<{ blob: Blob; fileName: string }> { const correlationId = createCorrelationId(); const requestHeaders: Record = { [CORRELATION_ID_HEADER]: correlationId, }; if (requestOptions.body !== undefined) { requestHeaders['Content-Type'] = 'application/json'; } const accessToken = accessTokenProvider(); if (accessToken !== null) { requestHeaders['Authorization'] = `Bearer ${accessToken}`; } let httpResponse: Response; try { httpResponse = await fetch(`${API_BASE_PATH}${endpointPath}`, { method: requestOptions.method ?? 'POST', headers: requestHeaders, body: requestOptions.body === undefined ? null : JSON.stringify(requestOptions.body), credentials: 'same-origin', ...(requestOptions.signal ? { signal: requestOptions.signal } : {}), }); } catch (networkError) { if (networkError instanceof DOMException && networkError.name === 'AbortError') { throw networkError; } throw new ApiError({ code: NETWORK_ERROR_CODE, message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.', statusCode: 0, requestId: correlationId, }); } const responseContentType = httpResponse.headers.get('Content-Type') ?? ''; // Eine JSON-Antwort auf eine Dateianfrage ist immer ein Fehler. if (responseContentType.includes('application/json')) { const parsedBody = (await httpResponse.json()) as Partial; throw new ApiError({ code: parsedBody.error?.code ?? MALFORMED_RESPONSE_CODE, message: parsedBody.error?.message ?? 'Die Datei konnte nicht erzeugt werden.', statusCode: httpResponse.status, requestId: parsedBody.error?.request_id ?? correlationId, }); } if (!httpResponse.ok) { throw new ApiError({ code: MALFORMED_RESPONSE_CODE, message: 'Die Datei konnte nicht erzeugt werden.', statusCode: httpResponse.status, requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId, }); } return { blob: await httpResponse.blob(), fileName: parseFileNameFromDisposition(httpResponse.headers.get('Content-Disposition')), }; } /** * Liest den Dateinamen aus dem Content-Disposition-Kopf. * * Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer waehlt einen — * ein erfundener Name aus dem Kopf zu lesen waere schlimmer als keiner. */ function parseFileNameFromDisposition(dispositionHeader: string | null): string { if (dispositionHeader === null) { return ''; } const fileNameMatch = /filename="([^"]+)"/.exec(dispositionHeader); return fileNameMatch === null ? '' : (fileNameMatch[1] ?? ''); }