UNPKG

@pagamio/frontend-commons-lib

Version:

Pagamio library for Frontend reusable components like the form engine and table container

342 lines (341 loc) 12.2 kB
import { ApiError, } from './types'; export class ApiClient { config; activeControllers = new Set(); mockConfig; constructor(config) { this.config = { timeout: 30000, retries: 1, retryDelay: 1000, defaultHeaders: { 'Content-Type': 'application/json', }, credentials: 'include', ...config, }; } /** * Sets the mock configurations for the API client. * @param mockConfig - Array of mock configurations. */ setMockConfig(mockConfig) { this.mockConfig = mockConfig; } /** * Makes a GET request. */ async get(endpoint, config) { return this.handleRequest(endpoint, 'GET', config); } /** * Makes a POST request. */ async post(endpoint, data, config) { if (data instanceof FormData) { const headers = {}; // Copy existing headers except Content-Type if (config?.headers) { Object.entries(config.headers).forEach(([key, value]) => { if (key !== 'Content-Type' && typeof value === 'string') { headers[key] = value; } }); } const defaultHeaders = {}; // Copy existing default headers except Content-Type if (this.config.defaultHeaders) { Object.entries(this.config.defaultHeaders).forEach(([key, value]) => { if (key !== 'Content-Type' && typeof value === 'string') { defaultHeaders[key] = value; } }); } const requestConfig = { ...config, body: data, headers, }; return this.handleRequest(endpoint, 'POST', requestConfig, defaultHeaders); } const body = data ? JSON.stringify(data) : undefined; return this.handleRequest(endpoint, 'POST', { ...config, body }); } /** * Makes a PUT request. */ async put(endpoint, data, config) { const body = data ? JSON.stringify(data) : undefined; return this.handleRequest(endpoint, 'PUT', { ...config, body }); } /** * Makes a PATCH request. */ async patch(endpoint, data, config) { const body = data ? JSON.stringify(data) : undefined; return this.handleRequest(endpoint, 'PATCH', { ...config, body }); } /** * Makes a DELETE request. */ async delete(endpoint, config) { return this.handleRequest(endpoint, 'DELETE', config); } /** * Aborts all ongoing requests. */ /** * Aborts all ongoing requests started without a custom signal. */ abort() { this.activeControllers.forEach((controller) => controller.abort()); this.activeControllers.clear(); } /** * Sets a default header for all requests. */ setDefaultHeader(key, value) { if (this.config.defaultHeaders) { this.config.defaultHeaders = { ...this.config.defaultHeaders, [key]: value, }; } else { this.config.defaultHeaders = { [key]: value }; } } /** * Finds a mock response that matches the request path, method, and params. * @param path - The API endpoint path. * @param method - The HTTP method. * @param params - The query parameters or payload. * @returns The mock response if a match is found, otherwise undefined. */ findMockResponse(path, method, params) { if (!this.mockConfig) return undefined; // Convert BodyInit (e.g., FormData, URLSearchParams) to a plain object const normalizedParams = this.normalizeParams(params); return this.mockConfig.find((mock) => mock.path === path && mock.method === method && (!mock.params || JSON.stringify(mock.params) === JSON.stringify(normalizedParams)))?.response; } /** * Normalizes request parameters or body into a plain object. * @param params - The query parameters or body. * @returns A plain object representation of the parameters or body. */ normalizeParams(params) { if (!params) return undefined; // Handle FormData if (params instanceof FormData) { const result = {}; params.forEach((value, key) => { result[key] = value; }); return result; } // Handle URLSearchParams if (params instanceof URLSearchParams) { const result = {}; params.forEach((value, key) => { result[key] = value; }); return result; } // Handle Blob, ArrayBuffer, etc. (wrap in an object) if (params instanceof Blob || params instanceof ArrayBuffer) { return { body: params }; // Wrap in an object to make it compatible } if (typeof params === 'string') { return JSON.parse(params); } // Handle plain objects or arrays return params; } /** * Creates a URL with query parameters. */ createUrl(endpoint, params) { const url = new URL(endpoint.startsWith('http') ? endpoint : `${this.config.baseURL}${endpoint}`); if (params) { Object.entries(params).forEach(([key, value]) => { url.searchParams.append(key, value); }); } return url.toString(); } /** * Handles a request by checking for a matching mock response. * If no mock is found, proceeds with the real API request. */ async handleRequest(endpoint, method, config = {}, overrideDefaultHeaders) { // Check for a mock response const mockResponse = this.findMockResponse(endpoint, method, config.params || config.body); if (mockResponse !== undefined) { // Simulate network delay await new Promise((resolve) => setTimeout(resolve, 500)); return mockResponse; } // Proceed with the real API request const { params, timeout = this.config.timeout, retries = this.config.retries, skipAuth = false, skipRetry = false, skipRefresh = false, signal, ...requestConfig } = config; let attempt = 0; const maxAttempts = skipRetry ? 1 : (retries ?? 0) + 1; while (attempt < maxAttempts) { try { const response = await this.makeRequest(endpoint, method, { params, timeout, skipAuth, skipRefresh, signal, requestConfig, overrideDefaultHeaders, }); const data = await this.handleResponse(response); if (!response.ok) { throw new ApiError(data?.message ?? 'Request failed', response.status, data); } return data; } catch (error) { attempt++; if (attempt >= maxAttempts) { await this.handleError(error); throw error; } await new Promise((resolve) => setTimeout(resolve, this.config.retryDelay)); } } throw new ApiError('Request failed after all retries', 0); } /** * Makes the actual HTTP request. */ async makeRequest(endpoint, method, config) { const { params, timeout, skipAuth, skipRefresh, requestConfig, overrideDefaultHeaders, signal, overrideDefaultCredentials, } = config; const url = this.createUrl(endpoint, params); // Use provided signal, or create a new AbortController for this request let requestSignal; let controller; if (signal) { requestSignal = signal; } else { controller = new AbortController(); this.activeControllers.add(controller); requestSignal = controller.signal; } let finalConfig = this.buildRequestConfig(requestConfig, method, overrideDefaultHeaders, overrideDefaultCredentials, requestSignal); finalConfig = await this.injectAuthHeader(finalConfig, skipAuth); finalConfig = await this.applyOnRequestHook(finalConfig); let response; try { response = await this.fetchWithTimeout(url, finalConfig, timeout, controller); } finally { if (controller) this.activeControllers.delete(controller); } if (await this.shouldHandle401(response, skipAuth, skipRefresh)) { return this.handle401AndRetry(endpoint, method, config); } return response; } buildRequestConfig(requestConfig, method, overrideDefaultHeaders, overrideDefaultCredentials, signal) { return { ...requestConfig, method, headers: { ...(overrideDefaultHeaders ?? this.config.defaultHeaders), ...requestConfig.headers, }, signal, credentials: requestConfig.credentials ?? overrideDefaultCredentials ?? this.config.credentials ?? 'same-origin', }; } async injectAuthHeader(finalConfig, skipAuth) { if (!skipAuth && this.config.tokenManager) { const { token } = this.config.tokenManager.getAccessToken(); if (token) { return { ...finalConfig, headers: { ...finalConfig.headers, Authorization: `Bearer ${token}`, }, }; } } return finalConfig; } async applyOnRequestHook(finalConfig) { if (this.config.onRequest) { return this.config.onRequest(finalConfig); } return finalConfig; } async fetchWithTimeout(url, finalConfig, timeout, controller) { let timeoutId = null; try { if (timeout && controller) { timeoutId = setTimeout(() => { controller.abort(); }, timeout); } return await fetch(url, finalConfig); } finally { if (timeoutId) clearTimeout(timeoutId); } } async shouldHandle401(response, skipAuth, skipRefresh) { return response.status === 401 && !skipAuth && !skipRefresh && !!this.config.tokenManager; } async handle401AndRetry(endpoint, method, config) { const refreshed = await this.config.tokenManager.refreshTokens(); if (refreshed) { const newToken = this.config.tokenManager.getAccessToken().token; if (newToken) { return this.makeRequest(endpoint, method, { ...config, skipRefresh: true, // Prevent infinite refresh loop }); } } this.config.onUnauthorized?.(); throw new ApiError('Unauthorized', 401); } /** * Handles the API response. */ async handleResponse(response) { let data; const contentType = response.headers.get('content-type'); if (contentType?.includes('application/json')) { data = await response.json(); } else { data = response; } if (this.config.onResponse) { data = await this.config.onResponse(response, data); } return data; } /** * Handles API errors. */ async handleError(error) { if (error instanceof ApiError) { await this.config.onError?.(error); } else if (error instanceof Error) { const apiError = new ApiError(error.message, 0, { originalError: error }); await this.config.onError?.(apiError); } } }