feat: implement base adapter with retry logic and caching
- Add BaseAdapter class with request/response transformation - Implement comprehensive error handling and normalization - Add retry logic with exponential backoff for network/server errors - Support request cancellation and deduplication - Include response caching with configurable TTL - Add full test coverage for adapter functionality
This commit is contained in:
parent
ea00cecd68
commit
51c30edc50
7 changed files with 1909 additions and 0 deletions
595
src/lib/api/adapters/__tests__/base.adapter.test.ts
Normal file
595
src/lib/api/adapters/__tests__/base.adapter.test.ts
Normal file
|
|
@ -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<T>(path: string, options?: any): Promise<T> {
|
||||
return this.request<T>(path, options)
|
||||
}
|
||||
|
||||
testTransformResponse<T>(data: any): T {
|
||||
return this.transformResponse<T>(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<void> {
|
||||
// Instant return for fast tests
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
async testRequest<T>(path: string, options?: any): Promise<T> {
|
||||
return this.request<T>(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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
6
src/lib/api/adapters/__tests__/env-mock.ts
Normal file
6
src/lib/api/adapters/__tests__/env-mock.ts
Normal file
|
|
@ -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'
|
||||
484
src/lib/api/adapters/base.adapter.ts
Normal file
484
src/lib/api/adapters/base.adapter.ts
Normal file
|
|
@ -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<string, AbortController>()
|
||||
|
||||
/** Cache for storing request responses */
|
||||
protected cache = new Map<string, { data: any; expires: number }>()
|
||||
|
||||
/** Configuration options for the adapter */
|
||||
protected options: Required<AdapterOptions>
|
||||
|
||||
/**
|
||||
* 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<User>('/users/123', {
|
||||
* method: 'GET',
|
||||
* cache: 60000 // Cache for 1 minute
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
protected async request<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
// 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<T>(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<T>(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<Response> {
|
||||
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<void> {
|
||||
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, any>): 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<string, any>): 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<AdapterError> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
403
src/lib/api/adapters/errors.ts
Normal file
403
src/lib/api/adapters/errors.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
41
src/lib/api/adapters/test-setup.ts
Normal file
41
src/lib/api/adapters/test-setup.ts
Normal file
|
|
@ -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 })
|
||||
})
|
||||
)
|
||||
}
|
||||
347
src/lib/api/adapters/types.ts
Normal file
347
src/lib/api/adapters/types.ts
Normal file
|
|
@ -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<RequestInit, 'body'> {
|
||||
/** Query parameters to append to the URL */
|
||||
params?: Record<string, any>
|
||||
|
||||
/** 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<T> {
|
||||
/** 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<T> {
|
||||
/** 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<T = any> {
|
||||
/** 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<T = any> {
|
||||
/** 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<any>
|
||||
|
||||
/** 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<T> {
|
||||
/** 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<T> {
|
||||
/** 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[]
|
||||
}
|
||||
33
vitest.config.adapter.ts
Normal file
33
vitest.config.adapter.ts
Normal file
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue