/** * API Client Factory * Creates a configured API client with consistent error handling */ import type { ApiClient, ApiClientConfig, ApiResult, RequestOptions } from './types'; import { buildQueryString, createApiError, getBaseUrl, getErrorCodeFromStatus, isRetryableError, parseErrorResponse, } from './utils'; import { sleep } from '@manacore/shared-utils'; const DEFAULT_TIMEOUT = 30000; const DEFAULT_RETRIES = 0; const DEFAULT_RETRY_DELAY = 1000; /** * Create a configured API client instance * * @example * ```typescript * import { createApiClient } from '@manacore/shared-api-client'; * import { authStore } from '$lib/stores/auth.svelte'; * * export const api = createApiClient({ * baseUrl: 'http://localhost:3014', * apiPrefix: '/api/v1', * getAuthToken: () => authStore.getValidToken(), * }); * * // Usage * const { data, error } = await api.get('/users'); * if (error) { * console.error('Failed:', error.message); * return; * } * // data is typed as User[] * ``` */ export function createApiClient(config: ApiClientConfig): ApiClient { const { apiPrefix = '', getAuthToken, timeout = DEFAULT_TIMEOUT, retries = DEFAULT_RETRIES, retryDelay = DEFAULT_RETRY_DELAY, onError, debug = false, } = config; /** * Internal fetch with error handling, timeout, and retries */ async function fetchWithRetry( endpoint: string, init: RequestInit, options: RequestOptions = {}, attemptNum = 0 ): Promise> { const baseUrl = config.useRuntimeUrl !== false ? getBaseUrl(config.baseUrl) : config.baseUrl; const queryString = options.params ? buildQueryString(options.params) : ''; const url = baseUrl + apiPrefix + endpoint + queryString; const requestTimeout = options.timeout ?? timeout; const maxRetries = options.retries ?? retries; // Create abort controller for timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), requestTimeout); try { // Get auth token if not skipping const headers: Record = { ...((init.headers as Record) || {}), ...(options.headers || {}), }; if (!options.skipAuth && getAuthToken) { const token = await getAuthToken(); if (token) { headers['Authorization'] = 'Bearer ' + token; } } if (debug) { console.log('[API] ' + init.method + ' ' + url); } const response = await fetch(url, { ...init, headers, signal: controller.signal, }); clearTimeout(timeoutId); // Handle 204 No Content if (response.status === 204) { return { data: null as T, error: null }; } // Handle error responses if (!response.ok) { const errorMessage = await parseErrorResponse(response); const error = createApiError( errorMessage, getErrorCodeFromStatus(response.status), response.status ); // Retry on server errors if (isRetryableError(error) && attemptNum < maxRetries) { if (debug) { console.log('[API] Retry ' + (attemptNum + 1) + '/' + maxRetries + ' for ' + url); } await sleep(retryDelay * (attemptNum + 1)); // Exponential backoff return fetchWithRetry(endpoint, init, options, attemptNum + 1); } if (onError) { onError(error, endpoint); } return { data: null, error }; } // Parse JSON response const contentType = response.headers.get('content-type'); if (contentType?.includes('application/json')) { const data = await response.json(); return { data, error: null }; } // Handle non-JSON responses (e.g., text, blob) const text = await response.text(); return { data: text as T, error: null }; } catch (err) { clearTimeout(timeoutId); // Handle abort (timeout) if (err instanceof DOMException && err.name === 'AbortError') { const error = createApiError('Request timed out after ' + requestTimeout + 'ms', 'TIMEOUT'); if (attemptNum < maxRetries) { if (debug) { console.log( '[API] Retry ' + (attemptNum + 1) + '/' + maxRetries + ' after timeout for ' + url ); } await sleep(retryDelay * (attemptNum + 1)); return fetchWithRetry(endpoint, init, options, attemptNum + 1); } if (onError) { onError(error, endpoint); } return { data: null, error }; } // Handle network errors const error = createApiError( err instanceof Error ? err.message : 'Network error', 'NETWORK_ERROR' ); if (attemptNum < maxRetries) { if (debug) { console.log( '[API] Retry ' + (attemptNum + 1) + '/' + maxRetries + ' after network error for ' + url ); } await sleep(retryDelay * (attemptNum + 1)); return fetchWithRetry(endpoint, init, options, attemptNum + 1); } if (onError) { onError(error, endpoint); } return { data: null, error }; } } /** * Prepare request body and headers */ function prepareBody(body: unknown): { body?: string; contentType?: string } { if (body === undefined || body === null) { return {}; } if (body instanceof FormData) { // Don't set Content-Type for FormData - browser handles it return {}; } return { body: JSON.stringify(body), contentType: 'application/json', }; } return { async get(endpoint: string, options?: RequestOptions): Promise> { return fetchWithRetry( endpoint, { method: 'GET', headers: { Accept: 'application/json' }, }, options ); }, async post( endpoint: string, body?: unknown, options?: RequestOptions ): Promise> { const { body: jsonBody, contentType } = prepareBody(body); return fetchWithRetry( endpoint, { method: 'POST', headers: { Accept: 'application/json', ...(contentType ? { 'Content-Type': contentType } : {}), }, body: jsonBody, }, options ); }, async put( endpoint: string, body?: unknown, options?: RequestOptions ): Promise> { const { body: jsonBody, contentType } = prepareBody(body); return fetchWithRetry( endpoint, { method: 'PUT', headers: { Accept: 'application/json', ...(contentType ? { 'Content-Type': contentType } : {}), }, body: jsonBody, }, options ); }, async patch( endpoint: string, body?: unknown, options?: RequestOptions ): Promise> { const { body: jsonBody, contentType } = prepareBody(body); return fetchWithRetry( endpoint, { method: 'PATCH', headers: { Accept: 'application/json', ...(contentType ? { 'Content-Type': contentType } : {}), }, body: jsonBody, }, options ); }, async delete(endpoint: string, options?: RequestOptions): Promise> { return fetchWithRetry( endpoint, { method: 'DELETE', headers: { Accept: 'application/json' }, }, options ); }, async upload( endpoint: string, formData: FormData, options?: RequestOptions ): Promise> { return fetchWithRetry( endpoint, { method: 'POST', // Don't set Content-Type - browser handles multipart boundary headers: { Accept: 'application/json' }, body: formData, }, options ); }, }; }