/** * A failed response does not always come from our API: nginx caps request bodies at 1 MB and * answers a larger one with its own HTML page, so there is no `{error}` object to read. */ const STATUS_MESSAGES: Record = { 400: 'Bad request', 403: 'Forbidden', 404: 'Not found', 413: 'Request too large', 500: 'Server error', 502: 'Server unavailable', 503: 'Server unavailable', 504: 'Server timed out', }; function statusMessage(status: number, statusText: string): string { return STATUS_MESSAGES[status] ?? (statusText || `HTTP ${status}`); } /** The request part of a JSON payload; nothing at all without one. */ export function jsonBody(payload?: unknown): Pick { return payload === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }; } /** * One call against the PHP API. Routing goes through the `r` query parameter because the site's * nginx config does not pass PATH_INFO to PHP; `query` adds further parameters beside it, which is * how an upload sends its file name while the body stays the raw bytes. An empty value is dropped * rather than sent as `&name=`. * * `mapError` lets a store turn a particular status into its own error type before the generic * one is thrown. */ export async function apiRequest( endpoint: string, method: string, route: string, init: Pick & { query?: Record } = {}, mapError?: (status: number, data: unknown) => Error | undefined, ): Promise { const { query, ...request } = init; let url = `${endpoint}?r=${encodeURIComponent(route)}`; for (const [key, value] of Object.entries(query ?? {})) { if (value !== '') url += `&${key}=${encodeURIComponent(value)}`; } const response = await fetch(url, { method, ...request }); if (response.status === 204) return undefined as T; const text = await response.text(); let data: unknown; try { data = text ? JSON.parse(text) : null; } catch { // A body nginx refused is an HTML page, so a failure is explained by its status rather than // as "invalid JSON" – which would only be true of a *successful* response. if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`); data = null; } const mapped = mapError?.(response.status, data); if (mapped) throw mapped; if (!response.ok) { const message = (data as { error?: string } | null)?.error ?? statusMessage(response.status, response.statusText); throw new Error(`API ${method} ${route}: ${message} (${response.status})`); } return data as T; }