// Single typed fetch client for the ERPCore API (docs/20-FRONTEND.md §1: "A single // typed fetch client against NEXT_PUBLIC_API_BASE_URL; all calls go through it. No // scattered fetch() in components."). Per-endpoint methods live in lib/api/*.ts. import { ProblemDetails } from "@/types/common" import { getAccessToken } from "@/lib/auth-token" const API_BASE = `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/api/v1` /** Normalized RFC 7807 error (docs/11-BACKEND-PHASE1.md §1.8) thrown on any non-2xx response. */ export class ApiError extends Error { status: number code?: string detail?: string errors?: Record traceId?: string constructor(problem: ProblemDetails) { super(problem.title || "Request failed") this.status = problem.status this.code = problem.code this.detail = problem.detail this.errors = problem.errors this.traceId = problem.traceId } } export interface RequestOptions extends Omit { body?: unknown /** Sent as If-Match for concurrency-guarded PUT/PATCH (docs/11 §1.6). */ ifMatch?: string /** Sent as Idempotency-Key for transactional POSTs (e.g. GRN confirm, docs/11 §1.6). */ idempotencyKey?: string } export interface ApiResult { data: T /** Response ETag header, when the resource is mutable (docs/11 §1.6). */ etag: string | null } async function rawRequest(path: string, options: RequestOptions = {}): Promise { const { body, ifMatch, idempotencyKey, headers, ...rest } = options const token = getAccessToken() const finalHeaders: Record = { Accept: "application/json", ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(ifMatch ? { "If-Match": ifMatch } : {}), ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), ...((headers as Record | undefined) ?? {}), } const response = await fetch(`${API_BASE}${path}`, { ...rest, headers: finalHeaders, body: body !== undefined ? JSON.stringify(body) : undefined, }) if (!response.ok) { let problem: ProblemDetails try { problem = (await response.json()) as ProblemDetails } catch { problem = { title: response.statusText || "Request failed", status: response.status } } if (!problem.status) problem.status = response.status throw new ApiError(problem) } return response } /** Fire a request and decode the JSON body only (no ETag needed). */ export async function apiRequest(path: string, options?: RequestOptions): Promise { const response = await rawRequest(path, options) if (response.status === 204) return undefined as T return (await response.json()) as T } /** Fire a request and also surface the ETag header, for resources that support If-Match. */ export async function apiRequestWithETag(path: string, options?: RequestOptions): Promise> { const response = await rawRequest(path, options) const etag = response.headers.get("ETag") const data = response.status === 204 ? (undefined as T) : ((await response.json()) as T) return { data, etag } } /** Build a `?a=1&b=2` query string, dropping null/undefined/empty values. */ export function buildQuery(params: object): string { const search = new URLSearchParams() for (const [key, value] of Object.entries(params) as [string, string | number | boolean | null | undefined][]) { if (value === null || value === undefined || value === "") continue search.set(key, String(value)) } const qs = search.toString() return qs ? `?${qs}` : "" }