/** * API service layer for backend communication. * Provides functions for all API endpoints with error handling. * Includes JWT token management and automatic token refresh. * * Requirements: 10.1, 10.3, 2.1, 3.1, 4.1, 4.4 */ import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; import type { Project } from '../atoms/project-atoms'; import type { Task } from '../atoms/task-atoms'; import type { Annotation } from '../atoms/annotation-atoms'; import { toast } from './toast'; /** * API base URL - defaults to localhost:8002 for development */ const API_BASE_URL = process.env.NX_API_BASE_URL || 'http://localhost:8002'; /** * Axios instance with default configuration */ const apiClient: AxiosInstance = axios.create({ baseURL: API_BASE_URL, timeout: 30000, headers: { 'Content-Type': 'application/json', }, }); /** * Flag to prevent multiple simultaneous token refresh requests */ let isRefreshing = false; /** * Queue of failed requests waiting for token refresh */ let failedQueue: Array<{ resolve: (value?: any) => void; reject: (error?: any) => void; }> = []; /** * Process queued requests after token refresh */ const processQueue = (error: any = null) => { failedQueue.forEach((promise) => { if (error) { promise.reject(error); } else { promise.resolve(); } }); failedQueue = []; }; /** * Get stored authentication tokens from localStorage */ const getStoredTokens = () => { try { const tokensStr = localStorage.getItem('auth_tokens'); return tokensStr ? JSON.parse(tokensStr) : null; } catch { return null; } }; /** * Update stored authentication tokens in localStorage */ const updateStoredTokens = (tokens: any) => { localStorage.setItem('auth_tokens', JSON.stringify(tokens)); }; /** * Clear stored authentication data */ const clearStoredAuth = () => { localStorage.removeItem('auth_tokens'); localStorage.removeItem('current_user'); }; /** * Request interceptor to automatically attach JWT token */ apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // Skip token attachment for auth endpoints if ( config.url?.includes('/api/auth/register') || config.url?.includes('/api/auth/login') || config.url?.includes('/api/auth/refresh') ) { return config; } // Get tokens from localStorage const tokens = getStoredTokens(); // Attach access token to Authorization header if (tokens?.access_token) { config.headers.Authorization = `Bearer ${tokens.access_token}`; } return config; }, (error) => { return Promise.reject(error); } ); /** * Response interceptor for error handling and automatic token refresh */ apiClient.interceptors.response.use( (response) => response, async (error: AxiosError) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean; }; // Handle network errors (CORS, connection refused, etc.) if (!error.response) { console.error('Network Error:', error.message); // Check if it's a CORS error or connection error if (error.message === 'Network Error' || error.code === 'ERR_NETWORK') { // Check if user has auth tokens - if yes, might be a CORS issue with expired token const tokens = getStoredTokens(); if (tokens) { // Clear auth data and redirect to login clearStoredAuth(); toast.error('网络连接失败或登录已过期,请重新登录', '连接错误', 2000); setTimeout(() => { window.location.href = '/login'; }, 500); } else { toast.error('无法连接到服务器,请检查网络连接', '网络错误'); } } return Promise.reject({ status: 0, message: error.message || '网络错误', originalError: error, }); } // Handle 401 Unauthorized errors (token expired or invalid) if (error.response?.status === 401) { const errorData = error.response.data as any; // If not a retry and error is due to token expiration, try to refresh if (!originalRequest._retry && errorData?.error_type === 'token_expired') { // Prevent infinite retry loop originalRequest._retry = true; // If already refreshing, queue this request if (isRefreshing) { return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); }) .then(() => { // Retry original request with new token return apiClient(originalRequest); }) .catch((err) => { return Promise.reject(err); }); } // Start token refresh process isRefreshing = true; try { const tokens = getStoredTokens(); if (!tokens?.refresh_token) { throw new Error('No refresh token available'); } // Call refresh token endpoint const response = await axios.post( `${API_BASE_URL}/api/auth/refresh`, { refresh_token: tokens.refresh_token, } ); const newTokens = { access_token: response.data.access_token, refresh_token: response.data.refresh_token, token_type: response.data.token_type, }; // Update stored tokens updateStoredTokens(newTokens); // Update user info if provided if (response.data.user) { localStorage.setItem( 'current_user', JSON.stringify(response.data.user) ); } // Process queued requests processQueue(); // Retry original request with new token originalRequest.headers.Authorization = `Bearer ${newTokens.access_token}`; return apiClient(originalRequest); } catch (refreshError) { // Token refresh failed - clear auth data and redirect to login processQueue(refreshError); clearStoredAuth(); // Show error message and wait a bit before redirecting toast.error('登录已过期,请重新登录', '认证失败', 2000); // Redirect to login page after a short delay to allow toast to show setTimeout(() => { window.location.href = '/login'; }, 500); return Promise.reject(refreshError); } finally { isRefreshing = false; } } else { // 401 error but not token expiration (invalid credentials, etc.) // OR token refresh already attempted but still failed // Clear auth data and redirect to login clearStoredAuth(); // Show error message and wait a bit before redirecting toast.error('认证失败,请重新登录', '认证失败', 2000); // Redirect to login page after a short delay to allow toast to show setTimeout(() => { window.location.href = '/login'; }, 500); return Promise.reject(error); } } // Extract error message from response let errorMessage = '发生了意外错误'; if (error.response?.data) { const data = error.response.data as any; // Handle FastAPI validation errors (array of error objects) if (Array.isArray(data.detail)) { // Format validation errors into readable message errorMessage = data.detail .map((err: any) => { const field = err.loc?.join('.') || '字段'; return `${field}: ${err.msg}`; }) .join('; '); } // Handle simple string error message else if (typeof data.detail === 'string') { errorMessage = data.detail; } // Handle object error message else if (typeof data.detail === 'object' && data.detail !== null) { errorMessage = JSON.stringify(data.detail); } // Fallback to error message else if (data.message) { errorMessage = data.message; } } else if (error.message) { errorMessage = error.message; } // Log error for debugging console.error('API Error:', { url: error.config?.url, method: error.config?.method, status: error.response?.status, message: errorMessage, originalData: error.response?.data, }); // Show error toast (skip for 401 errors as they're handled above with their own messages) if (error.response?.status !== 401) { // Determine toast type based on status code if (error.response?.status === 403) { toast.warning(errorMessage, '权限不足'); } else if (error.response?.status === 404) { toast.warning(errorMessage, '资源不存在'); } else if (error.response?.status && error.response.status >= 500) { toast.error(errorMessage, '服务器错误'); } else { toast.error(errorMessage, '请求失败'); } } // Return a rejected promise with formatted error return Promise.reject({ status: error.response?.status, message: errorMessage, originalError: error, }); } ); // ============================================================================ // Project API Functions // ============================================================================ /** * Project creation data */ export interface ProjectCreateData { name: string; description: string; config: string; } /** * Project update data */ export interface ProjectUpdateData { name?: string; description?: string; config?: string; } /** * List all projects */ export async function listProjects(): Promise { const response = await apiClient.get('/api/projects'); return response.data; } /** * Create a new project */ export async function createProject( data: ProjectCreateData ): Promise { const response = await apiClient.post('/api/projects', data); return response.data; } /** * Get project by ID */ export async function getProject(projectId: string): Promise { const response = await apiClient.get(`/api/projects/${projectId}`); return response.data; } /** * Update project */ export async function updateProject( projectId: string, data: ProjectUpdateData ): Promise { const response = await apiClient.put( `/api/projects/${projectId}`, data ); return response.data; } /** * Delete project */ export async function deleteProject(projectId: string): Promise { await apiClient.delete(`/api/projects/${projectId}`); } /** * Get tasks for a specific project */ export async function getProjectTasks(projectId: string): Promise { return listTasks({ project_id: projectId }); } // ============================================================================ // Task API Functions // ============================================================================ /** * Task creation data */ export interface TaskCreateData { project_id: string; name: string; data: Record; assigned_to?: string | null; } /** * Task update data */ export interface TaskUpdateData { name?: string; data?: Record; status?: 'pending' | 'in_progress' | 'completed'; assigned_to?: string | null; } /** * Task list filters */ export interface TaskListFilters { project_id?: string; status?: string; assigned_to?: string; } /** * List all tasks with optional filters */ export async function listTasks(filters?: TaskListFilters): Promise { const params = new URLSearchParams(); if (filters?.project_id) params.append('project_id', filters.project_id); if (filters?.status) params.append('status', filters.status); if (filters?.assigned_to) params.append('assigned_to', filters.assigned_to); const response = await apiClient.get('/api/tasks', { params }); return response.data; } /** * Create a new task */ export async function createTask(data: TaskCreateData): Promise { const response = await apiClient.post('/api/tasks', data); return response.data; } /** * Get task by ID */ export async function getTask(taskId: string): Promise { const response = await apiClient.get(`/api/tasks/${taskId}`); return response.data; } /** * Update task */ export async function updateTask( taskId: string, data: TaskUpdateData ): Promise { const response = await apiClient.put(`/api/tasks/${taskId}`, data); return response.data; } /** * Delete task */ export async function deleteTask(taskId: string): Promise { await apiClient.delete(`/api/tasks/${taskId}`); } // ============================================================================ // Annotation API Functions // ============================================================================ /** * Annotation creation data */ export interface AnnotationCreateData { task_id: string; user_id: string; result: Record; } /** * Annotation update data */ export interface AnnotationUpdateData { result: Record; } /** * Annotation list filters */ export interface AnnotationListFilters { task_id?: string; user_id?: string; } /** * List all annotations with optional filters */ export async function listAnnotations( filters?: AnnotationListFilters ): Promise { const params = new URLSearchParams(); if (filters?.task_id) params.append('task_id', filters.task_id); if (filters?.user_id) params.append('user_id', filters.user_id); const response = await apiClient.get('/api/annotations', { params, }); return response.data; } /** * Create a new annotation */ export async function createAnnotation( data: AnnotationCreateData ): Promise { const response = await apiClient.post('/api/annotations', data); return response.data; } /** * Get annotation by ID */ export async function getAnnotation(annotationId: string): Promise { const response = await apiClient.get( `/api/annotations/${annotationId}` ); return response.data; } /** * Update annotation */ export async function updateAnnotation( annotationId: string, data: AnnotationUpdateData ): Promise { const response = await apiClient.put( `/api/annotations/${annotationId}`, data ); return response.data; } /** * Get annotations by task ID */ export async function getTaskAnnotations(taskId: string): Promise { const response = await apiClient.get( `/api/annotations/tasks/${taskId}/annotations` ); return response.data; } /** * Export the configured axios instance for advanced usage */ export { apiClient };