export interface ApiErrorShape { error: string; message: string; details?: unknown; } export class ApiError extends Error { readonly status: number; readonly code: string; readonly details?: unknown; constructor(status: number, body: ApiErrorShape) { super(body.message); this.status = status; this.code = body.error; this.details = body.details; } } interface RequestOptions { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: TBody; signal?: AbortSignal; formData?: FormData; } async function request( path: string, options: RequestOptions = {}, ): Promise { const headers: Record = {}; let body: BodyInit | undefined; if (options.formData) { body = options.formData; } else if (options.body !== undefined) { headers['content-type'] = 'application/json'; body = JSON.stringify(options.body); } const response = await fetch(path, { method: options.method ?? 'GET', headers, body, credentials: 'include', signal: options.signal, }); const isJson = response.headers.get('content-type')?.includes('application/json'); const payload = isJson ? await response.json().catch(() => null) : null; if (!response.ok) { const errBody: ApiErrorShape = payload && typeof payload === 'object' && 'error' in payload ? (payload as ApiErrorShape) : { error: 'HTTP', message: response.statusText || 'Request failed' }; throw new ApiError(response.status, errBody); } return payload as TResponse; } export const api = { get: (path: string, signal?: AbortSignal) => request(path, { signal }), post: (path: string, body?: B) => request(path, { method: 'POST', body }), patch: (path: string, body?: B) => request(path, { method: 'PATCH', body }), delete: (path: string) => request(path, { method: 'DELETE' }), upload: (path: string, formData: FormData) => request(path, { method: 'POST', formData }), };