import { getStoredApiKey } from './auth'; import type { components } from './generated/v1'; type ProblemDetails = components['schemas']['ProblemDetails']; type RequestBody = BodyInit | Record | null; export class ApiError extends Error { readonly detail?: string | null; readonly problem?: ProblemDetails; readonly status: number; constructor(status: number, problem?: ProblemDetails) { super(problem?.title ?? `Request failed with status ${status}`); this.name = 'ApiError'; this.detail = problem?.detail; this.problem = problem; this.status = status; } } export interface ApiRequestOptions extends Omit { body?: RequestBody; } const mutatingMethods = new Set(['DELETE', 'PATCH', 'POST', 'PUT']); export interface ResponseWithMeta { data: T; /** Strong ETag of the resource's version, when the endpoint emits one (issue #253). */ etag: string | null; } /** * Like {@link request} but also returns the response's ETag. Used by the optimistic-concurrency * editors: read the ETag from the load GET, send it back as `If-Match` on the replace PUT, and * replace it from the PUT response's ETag on every successful save (issue #253 / spa-conventions). */ export async function requestWithMeta( path: string, options: ApiRequestOptions = {} ): Promise> { const method = (options.method ?? 'GET').toUpperCase(); const headers = normalizeHeaders({ Accept: 'application/json', ...headersToRecord(options.headers) }); const body = serializeBody(options.body, headers); const apiKey = getStoredApiKey(); if (apiKey && mutatingMethods.has(method)) { headers['X-Api-Key'] = apiKey; } const response = await fetch(path, { ...options, body, headers, method }); if (!response.ok) { throw new ApiError(response.status, await readProblemDetails(response)); } const etag = response.headers.get('ETag'); if (response.status === 204) { return { data: undefined as TResponse, etag }; } return { data: (await readJsonResponse(response)) as TResponse, etag }; } export async function request( path: string, options: ApiRequestOptions = {} ): Promise { const { data } = await requestWithMeta(path, options); return data; } function normalizeHeaders(headers: Record): Record { return Object.fromEntries( Object.entries(headers).map(([key, value]) => [canonicalHeaderName(key), value]) ); } function canonicalHeaderName(header: string): string { return header .toLowerCase() .split('-') .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) .join('-'); } function headersToRecord(headers?: HeadersInit): Record { if (!headers) { return {}; } if (headers instanceof Headers) { return Object.fromEntries(headers.entries()); } if (Array.isArray(headers)) { return Object.fromEntries(headers); } return headers; } function serializeBody(body: RequestBody | undefined, headers: Record): BodyInit | null | undefined { if (body === undefined || body === null) { return body; } if (body instanceof FormData || body instanceof URLSearchParams || body instanceof Blob) { return body; } if (typeof body === 'string') { return body; } headers['Content-Type'] ??= 'application/json'; return JSON.stringify(body); } async function readProblemDetails(response: Response): Promise { const contentType = response.headers.get('Content-Type') ?? ''; if (!isJsonContentType(contentType)) { return undefined; } return await response.json() as ProblemDetails; } async function readJsonResponse(response: Response): Promise { const contentLength = response.headers.get('Content-Length'); const contentType = response.headers.get('Content-Type') ?? ''; if (contentLength === '0' || !isJsonContentType(contentType)) { return undefined; } try { return await response.json(); } catch (error) { if (error instanceof SyntaxError) { return undefined; } throw error; } } function isJsonContentType(contentType: string): boolean { return contentType.includes('application/json') || contentType.includes('problem+json'); }