diff --git a/src/shared/lib/api-client.ts b/src/shared/lib/api-client.ts new file mode 100644 index 0000000..aeb410b --- /dev/null +++ b/src/shared/lib/api-client.ts @@ -0,0 +1,164 @@ +import { env } from '@/shared/config/env' +import type { + ApiError, + ClientError, + FetchError, + FrontendErrorCode, +} from '@/shared/types/api' +import { getAccessToken } from './auth-client' +import { logger } from './logger' + +const API_VERSION = '1.0' +const DEFAULT_TIMEOUT_MS = 5000 +const DEFAULT_RETRY = { max: 2, baseDelayMs: 250 } +const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'PUT', 'DELETE']) + +export interface ApiFetchOptions { + method?: string + headers?: Record + body?: unknown + timeoutMs?: number + retry?: { max: number; baseDelayMs: number } +} + +export async function apiFetch( + path: string, + options: ApiFetchOptions = {}, +): Promise { + const method = (options.method ?? 'GET').toUpperCase() + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const retry = + options.retry ?? (IDEMPOTENT_METHODS.has(method) ? DEFAULT_RETRY : { max: 0, baseDelayMs: 0 }) + + const url = path.startsWith('http') ? path : `${env.VITE_API_URL}${path}` + + const headers: Record = { + 'X-API-Version': API_VERSION, + ...(options.headers ?? {}), + } + + if (options.body !== undefined) { + headers['Content-Type'] = 'application/json' + } + + if (!path.startsWith('/health')) { + const token = await getAccessToken() + if (token) { + headers.Authorization = `Bearer ${token}` + } + } + + const init: RequestInit = { + method, + headers, + body: options.body !== undefined ? JSON.stringify(options.body) : undefined, + } + + let attempt = 0 + while (true) { + logger.info('API request', { method, path, attempt }) + + try { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(timeoutMs), + }) + + if (response.ok) { + try { + return (await response.json()) as T + } catch { + const err = clientError('PARSE_ERROR', 'Réponse serveur non-JSON') + logger.error('API parse failure', { method, path }) + throw err + } + } + + if (response.status >= 500 && attempt < retry.max) { + const delay = retry.baseDelayMs * Math.pow(2, attempt) + logger.warn('API retry', { + method, + path, + status: response.status, + attempt, + nextDelayMs: delay, + }) + await sleep(delay) + attempt++ + continue + } + + const apiErr = await parseApiError(response) + logger.error('API failure', { + method, + path, + code: apiErr.code, + status: response.status, + attempt, + }) + throw apiErr + } catch (err) { + if (isFetchError(err)) { + throw err + } + + if (err instanceof Error && err.name === 'TimeoutError') { + const clientErr = clientError('TIMEOUT', `Requête interrompue après ${timeoutMs}ms`) + logger.error('API timeout', { method, path, attempt, timeoutMs }) + throw clientErr + } + + if (attempt < retry.max) { + const delay = retry.baseDelayMs * Math.pow(2, attempt) + logger.warn('API network retry', { method, path, attempt, nextDelayMs: delay }) + await sleep(delay) + attempt++ + continue + } + + const clientErr = clientError('NETWORK_ERROR', 'Erreur réseau') + logger.error('API network failure', { method, path, attempt }) + throw clientErr + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function clientError(code: FrontendErrorCode, message: string): ClientError { + return { error: true, code, message } +} + +function isFetchError(err: unknown): err is FetchError { + return ( + typeof err === 'object' && + err !== null && + 'error' in err && + (err as { error: unknown }).error === true && + 'code' in err + ) +} + +async function parseApiError(response: Response): Promise { + try { + const body = await response.json() + if ( + body && + typeof body === 'object' && + body.error === true && + typeof body.code === 'string' + ) { + return body as ApiError + } + } catch { + // corps non-JSON — fallback INTERNAL_ERROR + } + return { + error: true, + code: 'INTERNAL_ERROR', + message: `HTTP ${response.status} sans corps valide`, + status: response.status, + } +} diff --git a/src/shared/lib/auth-client.ts b/src/shared/lib/auth-client.ts new file mode 100644 index 0000000..de6cca3 --- /dev/null +++ b/src/shared/lib/auth-client.ts @@ -0,0 +1,22 @@ +import { createClient } from '@supabase/supabase-js' +import { env } from '@/shared/config/env' +import { logger } from './logger' + +const supabase = createClient(env.VITE_SUPABASE_URL, env.VITE_SUPABASE_ANON_KEY) + +export async function getAccessToken(): Promise { + const { data, error } = await supabase.auth.getSession() + if (error) { + logger.error('Auth session fetch failed', { name: error.name }) + return null + } + return data.session?.access_token ?? null +} + +export async function signIn(email: string, password: string) { + return supabase.auth.signInWithPassword({ email, password }) +} + +export async function signOut() { + return supabase.auth.signOut() +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 8d7a7fb..5295511 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -21,3 +21,11 @@ export type ApiErrorCode = | 'INTERNAL_ERROR' export type FrontendErrorCode = 'TIMEOUT' | 'NETWORK_ERROR' | 'PARSE_ERROR' + +export interface ClientError { + error: true + code: FrontendErrorCode + message: string +} + +export type FetchError = ApiError | ClientError