export type ApiErrorPayload = { error?: { code?: string; title?: string; message?: string; meta?: Record; }; code?: string; message?: string; }; export type ApiError = Error & { status?: number; code?: string; meta?: Record; }; export type FetchJsonResult = { data: T | null; etag: string | null; notModified: boolean; status: number; }; type FetchJsonOptions = { method?: string; headers?: HeadersInit; body?: BodyInit | null; signal?: AbortSignal; etag?: string | null; noStore?: boolean; }; export async function fetchJson(url: string, options: FetchJsonOptions = {}): Promise> { const headers: Record = { Accept: 'application/json', }; if (options.noStore) { headers['Cache-Control'] = 'no-store'; } if (options.etag) { headers['If-None-Match'] = options.etag; } if (options.headers) { Object.assign(headers, options.headers as Record); } const response = await fetch(url, { method: options.method ?? 'GET', headers, body: options.body ?? null, signal: options.signal, credentials: 'include', }); if (response.status === 304) { return { data: null, etag: response.headers.get('ETag') ?? options.etag ?? null, notModified: true, status: response.status, }; } if (!response.ok) { const errorPayload = await safeParseError(response); const error: ApiError = new Error(errorPayload?.error?.message ?? errorPayload?.message ?? `Request failed (${response.status})`); error.status = response.status; error.code = errorPayload?.error?.code ?? errorPayload?.code; if (errorPayload?.error?.meta) { error.meta = errorPayload.error.meta; } throw error; } const data = (await response.json()) as T; return { data, etag: response.headers.get('ETag'), notModified: false, status: response.status, }; } async function safeParseError(response: Response): Promise { try { const payload = (await response.clone().json()) as ApiErrorPayload; return payload; } catch (error) { return null; } }