import type { ApiErrorBody, HealthResponse } from '@dorfteich/shared'; /** * Typed fetch helper for the REST api. Non-2xx responses reject with an * ApiError carrying the uniform body — callers translate `code` via the * errors namespace and map `details` onto form fields. */ export class ApiError extends Error { constructor( readonly status: number, readonly body: ApiErrorBody, ) { super(body.message); } } /** * Fired once any api call answers 503 `maintenance_mode` (an in-app backup * restore is running, issue #103) — App.tsx listens and swaps the UI for * the maintenance screen, whatever the user was doing. */ export const MAINTENANCE_EVENT = 'dorfteich:maintenance'; function noteMaintenance(status: number, body: ApiErrorBody | null): void { if (status === 503 && body?.code === 'maintenance_mode') { window.dispatchEvent(new CustomEvent(MAINTENANCE_EVENT)); } } async function requestJson(method: string, path: string, body?: unknown): Promise { let response: Response; try { response = await fetch(`/api/v1${path}`, { method, headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), }, body: body !== undefined ? JSON.stringify(body) : undefined, }); } catch { throw new ApiError(0, { code: 'network', message: 'network error' }); } if (!response.ok) { const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null; noteMaintenance(response.status, parsed); throw new ApiError( response.status, parsed ?? { code: `http_${response.status}`, message: response.statusText }, ); } // 201/204 responses may carry no body at all. const text = await response.text(); return (text ? JSON.parse(text) : undefined) as T; } export const apiGet = (path: string): Promise => requestJson('GET', path); export const apiPost = (path: string, body?: unknown): Promise => requestJson('POST', path, body); export const apiPut = (path: string, body?: unknown): Promise => requestJson('PUT', path, body); export const apiPatch = (path: string, body?: unknown): Promise => requestJson('PATCH', path, body); export const apiDelete = (path: string): Promise => requestJson('DELETE', path); /** Multipart upload (issue #27/#28) — a FormData body, unlike every other * endpoint here, so it can't share `requestJson`'s JSON serialization; the * error handling is kept identical. */ export async function apiUploadFile( path: string, file: File, fields: Record = {}, ): Promise { const form = new FormData(); form.append('file', file); for (const [name, value] of Object.entries(fields)) form.append(name, value); let response: Response; try { response = await fetch(`/api/v1${path}`, { method: 'POST', body: form }); } catch { throw new ApiError(0, { code: 'network', message: 'network error' }); } if (!response.ok) { const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null; throw new ApiError( response.status, parsed ?? { code: `http_${response.status}`, message: response.statusText }, ); } return response.json() as Promise; } /** Multipart upload of a whole form (issue #304's font upload: several files * plus metadata in one request). `apiUploadFile` above covers the single-file * case; this one takes the `FormData` the caller assembled. */ export async function apiPostForm(path: string, form: FormData): Promise { let response: Response; try { response = await fetch(`/api/v1${path}`, { method: 'POST', body: form }); } catch { throw new ApiError(0, { code: 'network', message: 'network error' }); } if (!response.ok) { const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null; throw new ApiError( response.status, parsed ?? { code: `http_${response.status}`, message: response.statusText }, ); } const text = await response.text(); return (text ? JSON.parse(text) : undefined) as T; } export function fetchHealth(): Promise { return apiGet('/healthz'); } /** Plain-text response (issue #30's Markdown export) — every other endpoint * here returns JSON, so this can't share `requestJson`'s `JSON.parse`. */ export async function apiGetText(path: string): Promise { let response: Response; try { response = await fetch(`/api/v1${path}`); } catch { throw new ApiError(0, { code: 'network', message: 'network error' }); } if (!response.ok) { const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null; throw new ApiError( response.status, parsed ?? { code: `http_${response.status}`, message: response.statusText }, ); } return response.text(); }