// Typed fetch wrapper for the pansy JSON API under /api/v1. // // Every non-2xx response throws an ApiError carrying the HTTP status and the // parsed response body. That contract matters beyond this issue: the optimistic // editor (per DESIGN.md § Sync) sends each PATCH/DELETE with a row `version` and, // on a 409, reads the *current* row back out of `ApiError.body` to reconcile. /** Base path for the versioned JSON API; also used to build server-nav links. */ export const API_BASE = '/api/v1' /** Error thrown for any non-2xx response (or a network failure, status 0). */ export class ApiError extends Error { readonly status: number /** Parsed response body (JSON object, string, or null). On a 409 this is the current server row. */ readonly body: unknown constructor(message: string, status: number, body: unknown) { super(message) this.name = 'ApiError' this.status = status this.body = body } /** A version conflict (DESIGN.md § Sync); `body` holds the current server row. */ get isConflict(): boolean { return this.status === 409 } /** No authenticated session; callers typically redirect to /login. */ get isUnauthorized(): boolean { return this.status === 401 } /** Resource is gone or masked (pansy returns 404 for no-access, not 403). */ get isNotFound(): boolean { return this.status === 404 } } type ParamValue = string | number | boolean | undefined | null export type Params = Record export interface RequestOptions { method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' /** Request body. A `FormData` goes out as multipart (a file upload — the * seed-packet scan); anything else is serialized as JSON. */ body?: unknown /** Query-string parameters; undefined/null/'' entries are omitted. */ params?: Params signal?: AbortSignal } function buildUrl(path: string, params?: Params): string { const url = API_BASE + path if (!params) return url const qs = new URLSearchParams() for (const [k, v] of Object.entries(params)) { if (v === undefined || v === null || v === '') continue qs.set(k, String(v)) } const s = qs.toString() return s ? `${url}?${s}` : url } async function parseBody(res: Response): Promise { if (res.status === 204) return null const text = await res.text() if (!text) return null const contentType = res.headers.get('content-type') ?? '' if (contentType.includes('application/json')) { try { return JSON.parse(text) } catch { return text } } return text } function messageFrom(body: unknown, status: number): string { if (body && typeof body === 'object') { const err = (body as { error?: { message?: string } }).error if (err?.message) return err.message const msg = (body as { message?: string }).message if (msg) return msg } return `Request failed (${status})` } /** Perform a request against /api/v1 and return the parsed JSON body as T. */ export async function apiFetch(path: string, opts: RequestOptions = {}): Promise { const { method = 'GET', body, params, signal } = opts // FormData must go out as multipart with a browser-generated boundary, so it's // sent as-is with NO content-type header (the browser sets it, boundary // included). Everything else is JSON — serialized before the try so a // JSON.stringify failure (e.g. a circular value) surfaces as itself, not as a // misleading "cannot reach the server" error. const isForm = typeof FormData !== 'undefined' && body instanceof FormData const requestBody = body === undefined ? undefined : isForm ? body : JSON.stringify(body) let res: Response try { res = await fetch(buildUrl(path, params), { method, signal, credentials: 'same-origin', // send the HttpOnly session cookie headers: { accept: 'application/json', ...(body !== undefined && !isForm ? { 'content-type': 'application/json' } : {}), }, body: requestBody as BodyInit | undefined, }) } catch (err) { if ((err as Error)?.name === 'AbortError') throw err throw new ApiError('Cannot reach the pansy server.', 0, null) } const payload = await parseBody(res) if (!res.ok) { throw new ApiError(messageFrom(payload, res.status), res.status, payload) } return payload as T } /** The server error code from an ApiError body ({error:{code,message}}), if any. */ export function apiErrorCode(err: unknown): string | null { if (err instanceof ApiError && err.body && typeof err.body === 'object') { const e = (err.body as { error?: { code?: unknown } }).error if (e && typeof e.code === 'string') return e.code } return null } /** * A user-facing message for a caught request error. An ApiError carries the * server's own message (already user-friendly), so that wins; `fallback` covers * everything else (unexpected throws). */ export function errorMessage(err: unknown, fallback = 'Something went wrong.'): string { if (err instanceof ApiError) return err.message return fallback } /** Convenience verbs over apiFetch. */ export const api = { get: (path: string, opts?: Omit) => apiFetch(path, { ...opts, method: 'GET' }), post: (path: string, body?: unknown, opts?: Omit) => apiFetch(path, { ...opts, method: 'POST', body }), postForm: (path: string, form: FormData, opts?: Omit) => apiFetch(path, { ...opts, method: 'POST', body: form }), patch: (path: string, body?: unknown, opts?: Omit) => apiFetch(path, { ...opts, method: 'PATCH', body }), delete: (path: string, opts?: Omit) => apiFetch(path, { ...opts, method: 'DELETE' }), }