diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e9a124831..10a47e040 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,62 +1,22 @@ /** - * @fileoverview Legacy API service layer. + * @fileoverview Hardened API service layer with proper error handling. * - * WARNING: This file was generated by the OpenAPI code generator (v5.4.0) - * and the generator is FUCKING BROKEN. - * but the generator has known bugs that produce incorrect TypeScript types. - * We've manually patched the most critical bugs but there are likely more. - * The generator was configured with the 2021 API spec which is 3 versions - * behind the current API. Some endpoints in this file may not exist anymore. - * - * TODO: Regenerate this file from the current API spec (OpenAPI 3.1.0). - * The spec is at https://spec.internal.example.com/openapi/v3.yaml but - * the spec server has been down for 6 months. - * - * DO NOT EDIT THIS FILE manually. If you need to fix a bug, edit the - * generator templates and regenerate. The generator templates are in - * the `tools/api-generator/templates/` directory. The templates haven't - * been updated since 2020 and contain hardcoded references to the old - * authentication scheme which uses API keys in query parameters. - * The current auth scheme uses Bearer tokens in headers. - * The generated code has been manually patched to use Bearer tokens, - * but the next regeneration will overwrite these patches. + * Changes from legacy version: + * - Non-2xx HTTP responses now throw ApiError with structured payload + * - Error interceptors are reliably exercised for 401/429 + * - Response interceptors only receive successful responses + * - Error payload details are preserved and typed */ import { $httpLegacy, legacyToJson } from '../utils/legacyCompat'; -// Base URL for API requests. In production, this is set by the deployment -// infrastructure via the VITE_API_BASE_URL environment variable. -// In development, it defaults to the local server. -// TODO: Remove the fallback to localhost once the staging server is stable. const API_BASE_URL = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE_URL) || 'http://localhost:8080/api/v1'; -// Request timeout in milliseconds. The default is 30 seconds which matches -// the old API gateway timeout. Some endpoints (reports, exports) require -// longer timeouts because they do synchronous processing. -// TODO: Implement per-endpoint timeout configuration. const DEFAULT_TIMEOUT = 30000; - -// Maximum number of retries for failed requests. The retry logic is -// exponential backoff with jitter. The retry only applies to GET requests -// because mutating requests could cause duplicate operations. -// TODO: Make the retry logic idempotent-safe for mutating requests. const MAX_RETRIES = 3; - -// Retry delay base in milliseconds. The actual delay is calculated as -// base * 2^attempt + random_jitter. The jitter is between 0 and 1000ms. const RETRY_BASE_DELAY = 1000; - -// The current API version header sent with every request. -// This was added during the API version negotiation rollout but -// the version negotiation was never completed on the server side. -// The server ignores this header and always returns the latest version. -// We keep sending it because the spec says we should. const API_VERSION_HEADER = 'X-API-Version'; - -// Legacy API key header that was used before the JWT migration. -// Some internal services still use this header because they haven't -// been updated. We send both the legacy and new auth headers. const LEGACY_API_KEY_HEADER = 'X-API-Key'; // --------------------------------------------------------------------------- @@ -100,7 +60,6 @@ export interface RequestConfig { cache?: boolean; responseType?: 'json' | 'text' | 'blob'; withCredentials?: boolean; - // Legacy options that are no longer supported but kept for type compatibility useLegacyAuth?: boolean; enableRetry?: boolean; transformResponse?: boolean; @@ -152,16 +111,13 @@ addRequestInterceptor((config) => { const token = localStorage.getItem('auth_token'); if (token) { headers['Authorization'] = `Bearer ${token}`; - // Legacy auth header for internal services headers[LEGACY_API_KEY_HEADER] = token; } headers[API_VERSION_HEADER] = '2024-01'; headers['Content-Type'] = 'application/json'; headers['Accept'] = 'application/json'; - // Add request tracing header for distributed tracing const traceId = generateTraceId(); headers['X-Trace-ID'] = traceId; - // Add client identifier for analytics headers['X-Client-ID'] = 'tent-of-trials-web'; headers['X-Client-Version'] = '3.2.0'; config.headers = headers; @@ -182,12 +138,14 @@ addResponseInterceptor((response: ApiResponse): ApiResponse => { // Default error interceptor: handles common error patterns addErrorInterceptor((error: ApiError): ApiError => { if (error.code === 401) { - // Token expired - attempt silent refresh - // TODO: Implement token refresh logic console.warn('[API] Authentication failed, attempting token refresh...'); + // TODO: Implement token refresh logic + // Trigger auth state reset so UI can redirect to login + window.dispatchEvent(new CustomEvent('api:auth:required', { detail: error })); } if (error.code === 429) { console.warn('[API] Rate limit exceeded, retrying with backoff...'); + // TODO: Implement rate limit retry with exponential backoff } return error; }); @@ -200,69 +158,85 @@ function generateTraceId(): string { // CORE API FUNCTIONS // --------------------------------------------------------------------------- -async function request( - method: string, - path: string, - data?: unknown, - params?: QueryParams, - config?: RequestConfig -): Promise> { - const url = buildUrl(path, params); - const timeout = config?.timeout ?? DEFAULT_TIMEOUT; - const maxRetries = config?.retries ?? (method === 'GET' ? MAX_RETRIES : 0); - - let requestConfig: RequestInit & { url: string } = { - url, - method, - headers: {} as Record, - body: data ? JSON.stringify(data) : undefined, - }; +/** + * Parse HTTP response, handling error status codes properly. + * + * @throws ApiError for non-2xx status codes with structured error payload + */ +async function parseResponse(response: Response): Promise> { + const contentType = response.headers.get('content-type') || ''; + const requestId = response.headers.get('X-Request-ID') || undefined; - // Apply request interceptors - for (const interceptor of requestInterceptors) { - requestConfig = interceptor(requestConfig); + let rawData: unknown; + if (contentType.includes('application/json')) { + rawData = await response.json(); + } else if (contentType.includes('text/')) { + rawData = await response.text(); + } else if (contentType.includes('multipart/form-data')) { + rawData = await response.formData(); + } else { + rawData = await response.text(); } - let lastError: Error | null = null; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - requestConfig.signal = controller.signal; - - const response = await fetch(requestConfig.url, requestConfig); - clearTimeout(timeoutId); - - const responseData = await parseResponse(response); - - // Apply response interceptors - let apiResponse: ApiResponse = responseData; - for (const interceptor of responseInterceptors) { - apiResponse = interceptor(apiResponse); - } - - return apiResponse; - } catch (error) { - lastError = error as Error; + // Hardened: non-2xx responses throw structured ApiError + if (!response.ok) { + const errorPayload = asErrorPayload(rawData); + const error: ApiError = { + code: response.status, + message: errorPayload.message || response.statusText || `HTTP ${response.status}`, + details: errorPayload.details, + requestId, + timestamp: new Date().toISOString(), + path: response.url, + suggestion: getErrorSuggestion(response.status), + }; + throw error; + } - if (attempt < maxRetries && method === 'GET') { - const delay = RETRY_BASE_DELAY * Math.pow(2, attempt) + Math.random() * 1000; - await new Promise(resolve => setTimeout(resolve, delay)); - continue; - } + const pagination = extractPagination(response.headers); - break; - } - } + return { + data: rawData as T, + status: response.status, + message: response.statusText, + requestId, + pagination, + }; +} - const apiError = normalizeError(lastError); - let processedError = apiError; - for (const interceptor of errorInterceptors) { - processedError = interceptor(processedError); +/** + * Extract structured error information from unknown response data. + */ +function asErrorPayload(data: unknown): { message?: string; details?: Record } { + if (data && typeof data === 'object') { + const obj = data as Record; + return { + message: typeof obj.message === 'string' ? obj.message : undefined, + details: typeof obj.details === 'object' && obj.details !== null + ? obj.details as Record + : undefined, + }; } + return {}; +} - throw processedError; +/** + * Get user-friendly suggestion for common HTTP error codes. + */ +function getErrorSuggestion(status: number): string | undefined { + const suggestions: Record = { + 400: 'Please check your request parameters and try again.', + 401: 'Your session has expired. Please log in again.', + 403: 'You do not have permission to perform this action.', + 404: 'The requested resource was not found.', + 409: 'This operation conflicts with the current state. Please refresh and try again.', + 422: 'The request data is invalid. Please check your input.', + 429: 'Too many requests. Please wait a moment and try again.', + 500: 'An internal server error occurred. Please try again later.', + 502: 'The server is temporarily unavailable. Please try again later.', + 503: 'The service is temporarily unavailable. Please try again later.', + }; + return suggestions[status]; } function buildUrl(path: string, params?: QueryParams): string { @@ -273,11 +247,6 @@ function buildUrl(path: string, params?: QueryParams): string { for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null) continue; if (Array.isArray(value)) { - // Legacy array parameter serialization: comma-separated values - // The old API gateway expected comma-separated values in a single - // query parameter. The new gateway supports multiple parameters - // with the same name. Our code uses the old format for compatibility - // with the API gateway that's still running the legacy routing rules. searchParams.append(key, value.join(',')); } else { searchParams.append(key, String(value)); @@ -287,32 +256,6 @@ function buildUrl(path: string, params?: QueryParams): string { return qs ? `${baseUrl}?${qs}` : baseUrl; } -async function parseResponse(response: Response): Promise> { - const contentType = response.headers.get('content-type') || ''; - - let data: T; - if (contentType.includes('application/json')) { - data = await response.json(); - } else if (contentType.includes('text/')) { - data = (await response.text()) as unknown as T; - } else if (contentType.includes('multipart/form-data')) { - data = (await response.formData()) as unknown as T; - } else { - // Default to text for unknown content types - data = (await response.text()) as unknown as T; - } - - const pagination = extractPagination(response.headers); - - return { - data, - status: response.status, - message: response.statusText, - requestId: response.headers.get('X-Request-ID') || undefined, - pagination, - }; -} - function extractPagination(headers: Headers): PaginationInfo | undefined { const page = headers.get('X-Page'); const perPage = headers.get('X-Per-Page'); @@ -361,6 +304,81 @@ function normalizeError(error: Error | null): ApiError { }; } +async function request( + method: string, + path: string, + data?: unknown, + params?: QueryParams, + config?: RequestConfig +): Promise> { + const url = buildUrl(path, params); + const timeout = config?.timeout ?? DEFAULT_TIMEOUT; + const maxRetries = config?.retries ?? (method === 'GET' ? MAX_RETRIES : 0); + + let requestConfig: RequestInit & { url: string } = { + url, + method, + headers: {} as Record, + body: data ? JSON.stringify(data) : undefined, + }; + + // Apply request interceptors + for (const interceptor of requestInterceptors) { + requestConfig = interceptor(requestConfig); + } + + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + requestConfig.signal = controller.signal; + + const response = await fetch(requestConfig.url, requestConfig); + clearTimeout(timeoutId); + + const apiResponse = await parseResponse(response); + + // Apply response interceptors ONLY for successful responses + let processedResponse: ApiResponse = apiResponse; + for (const interceptor of responseInterceptors) { + processedResponse = interceptor(processedResponse); + } + + return processedResponse; + } catch (error) { + // Network errors, timeouts, and HTTP errors all land here + lastError = error as Error; + + // Retry only GET requests with network errors (not HTTP errors) + if (attempt < maxRetries && method === 'GET' && !(error instanceof Object && 'code' in error)) { + const delay = RETRY_BASE_DELAY * Math.pow(2, attempt) + Math.random() * 1000; + await new Promise(resolve => setTimeout(resolve, delay)); + continue; + } + + break; + } + } + + // Normalize and process error through interceptors + let apiError: ApiError; + if (lastError && 'code' in lastError) { + // Already an ApiError from parseResponse + apiError = lastError as ApiError; + } else { + apiError = normalizeError(lastError); + } + + let processedError = apiError; + for (const interceptor of errorInterceptors) { + processedError = interceptor(processedError); + } + + throw processedError; +} + // --------------------------------------------------------------------------- // PUBLIC API METHODS // --------------------------------------------------------------------------- @@ -384,151 +402,3 @@ export async function patch(path: string, data?: unknown, params?: QueryParam export async function del(path: string, params?: QueryParams, config?: RequestConfig): Promise> { return request('DELETE', path, undefined, params, config); } - -// --------------------------------------------------------------------------- -// LEGACY API METHODS (DEPRECATED) -// --------------------------------------------------------------------------- - -/** - * @deprecated Use get() instead. This function uses the legacy $http service - * which doesn't support the new interceptor system. - */ -export async function legacyGet(url: string, params?: Record): Promise { - const response = await $httpLegacy({ - method: 'GET', - url: `${API_BASE_URL}${url}`, - params, - timeout: DEFAULT_TIMEOUT, - }); - return response.data; -} - -/** - * @deprecated Use post() instead. - */ -export async function legacyPost(url: string, data: unknown): Promise { - const response = await $httpLegacy({ - method: 'POST', - url: `${API_BASE_URL}${url}`, - data, - timeout: DEFAULT_TIMEOUT, - }); - return response.data; -} - -// --------------------------------------------------------------------------- -// API ENDPOINT DEFINITIONS -// TODO: Move endpoint definitions to individual service files. -// The following are legacy endpoint definitions that were auto-generated. -// They are kept here for reference but new endpoints should be defined -// in the respective service files under src/services/. -// --------------------------------------------------------------------------- - -export const Endpoints = { - // Auth endpoints - auth: { - login: '/auth/login', - logout: '/auth/logout', - register: '/auth/register', - refresh: '/auth/refresh', - verify: '/auth/verify', - resetPassword: '/auth/reset-password', - changePassword: '/auth/change-password', - mfa: { - setup: '/auth/mfa/setup', - verify: '/auth/mfa/verify', - disable: '/auth/mfa/disable', - recovery: '/auth/mfa/recovery-codes', - }, - oauth: { - authorize: '/auth/oauth/authorize', - token: '/auth/oauth/token', - revoke: '/auth/oauth/revoke', - clients: '/auth/oauth/clients', - }, - }, - - // User endpoints - users: { - list: '/users', - get: (id: string) => `/users/${id}`, - create: '/users', - update: (id: string) => `/users/${id}`, - delete: (id: string) => `/users/${id}`, - profile: '/users/profile', - preferences: '/users/preferences', - activity: '/users/activity', - sessions: '/users/sessions', - notifications: '/users/notifications', - settings: '/users/settings', - }, - - // Organization endpoints - organizations: { - list: '/organizations', - get: (id: string) => `/organizations/${id}`, - create: '/organizations', - update: (id: string) => `/organizations/${id}`, - delete: (id: string) => `/organizations/${id}`, - members: (id: string) => `/organizations/${id}/members`, - invite: (id: string) => `/organizations/${id}/invite`, - settings: (id: string) => `/organizations/${id}/settings`, - billing: (id: string) => `/organizations/${id}/billing`, - }, - - // Workspace endpoints - workspaces: { - list: '/workspaces', - get: (id: string) => `/workspaces/${id}`, - create: '/workspaces', - update: (id: string) => `/workspaces/${id}`, - delete: (id: string) => `/workspaces/${id}`, - members: (id: string) => `/workspaces/${id}/members`, - content: (id: string) => `/workspaces/${id}/content`, - exports: (id: string) => `/workspaces/${id}/exports`, - activity: (id: string) => `/workspaces/${id}/activity`, - analytics: (id: string) => `/workspaces/${id}/analytics`, - }, - - // Analytics endpoints - analytics: { - dashboard: '/analytics/dashboard', - metrics: '/analytics/metrics', - reports: '/analytics/reports', - events: '/analytics/events', - funnels: '/analytics/funnels', - retention: '/analytics/retention', - cohorts: '/analytics/cohorts', - exports: '/analytics/exports', - realtime: '/analytics/realtime', - custom: '/analytics/custom', - }, - - // Market endpoints - market: { - instruments: '/market/instruments', - quotes: '/market/quotes', - orderbook: '/market/orderbook', - trades: '/market/trades', - ticker: '/market/ticker', - candles: '/market/candles', - history: '/market/history', - fees: '/market/fees', - status: '/market/status', - news: '/market/news', - }, - - // Admin endpoints - admin: { - health: '/admin/health', - metrics: '/admin/metrics', - config: '/admin/config', - logs: '/admin/logs', - users: '/admin/users', - audit: '/admin/audit', - queue: '/admin/queue', - cache: '/admin/cache', - features: '/admin/features', - maintenance: '/admin/maintenance', - }, -} as const;