import Ajv from 'ajv'; import addFormats from 'ajv-formats'; import type { ValidateFunction } from 'ajv'; const ajv = new Ajv({ allErrors: false, coerceTypes: false }); addFormats(ajv); export const compileValidator = (schema: object): ValidateFunction => ajv.compile(schema); export class ApiError extends Error { readonly status: number; readonly code: string; constructor(status: number, code: string, message: string) { super(message); this.name = 'ApiError'; this.status = status; this.code = code; } } export interface ApiClientConfig { /** Base URL for the REST API, e.g. '/api' (same-origin) or 'http://host/api'. */ baseUrl: string; /** Supplies the current access token for the Authorization header, if any. */ getAccessToken?: () => string | null; /** * Refresh-token transport. 'cookie' (default) uses the httpOnly refresh cookie * (web). 'token' (native) sends `X-Auth-Mode: token`; the backend then returns * the refresh token in the body and accepts it from the body — cookies are * unreliable in React Native. */ authMode?: 'cookie' | 'token'; /** Current stored refresh token (token mode only). */ getRefreshToken?: () => string | null; } const toApiError = (status: number, json: unknown): ApiError => { if (typeof json === 'object' && json !== null && 'error' in json) { const code = typeof json.error === 'string' ? json.error : 'error'; const message = 'message' in json && typeof json.message === 'string' ? json.message : code; return new ApiError(status, code, message); } return new ApiError(status, 'error', `Request failed with status ${String(status)}`); }; // Perform a credentialed request (cookies included) and return the parsed body // as `unknown`. Throws ApiError on non-2xx. export const requestJson = async ( config: ApiClientConfig, method: string, path: string, body?: unknown, ): Promise => { const tokenMode = config.authMode === 'token'; const headers: Record = { accept: 'application/json' }; if (body !== undefined) { headers['content-type'] = 'application/json'; } const token = config.getAccessToken?.() ?? null; if (token !== null) { headers['authorization'] = `Bearer ${token}`; } if (tokenMode) { headers['x-auth-mode'] = 'token'; } const response = await fetch(`${config.baseUrl}${path}`, { method, headers, // Cookie mode relies on the refresh cookie; token mode carries the refresh // token explicitly, so no ambient credentials are needed. credentials: tokenMode ? 'omit' : 'include', body: body === undefined ? null : JSON.stringify(body), }); const text = await response.text(); let json: unknown; if (text.length > 0) { json = JSON.parse(text); } if (!response.ok) { throw toApiError(response.status, json); } return json; }; // Validate `data` against a compiled validator, narrowing to T (no assertions). export const parse = (validator: ValidateFunction, data: unknown): T => { if (!validator(data)) { throw new ApiError(500, 'invalid_response', 'Server response failed schema validation'); } return data; };