235 lines
7.5 KiB
TypeScript
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 };
|
|
},
|
|
};
|