Fixed multiple null/undefined type errors by adding proper null checks and default values. Changes: 1. CharacterRep.svelte: - Changed import from '$lib/types/enums' to '$lib/utils/element' - getElementClass in utils/element accepts undefined, enums version doesn't 2. ItemHeader.svelte: - Convert null to undefined for gridUncapLevel and gridTranscendence - getCharacterPose expects 'number | undefined', not 'number | null | undefined' 3. UncapStatusDisplay.svelte: - Added null coalescing for transcendenceStep check - Changed from `transcendenceStep > 0` to `(transcendenceStep ?? 0) > 0` 4. base.adapter.ts: - Provide default no-op function for optional onError callback - Required<AdapterOptions> needs all properties defined Result: 28 → 24 errors (-4) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
552 lines
16 KiB
TypeScript
552 lines
16 KiB
TypeScript
/**
|
|
* 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 { transformResponse, transformRequest } from '../schemas/transforms'
|
|
import type { AdapterOptions, RequestOptions, AdapterError } from './types'
|
|
import {
|
|
createErrorFromStatus,
|
|
normalizeError,
|
|
isRetryableError,
|
|
calculateRetryDelay,
|
|
CancelledError
|
|
} from './errors'
|
|
import { authStore } from '$lib/stores/auth.store'
|
|
import { browser } from '$app/environment'
|
|
|
|
/**
|
|
* 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>
|
|
|
|
/** Flag to disable caching entirely */
|
|
protected disableCache: boolean = false
|
|
|
|
/**
|
|
* 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 = {}) {
|
|
// Default to localhost if no baseURL provided
|
|
const baseURL = options.baseURL ?? 'http://localhost:3000/api/v1'
|
|
this.options = {
|
|
baseURL,
|
|
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 (support both params and query)
|
|
const url = this.buildURL(path, options.query || 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 (support both cacheTime and cacheTTL)
|
|
const cacheTime = options.cacheTTL ?? options.cacheTime ?? this.options.cacheTime
|
|
// Allow caching for any method if explicitly set (unless cache is disabled)
|
|
if (!this.disableCache && 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)
|
|
|
|
// Get Bearer token from auth store (only in browser)
|
|
let authHeaders: Record<string, string> = {}
|
|
if (browser) {
|
|
const token = await authStore.checkAndRefresh()
|
|
if (token) {
|
|
authHeaders['Authorization'] = `Bearer ${token}`
|
|
} else {
|
|
console.warn('[BaseAdapter] No auth token available in authStore for request:', path)
|
|
}
|
|
}
|
|
|
|
// Prepare request options
|
|
const fetchOptions: RequestInit = {
|
|
...options, // Allow overriding defaults
|
|
credentials: 'include', // Still include cookies for CORS and refresh token
|
|
signal: controller.signal,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...authHeaders,
|
|
...(options.headers || {})
|
|
}
|
|
}
|
|
|
|
// Debug logging for auth issues
|
|
if (browser && path.includes('grid_')) {
|
|
console.log('[BaseAdapter] Request to:', path, 'Headers:', fetchOptions.headers)
|
|
}
|
|
|
|
// Transform request body from camelCase to snake_case if present
|
|
if (options.body) {
|
|
if (typeof options.body === 'object') {
|
|
// Body is an object, transform and stringify
|
|
const transformed = this.transformRequest(options.body)
|
|
fetchOptions.body = JSON.stringify(transformed)
|
|
// Debug logging for 422 errors
|
|
if (browser && path.includes('grid_')) {
|
|
console.log('[BaseAdapter] Request body:', transformed)
|
|
}
|
|
} else if (typeof options.body === 'string') {
|
|
try {
|
|
const bodyData = JSON.parse(options.body)
|
|
const transformed = this.transformRequest(bodyData)
|
|
fetchOptions.body = JSON.stringify(transformed)
|
|
// Debug logging for 422 errors
|
|
if (browser && path.includes('grid_')) {
|
|
console.log('[BaseAdapter] Request body:', transformed)
|
|
}
|
|
} 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()
|
|
|
|
// Debug logging for grid operations
|
|
if (browser && path.includes('grid_')) {
|
|
console.log('[BaseAdapter] Response status:', response.status)
|
|
console.log('[BaseAdapter] Response data:', data)
|
|
}
|
|
|
|
const transformed = this.transformResponse<T>(data)
|
|
|
|
// Cache the successful response if caching is enabled (use cacheTTL or cache)
|
|
if (!this.disableCache && 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 and object->entity
|
|
*
|
|
* @template T - The expected response type
|
|
* @param data - Raw response data from the API
|
|
* @returns Transformed data with camelCase property names and proper entity fields
|
|
*/
|
|
protected transformResponse<T>(data: any): T {
|
|
if (data === null || data === undefined) {
|
|
return data
|
|
}
|
|
|
|
// Apply full transformation: snake_case->camelCase and object->entity
|
|
return transformResponse<T>(data)
|
|
}
|
|
|
|
/**
|
|
* Transforms request data from camelCase to snake_case and entity->object
|
|
*
|
|
* @param data - Request data with camelCase property names and entity fields
|
|
* @returns Transformed data with snake_case property names and object fields
|
|
*/
|
|
protected transformRequest(data: any): any {
|
|
if (data === null || data === undefined) {
|
|
return data
|
|
}
|
|
|
|
// Apply full transformation: entity->object and camelCase->snake_case
|
|
return transformRequest(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 fullPath = `${baseURL}${cleanPath}`
|
|
|
|
// Check if we have a relative URL (starts with /)
|
|
if (baseURL.startsWith('/')) {
|
|
// For relative URLs, we need to provide a base for the URL constructor
|
|
// but we'll return just the relative path for fetch
|
|
if (typeof window !== 'undefined') {
|
|
// In browser, use window.location.origin for URL construction
|
|
const url = new URL(fullPath, window.location.origin)
|
|
this.addQueryParams(url, params)
|
|
// Return just the pathname and search for relative fetch
|
|
return `${url.pathname}${url.search}`
|
|
} else {
|
|
// On server, construct the query string manually for relative paths
|
|
if (params && Object.keys(params).length > 0) {
|
|
const queryString = new URLSearchParams(this.transformRequest(params)).toString()
|
|
return `${fullPath}?${queryString}`
|
|
}
|
|
return fullPath
|
|
}
|
|
} else {
|
|
// For absolute base URLs, use the normal URL constructor
|
|
const url = new URL(fullPath)
|
|
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
|
|
|
|
// Transform query parameters from camelCase to snake_case
|
|
const transformed = this.transformRequest(params)
|
|
|
|
Object.entries(transformed).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()
|
|
}
|
|
}
|
|
}
|