Skip to content

Instantly share code, notes, and snippets.

@fry69
Created October 5, 2024 11:48
Show Gist options
  • Save fry69/6bdca157d02491809e577a4093273588 to your computer and use it in GitHub Desktop.
Save fry69/6bdca157d02491809e577a4093273588 to your computer and use it in GitHub Desktop.
Wrapper around fetch with similar features like axios

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.

Key Features to Implement

  1. Request Interception: Modify or log requests before they are sent.
  2. Progress Tracking: Monitor the download progress of the response.
  3. Error Handling: Gracefully handle HTTP errors and network issues.

Implementation Using a Wrapper Function

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

Explanation of the Implementation

  1. 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 and addResponseInterceptor functions.
  2. Progress Tracking:

    • The customFetch function accepts an onProgress 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.
  3. Error Handling:

    • The wrapper checks if response.ok is false, 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.
  4. 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 an onProgress callback to log download progress.
    • The response is processed as JSON, and data is logged to the console.

Notes and Considerations

  • Upload Progress: Tracking upload progress with fetch is more complex and not directly supported. You might need to use XMLHttpRequest for granular upload progress events or explore advanced features like the Streams API with fetch 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

Conclusion

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 Class Implementation

// 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.
}

Usage Example

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();

Explanation of the Implementation

  1. Type Definitions:

    • RequestConfig: Extends the native RequestInit interface to include the url property.
    • RequestInterceptor and ResponseInterceptor: Define the function signatures for interceptors.
    • ProgressEvent: Represents the structure of the progress data passed to the onDownloadProgress callback.
  2. 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.
  3. 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.
  4. 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.
  5. Convenience Methods:

    • Implements get and post methods for common HTTP verbs, simplifying usage. Additional methods like put, delete, etc., can be added similarly.
  6. 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.

Additional Considerations

  • 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 using XMLHttpRequest or explore more advanced solutions involving the Streams API.
  • Cancellation Tokens:

    • For enhanced control over requests, you might want to implement cancellation tokens using AbortController.
  • Default Configuration:

    • You can extend the HttpClient class to accept default configurations (e.g., base URL, default headers) to streamline request setup.
  • 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.

Conclusion

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment