What is the minimal boilerplate around the web API fetch()
call to support
- track request progress
- intercept middleware
- handle error states
similar to the very popular axios
library without relying on 3rd party libraries or implement the full axios
API?
To create a minimal boilerplate around the native fetch()
API that mimics some of axios
's popular features—such as request interception, progress tracking, and error handling—you can build a wrapper function or a simple class. Below is an example of how to achieve this without relying on third-party libraries.
- Request Interception: Modify or log requests before they are sent.
- Progress Tracking: Monitor the download progress of the response.
- Error Handling: Gracefully handle HTTP errors and network issues.
Here's a simplified example using a wrapper function that incorporates the desired features:
// Define a simple interceptor mechanism
const interceptors = {
request: [],
response: [],
};
// Function to add request interceptors
function addRequestInterceptor(fn) {
interceptors.request.push(fn);
}
// Function to add response interceptors
function addResponseInterceptor(fn) {
interceptors.response.push(fn);
}
// The main fetch wrapper
async function customFetch(input, options = {}, onProgress) {
let config = { input, options };
// Apply request interceptors
for (const interceptor of interceptors.request) {
config = await interceptor(config) || config;
}
const { input: reqInput, options: reqOptions } = config;
try {
const response = await fetch(reqInput, reqOptions);
// Check for HTTP error statuses
if (!response.ok) {
const error = new Error(`HTTP error! status: ${response.status}`);
error.response = response;
throw error;
}
// If progress tracking is requested and supported
if (onProgress && response.body && response.body.getReader) {
const contentLength = response.headers.get('Content-Length');
if (!contentLength) {
console.warn('Content-Length response header unavailable');
return response;
}
const total = parseInt(contentLength, 10);
let loaded = 0;
const reader = response.body.getReader();
const stream = new ReadableStream({
start(controller) {
function push() {
reader.read().then(({ done, value }) => {
if (done) {
controller.close();
return;
}
loaded += value.byteLength;
onProgress({ loaded, total });
controller.enqueue(value);
push();
}).catch(error => {
console.error('Error reading stream:', error);
controller.error(error);
});
}
push();
}
});
// Create a new response with the streamed body
const streamedResponse = new Response(stream, {
headers: response.headers,
status: response.status,
statusText: response.statusText,
});
// Apply response interceptors
let finalResponse = streamedResponse;
for (const interceptor of interceptors.response) {
finalResponse = await interceptor(finalResponse) || finalResponse;
}
return finalResponse;
}
// Apply response interceptors
let finalResponse = response;
for (const interceptor of interceptors.response) {
finalResponse = await interceptor(finalResponse) || finalResponse;
}
return finalResponse;
} catch (error) {
// Handle network errors or thrown HTTP errors
console.error('Fetch error:', error);
throw error;
}
}
// Example usage:
// Add a request interceptor
addRequestInterceptor(async (config) => {
// For example, add an Authorization header
config.options.headers = {
...config.options.headers,
'Authorization': 'Bearer your-token-here',
};
return config;
});
// Add a response interceptor
addResponseInterceptor(async (response) => {
// For example, log the response status
console.log('Response status:', response.status);
return response;
});
// Make a request with progress tracking
customFetch('https://jsonplaceholder.typicode.com/posts', {}, (progress) => {
const percent = (progress.loaded / progress.total) * 100;
console.log(`Download progress: ${percent.toFixed(2)}%`);
})
.then(response => response.json())
.then(data => {
console.log('Data received:', data);
})
.catch(error => {
console.error('Request failed:', error);
});
-
Interceptors:
- Request Interceptors: Functions that receive and can modify the request configuration before the fetch call is made. These are stored in the
interceptors.request
array. - Response Interceptors: Functions that receive and can modify the response before it is returned to the caller. These are stored in the
interceptors.response
array. - You can add interceptors using
addRequestInterceptor
andaddResponseInterceptor
functions.
- Request Interceptors: Functions that receive and can modify the request configuration before the fetch call is made. These are stored in the
-
Progress Tracking:
- The
customFetch
function accepts anonProgress
callback. - It checks if the response body can be read as a stream using
response.body.getReader()
. - Using the Streams API, it reads the response in chunks, calculates the loaded bytes, and calls
onProgress
with the progress details. - Note that Fetch does not natively support upload progress tracking. For uploads, you might need more complex solutions like
XMLHttpRequest
or using the Streams API.
- The
-
Error Handling:
- The wrapper checks if
response.ok
isfalse
, which indicates HTTP errors (like 4xx or 5xx status codes). If so, it throws an error. - Network errors or issues with reading the stream also result in errors being thrown.
- All errors are caught in the
catch
block, where you can handle them as needed.
- The wrapper checks if
-
Usage Example:
- A request interceptor adds an
Authorization
header to every request. - A response interceptor logs the response status.
- The
customFetch
function is called with a URL and anonProgress
callback to log download progress. - The response is processed as JSON, and data is logged to the console.
- A request interceptor adds an
-
Upload Progress: Tracking upload progress with
fetch
is more complex and not directly supported. You might need to useXMLHttpRequest
for granular upload progress events or explore advanced features like the Streams API withfetch
if suitable for your use case. -
Browser Support: The Streams API and some advanced features used in this implementation may not be supported in all browsers. Ensure to check compatibility or include necessary polyfills.
-
Extensibility: This is a minimal implementation. Depending on your needs, you can extend it with more features such as:
- Response data transformation
- Cancellation tokens
- Timeout handling
While the native fetch()
API is powerful and flexible, it requires additional boilerplate to match some of the convenience features provided by axios
. By creating a simple wrapper with interceptor support, progress tracking, and robust error handling, you can achieve a similar developer experience without introducing external dependencies.
Can you please rewrite this wrapper in TypeScript and abstract it into a class so I can import it as a utility function?
Certainly! Below is a TypeScript implementation of a HttpClient
class that wraps the native fetch
API, providing features similar to Axios, such as request and response interceptors, progress tracking, and error handling. This utility can be easily imported and used throughout your TypeScript projects.
// HttpClient.ts
type RequestConfig = RequestInit & { url: string };
type RequestInterceptor = (config: RequestConfig) => Promise<RequestConfig> | RequestConfig;
type ResponseInterceptor = (response: Response) => Promise<Response> | Response;
interface ProgressEvent {
loaded: number;
total: number;
}
export class HttpClient {
private requestInterceptors: RequestInterceptor[] = [];
private responseInterceptors: ResponseInterceptor[] = [];
/**
* Adds a request interceptor.
* @param interceptor Function to modify the request config.
*/
public addRequestInterceptor(interceptor: RequestInterceptor): void {
this.requestInterceptors.push(interceptor);
}
/**
* Adds a response interceptor.
* @param interceptor Function to modify the response.
*/
public addResponseInterceptor(interceptor: ResponseInterceptor): void {
this.responseInterceptors.push(interceptor);
}
/**
* Performs a fetch request with interceptors, progress tracking, and error handling.
* @param url The URL to fetch.
* @param options Fetch options.
* @param onDownloadProgress Callback for download progress.
* @returns A promise resolving to the Response object.
*/
public async request(
url: string,
options: RequestInit = {},
onDownloadProgress?: (progress: ProgressEvent) => void
): Promise<Response> {
// Initial request config
let config: RequestConfig = { ...options, url };
// Apply request interceptors
for (const interceptor of this.requestInterceptors) {
config = await interceptor(config);
}
const { url: reqUrl, ...fetchOptions } = config;
try {
const response = await fetch(reqUrl, fetchOptions);
// Check for HTTP error statuses
if (!response.ok) {
const error: any = new Error(`HTTP error! status: ${response.status}`);
error.response = response;
throw error;
}
// Handle download progress if callback is provided
if (onDownloadProgress && response.body && response.body.getReader) {
return this.handleDownloadProgress(response, onDownloadProgress);
}
// Apply response interceptors
let finalResponse = response;
for (const interceptor of this.responseInterceptors) {
finalResponse = await interceptor(finalResponse);
}
return finalResponse;
} catch (error) {
// Network errors or thrown HTTP errors
console.error('Fetch error:', error);
throw error;
}
}
/**
* Handles the download progress of a fetch response.
* @param response The original Response object.
* @param onProgress Callback for progress updates.
* @returns A promise resolving to a new Response object with the streamed body.
*/
private async handleDownloadProgress(response: Response, onProgress: (progress: ProgressEvent) => void): Promise<Response> {
const contentLength = response.headers.get('Content-Length');
if (!contentLength) {
console.warn('Content-Length header is missing');
return response;
}
const total = parseInt(contentLength, 10);
let loaded = 0;
const reader = response.body!.getReader();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
loaded += value.byteLength;
onProgress({ loaded, total });
controller.enqueue(value);
}
}
controller.close();
} catch (error) {
console.error('Error reading stream:', error);
controller.error(error);
}
},
});
// Create a new Response with the streamed body
const streamedResponse = new Response(stream, {
headers: response.headers,
status: response.status,
statusText: response.statusText,
});
// Apply response interceptors to the streamed response
let finalResponse = streamedResponse;
for (const interceptor of this.responseInterceptors) {
finalResponse = await interceptor(finalResponse);
}
return finalResponse;
}
/**
* Convenience methods for HTTP verbs.
*/
public get(url: string, options?: RequestInit, onDownloadProgress?: (progress: ProgressEvent) => void): Promise<Response> {
return this.request(url, { ...options, method: 'GET' }, onDownloadProgress);
}
public post(url: string, body: any, options?: RequestInit, onDownloadProgress?: (progress: ProgressEvent) => void): Promise<Response> {
return this.request(url, { ...options, method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json', ...(options?.headers || {}) } }, onDownloadProgress);
}
// Similarly, you can add put, delete, etc.
}
Here's how you can utilize the HttpClient
class in your TypeScript project:
// main.ts
import { HttpClient } from './HttpClient';
// Instantiate the HttpClient
const httpClient = new HttpClient();
// Add a request interceptor to include an Authorization header
httpClient.addRequestInterceptor(async (config) => {
const token = 'your-token-here';
config.headers = {
...config.headers,
'Authorization': `Bearer ${token}`,
};
return config;
});
// Add a response interceptor to log the response status
httpClient.addResponseInterceptor(async (response) => {
console.log('Response Status:', response.status);
return response;
});
// Function to perform a GET request with progress tracking
async function fetchPosts() {
try {
const response = await httpClient.get('https://jsonplaceholder.typicode.com/posts', {}, (progress) => {
const percent = (progress.loaded / progress.total) * 100;
console.log(`Download Progress: ${percent.toFixed(2)}%`);
});
const data = await response.json();
console.log('Data received:', data);
} catch (error) {
console.error('Request failed:', error);
}
}
// Execute the function
fetchPosts();
-
Type Definitions:
RequestConfig
: Extends the nativeRequestInit
interface to include theurl
property.RequestInterceptor
andResponseInterceptor
: Define the function signatures for interceptors.ProgressEvent
: Represents the structure of the progress data passed to theonDownloadProgress
callback.
-
Interceptors:
- Request Interceptors: Stored in the
requestInterceptors
array. They allow modification of the request configuration before the fetch call is made. - Response Interceptors: Stored in the
responseInterceptors
array. They allow processing or modification of the response before it's returned to the caller.
- Request Interceptors: Stored in the
-
Request Method:
- Applying Request Interceptors: Iterates through all registered request interceptors, allowing each to modify the
RequestConfig
. - Making the Fetch Call: Uses the modified configuration to perform the fetch request.
- Error Handling: Throws an error if the response status is not OK (i.e., not in the range 200-299).
- Download Progress Tracking: If an
onDownloadProgress
callback is provided and the response supports streaming, it processes the response in chunks, updating the progress accordingly. - Applying Response Interceptors: After processing the response (and handling progress if applicable), it applies all registered response interceptors.
- Applying Request Interceptors: Iterates through all registered request interceptors, allowing each to modify the
-
Progress Tracking:
- Utilizes the Streams API to read the response body in chunks.
- Calculates the loaded bytes and total bytes to provide progress updates via the
onDownloadProgress
callback.
-
Convenience Methods:
- Implements
get
andpost
methods for common HTTP verbs, simplifying usage. Additional methods likeput
,delete
, etc., can be added similarly.
- Implements
-
Usage Example:
- Demonstrates how to instantiate the
HttpClient
, add interceptors, and make a GET request with download progress tracking. - The request interceptor adds an
Authorization
header to all outgoing requests. - The response interceptor logs the HTTP status of all incoming responses.
- The
fetchPosts
function performs a GET request to retrieve posts, tracking and logging the download progress.
- Demonstrates how to instantiate the
-
Upload Progress Tracking:
- The native
fetch
API does not provide a straightforward way to track upload progress. If upload progress tracking is essential for your application, consider usingXMLHttpRequest
or explore more advanced solutions involving the Streams API.
- The native
-
Cancellation Tokens:
- For enhanced control over requests, you might want to implement cancellation tokens using
AbortController
.
- For enhanced control over requests, you might want to implement cancellation tokens using
-
Default Configuration:
- You can extend the
HttpClient
class to accept default configurations (e.g., base URL, default headers) to streamline request setup.
- You can extend the
-
Error Parsing:
- Customize error handling by parsing error responses (e.g., extracting error messages from JSON responses) before throwing them.
-
Timeout Handling:
- Implement request timeouts using
AbortController
to abort requests that take longer than expected.
- Implement request timeouts using
The HttpClient
class provided above offers a robust and type-safe way to interact with APIs using the native fetch
API in TypeScript. By incorporating request and response interceptors, progress tracking, and comprehensive error handling, it brings much of the convenience of libraries like Axios without introducing external dependencies. You can further customize and extend this utility to fit the specific needs of your projects.