@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
JavaScript
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);
}
}
}