import type { AuthSession } from "@rubis/shared"; import { env } from "./env"; import { authStore } from "./auth"; /** * Client HTTP — façade unique pour l'API REST. Centralise : * - Header Authorization (Bearer token depuis authStore) * - Cookie refresh httpOnly (`credentials: 'include'`) * - Silent refresh sur 401 (rotation du token + retry une fois) * - Gestion d'erreur uniforme via la classe ApiError * * Le contrat de réponse Adonis est `{ data: ... }` (cf. backend.md §6). * Les erreurs sont `{ errors: [{ code, message, field? }] }`. * * En dev avec VITE_USE_MOCKS=true, MSW intercepte transparently — ce * fichier fonctionne pareil dans les deux modes. */ export class ApiError extends Error { constructor( public readonly status: number, public readonly code: string, message: string, public readonly fieldErrors?: Record, ) { super(message); this.name = "ApiError"; } } type RequestOptions = { method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; body?: unknown; signal?: AbortSignal; /** Si true, n'inclut pas le header Authorization (utile pour /auth/login). */ anonymous?: boolean; /** * Si true, ne déroule pas l'enveloppe `{ data, meta }` — utile pour les * endpoints paginés qui ont besoin de meta.total / meta.page. */ envelope?: boolean; }; /** * Méta-info standard renvoyée par les endpoints paginés. Mappe le * `meta: { total, page }` que l'API Adonis renvoie aux côtés de `data`. */ export type PaginationMeta = { total: number; page: number; }; export type ListResponse = { data: T[]; meta?: PaginationMeta; }; /** * Refresh en cours partagé entre toutes les requêtes 401 simultanées : * si N requêtes reviennent 401 en même temps, on n'envoie qu'un seul * /auth/refresh et toutes attendent le même résultat. */ let pendingRefresh: Promise | null = null; async function performRefresh(): Promise { if (!pendingRefresh) { pendingRefresh = (async () => { try { const session = await rawRequest("/api/v1/auth/refresh", { method: "POST", anonymous: true, }); authStore.setSession(session.accessToken, session.user); } catch (err) { authStore.clear(); throw err; } finally { pendingRefresh = null; } })(); } return pendingRefresh; } /** * Bas niveau : fait la requête sans tenter de silent refresh. * Utilisé par performRefresh elle-même (sinon boucle infinie sur * /auth/refresh qui revient 401). */ async function rawRequest(path: string, options: RequestOptions = {}): Promise { const { method = "GET", body, signal, anonymous = false } = options; const isFormData = body instanceof FormData; const headers: Record = { Accept: "application/json", }; if (body !== undefined && !isFormData) headers["Content-Type"] = "application/json"; if (!anonymous && authStore.token) { headers.Authorization = `Bearer ${authStore.token}`; } const url = path.startsWith("http") ? path : `${env.VITE_API_URL}${path}`; const response = await fetch(url, { method, headers, credentials: "include", body: body === undefined ? undefined : isFormData ? body : JSON.stringify(body), signal, }); if (response.status === 204) { return undefined as T; } const json = (await response.json().catch(() => null)) as | { data?: T; errors?: Array<{ code: string; message: string; field?: string }> } | null; if (!response.ok) { const firstError = json?.errors?.[0]; const fieldErrors: Record | undefined = json?.errors?.reduce( (acc, err) => { if (err.field) { acc[err.field] = [...(acc[err.field] ?? []), err.message]; } return acc; }, {} as Record, ); throw new ApiError( response.status, firstError?.code ?? "unknown", firstError?.message ?? `Requête échouée (${response.status})`, fieldErrors, ); } // Convention de réponse Adonis : { data: ..., meta?: ... }. Par défaut on // extrait `data` (contrat documenté). Si le caller demande l'enveloppe // (`envelope: true`), on renvoie le json tel quel — utile pour récupérer // `meta` (total, page) sur les endpoints paginés. if (options.envelope) return json as T; return (json?.data ?? json) as T; } /** * Niveau public : ajoute le silent refresh sur 401. * * Si une requête authentifiée revient 401 : * 1. On lance (ou attend) un seul /auth/refresh * 2. Si le refresh réussit, on retry la requête originale avec le * nouveau token * 3. Si le refresh échoue, l'authStore est cleared (le router guard * redirige vers /login) et on propage le 401 original * * Les requêtes anonymes (signup, login, refresh lui-même) ne tentent * pas de refresh — elles n'ont pas de token à rafraîchir. */ async function request(path: string, options: RequestOptions = {}): Promise { try { return await rawRequest(path, options); } catch (err) { const isAuthEndpoint = path.startsWith("/api/v1/auth/"); const shouldRefresh = err instanceof ApiError && err.status === 401 && !options.anonymous && !isAuthEndpoint && authStore.token !== null; if (!shouldRefresh) throw err; try { await performRefresh(); } catch { // refresh KO → on propage le 401 original (le store est déjà cleared) throw err; } // Retry une seule fois avec le nouveau token. return rawRequest(path, options); } } export const api = { get: (path: string, options?: Omit): Promise => request(path, { ...options, method: "GET" }), /** * Variante GET qui renvoie l'enveloppe `{ data, meta }` complète — pour * les endpoints paginés où on a besoin de `meta.total` afin de rendre * un compteur "X factures" et des contrôles précédent/suivant. */ getList: ( path: string, options?: Omit, ): Promise> => request>(path, { ...options, method: "GET", envelope: true }), post: ( path: string, body?: unknown, options?: Omit, ): Promise => request(path, { ...options, method: "POST", body }), patch: ( path: string, body?: unknown, options?: Omit, ): Promise => request(path, { ...options, method: "PATCH", body }), delete: (path: string, options?: Omit): Promise => request(path, { ...options, method: "DELETE" }), /** * Fetch d'un binaire (PDF, image…) avec le Bearer auto-injecté. Renvoie * un Blob + le content-type. Pas de silent refresh sur 401 (cas rare * pour des assets longue durée), si besoin re-fetch côté caller. */ fetchBlob: async (path: string, signal?: AbortSignal): Promise<{ blob: Blob; contentType: string }> => { const url = path.startsWith("http") ? path : `${env.VITE_API_URL}${path}`; const res = await fetch(url, { headers: authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}, credentials: "include", signal, }); if (!res.ok) { throw new ApiError(res.status, "blob_fetch_failed", `HTTP ${res.status} on ${path}`); } const blob = await res.blob(); return { blob, contentType: res.headers.get("content-type") ?? blob.type }; }, };