/** * Improved HTTP Client * Provides reliable, type-safe API communication with consistent error handling, * retry logic, rate limiting, and proper logging. */ import { z } from 'zod'; // @ts-expect-error - Dynamic import for environment-specific config - Vite env types const isProduction = import.meta.env.PROD; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || (isProduction ? 'https://api.bugulma.city' : ''); // Security: Ensure HTTPS in production const isSecureUrl = API_BASE_URL.startsWith('https://') || API_BASE_URL.startsWith('localhost') || API_BASE_URL.startsWith('/'); if (isProduction && !isSecureUrl) { console.error('🚨 SECURITY WARNING: API_BASE_URL must use HTTPS in production!'); if (typeof window === 'undefined') { throw new Error('API_BASE_URL must use HTTPS in production environment'); } } // API Error Response Schema export const ApiErrorResponseSchema = z .object({ error: z.string(), message: z.string().optional(), details: z.record(z.unknown()).optional(), }) .partial(); // API Error class with proper typing export class ApiError extends Error { constructor( message: string, public status: number, public data?: z.infer, public retryable: boolean = false ) { super(message); this.name = 'ApiError'; // Determine if error is retryable this.retryable = this.isRetryableError(status); } private isRetryableError(status: number): boolean { // Retry on network errors, 5xx server errors, and rate limiting return status === 0 || status >= 500 || status === 429 || status === 408 || status === 503; } } // Request configuration interface export interface RequestConfig extends RequestInit { timeout?: number; retries?: number; retryDelay?: number; skipAuth?: boolean; skipRateLimit?: boolean; } // Rate limiter class with improved logic class RateLimiter { private calls = new Map(); isAllowed(key: string, maxCalls: number, windowMs: number): boolean { const now = Date.now(); const calls = this.calls.get(key) || []; // Remove old calls outside the window const validCalls = calls.filter((call) => now - call < windowMs); this.calls.set(key, validCalls); if (validCalls.length >= maxCalls) { console.warn(`[RateLimiter] Rate limit exceeded for ${key}`); return false; } validCalls.push(now); return true; } reset(key: string): void { this.calls.delete(key); } getRemainingCalls(key: string, maxCalls: number, windowMs: number): number { const calls = this.calls.get(key) || []; const validCalls = calls.filter((call) => Date.now() - call < windowMs); return Math.max(0, maxCalls - validCalls.length); } } // Global rate limiter instance const rateLimiter = new RateLimiter(); // Default request configuration const DEFAULT_CONFIG: Required> = { timeout: 30000, // 30 seconds retries: 3, retryDelay: 1000, // 1 second base delay }; /** * Creates a delay promise for retry logic */ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Get authentication token */ function getAuthToken(): string | null { try { return localStorage.getItem('auth_token'); } catch (error) { console.warn('[HTTP Client] Failed to access localStorage:', error); return null; } } /** * Enhanced error message formatting */ function formatErrorMessage( status: number, responseText: string, parsedError?: z.infer ): string { const isDev = import.meta.env.DEV; if (status === 401) { return 'Authentication failed. Please log in again.'; } else if (status === 403) { return 'You do not have permission to perform this action.'; } else if (status === 404) { return 'The requested resource was not found.'; } else if (status >= 500) { return 'A server error occurred. Please try again later.'; } else if (parsedError?.error) { return isDev ? parsedError.error : 'An error occurred. Please try again.'; } else if (typeof responseText === 'string' && responseText.length > 0) { return isDev ? responseText : 'An error occurred. Please try again.'; } else { return `HTTP ${status}: ${status >= 500 ? 'Server Error' : 'Request Failed'}`; } } /** * Core HTTP request function with retry logic and error handling */ async function makeRequest(endpoint: string, config: RequestConfig = {}): Promise { const { timeout = DEFAULT_CONFIG.timeout, retries = DEFAULT_CONFIG.retries, retryDelay = DEFAULT_CONFIG.retryDelay, skipAuth = false, skipRateLimit = false, ...fetchConfig } = config; // Build full URL const apiEndpoint = endpoint.startsWith('/api/v1/') ? endpoint : `/api/v1${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`; const url = `${API_BASE_URL}${apiEndpoint}`; // Rate limiting check if (!skipRateLimit) { const rateLimitKey = `${fetchConfig.method || 'GET'}:${endpoint}`; if (!rateLimiter.isAllowed(rateLimitKey, 10, 60000)) { // 10 calls per minute throw new ApiError('Too many requests. Please wait before trying again.', 429); } } let lastError: Error | null = null; for (let attempt = 0; attempt <= retries; attempt++) { try { // Set up headers const headers: HeadersInit = { 'Content-Type': 'application/json', ...fetchConfig.headers, }; // Add auth token unless skipped if (!skipAuth) { const token = getAuthToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } } // Create AbortController for timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); const response = await fetch(url, { ...fetchConfig, headers, signal: controller.signal, }); clearTimeout(timeoutId); // Handle non-OK responses if (!response.ok) { let errorData: z.infer | string; try { const responseText = await response.text(); const jsonData = JSON.parse(responseText); errorData = ApiErrorResponseSchema.parse(jsonData); } catch { try { errorData = await response.text(); } catch { errorData = `HTTP ${response.status}: ${response.statusText}`; } } const errorMessage = formatErrorMessage( response.status, typeof errorData === 'string' ? errorData : '', typeof errorData === 'object' ? errorData : undefined ); const apiError = new ApiError( errorMessage, response.status, typeof errorData === 'object' ? errorData : undefined ); // Don't retry on client errors (4xx) except 408, 429 if (!apiError.retryable || response.status < 400) { throw apiError; } lastError = apiError; // Wait before retrying (exponential backoff) if (attempt < retries) { const backoffDelay = retryDelay * Math.pow(2, attempt); console.warn( `[HTTP Client] Request failed (attempt ${attempt + 1}/${retries + 1}), retrying in ${backoffDelay}ms:`, errorMessage ); await delay(backoffDelay); continue; } throw apiError; } // Handle 204 No Content if (response.status === 204) { return undefined as T; } // Parse JSON response const data = await response.json(); if (import.meta.env.DEV) { console.log(`[HTTP Client] ${fetchConfig.method || 'GET'} ${endpoint} →`, { status: response.status, data: Array.isArray(data) ? `${data.length} items` : typeof data === 'object' ? Object.keys(data) : typeof data, }); } return data; } catch (error) { lastError = error as Error; // Don't retry on abort or non-retryable errors if (error instanceof ApiError && !error.retryable) { throw error; } if (error.name === 'AbortError') { throw new ApiError('Request timed out', 408); } // If this is the last attempt, throw the error if (attempt === retries) { if (error instanceof ApiError) { throw error; } // Network or other error const isNetworkError = !navigator.onLine || error.message.includes('fetch'); const status = isNetworkError ? 0 : 500; throw new ApiError( isNetworkError ? 'Network connection failed. Please check your internet connection.' : 'An unexpected error occurred.', status, undefined, true // Network errors are retryable ); } // Wait before retrying const backoffDelay = retryDelay * Math.pow(2, attempt); console.warn( `[HTTP Client] Network error (attempt ${attempt + 1}/${retries + 1}), retrying in ${backoffDelay}ms:`, error.message ); await delay(backoffDelay); } } // This should never be reached, but just in case throw lastError || new ApiError('Request failed after all retries', 500); } /** * HTTP Client methods with consistent interface */ export const httpClient = { /** * GET request */ get: (endpoint: string, config?: RequestConfig) => makeRequest(endpoint, { ...config, method: 'GET' }), /** * POST request */ post: (endpoint: string, data?: unknown, config?: RequestConfig) => { // Handle FormData (file uploads) if (data instanceof FormData) { return makeRequest(endpoint, { ...config, method: 'POST', body: data, headers: { // Don't set Content-Type for FormData - browser sets it automatically ...config?.headers, }, }); } // Regular JSON request return makeRequest(endpoint, { ...config, method: 'POST', body: data ? JSON.stringify(data) : undefined, }); }, /** * PUT request */ put: (endpoint: string, data?: unknown, config?: RequestConfig) => makeRequest(endpoint, { ...config, method: 'PUT', body: data ? JSON.stringify(data) : undefined, }), /** * PATCH request */ patch: (endpoint: string, data?: unknown, config?: RequestConfig) => makeRequest(endpoint, { ...config, method: 'PATCH', body: data ? JSON.stringify(data) : undefined, }), /** * DELETE request */ delete: (endpoint: string, config?: RequestConfig) => makeRequest(endpoint, { ...config, method: 'DELETE' }), /** * Login request (no auth required) */ login: async ( email: string, password: string ): Promise<{ token: string; user: { id: string; email: string; name: string; role: string }; }> => { const response = await fetch(`${API_BASE_URL}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email, password }), }); if (!response.ok) { let errorData: any; try { errorData = await response.json(); } catch { errorData = { error: 'Login failed' }; } throw new ApiError(errorData.error || 'Login failed', response.status, errorData); } return response.json(); }, /** * Utility methods */ utils: { getRateLimitRemaining: (key: string) => rateLimiter.getRemainingCalls(key, 10, 60000), resetRateLimit: (key: string) => rateLimiter.reset(key), }, }; // Legacy compatibility - keep the old apiClient for now export const apiClient = { get: httpClient.get, post: httpClient.post, delete: httpClient.delete, login: httpClient.login, sanitizeInput: (input: string) => input .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') .replace(/\//g, '/'), isValidEmail: (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email) && email.length <= 254, validateInput: ( input: string, options: { minLength?: number; maxLength?: number; allowSpecialChars?: boolean; allowNumbers?: boolean; } = {} ) => { const { minLength = 1, maxLength = 1000, allowSpecialChars = true, allowNumbers = true, } = options; if (!input || typeof input !== 'string') { return { isValid: false, error: 'Input must be a non-empty string' }; } if (input.length < minLength) { return { isValid: false, error: `Input must be at least ${minLength} characters` }; } if (input.length > maxLength) { return { isValid: false, error: `Input must be no more than ${maxLength} characters` }; } if (!allowSpecialChars && /[^a-zA-Z\s]/u.test(input)) { return { isValid: false, error: 'Special characters are not allowed' }; } if (!allowNumbers && /\d/u.test(input)) { return { isValid: false, error: 'Numbers are not allowed' }; } return { isValid: true }; }, };