// Shared API client utility import type { ApiResponse, ApiError, PaginationParams, ListParams } from '../types'; const DEFAULT_BASE_URL = 'http://localhost:8080/api/v1'; const DEFAULT_TIMEOUT = 30000; export interface ApiClientOptions { baseUrl?: string; timeout?: number; headers?: Record; getToken?: () => string | null; onError?: (error: ApiError) => void; } export class ApiClient { private baseUrl: string; private timeout: number; private defaultHeaders: Record; private getToken: () => string | null; private onError: (error: ApiError) => void; constructor(options: ApiClientOptions = {}) { this.baseUrl = options.baseUrl || DEFAULT_BASE_URL; this.timeout = options.timeout || DEFAULT_TIMEOUT; this.defaultHeaders = { 'Content-Type': 'application/json', ...options.headers, }; this.getToken = options.getToken || (() => null); this.onError = options.onError || (() => {}); } private async request( method: string, path: string, options: RequestInit = {} ): Promise> { const url = `${this.baseUrl}${path}`; const token = this.getToken(); const headers: Record = { ...this.defaultHeaders, ...(options.headers as Record), }; if (token) { headers['Authorization'] = `Bearer ${token}`; } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { const response = await fetch(url, { method, headers, body: options.body, signal: controller.signal, credentials: 'include', }); clearTimeout(timeoutId); const data = await response.json().catch(() => ({})) as ApiResponse; if (!response.ok) { const error: ApiError = { code: data.error?.code || 'UNKNOWN_ERROR', message: data.error?.message || response.statusText, details: data.error?.details, status_code: response.status, }; this.onError(error); throw error; } return data; } catch (error) { clearTimeout(timeoutId); if (error instanceof DOMException && error.name === 'AbortError') { const timeoutError: ApiError = { code: 'TIMEOUT', message: 'Request timed out', status_code: 408, }; this.onError(timeoutError); throw timeoutError; } throw error; } } // Convenience methods async get(path: string, params?: Record): Promise> { const queryString = params ? '?' + new URLSearchParams(params as Record).toString() : ''; return this.request('GET', `${path}${queryString}`); } async post(path: string, body?: unknown): Promise> { return this.request('POST', path, { body: body ? JSON.stringify(body) : undefined, }); } async put(path: string, body?: unknown): Promise> { return this.request('PUT', path, { body: body ? JSON.stringify(body) : undefined, }); } async patch(path: string, body?: unknown): Promise> { return this.request('PATCH', path, { body: body ? JSON.stringify(body) : undefined, }); } async delete(path: string): Promise> { return this.request('DELETE', path); } // Pagination helpers async list(path: string, params?: ListParams): Promise> { return this.get(path, params as Record); } async *paginate(path: string, params?: Omit): AsyncGenerator { let page = 1; const pageSize = params?.page_size || 20; while (true) { const response = await this.get(path, { ...params, page, page_size: pageSize, }); const items = response.data || []; yield items; if (items.length < pageSize || !response.meta?.pagination?.has_next) { break; } page++; } } // File upload async uploadFile( path: string, file: File, onProgress?: (progress: number) => void ): Promise> { const formData = new FormData(); formData.append('file', file); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable && onProgress) { onProgress((event.loaded / event.total) * 100); } }); xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { try { const data = JSON.parse(xhr.responseText) as ApiResponse; resolve(data); } catch { resolve({ success: true, data: xhr.responseText as unknown as T }); } } else { try { const data = JSON.parse(xhr.responseText) as ApiResponse; const error = data.error || { code: 'UPLOAD_FAILED', message: xhr.statusText, status_code: xhr.status, }; this.onError(error); reject(error); } catch { const error: ApiError = { code: 'UPLOAD_FAILED', message: xhr.statusText, status_code: xhr.status, }; this.onError(error); reject(error); } } }); xhr.addEventListener('error', () => { const error: ApiError = { code: 'NETWORK_ERROR', message: 'Network error during upload', status_code: 0, }; this.onError(error); reject(error); }); xhr.open('POST', `${this.baseUrl}${path}`); const token = this.getToken(); if (token) { xhr.setRequestHeader('Authorization', `Bearer ${token}`); } xhr.send(formData); }); } // Update base URL setBaseUrl(url: string): void { this.baseUrl = url; } // Update default headers setHeaders(headers: Record): void { this.defaultHeaders = { ...this.defaultHeaders, ...headers }; } } // Singleton instance let apiClientInstance: ApiClient | null = null; export function getApiClient(options?: ApiClientOptions): ApiClient { if (!apiClientInstance) { apiClientInstance = new ApiClient(options); } return apiClientInstance; } export function setApiClient(client: ApiClient): void { apiClientInstance = client; } // React hook for API client (if using React) // export function useApiClient(): ApiClient { // const [client] = useState(() => getApiClient()); // return client; // }