import Cookies from 'js-cookie'; /** * Thin wrapper around fetch that: * - prepends the configured API base URL * - includes Django session cookies * - attaches the CSRF token from cookies * - parses structured DRF errors into a single ApiError * * The 401 handler is pluggable (see `setUnauthorizedHandler`) so the auth * layer can react (clear session, route to /login) without this module * importing from $auth (which would create a cycle). * * Default base URL is the relative path `/api` so the app works out of the * box against either the Vite dev proxy, the mock plugin, or a same-origin * production deployment. Set VITE_API_BASE_URL to point directly at a * cross-origin backend if needed. */ export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api'; export class ApiError extends Error { constructor( public status: number, message: string, public details?: unknown, ) { super(message); this.name = 'ApiError'; } } type UnauthorizedHandler = () => void; let onUnauthorized: UnauthorizedHandler | null = null; export function setUnauthorizedHandler(fn: UnauthorizedHandler | null) { onUnauthorized = fn; } async function ensureCsrf(): Promise { const existing = Cookies.get('csrftoken'); if (existing) return existing; await fetch(`${API_BASE_URL}/csrf/`, { method: 'GET', credentials: 'include' }); return Cookies.get('csrftoken') ?? ''; } function extractError(body: unknown, fallback: string): string { if (body && typeof body === 'object') { const b = body as Record; if (typeof b.detail === 'string') return b.detail; if (b.non_field_errors && Array.isArray(b.non_field_errors)) { return b.non_field_errors.join(', '); } if (b.field_errors && typeof b.field_errors === 'object') { return Object.values(b.field_errors as Record) .flat() .join(', '); } } return fallback; } export interface RequestOptions extends Omit { body?: unknown; query?: Record; } function buildUrl(path: string, query?: RequestOptions['query']): string { let url = `${API_BASE_URL}${path}`; if (!query) return url; const params = new URLSearchParams(); for (const [k, v] of Object.entries(query)) { if (v !== undefined) params.set(k, String(v)); } const qs = params.toString(); if (qs) url += (url.includes('?') ? '&' : '?') + qs; return url; } export async function request(path: string, options: RequestOptions = {}): Promise { const csrf = await ensureCsrf(); const init: RequestInit = { ...options, credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf, ...options.headers, }, body: options.body !== undefined ? JSON.stringify(options.body) : undefined, }; const res = await fetch(buildUrl(path, options.query), init); if (res.status === 401) onUnauthorized?.(); if (!res.ok) { let body: unknown = null; try { body = await res.json(); } catch { // ignore } throw new ApiError(res.status, extractError(body, res.statusText), body); } if (res.status === 204) return undefined as T; return (await res.json()) as T; } export const api = { get: (path: string, options?: RequestOptions) => request(path, { ...options, method: 'GET' }), post: (path: string, body?: unknown, options?: RequestOptions) => request(path, { ...options, method: 'POST', body }), put: (path: string, body?: unknown, options?: RequestOptions) => request(path, { ...options, method: 'PUT', body }), patch: (path: string, body?: unknown, options?: RequestOptions) => request(path, { ...options, method: 'PATCH', body }), delete: (path: string, options?: RequestOptions) => request(path, { ...options, method: 'DELETE' }), };