/** * API Client * Centralized HTTP client with interceptors, error handling, and type safety. * (Ported from fe0/src/shared/api/client.ts — only the two intra-package import paths changed.) */ import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig, AxiosRequestConfig, } from 'axios'; import { env } from '../config/env'; import { getAccessToken, clearAccessToken, setAccessToken } from '../utils/token'; export interface ApiError { message: string; status: number; data?: any; code?: string; } /** Thrown by {@link ApiClient.postArrayBuffer} on HTTP error responses so callers can inspect status (e.g. 501). */ export class ApiArrayBufferRequestError extends Error { readonly status: number; constructor(message: string, status: number) { super(message); this.name = 'ApiArrayBufferRequestError'; this.status = status; } } /** True when the client rejected a response with 404 (Axios or normalized {@link ApiError}). */ export function isApiNotFoundError(err: unknown): boolean { if (err instanceof AxiosError && err.response?.status === 404) return true; if (err && typeof err === 'object') { const s = (err as Partial).status; if (typeof s === 'number' && s === 404) return true; } return false; } /** Used by QueryClient retry: never backoff-retry auth failures (avoids poll storms on expired sessions). */ export function isAuthQueryError(err: unknown): boolean { if (err instanceof AxiosError) { const s = err.response?.status; return s === 401 || s === 403; } if (err && typeof err === 'object') { const s = (err as Partial).status; if (typeof s === 'number') return s === 401 || s === 403; } return false; } function summarizeResponseDataForDevLog(data: unknown): unknown { if (data == null) return data; if (typeof data !== 'object') return data; const rec = data as Record; if (Array.isArray(rec.data)) { return { ...rec, data: `[${rec.data.length} items]` }; } return data; } function stringifyFastApiDetail(detail: unknown): string | undefined { if (detail == null) return undefined; if (typeof detail === 'string') return detail; if (Array.isArray(detail)) { const parts = detail .map((x) => typeof x === 'object' && x && 'msg' in x ? String((x as { msg: string }).msg) : String(x), ) .filter(Boolean); return parts.length ? parts.join(' ') : undefined; } return undefined; } /** True for the synthetic "HTTP " message the interceptor attaches to 4xx rejections. */ function isSyntheticHttpMessage(message: unknown): boolean { return typeof message === 'string' && /^HTTP \d+$/.test(message.trim()); } /** Human-readable message from ApiClient errors, ApiError-shaped objects, or raw AxiosErrors. */ export function detailFromApiError(err: unknown, fallback = 'Lỗi không xác định.'): string { if (err && typeof err === 'object') { const e = err as ApiError & { response?: { data?: { detail?: unknown; message?: string } }; }; // Prefer the FastAPI `{detail}` — on a raw AxiosError it lives at err.response.data.detail; // on a normalized ApiError it lives at err.data.detail. Either beats the synthetic "HTTP ". const detail = stringifyFastApiDetail(e.response?.data?.detail ?? e.data?.detail); if (detail) return detail; const respMessage = e.response?.data?.message; if (typeof respMessage === 'string' && respMessage.trim()) return respMessage; if (typeof e.message === 'string' && e.message.trim() && !isSyntheticHttpMessage(e.message)) { return e.message; } } if (err instanceof Error && err.message.trim() && !isSyntheticHttpMessage(err.message)) { return err.message; } return fallback; } class ApiClient { private client: AxiosInstance; constructor() { if (import.meta.env.DEV) { console.log('API Client initialized with baseURL:', env.API_URL); } this.client = axios.create({ baseURL: env.API_URL, timeout: 60000, // 60s for Ollama responses headers: { 'Content-Type': 'application/json', }, withCredentials: true, validateStatus: (status) => status < 500, // Don't throw on 4xx errors }); this.setupInterceptors(); } private setupInterceptors(): void { this.client.interceptors.request.use( (config: InternalAxiosRequestConfig) => { const token = getAccessToken(); if (token && config.headers) { config.headers.Authorization = `Bearer ${token}`; } // Multipart uploads: let the browser set Content-Type (boundary). A global // application/json default breaks FastAPI's UploadFile / Form parsing. if (config.data instanceof FormData && config.headers) { delete (config.headers as Record)['Content-Type']; } return config; }, (error) => Promise.reject(error), ); this.client.interceptors.response.use( (response) => { // Default validateStatus allows 4xx; reject here so `.then(res => res.data)` never treats // FastAPI `{ detail }` bodies as successful entity payloads. if (response.status >= 400) { const err = new AxiosError( `HTTP ${response.status}`, AxiosError.ERR_BAD_REQUEST, response.config, response.request, response, ); return Promise.reject(err); } if (import.meta.env.DEV) { console.debug('✅ API OK', { url: response.config.url, status: response.status, data: summarizeResponseDataForDevLog(response.data), }); } return response; }, async (error: AxiosError) => { if (import.meta.env.DEV) { console.error('❌ API Error:', { message: error.message, code: error.code, response: error.response ? { status: error.response.status, data: error.response.data } : null, config: { url: error.config?.url, baseURL: error.config?.baseURL, method: error.config?.method, }, }); } const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean; }; // Handle 401 - Unauthorized → try one refresh. if (error.response?.status === 401 && originalRequest && !originalRequest._retry) { originalRequest._retry = true; try { const newToken = await this.refreshToken(); if (newToken && originalRequest.headers) { originalRequest.headers.Authorization = `Bearer ${newToken}`; return this.client(originalRequest); } } catch (refreshError) { clearAccessToken(); if (window.location.pathname !== '/login') { window.location.href = '/login'; } return Promise.reject(refreshError); } } return Promise.reject(this.handleError(error)); }, ); } private async refreshToken(): Promise { try { const response = await axios.post( `${env.API_URL}/api/v1/auth/refresh`, {}, { withCredentials: true, timeout: 15_000 }, ); const { accessToken } = response.data; setAccessToken(accessToken); return accessToken; } catch (error) { if (import.meta.env.DEV) { console.error('Token refresh failed:', error); } return null; } } private handleError(error: AxiosError): ApiError { if (error.response) { const data = error.response.data as any; const detailStr = stringifyFastApiDetail(data?.detail); return { message: data?.message || detailStr || 'An error occurred', status: error.response.status, data, code: data?.code, }; } else if (error.request) { return { message: `Network error. Please check your connection. URL: ${error.config?.baseURL}${error.config?.url}`, status: 0, code: 'NETWORK_ERROR', }; } return { message: error.message || 'An unexpected error occurred', status: 0, code: 'UNKNOWN_ERROR', }; } public get(url: string, config?: AxiosRequestConfig): Promise { return this.client.get(url, config).then((res) => res.data); } public post(url: string, data?: any, config?: AxiosRequestConfig): Promise { return this.client.post(url, data, config).then((res) => res.data); } /** Binary POST (e.g. .docx). On 4xx/5xx, attempts to read JSON or text from the error body. */ public postArrayBuffer(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { const decodeErrorBody = (raw: ArrayBuffer, status: number) => { const text = new TextDecoder().decode(new Uint8Array(raw)); let message = `HTTP ${status}`; try { const j = JSON.parse(text) as { detail?: unknown; message?: string }; const d = j.detail; if (typeof d === 'string') message = d; else if (Array.isArray(d) && d[0] && typeof (d[0] as { msg?: string }).msg === 'string') { message = (d[0] as { msg: string }).msg; } else if (j.message) message = j.message; } catch { if (text.trim()) message = text; } return new ApiArrayBufferRequestError(message, status); }; return this.client .post(url, data, { ...config, responseType: 'arraybuffer' }) .then((res) => { if (res.status >= 400) { return Promise.reject(decodeErrorBody(res.data as ArrayBuffer, res.status)); } return res.data as ArrayBuffer; }) .catch((err: unknown) => { const ax = err as { response?: { status?: number; data?: ArrayBuffer } }; if (ax?.response?.data instanceof ArrayBuffer && ax.response?.status) { return Promise.reject(decodeErrorBody(ax.response.data, ax.response.status)); } if (err && typeof err === 'object' && 'status' in err) { const ae = err as ApiError; if (typeof ae.status === 'number' && ae.status >= 400) { const msg = typeof ae.message === 'string' && ae.message.trim() ? ae.message : stringifyFastApiDetail(ae.data?.detail) || `HTTP ${ae.status}`; return Promise.reject(new ApiArrayBufferRequestError(msg, ae.status)); } } return Promise.reject(err); }); } public put(url: string, data?: any, config?: AxiosRequestConfig): Promise { return this.client.put(url, data, config).then((res) => res.data); } public delete(url: string, config?: AxiosRequestConfig): Promise { return this.client.delete(url, config).then((res) => res.data); } public patch(url: string, data?: any, config?: AxiosRequestConfig): Promise { return this.client.patch(url, data, config).then((res) => res.data); } } export const apiClient = new ApiClient();