import { toast } from 'sonner'; export const getBaseUrl = () => localStorage.getItem('server_url') ?? ''; export const getUserId = () => localStorage.getItem('username') ?? ''; /** * Structured error thrown for non-2xx HTTP responses. * `message` contains the human-readable detail extracted from the backend. */ export class ApiError extends Error { readonly status: number; readonly detail: string; constructor(status: number, detail: string) { super(detail); this.name = 'ApiError'; this.status = status; this.detail = detail; } } interface RequestOptions { method?: string; body?: unknown; params?: Record; /** When true, suppresses the automatic error toast. Useful when the caller shows its own inline error UI. */ silent?: boolean; } function buildHeaders(hasBody: boolean): Record { const headers: Record = { 'X-User-ID': getUserId() }; if (hasBody) headers['Content-Type'] = 'application/json'; return headers; } /** Parse the response body and extract the `detail` field if the backend returned JSON. */ async function extractErrorDetail(res: Response): Promise { const text = await res.text(); try { const json = JSON.parse(text) as { detail?: unknown }; if (typeof json.detail === 'string') return json.detail; if (json.detail !== undefined) return JSON.stringify(json.detail); } catch { // not JSON – fall through } return text || res.statusText; } async function request(path: string, options: RequestOptions = {}): Promise { const { method = 'GET', body, params, silent = false } = options; const url = new URL(path, getBaseUrl()); if (params) { Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); } const res = await fetch(url.toString(), { method, headers: buildHeaders(body !== undefined), body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) { const detail = await extractErrorDetail(res); const error = new ApiError(res.status, detail); if (!silent) toast.error(detail); throw error; } if (res.status === 204) return undefined as T; return res.json() as Promise; } async function streamRequest( path: string, options: RequestOptions & { signal?: AbortSignal } = {}, ): Promise { const { method = 'GET', body, params, signal, silent = false } = options; const url = new URL(path, getBaseUrl()); if (params) { Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); } const res = await fetch(url.toString(), { method, headers: buildHeaders(body !== undefined), body: body ? JSON.stringify(body) : undefined, signal, }); if (!res.ok) { const detail = await extractErrorDetail(res); const error = new ApiError(res.status, detail); if (!silent) toast.error(detail); throw error; } return res; } export const client = { get: (path: string, params?: Record) => request(path, { method: 'GET', params }), post: (path: string, body?: unknown, params?: Record) => request(path, { method: 'POST', body, params }), patch: (path: string, body?: unknown, params?: Record) => request(path, { method: 'PATCH', body, params }), delete: (path: string, params?: Record) => request(path, { method: 'DELETE', params }), stream: (path: string, options?: RequestOptions & { signal?: AbortSignal }) => streamRequest(path, options), };