Backend: rate limiter, security headers, blockchain cache service avec RPC, public API (7 endpoints read-only), WebSocket auth + heartbeat, DB connection pooling, structured logging, health check DB. Frontend: API retry/timeout, WebSocket auth + heartbeat + typed events, notifications toast, mobile hamburger + drawer, error boundary, offline banner, loading skeletons, dashboard enrichi. Documentation: guides utilisateur complets (demarrage, vote, sanctuaire, FAQ 30+), guide deploiement, politique securite. 123 tests, 155 fichiers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
197 lines
6.3 KiB
TypeScript
197 lines
6.3 KiB
TypeScript
/**
|
|
* Composable for making authenticated API calls to the Glibredecision backend.
|
|
*
|
|
* Uses the runtime config `apiBase` and automatically injects the Bearer token
|
|
* from the auth store when available.
|
|
*
|
|
* Production-grade features:
|
|
* - Error interceptor with user-friendly French messages
|
|
* - Retry logic with exponential backoff for 5xx errors
|
|
* - Request timeout (30s default)
|
|
* - AbortController support for cancellation
|
|
*/
|
|
|
|
/** Map of HTTP status codes to user-friendly French error messages. */
|
|
const HTTP_ERROR_MESSAGES: Record<number, string> = {
|
|
401: 'Session expirée, veuillez vous reconnecter',
|
|
403: 'Accès non autorisé',
|
|
404: 'Ressource introuvable',
|
|
409: 'Conflit avec une ressource existante',
|
|
422: 'Données invalides',
|
|
429: 'Trop de requêtes, veuillez patienter',
|
|
500: 'Erreur serveur, veuillez réessayer',
|
|
502: 'Service temporairement indisponible',
|
|
503: 'Service en maintenance, veuillez réessayer',
|
|
504: 'Délai de réponse dépassé',
|
|
}
|
|
|
|
/** Default request timeout in milliseconds. */
|
|
const DEFAULT_TIMEOUT_MS = 30_000
|
|
|
|
/** Maximum number of retry attempts for 5xx errors. */
|
|
const MAX_RETRIES = 3
|
|
|
|
/** Base delay for exponential backoff in milliseconds. */
|
|
const BASE_BACKOFF_MS = 1_000
|
|
|
|
export interface ApiOptions extends Record<string, any> {
|
|
/** Custom timeout in milliseconds (default: 30000). */
|
|
timeout?: number
|
|
/** External AbortController for request cancellation. */
|
|
signal?: AbortSignal
|
|
/** Disable automatic retry for this request. */
|
|
noRetry?: boolean
|
|
}
|
|
|
|
export interface ApiError {
|
|
status: number
|
|
message: string
|
|
detail?: string
|
|
}
|
|
|
|
/**
|
|
* Resolve a user-friendly error message from an HTTP status code.
|
|
*/
|
|
function resolveErrorMessage(status: number, fallback?: string): string {
|
|
return HTTP_ERROR_MESSAGES[status] || fallback || 'Une erreur inattendue est survenue'
|
|
}
|
|
|
|
/**
|
|
* Wait for the specified duration in milliseconds.
|
|
*/
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
}
|
|
|
|
/**
|
|
* Determine whether an HTTP status code is retryable (5xx server errors).
|
|
*/
|
|
function isRetryable(status: number): boolean {
|
|
return status >= 500 && status < 600
|
|
}
|
|
|
|
export function useApi() {
|
|
const config = useRuntimeConfig()
|
|
const auth = useAuthStore()
|
|
|
|
/**
|
|
* Perform a typed fetch against the backend API.
|
|
*
|
|
* @param path - API path relative to apiBase, e.g. "/documents"
|
|
* @param options - $fetch options (method, body, query, headers, timeout, signal, noRetry)
|
|
* @returns Typed response
|
|
* @throws ApiError with status, message, and optional detail
|
|
*/
|
|
async function $api<T>(path: string, options: ApiOptions = {}): Promise<T> {
|
|
const {
|
|
timeout = DEFAULT_TIMEOUT_MS,
|
|
signal: externalSignal,
|
|
noRetry = false,
|
|
...fetchOptions
|
|
} = options
|
|
|
|
const headers: Record<string, string> = {}
|
|
if (auth.token) {
|
|
headers.Authorization = `Bearer ${auth.token}`
|
|
}
|
|
|
|
const maxAttempts = noRetry ? 1 : MAX_RETRIES
|
|
let lastError: any = null
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
// Create an AbortController for timeout management
|
|
const controller = new AbortController()
|
|
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
|
|
|
// If an external signal is provided, forward its abort
|
|
let externalAbortHandler: (() => void) | null = null
|
|
if (externalSignal) {
|
|
if (externalSignal.aborted) {
|
|
clearTimeout(timeoutId)
|
|
throw _createApiError(0, 'Requête annulée')
|
|
}
|
|
externalAbortHandler = () => controller.abort()
|
|
externalSignal.addEventListener('abort', externalAbortHandler, { once: true })
|
|
}
|
|
|
|
try {
|
|
const result = await $fetch<T>(`${config.public.apiBase}${path}`, {
|
|
...fetchOptions,
|
|
headers: { ...headers, ...fetchOptions.headers },
|
|
signal: controller.signal,
|
|
})
|
|
|
|
return result
|
|
} catch (err: any) {
|
|
lastError = err
|
|
|
|
const status = err?.response?.status || err?.status || 0
|
|
|
|
// Handle 401: auto-logout for expired sessions
|
|
if (status === 401 && auth.token) {
|
|
auth.token = null
|
|
auth.identity = null
|
|
auth._clearToken()
|
|
navigateTo('/login')
|
|
}
|
|
|
|
// Handle abort (timeout or external cancellation)
|
|
if (err?.name === 'AbortError' || controller.signal.aborted) {
|
|
if (externalSignal?.aborted) {
|
|
throw _createApiError(0, 'Requête annulée')
|
|
}
|
|
throw _createApiError(0, 'Délai de réponse dépassé')
|
|
}
|
|
|
|
// Retry only for 5xx errors and if we have remaining attempts
|
|
if (isRetryable(status) && attempt < maxAttempts) {
|
|
const backoffMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1)
|
|
await delay(backoffMs)
|
|
continue
|
|
}
|
|
|
|
// Build and throw a structured error
|
|
const detail = err?.data?.detail || err?.message || undefined
|
|
const message = resolveErrorMessage(status, detail)
|
|
throw _createApiError(status, message, detail)
|
|
} finally {
|
|
clearTimeout(timeoutId)
|
|
if (externalAbortHandler && externalSignal) {
|
|
externalSignal.removeEventListener('abort', externalAbortHandler)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: should not reach here, but handle gracefully
|
|
const fallbackStatus = lastError?.response?.status || lastError?.status || 0
|
|
const fallbackDetail = lastError?.data?.detail || lastError?.message || undefined
|
|
throw _createApiError(fallbackStatus, resolveErrorMessage(fallbackStatus, fallbackDetail), fallbackDetail)
|
|
}
|
|
|
|
/**
|
|
* Create a structured ApiError object.
|
|
*/
|
|
function _createApiError(status: number, message: string, detail?: string): ApiError {
|
|
const error: ApiError = { status, message }
|
|
if (detail) error.detail = detail
|
|
return error
|
|
}
|
|
|
|
/**
|
|
* Create an AbortController for manual request cancellation.
|
|
* Useful for component unmount cleanup.
|
|
*
|
|
* @example
|
|
* const { controller, signal } = createAbortController()
|
|
* await $api('/documents', { signal })
|
|
* // On unmount:
|
|
* controller.abort()
|
|
*/
|
|
function createAbortController() {
|
|
const controller = new AbortController()
|
|
return { controller, signal: controller.signal }
|
|
}
|
|
|
|
return { $api, createAbortController }
|
|
}
|