export interface ApiOptions { init?: RequestInit; request?: Request; } export interface PaginatedEnvelope { data: T[]; meta: { has_more: boolean }; } export class ApiError extends Error { readonly status: number; readonly requestId: string | null; readonly body: unknown; constructor({ status, message, requestId, body, }: { status: number; message: string; requestId: string | null; body: unknown; }) { super(message); this.name = "ApiError"; this.status = status; this.requestId = requestId; this.body = body; } } const API_PREFIX = "/api/v1"; const PAGINATED_API_MAX_PAGES = 50; const PAGINATED_API_MAX_ITEMS = 5000; export function apiPath(path: string): string { return path.startsWith(API_PREFIX) ? path : `${API_PREFIX}${path}`; } export function isNotAvailable(status: number): boolean { return status === 404 || status === 501; } export function extractRequestId(body: unknown): string | null { if (!body || typeof body !== "object") return null; const record = body as Record; if (typeof record.request_id === "string") return record.request_id; if (typeof record.requestId === "string") return record.requestId; const errors = record.errors; if (!Array.isArray(errors) || errors.length === 0) return null; const first = errors[0]; if (!first || typeof first !== "object") return null; const error = first as Record; if (typeof error.request_id === "string") return error.request_id; if (typeof error.requestId === "string") return error.requestId; if (typeof error.detail === "string") { const match = error.detail.match(/request[_ ]id[=:]?\s*([a-zA-Z0-9-_]+)/i); if (match) return match[1]; } return null; } function requestIdFromHeaders(headers: Headers): string | null { return ( headers.get("x-request-id") ?? headers.get("x-fabro-request-id") ?? headers.get("request-id") ); } async function parseResponseBody(response: Response): Promise { const contentType = response.headers.get("content-type") ?? ""; const text = await response.text().catch(() => ""); if (!text) return null; if (contentType.includes("json")) { try { return JSON.parse(text); } catch { return text; } } try { return JSON.parse(text); } catch { return text; } } async function apiErrorFromResponse(response: Response): Promise { const body = await parseResponseBody(response); const requestId = requestIdFromHeaders(response.headers) ?? extractRequestId(body); return new ApiError({ status: response.status, message: response.statusText || `HTTP ${response.status}`, requestId, body, }); } export async function apiRequest(path: string, options?: ApiOptions): Promise { const { init, request } = options ?? {}; const response = await fetch(apiPath(path), { ...init, credentials: "include", headers: init?.headers, ...(request?.signal ? { signal: request.signal } : {}), }); if (response.status === 401) { if (typeof window !== "undefined") { window.location.href = "/login"; } throw await apiErrorFromResponse(response); } return response; } export async function apiFetcher(key: string): Promise { const response = await apiRequest(key); if (!response.ok) { throw await apiErrorFromResponse(response); } if (response.status === 204) return undefined as T; return response.json() as Promise; } export async function apiTextFetcher(key: string): Promise { const response = await apiRequest(key); if (!response.ok) { throw await apiErrorFromResponse(response); } return response.text(); } export async function apiNullableFetcher(key: string): Promise { const response = await apiRequest(key); if (isNotAvailable(response.status)) return null; if (!response.ok) { throw await apiErrorFromResponse(response); } return response.json() as Promise; } export async function apiNullableTextFetcher(key: string): Promise { const response = await apiRequest(key); if (isNotAvailable(response.status)) return null; if (!response.ok) { throw await apiErrorFromResponse(response); } return response.text(); } function paginatedApiPath(key: string, limit: number, offset: number): string { const url = new URL(apiPath(key), "http://fabro.local"); url.searchParams.set("page[limit]", String(limit)); url.searchParams.set("page[offset]", String(offset)); return `${url.pathname}${url.search}`; } export async function apiPaginatedFetcher( key: string, ): Promise & TExtra> { const limit = 100; let offset = 0; const data: TItem[] = []; let extras: TExtra | null = null; let pagesLoaded = 0; while (true) { const response = await apiRequest(paginatedApiPath(key, limit, offset)); if (!response.ok) { throw await apiErrorFromResponse(response); } const page = (await response.json()) as PaginatedEnvelope & TExtra; if (extras == null) { const { data: _data, meta: _meta, ...rest } = page as PaginatedEnvelope & Record; extras = rest as TExtra; } pagesLoaded += 1; const remainingItemBudget = PAGINATED_API_MAX_ITEMS - data.length; const pageItems = remainingItemBudget > 0 ? page.data.slice(0, remainingItemBudget) : []; data.push(...pageItems); if (!page.meta.has_more || page.data.length === 0) { return { ...(extras ?? ({} as TExtra)), data, meta: { has_more: false }, }; } if ( pagesLoaded >= PAGINATED_API_MAX_PAGES || pageItems.length < page.data.length || data.length >= PAGINATED_API_MAX_ITEMS ) { console.warn( `Stopped paginated API fetch for ${key} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`, ); return { ...(extras ?? ({} as TExtra)), data, meta: { has_more: true }, }; } offset += page.data.length; } } export async function apiJsonMutation( key: string, { arg }: { arg: TArg }, ): Promise { const response = await apiRequest(key, { init: { method: "POST", headers: { "Content-Type": "application/json" }, body: arg === undefined ? undefined : JSON.stringify(arg), }, }); if (!response.ok) { throw await apiErrorFromResponse(response); } if (response.status === 204) return undefined as TResponse; return response.json() as Promise; }