import { errorResponseSchema, routes, type ErrorCode, type RouteName, type RouteRequest, type RouteResponse, } from "@deadhead/schema"; import { deviceId } from "./device"; /** The server answered with its documented error format. */ export class ApiError extends Error { constructor( readonly status: number, readonly code: ErrorCode, message: string, ) { super(message); this.name = "ApiError"; } } /** The server's response did not match the route catalog. */ export class ApiValidationError extends Error { constructor( readonly route: RouteName, message: string, ) { super(`Unexpected response from ${route}: ${message}`); this.name = "ApiValidationError"; } } export interface ApiClientOptions { /** Defaults to the global `fetch`. */ fetch?: typeof fetch; /** Defaults to "" (same origin, decision 0.5.20). */ baseUrl?: string; } export interface ApiClient { call(name: N, input: RouteRequest): Promise>; } /** Builds a request URL and body from a route and its input. */ function buildRequest(path: string, method: "GET" | "POST", input: Record) { const rest = { ...input }; const url = path.replace(/:(\w+)/g, (_match, param: string) => { const value = rest[param]; delete rest[param]; return encodeURIComponent(String(value)); }); if (method === "GET") { const query = new URLSearchParams( Object.entries(rest).map(([key, value]) => [key, String(value)]), ).toString(); return { url: query ? `${url}?${query}` : url, body: undefined }; } return { url, body: Object.keys(rest).length > 0 ? JSON.stringify(rest) : undefined }; } /** A client for every catalog route: typed inputs and validated, typed outputs. */ export function createApiClient(options: ApiClientOptions = {}): ApiClient { const doFetch = options.fetch ?? ((...args: Parameters) => fetch(...args)); const baseUrl = options.baseUrl ?? ""; return { async call(name, input) { const route = routes[name]; const { url, body } = buildRequest( route.path, route.method, input as Record, ); const response = await doFetch(baseUrl + url, { method: route.method, credentials: "include", headers: { ...(body ? { "content-type": "application/json" } : {}), // Restore attempts are rate-limited per device (CR-011). ...(name === "restoreCompany" ? { "x-device-id": deviceId() } : {}), }, body, }); const json: unknown = await response.json().catch(() => undefined); if (!response.ok) { const parsed = errorResponseSchema.safeParse(json); if (parsed.success) { throw new ApiError(response.status, parsed.data.error.code, parsed.data.error.message); } throw new ApiError( response.status, "internal-error", `Request failed (${response.status}).`, ); } const parsed = route.response.safeParse(json); if (!parsed.success) throw new ApiValidationError(name, parsed.error.message); return parsed.data as RouteResponse; }, }; }