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
text/typescript
/**
* @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;
}