UNPKG

mcp-quiz-server

Version:

🧠 AI-Powered Quiz Management via Model Context Protocol (MCP) - Create, manage, and take quizzes directly from VS Code, Claude, and other AI agents.

529 lines (477 loc) • 14.1 kB
/** * @fileoverview API Client - Unified HTTP Communication Layer * @version 1.0.0 * @since 2025-08-04 * @lastUpdated 2025-08-04 * @module ApiClient * @description Centralized API client with authentication and error handling * @contributors Claude Code Agent * @dependencies AuthService * @requirements REQ-API-001 (Unified API client) * @testCoverage HTTP requests, authentication, error handling */ import { AuthService } from './AuthService'; /** * API Client * * @description Centralized HTTP client with automatic authentication, * error handling, and request/response transformation. * * @example * ```typescript * const apiClient = ApiClient.getInstance(); * const data = await apiClient.get('/api/users'); * ``` * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-API-001 (Unified API client), REQ-AUTH-002 (Authenticated requests) */ export class ApiClient { private static instance: ApiClient; private authService: AuthService; private baseURL: string; constructor(baseURL: string = '') { this.baseURL = baseURL; this.authService = AuthService.getInstance(); } /** * Get singleton instance * * @description Returns singleton instance following established pattern * * @returns {ApiClient} Singleton instance * * @since 2025-08-04 * @author Claude Code Agent */ static getInstance(): ApiClient { if (!ApiClient.instance) { ApiClient.instance = new ApiClient(); } return ApiClient.instance; } /** * GET request * * @description Performs authenticated GET request * * @param {string} url - Request URL * @param {RequestConfig} config - Optional request configuration * @returns {Promise<ApiResponse>} Response data * * @since 2025-08-04 * @author Claude Code Agent */ async get(url: string, config: RequestConfig = {}): Promise<ApiResponse> { return this.request(url, { ...config, method: 'GET' }); } /** * POST request * * @description Performs authenticated POST request * * @param {string} url - Request URL * @param {any} data - Request body data * @param {RequestConfig} config - Optional request configuration * @returns {Promise<ApiResponse>} Response data * * @since 2025-08-04 * @author Claude Code Agent */ async post(url: string, data?: any, config: RequestConfig = {}): Promise<ApiResponse> { return this.request(url, { ...config, method: 'POST', data }); } /** * PUT request * * @description Performs authenticated PUT request * * @param {string} url - Request URL * @param {any} data - Request body data * @param {RequestConfig} config - Optional request configuration * @returns {Promise<ApiResponse>} Response data * * @since 2025-08-04 * @author Claude Code Agent */ async put(url: string, data?: any, config: RequestConfig = {}): Promise<ApiResponse> { return this.request(url, { ...config, method: 'PUT', data }); } /** * DELETE request * * @description Performs authenticated DELETE request * * @param {string} url - Request URL * @param {RequestConfig} config - Optional request configuration * @returns {Promise<ApiResponse>} Response data * * @since 2025-08-04 * @author Claude Code Agent */ async delete(url: string, config: RequestConfig = {}): Promise<ApiResponse> { return this.request(url, { ...config, method: 'DELETE' }); } /** * PATCH request * * @description Performs authenticated PATCH request * * @param {string} url - Request URL * @param {any} data - Request body data * @param {RequestConfig} config - Optional request configuration * @returns {Promise<ApiResponse>} Response data * * @since 2025-08-04 * @author Claude Code Agent */ async patch(url: string, data?: any, config: RequestConfig = {}): Promise<ApiResponse> { return this.request(url, { ...config, method: 'PATCH', data }); } /** * Unified request method * * @description Core request method with authentication and error handling * * @param {string} url - Request URL * @param {RequestConfig} config - Request configuration * @returns {Promise<ApiResponse>} Response data * * @since 2025-08-04 * @author Claude Code Agent */ private async request(url: string, config: RequestConfig): Promise<ApiResponse> { const fullURL = this.buildURL(url); const requestConfig = await this.buildRequestConfig(config); try { const response = await fetch(fullURL, requestConfig); return await this.handleResponse(response); } catch (error) { throw this.handleError(error); } } /** * Build full URL * * @description Constructs full URL from base URL and path * * @param {string} url - Request path * @returns {string} Full URL * * @since 2025-08-04 * @author Claude Code Agent */ private buildURL(url: string): string { if (url.startsWith('http://') || url.startsWith('https://')) { return url; } const base = this.baseURL || window.location.origin; const cleanBase = base.replace(/\/$/, ''); const cleanPath = url.replace(/^\//, ''); return `${cleanBase}/${cleanPath}`; } /** * Build request configuration * * @description Prepares fetch configuration with authentication * * @param {RequestConfig} config - Input configuration * @returns {Promise<RequestInit>} Fetch configuration * * @since 2025-08-04 * @author Claude Code Agent */ private async buildRequestConfig(config: RequestConfig): Promise<RequestInit> { const headers = new Headers(config.headers); // Add authentication if (!config.skipAuth) { const authHeader = this.authService.getAuthHeader(); if (authHeader) { headers.set('Authorization', authHeader); } } // Add content type for data requests if (config.data && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } const requestConfig: RequestInit = { method: config.method || 'GET', headers, credentials: 'include', // Include cookies for hybrid auth }; // Add body for data requests if (config.data) { if (config.data instanceof FormData) { requestConfig.body = config.data; headers.delete('Content-Type'); // Let browser set multipart boundary } else { requestConfig.body = JSON.stringify(config.data); } } // Add signal for request cancellation if (config.signal) { requestConfig.signal = config.signal; } return requestConfig; } /** * Handle response * * @description Processes fetch response and handles errors * * @param {Response} response - Fetch response * @returns {Promise<ApiResponse>} Processed response * * @since 2025-08-04 * @author Claude Code Agent */ private async handleResponse(response: Response): Promise<ApiResponse> { const contentType = response.headers.get('Content-Type') || ''; const isJSON = contentType.includes('application/json'); let data: any; try { if (isJSON) { data = await response.json(); } else { data = await response.text(); } } catch (error) { data = null; } // Handle authentication errors if (response.status === 401) { await this.handleUnauthorized(); throw new ApiError('Authentication required', 401, data); } // Handle other HTTP errors if (!response.ok) { const message = this.extractErrorMessage(data); throw new ApiError(message, response.status, data); } return { data, status: response.status, statusText: response.statusText, headers: this.responseHeadersToObject(response.headers), }; } /** * Handle unauthorized response * * @description Attempts token refresh or redirects to login * * @since 2025-08-04 * @author Copilot/Jorge */ private async handleUnauthorized(): Promise<void> { try { // Attempt to refresh token await this.authService.refreshToken(); } catch (error) { // Refresh failed, redirect to login await this.authService.logout(); // Only redirect if not already on auth page if (!window.location.pathname.includes('/auth/')) { window.location.href = '/auth/login.html'; } } } /** * Extract error message from response * * @description Extracts user-friendly error message from API response * * @param {any} data - Response data * @returns {string} Error message * * @since 2025-08-04 * @author Claude Code Agent */ private extractErrorMessage(data: any): string { if (!data) return 'Request failed'; // Handle different error formats if (typeof data === 'string') { return data; } if (data.error) { if (typeof data.error === 'string') { return data.error; } if (data.error.message) { return data.error.message; } } if (data.message) { return data.message; } return 'Request failed'; } /** * Handle request error * * @description Processes request errors (network, etc.) * * @param {any} error - Error object * @returns {ApiError} Processed error * * @since 2025-08-04 * @author Claude Code Agent */ private handleError(error: any): ApiError { if (error instanceof ApiError) { return error; } if (error.name === 'AbortError') { return new ApiError('Request cancelled', 0, error); } if (error.name === 'TypeError' && error.message.includes('fetch')) { return new ApiError('Network error. Please check your connection.', 0, error); } return new ApiError(error.message || 'An unexpected error occurred', 0, error); } /** * Convert response headers to object * * @description Converts Headers object to plain object * * @param {Headers} headers - Response headers * @returns {Record<string, string>} Headers object * * @since 2025-08-04 * @author Claude Code Agent */ private responseHeadersToObject(headers: Headers): Record<string, string> { const result: Record<string, string> = {}; headers.forEach((value, key) => { result[key] = value; }); return result; } /** * Create request with timeout * * @description Creates request with automatic timeout * * @param {string} url - Request URL * @param {RequestConfig} config - Request configuration * @param {number} timeout - Timeout in milliseconds * @returns {Promise<ApiResponse>} Response data * * @since 2025-08-04 * @author Claude Code Agent */ async requestWithTimeout( url: string, config: RequestConfig, timeout: number = 30000 ): Promise<ApiResponse> { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await this.request(url, { ...config, signal: controller.signal, }); clearTimeout(timeoutId); return response; } catch (error) { clearTimeout(timeoutId); throw error; } } /** * Upload file * * @description Uploads file with progress tracking * * @param {string} url - Upload URL * @param {File} file - File to upload * @param {UploadConfig} config - Upload configuration * @returns {Promise<ApiResponse>} Upload response * * @since 2025-08-04 * @author Claude Code Agent */ async uploadFile(url: string, file: File, config: UploadConfig = {}): Promise<ApiResponse> { const formData = new FormData(); formData.append(config.fieldName || 'file', file); // Add additional fields if (config.fields) { Object.entries(config.fields).forEach(([key, value]) => { formData.append(key, value); }); } return this.post(url, formData, { skipAuth: config.skipAuth, headers: config.headers, }); } /** * Set base URL * * @description Updates the base URL for all requests * * @param {string} baseURL - New base URL * * @since 2025-08-04 * @author Claude Code Agent */ setBaseURL(baseURL: string): void { this.baseURL = baseURL; } /** * Get base URL * * @description Returns current base URL * * @returns {string} Current base URL * * @since 2025-08-04 * @author Claude Code Agent */ getBaseURL(): string { return this.baseURL; } } /** * API Error Class * * @description Custom error class for API-related errors * * @since 2025-08-04 * @author Claude Code Agent */ export class ApiError extends Error { public status: number; public data: any; constructor(message: string, status: number = 0, data: any = null) { super(message); this.name = 'ApiError'; this.status = status; this.data = data; } } /** * Type Definitions */ export interface RequestConfig { method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; headers?: Record<string, string>; data?: any; skipAuth?: boolean; signal?: AbortSignal; } export interface ApiResponse { data: any; status: number; statusText: string; headers: Record<string, string>; } export interface UploadConfig { fieldName?: string; fields?: Record<string, string>; skipAuth?: boolean; headers?: Record<string, string>; onProgress?: (progress: number) => void; }