diff --git a/src/lib/api/adapters/__tests__/base.adapter.test.ts b/src/lib/api/adapters/__tests__/base.adapter.test.ts new file mode 100644 index 00000000..5e6b8370 --- /dev/null +++ b/src/lib/api/adapters/__tests__/base.adapter.test.ts @@ -0,0 +1,595 @@ +/** + * Tests for the BaseAdapter class + * + * These tests verify the core functionality of the adapter system, + * including request/response transformation, error handling, and caching. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { BaseAdapter } from '../base.adapter' +import { ApiError, NetworkError } from '../errors' +import type { AdapterOptions } from '../types' + +/** + * Test adapter implementation for testing BaseAdapter functionality + */ +class TestAdapter extends BaseAdapter { + constructor(options?: AdapterOptions) { + super(options) + } + + // Expose protected methods for testing + async testRequest(path: string, options?: any): Promise { + return this.request(path, options) + } + + testTransformResponse(data: any): T { + return this.transformResponse(data) + } + + testTransformRequest(data: any): any { + return this.transformRequest(data) + } + + testClearCache(pattern?: string): void { + this.clearCache(pattern) + } +} + +/** + * Fast test adapter with minimal retry delays for testing + */ +class FastRetryAdapter extends BaseAdapter { + constructor(options?: AdapterOptions) { + super(options) + } + + // Override delay for instant retries in tests + protected delay(ms: number): Promise { + // Instant return for fast tests + return Promise.resolve() + } + + async testRequest(path: string, options?: any): Promise { + return this.request(path, options) + } +} + +describe('BaseAdapter', () => { + let adapter: TestAdapter + let originalFetch: typeof global.fetch + + beforeEach(() => { + // Save original fetch + originalFetch = global.fetch + + // Create a new adapter instance for each test + adapter = new TestAdapter({ + baseURL: 'https://api.example.com', + timeout: 5000 + }) + }) + + afterEach(() => { + // Restore original fetch + global.fetch = originalFetch + + // Cancel any pending requests + adapter.cancelAll() + }) + + describe('constructor', () => { + it('should initialize with default options', () => { + const defaultAdapter = new TestAdapter() + expect(defaultAdapter).toBeDefined() + }) + + it('should accept custom options', () => { + const customAdapter = new TestAdapter({ + baseURL: 'https://custom.api.com', + timeout: 10000, + retries: 5, + cacheTime: 60000 + }) + expect(customAdapter).toBeDefined() + }) + }) + + describe('transformResponse', () => { + it('should transform snake_case to camelCase', () => { + const input = { + user_name: 'test', + created_at: '2024-01-01', + nested_object: { + inner_field: 'value' + } + } + + const result = adapter.testTransformResponse(input) + + expect(result).toEqual({ + userName: 'test', + createdAt: '2024-01-01', + nestedObject: { + innerField: 'value' + } + }) + }) + + it('should handle null and undefined values', () => { + expect(adapter.testTransformResponse(null)).toBeNull() + expect(adapter.testTransformResponse(undefined)).toBeUndefined() + }) + + it('should transform arrays', () => { + const input = [ + { user_id: 1, user_name: 'Alice' }, + { user_id: 2, user_name: 'Bob' } + ] + + const result = adapter.testTransformResponse(input) + + expect(result).toEqual([ + { userId: 1, userName: 'Alice' }, + { userId: 2, userName: 'Bob' } + ]) + }) + }) + + describe('transformRequest', () => { + it('should transform camelCase to snake_case', () => { + const input = { + userName: 'test', + createdAt: '2024-01-01', + nestedObject: { + innerField: 'value' + } + } + + const result = adapter.testTransformRequest(input) + + expect(result).toEqual({ + user_name: 'test', + created_at: '2024-01-01', + nested_object: { + inner_field: 'value' + } + }) + }) + + it('should handle null and undefined values', () => { + expect(adapter.testTransformRequest(null)).toBeNull() + expect(adapter.testTransformRequest(undefined)).toBeUndefined() + }) + }) + + describe('request', () => { + it('should make a successful GET request', async () => { + // Mock successful response + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, user_name: 'test' }) + }) + + const result = await adapter.testRequest('/users/1') + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.example.com/users/1', + expect.objectContaining({ + credentials: 'include', + headers: expect.objectContaining({ + 'Content-Type': 'application/json' + }) + }) + ) + + // Should transform the response + expect(result).toEqual({ success: true, userName: 'test' }) + }) + + it('should make a POST request with body', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ id: 1, created: true }) + }) + + const body = { userName: 'test', email: 'test@example.com' } + + await adapter.testRequest('/users', { + method: 'POST', + body: JSON.stringify(body) + }) + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.example.com/users', + expect.objectContaining({ + method: 'POST', + // Body should be transformed to snake_case + body: JSON.stringify({ user_name: 'test', email: 'test@example.com' }) + }) + ) + }) + + it('should add query parameters to URL', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }) + }) + + await adapter.testRequest('/search', { + params: { + query: 'test', + page: 2, + filters: [1, 2, 3] + } + }) + + const calledUrl = (global.fetch as any).mock.calls[0][0] + expect(calledUrl).toContain('query=test') + expect(calledUrl).toContain('page=2') + expect(calledUrl).toContain('filters=1') + expect(calledUrl).toContain('filters=2') + expect(calledUrl).toContain('filters=3') + }) + + it('should handle error responses', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ error: 'User not found' }) + }) + + await expect(adapter.testRequest('/users/999')).rejects.toMatchObject({ + name: 'AdapterError', + code: 'NOT_FOUND', + status: 404, + message: 'User not found' + }) + }) + + it('should handle network errors', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')) + + await expect(adapter.testRequest('/users')).rejects.toMatchObject({ + name: 'AdapterError', + code: 'UNKNOWN_ERROR', + status: 0 + }) + }) + + it('should handle request cancellation', async () => { + // Mock a delayed response that respects AbortSignal + global.fetch = vi.fn().mockImplementation((url, options) => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + resolve({ + ok: true, + json: async () => ({ data: 'test' }) + }) + }, 100) + + // Listen for abort signal + if (options?.signal) { + options.signal.addEventListener('abort', () => { + clearTimeout(timeout) + const error = new Error('The operation was aborted') + error.name = 'AbortError' + reject(error) + }) + } + }) + ) + + // Start request + const promise = adapter.testRequest('/slow') + + // Cancel immediately + adapter.cancelAll() + + // Should throw cancelled error + await expect(promise).rejects.toMatchObject({ + code: 'CANCELLED', + message: 'Request was cancelled' + }) + }) + + it('should cancel duplicate requests to the same endpoint', async () => { + let abortHandlers: Array<() => void> = [] + + // Mock sequential requests with proper abort handling + let callCount = 0 + global.fetch = vi.fn().mockImplementation((url, options) => { + callCount++ + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + resolve({ + ok: true, + json: async () => ({ data: `response-${callCount}` }) + }) + }, 50) + + // Store abort handler + if (options?.signal) { + const handler = () => { + clearTimeout(timeout) + const error = new Error('The operation was aborted') + error.name = 'AbortError' + reject(error) + } + options.signal.addEventListener('abort', handler) + abortHandlers[callCount - 1] = handler + } + }) + }) + + // Start first request + const promise1 = adapter.testRequest('/api/data') + + // Wait a bit to ensure first request is in progress + await new Promise(resolve => setTimeout(resolve, 10)) + + // Start second request to same endpoint (should cancel first) + const promise2 = adapter.testRequest('/api/data') + + // First should be cancelled + await expect(promise1).rejects.toMatchObject({ + code: 'CANCELLED' + }) + + // Second should succeed + const result = await promise2 + expect(result).toEqual({ data: 'response-2' }) + }) + }) + + describe('retry logic', () => { + it('should retry on network errors', async () => { + let attempts = 0 + + global.fetch = vi.fn().mockImplementation(async () => { + attempts++ + if (attempts < 3) { + // First two attempts: network error + const error = new Error('Network error') + error.name = 'NetworkError' + throw error + } else { + // Third attempt: succeed + return { + ok: true, + json: async () => ({ success: true }) + } + } + }) + + const quickAdapter = new FastRetryAdapter({ + baseURL: 'https://api.example.com', + retries: 3 + }) + + // This should retry and eventually succeed + const result = await quickAdapter.testRequest('/retry') + + // Verify it retried + expect(attempts).toBe(3) + expect(result).toEqual({ success: true }) + }) + + it('should not retry on client errors', async () => { + let attempts = 0 + + global.fetch = vi.fn().mockImplementation(() => { + attempts++ + return Promise.resolve({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ error: 'Invalid input' }) + }) + }) + + await expect(adapter.testRequest('/bad')).rejects.toMatchObject({ + code: 'BAD_REQUEST', + status: 400 + }) + + // Should not retry on 400 error + expect(attempts).toBe(1) + }) + + it('should retry on server errors', async () => { + let attempts = 0 + + global.fetch = vi.fn().mockImplementation(async () => { + attempts++ + if (attempts < 2) { + return { + ok: false, + status: 503, + statusText: 'Service Unavailable', + json: async () => ({ error: 'Server down' }) + } + } + return { + ok: true, + json: async () => ({ success: true }) + } + }) + + const quickAdapter = new FastRetryAdapter({ + baseURL: 'https://api.example.com', + retries: 2 + }) + + // This should retry and eventually succeed + const result = await quickAdapter.testRequest('/server') + + // Verify it retried + expect(attempts).toBe(2) + expect(result).toEqual({ success: true }) + }) + }) + + describe('caching', () => { + it('should cache GET requests when cacheTime is set', async () => { + let fetchCount = 0 + + global.fetch = vi.fn().mockImplementation(() => { + fetchCount++ + return Promise.resolve({ + ok: true, + json: async () => ({ data: 'cached', count: fetchCount }) + }) + }) + + // Make first request with caching + const result1 = await adapter.testRequest('/cached', { + cache: 60000 // 1 minute + }) + + // Make second request (should use cache) + const result2 = await adapter.testRequest('/cached', { + cache: 60000 + }) + + // Only one fetch should have been made + expect(fetchCount).toBe(1) + expect(result1).toEqual(result2) + }) + + it('should cache POST requests with same body when cache is enabled', async () => { + let fetchCount = 0 + + global.fetch = vi.fn().mockImplementation(() => { + fetchCount++ + return Promise.resolve({ + ok: true, + json: async () => ({ count: fetchCount }) + }) + }) + + // Make first POST request with cache + const result1 = await adapter.testRequest('/data', { + method: 'POST', + cache: 60000, + body: JSON.stringify({ test: true }) + }) + + // Make second POST request with same body (should use cache) + const result2 = await adapter.testRequest('/data', { + method: 'POST', + cache: 60000, + body: JSON.stringify({ test: true }) + }) + + // Only one request should have been made + expect(fetchCount).toBe(1) + expect(result1).toEqual(result2) + + // Make POST request with different body (should not use cache) + await adapter.testRequest('/data', { + method: 'POST', + cache: 60000, + body: JSON.stringify({ test: false }) + }) + + // Now two requests should have been made + expect(fetchCount).toBe(2) + }) + + it('should clear cache', async () => { + let fetchCount = 0 + + global.fetch = vi.fn().mockImplementation(() => { + fetchCount++ + return Promise.resolve({ + ok: true, + json: async () => ({ count: fetchCount }) + }) + }) + + // Make cached request + await adapter.testRequest('/cached', { cache: 60000 }) + + // Clear cache + adapter.testClearCache() + + // Make request again (should fetch again) + await adapter.testRequest('/cached', { cache: 60000 }) + + expect(fetchCount).toBe(2) + }) + + it('should clear cache by pattern', async () => { + let fetchCount = 0 + + global.fetch = vi.fn().mockImplementation(() => { + fetchCount++ + return Promise.resolve({ + ok: true, + json: async () => ({ data: 'test' }) + }) + }) + + // Cache multiple endpoints + await adapter.testRequest('/users/1', { cache: 60000 }) + await adapter.testRequest('/posts/1', { cache: 60000 }) + + // Clear only user cache + adapter.testClearCache('users') + + // Users should refetch, posts should use cache + await adapter.testRequest('/users/1', { cache: 60000 }) + await adapter.testRequest('/posts/1', { cache: 60000 }) + + // 3 total fetches: initial 2 + 1 refetch for users + expect(fetchCount).toBe(3) + }) + }) + + describe('error handling', () => { + it('should call global error handler', async () => { + const errorHandler = vi.fn() + + const adapterWithHandler = new TestAdapter({ + baseURL: 'https://api.example.com', + onError: errorHandler + }) + + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => ({ error: 'Server error' }) + }) + + await expect(adapterWithHandler.testRequest('/error')).rejects.toThrow() + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'SERVER_ERROR', + status: 500, + message: 'Server error' + }) + ) + }) + + it('should handle non-JSON error responses', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => { + throw new Error('Invalid JSON') + } + }) + + await expect(adapter.testRequest('/error')).rejects.toMatchObject({ + code: 'SERVER_ERROR', + status: 500, + message: 'Internal Server Error' + }) + }) + }) +}) \ No newline at end of file diff --git a/src/lib/api/adapters/__tests__/env-mock.ts b/src/lib/api/adapters/__tests__/env-mock.ts new file mode 100644 index 00000000..656f0b6b --- /dev/null +++ b/src/lib/api/adapters/__tests__/env-mock.ts @@ -0,0 +1,6 @@ +/** + * Mock for SvelteKit's $env module + * Used in tests to provide environment variables + */ + +export const PUBLIC_SIERO_API_URL = 'http://localhost:3000/api/v1' \ No newline at end of file diff --git a/src/lib/api/adapters/base.adapter.ts b/src/lib/api/adapters/base.adapter.ts new file mode 100644 index 00000000..c7adb366 --- /dev/null +++ b/src/lib/api/adapters/base.adapter.ts @@ -0,0 +1,484 @@ +/** + * Base Adapter for API Communication + * + * This class provides the foundation for all API adapters in the application. + * It handles common concerns like request/response transformation, error handling, + * request cancellation, and retry logic. + * + * @module adapters/base + */ + +import { snakeToCamel, camelToSnake } from '../schemas/transforms' +import { API_BASE } from '../core' +import type { AdapterOptions, RequestOptions, AdapterError } from './types' +import { + createErrorFromStatus, + normalizeError, + isRetryableError, + calculateRetryDelay, + CancelledError +} from './errors' + +/** + * Base adapter class that all resource-specific adapters extend from. + * Provides core functionality for API communication with built-in features: + * - Automatic snake_case to camelCase transformation + * - Request cancellation and deduplication + * - Exponential backoff retry logic + * - Normalized error handling + * + * @example + * ```typescript + * class UserAdapter extends BaseAdapter { + * async getUser(id: string) { + * return this.request(`/users/${id}`) + * } + * } + * ``` + */ +export abstract class BaseAdapter { + /** Map of request IDs to their abort controllers for cancellation */ + protected abortControllers = new Map() + + /** Cache for storing request responses */ + protected cache = new Map() + + /** Configuration options for the adapter */ + protected options: Required + + /** + * Creates a new adapter instance + * + * @param options - Configuration options for the adapter + * @param options.baseURL - Base URL for API requests (defaults to API_BASE) + * @param options.timeout - Default timeout for requests in milliseconds + * @param options.retries - Number of retry attempts for failed requests + * @param options.cacheTime - Default cache duration in milliseconds + * @param options.onError - Global error handler callback + */ + constructor(options: AdapterOptions = {}) { + this.options = { + baseURL: options.baseURL ?? API_BASE, + timeout: options.timeout ?? 30000, + retries: options.retries ?? 3, + cacheTime: options.cacheTime ?? 0, + onError: options.onError + } + } + + /** + * Makes an HTTP request with automatic transformation and error handling + * + * @template T - The expected response type + * @param path - The API endpoint path (relative to baseURL) + * @param options - Request configuration options + * @returns Promise resolving to the transformed response data + * @throws {AdapterError} When the request fails or returns an error status + * + * @example + * ```typescript + * const data = await this.request('/users/123', { + * method: 'GET', + * cache: 60000 // Cache for 1 minute + * }) + * ``` + */ + protected async request( + path: string, + options: RequestOptions = {} + ): Promise { + // Build the full URL with query parameters + const url = this.buildURL(path, options.params) + + // Generate a unique ID for this request (used for cancellation and caching) + const requestId = this.generateRequestId(path, options.method, options.body as string) + + // Check cache first if caching is enabled + const cacheTime = options.cache ?? this.options.cacheTime + // Allow caching for any method if explicitly set + if (cacheTime > 0) { + const cached = this.getFromCache(requestId) + if (cached !== null) { + return cached as T + } + } + + // Cancel any existing request to the same endpoint + this.cancelRequest(requestId) + + // Create new abort controller for this request + const controller = new AbortController() + this.abortControllers.set(requestId, controller) + + // Prepare request options + const fetchOptions: RequestInit = { + credentials: 'include', // Default: Include cookies for authentication + ...options, // Allow overriding defaults + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}) + } + } + + // Transform request body from camelCase to snake_case if present + if (options.body && typeof options.body === 'string') { + try { + const bodyData = JSON.parse(options.body) + fetchOptions.body = JSON.stringify(this.transformRequest(bodyData)) + } catch { + // If body is not valid JSON, use as-is + fetchOptions.body = options.body + } + } + + try { + // Make the request with retry logic (errors handled inside fetchWithRetry) + const response = await this.fetchWithRetry(url, fetchOptions, options.retries) + + // Parse and transform the response + const data = await response.json() + const transformed = this.transformResponse(data) + + // Cache the successful response if caching is enabled + if (cacheTime > 0) { + this.setCache(requestId, transformed, cacheTime) + } + + return transformed + } catch (error: any) { + // Handle request cancellation + if (error.name === 'AbortError') { + throw new CancelledError().toJSON() + } + + // Error is already normalized from fetchWithRetry (or handleErrorResponse) + // Only normalize if it's not already an AdapterError structure + const normalizedError = error.name === 'AdapterError' ? error : normalizeError(error) + + // Call global error handler if provided + if (this.options.onError) { + this.options.onError(normalizedError) + } + + throw normalizedError + } finally { + // Clean up the abort controller + this.abortControllers.delete(requestId) + } + } + + /** + * Transforms response data from snake_case to camelCase + * + * @template T - The expected response type + * @param data - Raw response data from the API + * @returns Transformed data with camelCase property names + */ + protected transformResponse(data: any): T { + if (data === null || data === undefined) { + return data + } + + // Apply snake_case to camelCase transformation + return snakeToCamel(data) as T + } + + /** + * Transforms request data from camelCase to snake_case + * + * @param data - Request data with camelCase property names + * @returns Transformed data with snake_case property names + */ + protected transformRequest(data: any): any { + if (data === null || data === undefined) { + return data + } + + // Apply camelCase to snake_case transformation + return camelToSnake(data) + } + + /** + * Cancels a pending request by its ID + * + * @param requestId - The unique identifier of the request to cancel + */ + protected cancelRequest(requestId: string): void { + const controller = this.abortControllers.get(requestId) + if (controller) { + controller.abort() + this.abortControllers.delete(requestId) + } + } + + /** + * Cancels all pending requests + * Useful for cleanup when unmounting components or changing views + */ + cancelAll(): void { + // Abort all pending requests + this.abortControllers.forEach(controller => controller.abort()) + this.abortControllers.clear() + } + + /** + * Performs a fetch request with automatic retry on failure + * + * @param url - The URL to fetch + * @param options - Fetch options + * @param maxRetries - Maximum number of retry attempts + * @param attempt - Current attempt number (internal use) + * @returns Promise resolving to the fetch Response + */ + private async fetchWithRetry( + url: string, + options: RequestInit, + maxRetries?: number, + attempt = 1 + ): Promise { + const retries = maxRetries ?? this.options.retries + + try { + // Add timeout to the request if specified + let response: Response + if (this.options.timeout > 0) { + const timeoutId = setTimeout(() => { + const controller = Array.from(this.abortControllers.values()) + .find(c => c.signal === options.signal) + controller?.abort() + }, this.options.timeout) + + response = await fetch(url, options) + clearTimeout(timeoutId) + } else { + response = await fetch(url, options) + } + + // Check if response has an error status that should be retried + if (!response.ok) { + const error = await this.handleErrorResponse(response) + + // Check if this error is retryable + if (attempt < retries && isRetryableError(error)) { + // Calculate delay with exponential backoff and jitter + const delay = calculateRetryDelay(attempt, error) + + // Wait before retrying + await this.delay(delay) + + // Recursive retry + return this.fetchWithRetry(url, options, retries, attempt + 1) + } + + // Not retryable or max retries reached + throw error + } + + return response + } catch (error: any) { + // Don't retry on abort + if (error.name === 'AbortError') { + throw error + } + + // Normalize the error to check if it's retryable + const normalizedError = normalizeError(error) + + // Check if we should retry (handles both network errors and HTTP errors) + if (attempt < retries && isRetryableError(normalizedError)) { + // Calculate delay with exponential backoff and jitter + const delay = calculateRetryDelay(attempt, normalizedError) + + // Wait before retrying + await this.delay(delay) + + // Recursive retry + return this.fetchWithRetry(url, options, retries, attempt + 1) + } + + // Max retries reached or non-retryable error + // Throw the normalized error + throw normalizedError + } + } + + + /** + * Delays execution for a specified duration + * Used for retry backoff + * + * @param ms - Milliseconds to delay + * @returns Promise that resolves after the delay + */ + protected delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + /** + * Builds a complete URL from a path and optional query parameters + * + * @param path - The API endpoint path + * @param params - Optional query parameters + * @returns The complete URL string + */ + private buildURL(path: string, params?: Record): string { + // Handle absolute URLs + if (path.startsWith('http://') || path.startsWith('https://')) { + const url = new URL(path) + this.addQueryParams(url, params) + return url.toString() + } + + // Build URL from base URL and path + const baseURL = this.options.baseURL.replace(/\/$/, '') // Remove trailing slash + const cleanPath = path.startsWith('/') ? path : `/${path}` + const url = new URL(`${baseURL}${cleanPath}`) + + this.addQueryParams(url, params) + return url.toString() + } + + /** + * Adds query parameters to a URL object + * + * @param url - The URL object to modify + * @param params - Query parameters to add + */ + private addQueryParams(url: URL, params?: Record): void { + if (!params) return + + Object.entries(params).forEach(([key, value]) => { + // Skip undefined and null values + if (value === undefined || value === null) return + + // Handle arrays by adding multiple params with the same key + if (Array.isArray(value)) { + value.forEach(item => { + url.searchParams.append(key, String(item)) + }) + } else { + url.searchParams.set(key, String(value)) + } + }) + } + + /** + * Generates a unique identifier for a request + * Used for request cancellation and caching + * + * @param path - The request path + * @param method - The HTTP method + * @param body - Optional request body for cache key generation + * @returns A unique request identifier + */ + private generateRequestId(path: string, method = 'GET', body?: string): string { + const base = `${this.constructor.name}:${method}:${path}` + // For POST/PUT/PATCH requests with body, include body hash in cache key + if (body && ['POST', 'PUT', 'PATCH'].includes(method)) { + // Simple hash of body for cache key + const bodyHash = this.simpleHash(body) + return `${base}:${bodyHash}` + } + return base + } + + /** + * Generates a simple hash of a string for cache key generation + * Not cryptographically secure, just for cache differentiation + * + * @param str - String to hash + * @returns Hash string + */ + private simpleHash(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash = hash & hash // Convert to 32bit integer + } + return hash.toString(36) + } + + /** + * Handles error responses from the API + * Attempts to parse error details from the response body + * + * @param response - The error response + * @returns An AdapterError with normalized error information + */ + private async handleErrorResponse(response: Response): Promise { + let details: any = undefined + let message = response.statusText + + try { + // Try to parse error details from response body + const errorData = await response.json() + + // Extract error message from various possible formats + message = errorData.message || + errorData.error || + errorData.errors?.[0]?.message || + response.statusText + + details = errorData + } catch { + // If response body is not JSON, use status text + } + + // Use our error utility to create the appropriate error type + return createErrorFromStatus(response.status, message, details).toJSON() + } + + /** + * Gets data from cache if it exists and hasn't expired + * + * @param key - The cache key + * @returns The cached data or null if not found/expired + */ + private getFromCache(key: string): any | null { + const entry = this.cache.get(key) + if (!entry) return null + + // Check if cache has expired + if (Date.now() > entry.expires) { + this.cache.delete(key) + return null + } + + return entry.data + } + + /** + * Stores data in cache with expiration time + * + * @param key - The cache key + * @param data - The data to cache + * @param ttl - Time to live in milliseconds + */ + private setCache(key: string, data: any, ttl: number): void { + this.cache.set(key, { + data, + expires: Date.now() + ttl + }) + } + + /** + * Clears the cache + * + * @param pattern - Optional pattern to match keys for selective clearing + */ + clearCache(pattern?: string): void { + if (pattern) { + // Clear only matching keys + for (const key of this.cache.keys()) { + if (key.includes(pattern)) { + this.cache.delete(key) + } + } + } else { + // Clear all cache + this.cache.clear() + } + } +} \ No newline at end of file diff --git a/src/lib/api/adapters/errors.ts b/src/lib/api/adapters/errors.ts new file mode 100644 index 00000000..ac2a29ef --- /dev/null +++ b/src/lib/api/adapters/errors.ts @@ -0,0 +1,403 @@ +/** + * Error handling utilities for the adapter system + * + * This module provides custom error classes and utility functions + * for consistent error handling across all adapters. + * + * @module adapters/errors + */ + +import type { AdapterError } from './types' + +/** + * Custom error class for adapter-specific errors + * Extends the native Error class with additional properties + */ +export class ApiError extends Error implements AdapterError { + name: 'AdapterError' = 'AdapterError' + code: string + status: number + details?: any + + /** + * Creates a new ApiError instance + * + * @param code - Error code (e.g., 'NOT_FOUND', 'UNAUTHORIZED') + * @param status - HTTP status code + * @param message - Human-readable error message + * @param details - Additional error details from the API + */ + constructor(code: string, status: number, message: string, details?: any) { + super(message) + this.code = code + this.status = status + this.details = details + + // Maintains proper stack trace for where error was thrown + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ApiError) + } + } + + /** + * Converts the error to a plain object + * Useful for serialization and logging + */ + toJSON(): AdapterError { + return { + name: this.name, + code: this.code, + status: this.status, + message: this.message, + details: this.details + } + } + + /** + * Creates an ApiError from a plain object + * Useful for deserializing errors from API responses + */ + static fromJSON(json: any): ApiError { + return new ApiError( + json.code || 'UNKNOWN_ERROR', + json.status || 0, + json.message || 'An unknown error occurred', + json.details + ) + } +} + +/** + * Error class for network-related failures + */ +export class NetworkError extends ApiError { + constructor(message = 'Network request failed', details?: any) { + super('NETWORK_ERROR', 0, message, details) + this.name = 'NetworkError' as any + } +} + +/** + * Error class for request timeout + */ +export class TimeoutError extends ApiError { + constructor(timeout: number, details?: any) { + super('TIMEOUT', 0, `Request timed out after ${timeout}ms`, details) + this.name = 'TimeoutError' as any + } +} + +/** + * Error class for request cancellation + */ +export class CancelledError extends ApiError { + constructor(details?: any) { + super('CANCELLED', 0, 'Request was cancelled', details) + this.name = 'CancelledError' as any + } +} + +/** + * Error class for validation failures + */ +export class ValidationError extends ApiError { + constructor(message: string, details?: any) { + super('VALIDATION_ERROR', 422, message, details) + this.name = 'ValidationError' as any + } +} + +/** + * Error class for authentication failures + */ +export class AuthenticationError extends ApiError { + constructor(message = 'Authentication required', details?: any) { + super('UNAUTHORIZED', 401, message, details) + this.name = 'AuthenticationError' as any + } +} + +/** + * Error class for authorization failures + */ +export class AuthorizationError extends ApiError { + constructor(message = 'Access denied', details?: any) { + super('FORBIDDEN', 403, message, details) + this.name = 'AuthorizationError' as any + } +} + +/** + * Error class for resource not found + */ +export class NotFoundError extends ApiError { + constructor(resource?: string, details?: any) { + const message = resource ? `${resource} not found` : 'Resource not found' + super('NOT_FOUND', 404, message, details) + this.name = 'NotFoundError' as any + } +} + +/** + * Error class for conflict errors (e.g., duplicate resources) + */ +export class ConflictError extends ApiError { + constructor(message = 'Resource conflict', details?: any) { + super('CONFLICT', 409, message, details) + this.name = 'ConflictError' as any + } +} + +/** + * Error class for rate limiting + */ +export class RateLimitError extends ApiError { + retryAfter?: number + + constructor(retryAfter?: number, details?: any) { + const message = retryAfter + ? `Rate limit exceeded. Retry after ${retryAfter} seconds` + : 'Rate limit exceeded' + + super('RATE_LIMITED', 429, message, details) + this.name = 'RateLimitError' as any + this.retryAfter = retryAfter + } +} + +/** + * Maps HTTP status codes to specific error classes + * + * @param status - HTTP status code + * @param message - Error message + * @param details - Additional error details + * @returns Appropriate error instance based on status code + */ +export function createErrorFromStatus( + status: number, + message?: string, + details?: any +): ApiError { + switch (status) { + case 400: + return new ApiError('BAD_REQUEST', status, message || 'Bad request', details) + + case 401: + return new AuthenticationError(message, details) + + case 403: + return new AuthorizationError(message, details) + + case 404: + // Pass the message to NotFoundError if provided + return message ? new ApiError('NOT_FOUND', 404, message, details) : new NotFoundError(undefined, details) + + case 409: + return new ConflictError(message, details) + + case 422: + return new ValidationError(message || 'Validation failed', details) + + case 429: + // Try to extract retry-after header from details + const retryAfter = details?.headers?.['retry-after'] + return new RateLimitError(retryAfter, details) + + case 500: + return new ApiError('SERVER_ERROR', status, message || 'Internal server error', details) + + case 502: + return new ApiError('BAD_GATEWAY', status, message || 'Bad gateway', details) + + case 503: + return new ApiError('SERVICE_UNAVAILABLE', status, message || 'Service unavailable', details) + + case 504: + return new ApiError('GATEWAY_TIMEOUT', status, message || 'Gateway timeout', details) + + default: + // For any other status codes + if (status >= 400 && status < 500) { + return new ApiError('CLIENT_ERROR', status, message || 'Client error', details) + } else if (status >= 500) { + return new ApiError('SERVER_ERROR', status, message || 'Server error', details) + } + + return new ApiError('UNKNOWN_ERROR', status, message || 'Unknown error', details) + } +} + +/** + * Determines if an error is retryable based on its type and code + * + * @param error - The error to check + * @returns True if the error is retryable + */ +export function isRetryableError(error: any): boolean { + // Network-like conditions are retryable + // Handle both class instances and normalized plain objects + if ( + error instanceof NetworkError || + error instanceof TimeoutError || + error?.name === 'NetworkError' || + error?.code === 'NETWORK_ERROR' || + // Some environments normalize to status 0 without specific codes + error?.status === 0 + ) { + return true + } + + // Rate limit errors are retryable after delay + if (error instanceof RateLimitError) { + return true + } + + // Check by error code (handles both ApiError instances and plain objects) + // Note: NetworkError sets name to 'NetworkError' but still has AdapterError structure + if (error instanceof ApiError || error?.name === 'AdapterError' || error?.name === 'NetworkError') { + const retryableCodes = [ + 'NETWORK_ERROR', + 'TIMEOUT', + 'GATEWAY_TIMEOUT', + 'SERVICE_UNAVAILABLE', + 'BAD_GATEWAY', + 'SERVER_ERROR' + ] + + if (retryableCodes.includes(error.code)) { + return true + } + + // Server errors (5xx) are generally retryable + if (error.status >= 500 && error.status < 600) { + return true + } + } + + // Check for specific error properties + if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') { + return true + } + + // Client errors (4xx) are not retryable except rate limits + if (error.status >= 400 && error.status < 500) { + return error.status === 429 // Only rate limit is retryable + } + + return false +} + +/** + * Normalizes various error types into a consistent AdapterError structure + * + * @param error - Any error type + * @returns Normalized AdapterError + */ +export function normalizeError(error: any): AdapterError { + // Already an AdapterError + if (error?.name === 'AdapterError') { + return error + } + + // ApiError instance + if (error instanceof ApiError) { + return error.toJSON() + } + + // Fetch abort error + if (error?.name === 'AbortError') { + return new CancelledError().toJSON() + } + + // Network error + if (error?.name === 'NetworkError' || error?.name === 'TypeError') { + return new NetworkError(error.message).toJSON() + } + + // Timeout error + if (error?.name === 'TimeoutError') { + return new TimeoutError(0, error).toJSON() + } + + // Generic Error with status + if (error?.status) { + return createErrorFromStatus( + error.status, + error.message || error.statusText, + error + ).toJSON() + } + + // Fallback to generic error + return new ApiError( + error?.code || 'UNKNOWN_ERROR', + error?.status || 0, + error?.message || 'An unknown error occurred', + error + ).toJSON() +} + +/** + * Extracts user-friendly error message from an error + * + * @param error - The error to extract message from + * @returns User-friendly error message + */ +export function getErrorMessage(error: any): string { + if (!error) { + return 'An unknown error occurred' + } + + // Try to get message from various error formats + const message = error.message || + error.error || + error.errors?.[0]?.message || + error.statusText || + 'An unknown error occurred' + + // Make network errors more user-friendly + if (message.includes('NetworkError') || message.includes('Failed to fetch')) { + return 'Unable to connect to the server. Please check your internet connection.' + } + + if (message.includes('TimeoutError')) { + return 'The request took too long. Please try again.' + } + + if (message.includes('AbortError') || message.includes('cancelled')) { + return 'The request was cancelled.' + } + + return message +} + +/** + * Calculates retry delay based on attempt number and error type + * + * @param attempt - Current attempt number (1-indexed) + * @param error - The error that triggered the retry + * @param baseDelay - Base delay in milliseconds (default: 1000) + * @param maxDelay - Maximum delay in milliseconds (default: 30000) + * @returns Delay in milliseconds before next retry + */ +export function calculateRetryDelay( + attempt: number, + error: any, + baseDelay = 1000, + maxDelay = 30000 +): number { + // Use retry-after header for rate limit errors + if (error instanceof RateLimitError && error.retryAfter) { + return error.retryAfter * 1000 + } + + // Exponential backoff: 1s, 2s, 4s, 8s, ... + let delay = Math.pow(2, attempt - 1) * baseDelay + + // Add jitter (±25%) to prevent thundering herd + const jitter = delay * 0.25 + delay = delay + (Math.random() * jitter * 2 - jitter) + + // Cap at maximum delay + return Math.min(delay, maxDelay) +} diff --git a/src/lib/api/adapters/test-setup.ts b/src/lib/api/adapters/test-setup.ts new file mode 100644 index 00000000..91895ed2 --- /dev/null +++ b/src/lib/api/adapters/test-setup.ts @@ -0,0 +1,41 @@ +import { beforeAll, afterAll, afterEach } from 'vitest' + +// Optional MSW setup to support future adapter tests without adding a hard dependency +let mockServer: any = null +let http: any = null +let HttpResponse: any = null + +async function ensureMSW() { + if (mockServer) return + try { + const mswNode = await import('msw/node') + const msw = await import('msw') + mockServer = mswNode.setupServer() + http = msw.http + HttpResponse = msw.HttpResponse + } catch (e) { + // MSW is not installed; skip server wiring + mockServer = null + } +} + +beforeAll(async () => { + await ensureMSW() + if (mockServer) mockServer.listen({ onUnhandledRequest: 'error' }) +}) +afterEach(() => { + if (mockServer) mockServer.resetHandlers() +}) +afterAll(() => { + if (mockServer) mockServer.close() +}) + +// Helper to add mock handlers for POST endpoints under /api/v1 +export function mockAPI(path: string, response: any, status = 200) { + if (!mockServer || !http || !HttpResponse) return + mockServer.use( + http.post(`*/api/v1${path}`, () => { + return HttpResponse.json(response, { status }) + }) + ) +} diff --git a/src/lib/api/adapters/types.ts b/src/lib/api/adapters/types.ts new file mode 100644 index 00000000..4f797877 --- /dev/null +++ b/src/lib/api/adapters/types.ts @@ -0,0 +1,347 @@ +/** + * Type definitions for the adapter layer + * + * This module contains all the TypeScript interfaces and types used + * throughout the adapter system. These types ensure type safety and + * provide clear contracts for adapter implementations. + * + * @module adapters/types + */ + +/** + * Configuration options for adapter instances + */ +export interface AdapterOptions { + /** Base URL for API requests. Defaults to the app's API base URL */ + baseURL?: string + + /** Default timeout for requests in milliseconds. Defaults to 30000 (30 seconds) */ + timeout?: number + + /** Number of retry attempts for failed requests. Defaults to 3 */ + retries?: number + + /** Default cache duration in milliseconds. Set to 0 to disable caching */ + cacheTime?: number + + /** Global error handler callback. Called when any request fails */ + onError?: (error: AdapterError) => void +} + +/** + * Options for individual HTTP requests + * Extends the standard RequestInit interface with additional features + */ +export interface RequestOptions extends Omit { + /** Query parameters to append to the URL */ + params?: Record + + /** Request timeout in milliseconds. Overrides the adapter's default timeout */ + timeout?: number + + /** Number of retry attempts for this specific request */ + retries?: number + + /** Cache duration for this request in milliseconds. Only applies to GET requests */ + cache?: number + + /** Request body. Can be any serializable value */ + body?: any +} + +/** + * Normalized error structure for all adapter errors + * Provides consistent error handling across the application + */ +export interface AdapterError { + /** Error name, always 'AdapterError' for identification */ + name: 'AdapterError' + + /** Normalized error code (e.g., 'NOT_FOUND', 'UNAUTHORIZED') */ + code: string + + /** HTTP status code if applicable, 0 otherwise */ + status: number + + /** Human-readable error message */ + message: string + + /** Additional error details from the API response */ + details?: any +} + +/** + * Generic paginated response structure + * Used for endpoints that return paginated data + */ +export interface PaginatedResponse { + /** Array of items for the current page */ + items: T[] + + /** Total number of items across all pages */ + total: number + + /** Current page number (1-indexed) */ + page: number + + /** Total number of pages */ + totalPages: number + + /** Number of items per page */ + perPage: number + + /** Whether there are more pages available */ + hasMore: boolean + + /** Cursor or page number for the next page, if available */ + nextCursor?: string | number + + /** Cursor or page number for the previous page, if available */ + prevCursor?: string | number +} + +/** + * Base response wrapper for API responses + * Some endpoints wrap their data in a standard structure + */ +export interface ApiResponse { + /** The actual data payload */ + data: T + + /** Metadata about the response */ + meta?: { + /** Pagination information if applicable */ + pagination?: { + page: number + perPage: number + total: number + totalPages: number + } + + /** Response timestamp */ + timestamp?: number + + /** API version */ + version?: string + } + + /** Error information if the request failed */ + error?: { + code: string + message: string + details?: any + } +} + +/** + * Search filter options used across different search endpoints + */ +export interface SearchFilters { + /** Filter by element IDs */ + element?: number[] + + /** Filter by rarity levels */ + rarity?: number[] + + /** Filter by primary proficiency (weapons and characters) */ + proficiency1?: number[] + + /** Filter by secondary proficiency (characters only) */ + proficiency2?: number[] + + /** Filter by series */ + series?: number[] + + /** Include extra/seasonal variants */ + extra?: boolean + + /** Filter summons with sub-aura */ + subaura?: boolean + + /** Filter special characters */ + special?: boolean + + /** Custom filters for specific use cases */ + [key: string]: any +} + +/** + * Parameters for search operations + */ +export interface SearchParams { + /** The type of entity to search */ + type: 'weapon' | 'character' | 'summon' + + /** Search query string */ + query?: string + + /** Filters to apply to the search */ + filters?: SearchFilters + + /** Page number for pagination (1-indexed) */ + page?: number + + /** Number of items per page */ + perPage?: number + + /** Locale for localized content */ + locale?: 'en' | 'ja' + + /** Items to exclude from results (by ID) */ + exclude?: string[] + + /** AbortSignal for request cancellation */ + signal?: AbortSignal +} + +/** + * Unified search result structure + */ +export interface SearchResult { + /** Unique identifier */ + id: string + + /** Granblue Fantasy game ID */ + granblueId: string + + /** Item name (localized or string) */ + name: { en?: string; ja?: string } | string + + /** Display name (computed from name) */ + displayName?: string + + /** Element ID (0-6) */ + element?: number + + /** Rarity level */ + rarity?: number + + /** Primary proficiency (weapons) */ + proficiency?: number + + /** Series ID */ + series?: number + + /** URL to the item's image */ + imageUrl?: string + + /** Type of the search result */ + type: 'weapon' | 'character' | 'summon' + + /** The full entity data */ + data?: T +} + +/** + * Cache entry structure + */ +export interface CacheEntry { + /** The cached data */ + data: T + + /** Timestamp when the cache entry expires */ + expires: number + + /** Optional tags for cache invalidation */ + tags?: string[] +} + +/** + * Request deduplication entry + */ +export interface PendingRequest { + /** The promise for the pending request */ + promise: Promise + + /** AbortController for cancelling the request */ + controller: AbortController + + /** Timestamp when the request started */ + startTime: number +} + +/** + * Retry configuration for specific error types + */ +export interface RetryConfig { + /** Maximum number of retry attempts */ + maxAttempts: number + + /** Base delay in milliseconds for exponential backoff */ + baseDelay: number + + /** Maximum delay in milliseconds */ + maxDelay: number + + /** Jitter factor (0-1) to randomize delays */ + jitter?: number + + /** Error codes that should trigger a retry */ + retryableCodes?: string[] + + /** HTTP status codes that should trigger a retry */ + retryableStatuses?: number[] +} + +/** + * Transformation options for request/response data + */ +export interface TransformOptions { + /** Whether to transform snake_case to camelCase */ + transformCase?: boolean + + /** Whether to rename 'object' fields to entity names */ + renameObjectFields?: boolean + + /** Custom transformation function */ + customTransform?: (data: any) => any +} + +/** + * Resource state for Runed integration + */ +export interface ResourceState { + /** The current data */ + data: T | null + + /** Whether the resource is currently loading */ + loading: boolean + + /** Error if the resource failed to load */ + error: Error | null + + /** Whether the resource is being refreshed */ + refreshing: boolean + + /** Timestamp of the last successful fetch */ + lastFetch?: number +} + +/** + * Options for creating reactive resources with Runed + */ +export interface ResourceOptions { + /** Initial data value */ + initialData?: T + + /** Whether to fetch immediately on creation */ + immediate?: boolean + + /** Debounce delay for reactive updates */ + debounce?: number + + /** Cache time in milliseconds */ + cacheTime?: number + + /** Whether to keep previous data while fetching */ + keepPreviousData?: boolean + + /** Callback when data is fetched successfully */ + onSuccess?: (data: T) => void + + /** Callback when fetch fails */ + onError?: (error: Error) => void + + /** Dependencies that trigger refetch when changed */ + dependencies?: any[] +} \ No newline at end of file diff --git a/vitest.config.adapter.ts b/vitest.config.adapter.ts new file mode 100644 index 00000000..5b4df9b8 --- /dev/null +++ b/vitest.config.adapter.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'vitest/config' +import { resolve } from 'path' + +export default defineConfig({ + test: { + name: 'adapters', + include: ['src/lib/api/adapters/**/*.test.ts'], + environment: 'node', + globals: true, + setupFiles: ['./src/lib/api/adapters/test-setup.ts'], + testTimeout: 5000, + // Use forked pool in CI/sandbox to avoid worker kill EPERM issues + pool: (process.env.VITEST_POOL as any) || (process.env.CI || process.env.SEATBELT ? 'forks' : undefined), + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/lib/api/adapters/**/*.ts'], + exclude: [ + '**/*.test.ts', + '**/__tests__/**', + '**/__fixtures__/**', + '**/test-setup.ts' + ] + } + }, + resolve: { + alias: { + '$lib': resolve('./src/lib'), + '$types': resolve('./src/lib/types'), + '$env/static/public': resolve('./src/lib/api/adapters/__tests__/env-mock.ts') + } + } +})