import { useEffect, useMemo, useRef } from 'react'; import { useAuth } from './useAuth'; /** Erreur levée lorsqu'une réponse API n'est pas OK (hors 401 géré par renouvellement). */ export class InwinkApiError extends Error { constructor( public readonly status: number, public readonly body: unknown, ) { super(`Inwink API error ${status}`); this.name = 'InwinkApiError'; } } export interface InwinkApi { request: (path: string, init?: RequestInit) => Promise; get: (path: string, init?: RequestInit) => Promise; post: (path: string, body?: unknown, init?: RequestInit) => Promise; put: (path: string, body?: unknown, init?: RequestInit) => Promise; del: (path: string, init?: RequestInit) => Promise; } async function safeBody(response: Response): Promise { try { return await response.json(); } catch { return undefined; } } /** Nombre de rejeux sur 429 (rate limiting) avant d'abandonner. */ const MAX_RETRIES_429 = 2; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** Délai avant rejeu d'un 429 : respecte `Retry-After`, sinon backoff exponentiel. */ function retryDelayMs(response: Response, attempt: number): number { const retryAfter = response.headers.get('Retry-After'); if (retryAfter) { const seconds = Number(retryAfter); if (!Number.isNaN(seconds)) return seconds * 1000; const date = Date.parse(retryAfter); if (!Number.isNaN(date)) return Math.max(0, date - Date.now()); } return 2 ** attempt * 500; // 500 ms, 1 s, … } /** * Hook générique d'appel aux API inwink. Encapsule l'envoi du Bearer token, * le renouvellement automatique sur 401 (signinSilent puis rejeu), et le * fallback sur login interactif si le renouvellement échoue. * * Préférez les hooks dédiés (`useInwinkEventApi`, etc.) ; ce hook générique * sert aux hôtes externes déclarés ou à des bases d'URL spécifiques. */ export function useInwinkApi(baseUrl: string): InwinkApi { // Les fonctions d'auth sont lues via une ref (motif « latest ref », cf. `userRef` // dans AuthProvider) : l'objet API renvoyé reste ainsi stable (mémoïsé sur `baseUrl`, // une constante) tout en utilisant toujours les callbacks frais au moment de l'appel. const auth = useAuth(); const authRef = useRef(auth); useEffect(() => { authRef.current = auth; }, [auth]); return useMemo(() => { async function doFetch(path: string, token: string, init?: RequestInit): Promise { return fetch(`${baseUrl}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...(init?.headers as Record | undefined), }, }); } async function request(path: string, init?: RequestInit): Promise { const { getAccessToken, renewToken, login, whenReady } = authRef.current; // Attend la fin de la résolution d'auth initiale avant de lire le token : // sans ce gate, un appel parti depuis l'effet de montage d'un composant // (hors ProtectedRoute) verrait token=null et déclencherait un login() // intempestif alors que la session est en cours de chargement. await whenReady(); let token = getAccessToken(); if (!token) { await login(); throw new InwinkApiError(401, 'Non authentifié.'); } let response = await doFetch(path, token, init); if (response.status === 401) { const renewed = await renewToken(); if (renewed) { token = renewed; response = await doFetch(path, token, init); } if (response.status === 401) { await login(); throw new InwinkApiError(401, 'Session expirée, reconnexion requise.'); } } // Rate limiting : on respecte Retry-After (ou un backoff exponentiel) et on // rejoue l'appel quelques fois avant d'abandonner. for (let attempt = 0; response.status === 429 && attempt < MAX_RETRIES_429; attempt++) { await sleep(retryDelayMs(response, attempt)); response = await doFetch(path, token, init); } if (!response.ok) { throw new InwinkApiError(response.status, await safeBody(response)); } if (response.status === 204) { return undefined as T; } return (await response.json()) as T; } const withBody = (method: string) => (path: string, body?: unknown, init?: RequestInit): Promise => request(path, { ...init, method, body: body === undefined ? init?.body : JSON.stringify(body), }); return { request, get: (path: string, init?: RequestInit) => request(path, { ...init, method: 'GET' }), post: withBody('POST'), put: withBody('PUT'), del: (path: string, init?: RequestInit) => request(path, { ...init, method: 'DELETE' }), }; }, [baseUrl]); }