/** * Real API client for connecting to the FastAPI backend. * Handles auth tokens, request/response encryption, snake_case→camelCase conversion. */ import { encrypt, decrypt, isCryptoAvailable } from './crypto'; const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable(); interface RequestOptions { method?: string; body?: unknown; auth?: boolean; encryptBody?: boolean; } /** Convert snake_case string to camelCase */ function toCamel(s: string): string { return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); } /** Recursively convert all snake_case keys in an object/array to camelCase */ function keysToCamel(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(keysToCamel); if (obj !== null && typeof obj === 'object') { return Object.fromEntries( Object.entries(obj as Record).map(([k, v]) => [toCamel(k), keysToCamel(v)]) ); } return obj; } function getToken(): string | null { return localStorage.getItem('auth_token'); } export function setToken(token: string): void { localStorage.setItem('auth_token', token); } export function clearToken(): void { localStorage.removeItem('auth_token'); } /** Try to decrypt response data, return null if it fails */ async function tryDecrypt(data: string): Promise { try { return await decrypt(data); } catch { return null; } } export async function apiRequest(path: string, options: RequestOptions = {}): Promise { const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION } = options; const headers: Record = { 'Content-Type': 'application/json', }; if (auth) { const token = getToken(); if (token) headers['Authorization'] = `Bearer ${token}`; } let bodyStr: string | undefined; if (body !== undefined) { const json = JSON.stringify(body); if (encryptBody) { headers['X-Encrypted'] = 'true'; bodyStr = JSON.stringify({ data: await encrypt(json) }); } else { bodyStr = json; } } const res = await fetch(`${BASE_URL}/api${path}`, { method, headers, body: bodyStr, }); if (res.status === 204) return undefined as T; const text = await res.text(); if (!text) return undefined as T; // Parse the response body let parsed: any; try { parsed = JSON.parse(text); } catch { throw new Error(`响应解析失败 (${res.status})`); } // If the response is encrypted, decrypt it const hasEncryptedData = parsed && typeof parsed.data === 'string'; if (hasEncryptedData && encryptBody) { const decrypted = await tryDecrypt(parsed.data); if (decrypted !== null) { parsed = JSON.parse(decrypted); } // If decryption fails, fall through — parsed still has {data: "..."} // which means the response was NOT actually encrypted (just has a "data" field) } // Handle error responses if (!res.ok) { const msg = parsed?.detail || `请求失败 (${res.status})`; if (res.status === 401) { clearToken(); // Only redirect to login if not already on login page const currentPath = window.location.pathname; if (currentPath !== '/login') { window.location.href = '/login'; } } throw new Error(msg); } return keysToCamel(parsed) as T; } // Convenience methods export const api = { get: (path: string, auth = true) => apiRequest(path, { auth }), post: (path: string, body?: unknown, auth = true) => apiRequest(path, { method: 'POST', body, auth }), put: (path: string, body?: unknown, auth = true) => apiRequest(path, { method: 'PUT', body, auth }), delete: (path: string, auth = true) => apiRequest(path, { method: 'DELETE', auth }), };