export type RequestOptions = { method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; token?: string | null; body?: unknown; }; export type ResolveErrorInput = { code?: string; status?: number; fallbackMessage?: string; }; export type CreateApiClientConfig = { baseUrl: string; resolveError?: (input: ResolveErrorInput) => string; inferErrorCodeFromStatus?: (status?: number | null) => string | undefined; fetchImpl?: typeof fetch; }; export class ApiError extends Error { status: number; code?: string; requestId?: string; details?: unknown; rawMessage?: string; constructor({ message, status, code, requestId, details, rawMessage, }: { message: string; status: number; code?: string; requestId?: string; details?: unknown; rawMessage?: string; }) { super(message); this.name = 'ApiError'; this.status = status; this.code = code; this.requestId = requestId; this.details = details; this.rawMessage = rawMessage; } } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value != null; } function parseErrorPayload(data: unknown) { if (!isRecord(data)) { return { code: undefined as string | undefined, rawMessage: undefined as string | undefined, requestId: undefined as string | undefined, details: undefined as unknown, }; } const code = typeof data.code === 'string' ? data.code : undefined; const rawMessage = typeof data.error === 'string' ? data.error : undefined; const requestId = typeof data.requestId === 'string' ? data.requestId : undefined; const details = data.details; return { code, rawMessage, requestId, details }; } function defaultResolveError({ status, fallbackMessage }: ResolveErrorInput): string { if (fallbackMessage) { return fallbackMessage; } if (status != null) { return `Request failed (${status}).`; } return 'Request failed. Please try again.'; } export function createApiClient(config: CreateApiClientConfig) { const { baseUrl, resolveError = defaultResolveError, inferErrorCodeFromStatus, fetchImpl, } = config; async function request(path: string, options: RequestOptions = {}): Promise { const { method = 'GET', token, body } = options; const runFetch = fetchImpl ?? fetch; const response = await runFetch(`${baseUrl}${path}`, { method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: body ? JSON.stringify(body) : undefined, }); const data = await response.json().catch(() => null); if (!response.ok) { const parsed = parseErrorPayload(data); const code = parsed.code ?? inferErrorCodeFromStatus?.(response.status); const message = resolveError({ code, status: response.status, fallbackMessage: parsed.rawMessage, }); throw new ApiError({ message, status: response.status, code, requestId: parsed.requestId, details: parsed.details, rawMessage: parsed.rawMessage, }); } return data as T; } return { request, }; }