export class ApiError extends Error { readonly status: number; readonly code: string; readonly traceId?: string; constructor(status: number, code: string, message: string, traceId?: string) { super(message); this.name = "ApiError"; this.status = status; this.code = code; this.traceId = traceId; } } type RequestOptions = { method: string; path: string; query?: Record; body?: unknown; expectedStatus: number; }; function buildUrl(path: string, query?: RequestOptions["query"]): string { const url = new URL(path, window.location.origin); if (query) { for (const [key, value] of Object.entries(query)) { if (value !== undefined && value !== "") { url.searchParams.set(key, String(value)); } } } return `${url.pathname}${url.search}`; } export async function request(options: RequestOptions): Promise { const headers: Record = { Accept: "application/json" }; if (options.body !== undefined) { headers["Content-Type"] = "application/json"; } const response = await fetch(buildUrl(options.path, options.query), { method: options.method, headers, body: options.body === undefined ? undefined : JSON.stringify(options.body), }); if (response.status === 204 || options.expectedStatus === 204) { if (!response.ok && response.status !== options.expectedStatus) { throw await toApiError(response); } return undefined as T; } if (response.status !== options.expectedStatus) { throw await toApiError(response); } if (response.headers.get("content-type")?.includes("application/json")) { return (await response.json()) as T; } return undefined as T; } async function toApiError(response: Response): Promise { const traceId = response.headers.get("x-request-id") ?? undefined; try { const payload = (await response.json()) as { error?: { code?: string; message?: string; trace_id?: string }; }; return new ApiError( response.status, payload.error?.code ?? "http_error", payload.error?.message ?? response.statusText, payload.error?.trace_id ?? traceId, ); } catch { return new ApiError(response.status, "http_error", response.statusText, traceId); } }