const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'; export class ApiError extends Error { constructor( public readonly statusCode: number, message: string, public readonly error?: string, ) { super(message); this.name = 'ApiError'; } } type RequestOptions = Omit & { body?: unknown; }; /** * Gancho de autenticação, preenchido pela Etapa 13. Antes da Etapa 13 * existir, o client simplesmente não tem access token para anexar e não * tenta renovar sessão em respostas 401. */ export const authInterceptor: { getAccessToken: () => string | null; onUnauthorized: (() => Promise) | null; } = { getAccessToken: () => null, onUnauthorized: null, }; async function parseErrorResponse(response: Response): Promise { let body: { message?: string | string[]; error?: string } = {}; try { body = await response.json(); } catch { // corpo não é JSON (ex: erro de proxy/gateway) — segue com mensagem genérica } const message = Array.isArray(body.message) ? body.message.join(', ') : (body.message ?? response.statusText ?? 'Erro inesperado'); return new ApiError(response.status, message, body.error); } async function request(path: string, options: RequestOptions = {}, isRetry = false): Promise { const { body, headers, ...rest } = options; const accessToken = authInterceptor.getAccessToken(); // `FormData` (ex: upload de logo, Etapa 18) precisa ir sem `Content-Type` // explícito — o browser define `multipart/form-data; boundary=...` // sozinho — e sem `JSON.stringify` (quebraria o corpo binário). const isFormData = typeof FormData !== 'undefined' && body instanceof FormData; const response = await fetch(`${API_URL}${path}`, { ...rest, credentials: 'include', headers: { ...(body !== undefined && !isFormData ? { 'Content-Type': 'application/json' } : {}), ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), ...headers, }, body: body === undefined ? undefined : isFormData ? (body as FormData) : JSON.stringify(body), }); if (response.status === 401 && !isRetry && authInterceptor.onUnauthorized) { const recovered = await authInterceptor.onUnauthorized(); if (recovered) { return request(path, options, true); } } if (!response.ok) { throw await parseErrorResponse(response); } if (response.status === 204) { return undefined as T; } return response.json() as Promise; } export const apiClient = { 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' }), }; /** * Chamada "crua" ao `/auth/refresh`, fora do `request()`/`authInterceptor` * de propósito: é ela quem *alimenta* `authInterceptor.onUnauthorized`, e * se passasse pelo mesmo caminho, um 401 na própria renovação chamaria o * interceptor de novo → recursão infinita. Usa só o cookie httpOnly * (`credentials: 'include'`), sem access token. */ export async function fetchRefreshToken(): Promise<{ access_token: string } | null> { try { const response = await fetch(`${API_URL}/auth/refresh`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); if (!response.ok) return null; return (await response.json()) as { access_token: string }; } catch { return null; } }