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