import axios, { AxiosError, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'; import { logError } from '@/utils/errorLogger'; export interface ApiError { status: number; message: string; detail?: string; validationErrors?: Record; } export const apiClient = axios.create({ baseURL: '/api/v1', withCredentials: true, headers: { 'Content-Type': 'application/json', }, }); // CSRF token storage — persisted in sessionStorage, sent on all unsafe methods const CSRF_KEY = 'leocrm_csrf_token'; let csrfToken: string | null = sessionStorage.getItem(CSRF_KEY); export function setCsrfToken(token: string | null) { csrfToken = token; if (token) { sessionStorage.setItem(CSRF_KEY, token); } else { sessionStorage.removeItem(CSRF_KEY); } } export function getCsrfToken(): string | null { return csrfToken; } let onUnauthorized: (() => void) | null = null; let onValidationError: ((errors: Record) => void) | null = null; export function setUnauthorizedHandler(handler: () => void) { onUnauthorized = handler; } export function setValidationErrorHandler(handler: (errors: Record) => void) { onValidationError = handler; } apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // Attach CSRF token on unsafe methods const unsafe = ['post', 'put', 'patch', 'delete']; if (unsafe.includes(config.method?.toLowerCase() ?? '') && csrfToken) { config.headers['X-CSRF-Token'] = csrfToken; } return config; }, (error) => Promise.reject(error) ); // Retry interceptor for 5xx errors (max 1 retry with short delay) apiClient.interceptors.response.use( (response) => response, async (error: AxiosError) => { const config = error.config as InternalAxiosRequestConfig & { _retried?: boolean }; const status = error.response?.status || 0; // Retry 5xx errors once with a short delay if (status >= 500 && status < 600 && config && !config._retried) { config._retried = true; await new Promise((resolve) => setTimeout(resolve, 500)); return apiClient.request(config); } return Promise.reject(error); } ); // Main error-handling interceptor apiClient.interceptors.response.use( (response) => response, (error: AxiosError) => { const status = error.response?.status || 0; const data = error.response?.data as any; // Only call onUnauthorized for 401 — NOT for 403 or network errors if (status === 401) { if (onUnauthorized) { onUnauthorized(); } } // 403: Permission error — do NOT logout, let the calling code handle it // Network errors (status === 0): do NOT logout, transient issue if (status === 422 && data?.detail) { const validationErrors: Record = {}; if (Array.isArray(data.detail)) { for (const item of data.detail) { if (item.loc && item.loc.length > 1) { const field = item.loc[item.loc.length - 1]; if (!validationErrors[field]) { validationErrors[field] = []; } validationErrors[field].push(item.msg || 'Invalid value'); } } } if (onValidationError) { onValidationError(validationErrors); } } const apiError: ApiError = { status, message: data?.detail?.message || data?.message || error.message || 'An error occurred', detail: typeof data?.detail === 'string' ? data.detail : undefined, validationErrors: status === 422 ? extractValidationErrors(data) : undefined, }; // Report API errors to backend error logger (for Forgejo issue creation) // Skip 401 (auth) and 403 (permission) — these are expected, not bugs // Report: 422 (validation), 404 (not found), 5xx (server), 0 (network) if (status !== 401 && status !== 403) { logError(apiError.message, { status, url: error.config?.url, method: error.config?.method, detail: apiError.detail, }); } return Promise.reject(apiError); } ); function extractValidationErrors(data: any): Record | undefined { if (!data?.detail || !Array.isArray(data.detail)) return undefined; const errors: Record = {}; for (const item of data.detail) { if (item.loc && item.loc.length > 1) { const field = item.loc[item.loc.length - 1]; if (!errors[field]) { errors[field] = []; } errors[field].push(item.msg || 'Invalid value'); } } return Object.keys(errors).length > 0 ? errors : undefined; } export async function apiGet(url: string, config?: AxiosRequestConfig): Promise { const response = await apiClient.get(url, config); return response.data; } export async function apiPost(url: string, data?: any, config?: AxiosRequestConfig): Promise { const response = await apiClient.post(url, data, config); return response.data; } export async function apiPut(url: string, data?: any, config?: AxiosRequestConfig): Promise { const response = await apiClient.put(url, data, config); return response.data; } export async function apiPatch(url: string, data?: any, config?: AxiosRequestConfig): Promise { const response = await apiClient.patch(url, data, config); return response.data; } export async function apiDelete(url: string, config?: AxiosRequestConfig): Promise { const response = await apiClient.delete(url, config); return response.data; } export default apiClient;