319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
/**
|
|
* 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<ApiError>).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<ApiError>).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<string, unknown>;
|
|
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 <status>" 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 <status>".
|
|
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<string, unknown>)['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<string | null> {
|
|
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<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
return this.client.get<T>(url, config).then((res) => res.data);
|
|
}
|
|
|
|
public post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
|
return this.client.post<T>(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<ArrayBuffer> {
|
|
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<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
|
return this.client.put<T>(url, data, config).then((res) => res.data);
|
|
}
|
|
|
|
public delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
return this.client.delete<T>(url, config).then((res) => res.data);
|
|
}
|
|
|
|
public patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
|
return this.client.patch<T>(url, data, config).then((res) => res.data);
|
|
}
|
|
}
|
|
|
|
export const apiClient = new ApiClient();
|