fetch-api-client
Version:
A TypeScript API client using fetch with axios-like interface
267 lines (266 loc) • 9.24 kB
JavaScript
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);
}