rubis/apps/web/src/lib/api.ts
ordinarthur 1633fb9bf0
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 59s
Build & Deploy API / build-and-deploy (push) Successful in 1m37s
add factories
2026-05-07 11:34:00 +02:00

235 lines
7.5 KiB
TypeScript

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<string, string[]>,
) {
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<T> = {
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<void> | null = null;
async function performRefresh(): Promise<void> {
if (!pendingRefresh) {
pendingRefresh = (async () => {
try {
const session = await rawRequest<AuthSession>("/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<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = "GET", body, signal, anonymous = false } = options;
const isFormData = body instanceof FormData;
const headers: Record<string, string> = {
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<string, string[]> | undefined = json?.errors?.reduce(
(acc, err) => {
if (err.field) {
acc[err.field] = [...(acc[err.field] ?? []), err.message];
}
return acc;
},
{} as Record<string, string[]>,
);
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<T>(path: string, options: RequestOptions = {}): Promise<T> {
try {
return await rawRequest<T>(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<T>(path, options);
}
}
export const api = {
get: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
request<T>(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: <T>(
path: string,
options?: Omit<RequestOptions, "method" | "body" | "envelope">,
): Promise<ListResponse<T>> =>
request<ListResponse<T>>(path, { ...options, method: "GET", envelope: true }),
post: <T>(
path: string,
body?: unknown,
options?: Omit<RequestOptions, "method" | "body">,
): Promise<T> => request<T>(path, { ...options, method: "POST", body }),
patch: <T>(
path: string,
body?: unknown,
options?: Omit<RequestOptions, "method" | "body">,
): Promise<T> => request<T>(path, { ...options, method: "PATCH", body }),
delete: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
request<T>(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 };
},
};