hensei-web/src/lib/api/adapters/base.adapter.ts
Justin Edmund e0810781f4 fix: Phase 7d - fix null/undefined handling (28 -> 24 errors)
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>
2025-11-28 20:04:02 -08:00

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()
}
}
}