UNPKG

fetch-api-client

Version:

A TypeScript API client using fetch with axios-like interface

267 lines (266 loc) 9.24 kB
import { RequestInterceptorManager, ResponseInterceptorManager } from './interceptors'; /** * Type guard to check if error is a fetch TypeError */ function isFetchTypeError(error) { return error instanceof TypeError && typeof error.message === 'string'; } /** * Type guard to check if error is an AbortError */ function isAbortError(error) { return error instanceof Error && error.name === 'AbortError'; } /** * Type guard to check if error is our custom ApiError */ function isApiError(error) { return typeof error === 'object' && error !== null && 'status' in error && 'message' in error; } /** * Main API Client class that provides axios-like interface using fetch */ export class ApiClient { constructor(config = {}) { // Set default configuration this.config = { baseURL: '', timeout: 30000, headers: { 'Content-Type': 'application/json', }, getToken: () => null, credentials: 'same-origin', // ✅ Default to 'same-origin', can be overridden with 'include' validateStatus: (status) => status >= 200 && status < 300, ...config, }; // Initialize interceptor managers this.interceptors = { request: new RequestInterceptorManager(), response: new ResponseInterceptorManager(), }; } /** * Build complete URL from base URL and endpoint */ buildUrl(url, params) { const fullUrl = url.startsWith('http') ? url : `${this.config.baseURL}${url}`; if (!params) return fullUrl; const urlObj = new URL(fullUrl); Object.entries(params).forEach(([key, value]) => { if (value !== null && value !== undefined) { urlObj.searchParams.append(key, String(value)); } }); return urlObj.toString(); } /** * Prepare headers for the request */ async prepareHeaders(config) { const headers = new Headers(); // Add default headers Object.entries(this.config.headers).forEach(([key, value]) => { headers.set(key, value); }); // Add request-specific headers if (config.headers) { Object.entries(config.headers).forEach(([key, value]) => { headers.set(key, value); }); } // Add authorization token if available try { const token = await this.config.getToken(); if (token) { headers.set('Authorization', `Bearer ${token}`); } } catch (error) { console.warn('Failed to get token:', error); } return headers; } /** * Prepare request body */ prepareBody(data, headers) { if (!data) return null; const contentType = headers.get('Content-Type'); if (data instanceof FormData) { // Remove Content-Type header for FormData to let browser set it headers.delete('Content-Type'); return data; } if (contentType?.includes('application/json')) { return JSON.stringify(data); } if (contentType?.includes('application/x-www-form-urlencoded')) { const params = new URLSearchParams(); Object.entries(data).forEach(([key, value]) => { params.append(key, String(value)); }); return params.toString(); } return String(data); } /** * Create AbortController with timeout */ createAbortController(timeout) { const controller = new AbortController(); const timeoutMs = timeout ?? this.config.timeout; if (timeoutMs > 0) { setTimeout(() => { controller.abort(); }, timeoutMs); } return controller; } /** * Parse response based on content type */ async parseResponse(response) { const contentType = response.headers.get('Content-Type') || ''; if (contentType.includes('application/json')) { return response.json(); } if (contentType.includes('text/')) { return response.text(); } if (contentType.includes('application/octet-stream') || contentType.includes('image/')) { return response.blob(); } return response.text(); } /** * Create standardized error object */ createError(message, status, statusText, data, config) { return { message, status, statusText, data, config, code: status ? `HTTP_${status}` : 'NETWORK_ERROR', }; } /** * Core request method */ async request(config) { try { // Process request through interceptors const processedConfig = await this.interceptors.request.execute(config); // Prepare request components const url = this.buildUrl(processedConfig.url || '', processedConfig.params); const headers = await this.prepareHeaders(processedConfig); const body = this.prepareBody(processedConfig.data, headers); const controller = processedConfig.signal ? { signal: processedConfig.signal } : this.createAbortController(processedConfig.timeout); // Execute fetch request const response = await fetch(url, { method: processedConfig.method || 'GET', headers, body, credentials: processedConfig.credentials || this.config.credentials, // ✅ Use native fetch credentials ...controller, }); // Parse response data const data = await this.parseResponse(response); // Check if response status is valid if (!this.config.validateStatus(response.status)) { const error = this.createError(`Request failed with status ${response.status}`, response.status, response.statusText, data, processedConfig); const processedError = await this.interceptors.response.executeRejected(error); throw processedError; } // Create successful response const apiResponse = { data, status: response.status, statusText: response.statusText, headers: response.headers, config: processedConfig, }; // Process response through interceptors return await this.interceptors.response.executeFulfilled(apiResponse); } catch (error) { // Handle different types of errors with proper type checking if (isFetchTypeError(error) && error.message.includes('fetch')) { const networkError = this.createError('Network Error', undefined, undefined, undefined, config); const processedError = await this.interceptors.response.executeRejected(networkError); throw processedError; } if (isAbortError(error)) { const timeoutError = this.createError('Request Timeout', undefined, undefined, undefined, config); const processedError = await this.interceptors.response.executeRejected(timeoutError); throw processedError; } // Re-throw if it's already our custom error if (isApiError(error)) { throw error; } // Create generic error for unknown error types const errorMessage = error instanceof Error ? error.message : 'Unknown Error'; const genericError = this.createError(errorMessage, undefined, undefined, undefined, config); const processedError = await this.interceptors.response.executeRejected(genericError); throw processedError; } } /** * GET request */ async get(url, config) { return this.request({ ...config, method: 'GET', url }); } /** * POST request */ async post(url, data, config) { return this.request({ ...config, method: 'POST', url, data }); } /** * PUT request */ async put(url, data, config) { return this.request({ ...config, method: 'PUT', url, data }); } /** * DELETE request */ async delete(url, config) { return this.request({ ...config, method: 'DELETE', url }); } /** * PATCH request */ async patch(url, data, config) { return this.request({ ...config, method: 'PATCH', url, data }); } /** * HEAD request */ async head(url, config) { return this.request({ ...config, method: 'HEAD', url }); } /** * OPTIONS request */ async options(url, config) { return this.request({ ...config, method: 'OPTIONS', url }); } } /** * Create a new API client instance */ export function createClient(config) { return new ApiClient(config); }