Ryan-Zayne / CallApi

A lightweight wrapper over fetch with quality of life improvements like built-in request cancellation, retries, interceptors and more

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

CallApi

Build SizeVersion

CallApi Fetch is an extra-lightweight wrapper over fetch that provides convenient options for making HTTP requests, while keeping the API familiar to the fetch api.

It takes in a url and a request options object, just like fetch, but with some additional options to make your life easier. Check out the API Reference for more details.

Installing CallApi

Through npm (recommended)

# npm
npm install @zayne-labs/callapi

# pnpm
pnpm add @zayne-labs/callapi

Then you can use it by importing it in your JavaScript file.

import { callApi } from "@zayne-labs/callapi";

Using callApi without npm

You can import callApi directly into JavaScript through a CDN.

To do this, you first need to set your script's type to module, then import callApi.

<script type="module">
 import { callApi } from "https://esm.run/@zayne-labs/callapi";
</script>

Quick Start

You can use callApi just like a normal fetch function. The only difference is you don't have to write a response.json or response.text, you could just destructure the data and error directly.

This also means that all options for the native fetch options are supported, and you can use the same syntax to send requests.

const { data, error } = await callApi("url", fetchOptions);

You also have access to the response object itself via destructuring:

const { data, error, response } = await callApi("url", fetchOptions);

To see how to use callApi with typescript for extra autocomplete convenience, visit the Typescript section

Supported response types

CallApi supports all response types offered by the fetch api like json, text,blob,formData etc, so you don't have to write response.json(), response.text(), response.formData() etc.

You can configure the response type you prefer by passing in the responseType option and setting it to the form you want the data from the response to be in. By default it's set to json.

// Json (default)
const { data } = await callApi("url", { responseType: "json" });
// Text
const { data } = await callApi("url", {responseType: "text"});
// Blob, etc
const { data } = await callApi("url", {responseType: "blob"});


// Doing this in fetch would imply:
const response = await fetch("some-url");

const data = await response.json(); // Or response.text() or response.blob() etc

Easy error handling when using async/await

CallApi lets you access all errors, both http errors and other regular javascript errors, in an error object. This object contains the errorName (eg: 'TypeError', 'SyntaxError' etc) and the error message as well.

If the error is an http error, the errorName property will be set to "HTTPError" and the error object will also have a property errorData.

This property would contains the error response data coming from the api. If the error is not an http error but some other error, the errorData property will be set to null.

const { data, error } = await callApi("some-url");

console.log(error.errorName);
console.log(error.message);
// Will be null if not an http error, else would contain the parsed error response data
console.log(error.errorData);

For extra convenience with typescript, visit the Typescript section

Helpful Features

✔️ Automatic Cancellation of Redundant Requests (No more race conditions🤩)

CallApi implements an internal request management system to prevent race conditions and ensure that only the most recent request to a given URL is processed.

How this feature Works in detail:

  • When a new request is made, callApi internals first check if there's an ongoing request to the same URL.
  • If a pending request exists, it's automatically cancelled.
  • The new request is then processed, ensuring that only the most recent data is retrieved and handled.

Key takeaways:

  • Automatic Cancellation: When multiple requests are made to the same URL in quick succession, callApi automatically cancels any pending previous requests, allowing only the latest request to proceed.
  • Race Condition Prevention: This mechanism eliminates race conditions that can occur when rapid, successive API calls are made, such as during fast typing in a search input, button clicks, etc.
  • Ideal for React Hooks: This feature is particularly useful when callApi is used within React's useEffect hook or similar scenarios where component updates might trigger multiple API calls.
  • Configurable: If you prefer to handle request management differently, you can disable this feature by setting { cancelRedundantRequests: false } in the fetch options. No pressure 👌.
  • Manual Cancellation: You can manually cancel requests to a specific URL using the cancel method attached to callApi. You can also pass an abort controller signal to callApi (just like with fetch) as an option and abort the request when you want to.
callApi("some-url");

callApi.cancel("some-url");
const controller = new AbortController();

callApi("some-url", { signal: controller.signal });

controller.abort();

✔️ Query search params

You can add query object as an option and callApi will create a query string for you automatically.

callApi("some-url", {
 query: {
  param1: "value1",
  param2: "to encode",
 },
});

// The above request can be written in Fetch like this:
fetch("url?param1=value1&param2=to%20encode");

✔️ Content-Type generation based on body content

CallApi sets Content-Type automatically depending on your body data. Supported data types for this automatic setting include:

  • Object
  • Query Strings
  • FormData

If you pass in an object, callApi will set Content-Type to application/json. It will also JSON.stringify your body so you don't have to do it yourself.

callApi.post("some-url", {
 body: { message: "Good game" },
});

// The above request is equivalent to this
fetch("some-url", {
 method: "post",
 headers: { "Content-Type": "application/json" },
 body: JSON.stringify({ message: "Good game" }),
});

If you pass in a string, callApi will set Content-Type to application/x-www-form-urlencoded.

CallApi also contains a toQueryString method that can help you convert objects to query strings so you can use this option easily.

import { toQueryString } from "@zayne-labs/callapi";

callApi("some-url", {
 method: "POST",
 body: toQueryString({ message: "Good game" }),
});

// The above request is equivalent to this
fetch("some-url", {
 method: "post",
 headers: { "Content-Type": "application/x-www-form-urlencoded" },
 body: "message=Good%20game",
});

If you pass in a FormData, callApi will let the native fetch function handle the Content-Type. Generally, this will use multipart/form-data with the default options.

const data = new FormData(form.elements);

callApi("some-url", { body: data });

✔️ Authorization header helpers

If you provide callApi with an auth property, it will conveniently generate an Authorization Header for you.

If you pass in a string (commonly for tokens) , it will generate a Bearer Auth.

But if you pass in an object, you would have two options to chose from:

  • Use bearer option if you want to generate a Bearer Auth Header.
  • Use token if you want to generate a Token Auth Header.
// Passing a string
callApi("some-url", { auth: "token12345" });

// The above request can be written in Fetch like this:
fetch("some-url", {
 headers: { Authorization: `Bearer token12345` },
});

// Passing an object:

// For Bearer Auth
callApi("some-url", { auth: { bearer: "token12345" } });
// For Token Auth
callApi("some-url", { auth: { token: "token12345" } });

// The above requests can be written in Fetch like this:

// For Bearer Auth
fetch("some-url", {
 headers: { Authorization: `Bearer token12345` },
});

// For Token Auth
fetch("some-url", {
 headers: { Authorization: `Token token12345` },
});

✔️ Creating a callApi Instance

You can create an instance of callApi with predefined options. This is super helpful if you need to send requests with similar options.

Things to note:

  • All options that can be passed to callApi can also be passed to callApi.create.
  • Any options passed to callApi.create will be applied to all requests made with the instance.
  • If you pass a similar options property to the instance, the instance's options will take precedence.
import { callApi } from "@zayne-labs/callapi";

// Creating the instance, with some base options
const callAnotherApi = callApi.create({
 timeout: 5000,
 baseURL: "https://api.example.com"
});

// Using the instance
const { data, error } = await callAnotherApi("some-url");

// Overriding the timeout option (all base options can be overridden via the instance)
const { data, error } = await callAnotherApi("some-url", {
 timeout: 10000,
});

You could also use the createFetchClient function to create an instance, if you don't want to use callApi.create.

import { createFetchClient } from "@zayne-labs/callapi";

const callAnotherApi = createFetchClient({
 timeout: 5000,
 baseURL: "https://api.example.com"
});

✔️ Custom response parser and custom body serializer

By default callApi supports all response types offered by the fetch api like json, text,blob etc, so you don't have to write response.json(), response.text() or response.blob().

But if you want to parse a response with a custom function other than the default JSON.parse, you can pass a custom parser function to the responseParser option.

const { data, error } = await callApi("url", {
 responseParser: customResponseParser,
});

Or even better, provide it as a callApi base option.

const callAnotherApi = callApi.create({
 responseParser: customResponseParser,
});

You could also provide a custom serializer/stringifier, other the default JSON.stringify, for objects passed to the reuqest body via the bodySerializer option.

const callAnotherApi = callApi.create({
 bodySerializer: customBodySerializer,
});

✔️Validator function

CallApi also provides a responseValidator option, which could pass in a function that would validate the data returned from the server.

A good use case for this would to pass in, for instance, a zod schema parse/safeParse function to validate the data.

If your parser function throws an error, and have the throwOnError option set to true, you will expected to check and handle the errors in a catch block. But, if throwOnError is set to false (default), it will just return the error object as usual.

const callMainApi = await callApi.create({
 responseValidator: zodSchema.parse, // or zodSchema.safeParse or any other validator you wish to use
});

✔️ Interceptors (just like axios)

Providing interceptors to hook into lifecycle events of a callApi call is possible.

These interceptors can be either asynchronous or synchronous.

Note: You might want to use callApi.create to set shared interceptors

onRequest({ request, options })

onRequest is called function that is called just before the request is made, allowing you to modify the request or perform additional operations.

await callApi("/api", {
 onRequest: ({ request, options }) => {
  // Log request
  console.log(request, options);

  // Do other stuff
 },
});

onRequestError({ error, request, options,})

onRequestError when an error occurs during the fetch request and it fails, providing access to the error object, request details and fetch options.

await callApi("/api", {
 onRequestError: ({ request, options, error }) => {
  // Log error
  console.log("[fetch request error]", request, error);
 },
});

onResponse({ response, request, options })

onResponse will be called when a successful response is received, providing access to the response, request details and fetch options.

The response object here contains all regular fetch response properties, plus a data property, which contains the parsed response body.

await callApi("/api", {
 onResponse: ({ request, response, options }) => {
  // Log response
  console.log(request, response.status, response.data);

  // Do other stuff
 },
});

onResponseError({ request, options, response })

onResponseError is called when an error response (status code >= 400) is received from the api, providing access to the response object, request details, and fetch options used.

The response object here contains all regular fetch response properties, plus an errorData property, which contains the parsed response error json response, if the server returns one.

This to note for this interceptor to be triggered:

  • The response.ok property will be false.
  • The response.status property will be >= 400.
  • Essentially only error http responses return by the api will trigger this interceptor.
  • It won't trigger for error responses not from the api, like network errors, syntax errors etc. Handle those in onRequestError interceptor.

The response object here contains all regular fetch response properties, plus an errorData property, which contains the parsed response error json response, if the server returns one.

This example uses a shared interceptor for all requests made with the instance.

const callAnotherApi = callApi.create({
 onResponseError: ({ response, request, options }) => {
  // Log error response
  console.log(request, response.status, response.errorData);

  // Perform action on various error conditions
  if (response.status === 401) {
   actions.clearSession();
  }

  if (response.status === 429) {
   toast.error("Too may requests!");
  }

  if (response.status === 403 && response.errorData?.message === "2FA is required") {
   toast.error(response.errorData?.message, {
    description: "Please authenticate to continue",
   });
  }

  if (response.status === 500) {
   toast.error("Internal server Error!");
  }
 },
});

✔️ Retries

CallApi support retries for requests if an error happens and if the response status code is included in retryStatusCodes list:

Default Retry status codes:

  • 408 - Request Timeout
  • 409 - Conflict
  • 425 - Too Early
  • 429 - Too Many Requests
  • 500 - Internal Server Error
  • 502 - Bad Gateway
  • 503 - Service Unavailable
  • 504 - Gateway Timeout

You can specify the amount of retries and delay between them using retry and retryDelay options and also pass a custom array of codes using retryStatusCodes option.

You can also specify which methods should be retried by passing in a custom retryMethods array.

The default for retry is 0 retries. The default for retryDelay is 0 ms. The default for retryMethods is ["GET", "POST"].

await callApi("http://google.com/404", {
 retry: 3,
 retryDelay: 500, // ms
 retryStatusCodes: [404, 502, 503, 504], // custom status codes for retries
 retryMethods: ["POST", "PUT", "PATCH", "DELETE"], // custom methods for retries
});

✔️ Timeout

You can specify timeout in milliseconds to automatically abort a request after a timeout (default is disabled).

await callApi("http://google.com/404", {
 timeout: 3000, // Timeout after 3 seconds
});

✔️ Throw on all errors

You can throw an error on all errors (including http errors) by passing throwOnError option. This makes callApi play nice with other libraries that expect a promise to resolve to a value, for example React Query.

const callMainApi = callApi.create({
 throwOnError: true,
});

const { data, error } = useQuery({
 queryKey: ["todos"],
 queryFn: async () => {
  // CallApi will throw an error if the request fails or there is an error response, which react query would handle
  const { data } = await callMainApi("todos");

  return data;
 },
});

Doing this with regular fetch would imply the following extra steps:

const { data, error } = useQuery({
 queryKey: ["todos"],
 queryFn: async () => {
  const response = await fetch("todos");

  if (!response.ok) {
   throw new Error("Failed to fetch");
  }

  return response.json();
 },
});

For even more convenience, you can specify a resultMode for callApi in addition with the throwOnError option. Use this if you feel to lazy to make a tiny wrapper over callApi for something like react query:

const callMainApi = callApi.create({
 throwOnError: true,
 resultMode: "onlySuccess",
});

const { data, error } = useQuery({
 queryKey: ["todos"],
 // CallApi will throw on errors here, and also return only data, which react query is interested in
 queryFn: () => callMainApi("todos"),
});

Usage with Typescript

  • You can provide types for the success and error data via generics, to enable autocomplete and type checking in your codebase.
const callMainApi = callApi.create<FormResponseDataType, FormErrorResponseType>({
 baseURL: BASE_AUTH_URL,

 method: "POST",

 retries: 3,

 credentials: "same-origin",
});
  • Just like the fetch options, all type parameters (generics) can also be overriden per instance level

const { data } = callMainApi({ method: "GET",

retries: 5, });


- Since the `data` and `error` properties destructured from callApi are in a discriminated union, simply checking for and handling the `error` property will narrow down the type of the data. The reverse case also holds (checking for data to narrow error type).

This simply means that if data is available error will be null, and if error is available data will be null. Both cannot exist at the same time.

```ts
// As is, both data and error could be null
const { data, error } = await callMainApi("some-url", {
 body: { message: "Good game" },
});

if (error) {
 console.error(error);
 return;
}

// Now, data is no longer null
console.log(data);
  • The types for the object passed to onResponse and onResponseError could be augmented with type helpers provided by @zayne-labs/callapi.
const callAnotherApi = callApi.create({
 onResponseError: ({ response, request, options }: ResponseErrorContext<{ message?: string }>) => {
  // Log error response
  console.log(
   request,
   response.status,
   // error data, coming back from api
   response.errorData,
   // Typescript will then understand the errorData might contains a message property
   response.errorData?.message
  );
 },
});

Api Reference

Fetch Options

  • All Regular fetch options are supported as is, with only body extended to support more types.

  • body: Optional body of the request, can be an object or any other supported body type.

  • query: Query parameters to append to the URL.

  • auth: Authorization header value.

  • bodySerializer: Custom function to serialize the body object into a string.

  • responseParser: Custom function to parse the response string into an object.

  • resultMode: Mode of the result, can influence how results are handled or returned. (default: "all")

  • cancelRedundantRequests: If true, cancels previous unfinished requests to the same URL. (default: true)

  • baseURL: Base URL to be prepended to all request URLs.

  • timeout: Request timeout in milliseconds.

  • defaultErrorMessage: Default error message to use if none is provided from a response. (default: "Failed to fetch data from server!")

  • throwOnError: If true or the function returns true, throws errors instead of returning them.

  • responseType: Expected response type, affects how response is parsed. (default: "json")

  • retries: Number of retry attempts for failed requests. (default: 0)

  • retryDelay: Delay between retries in milliseconds. (default: 500)

  • retryCodes: HTTP status codes that trigger a retry. (default: [409, 425, 429, 500, 502, 503, 504])

  • retryMethods: HTTP methods that are allowed to retry. (default: ["GET", "POST"])

  • meta: An optional field that can contain additional information about a request, which could be helpful in differentiating between different requests in a shared interceptor.

  • onRequest: Interceptor called just before the request is made, allowing for modifications or additional operations.

  • onRequestError: Interceptor called when an error occurs during the fetch request.

  • onResponse: Interceptor called when a successful response is received from the API.

  • onResponseError: Interceptor called when an error response is received from the API.

Methods

  • callApi.create(options): Creates an instance of callApi with shared base configurations.
  • callApi.cancel(url: string): Cancels an ongoing request to the specified URL.

Utility Functions

  • isHTTPError: Type guard for if an error is an HTTPError

  • isHTTPErrorInstance: Type guard for if an error is an instance of HTTPError. Useful for when throwAllErrors option is set to true

  • toQueryString: Converts an object to a URL query string

Acknowledgements

  • Credits to ofetch by unjs for some of the ideas for the features in the library like the function-based interceptors, retries etc
  • Credits to zl-fetch fetch wrapper as well for the inspiration behind a few features in this library as well

About

A lightweight wrapper over fetch with quality of life improvements like built-in request cancellation, retries, interceptors and more

License:MIT License


Languages

Language:TypeScript 68.1%Language:JavaScript 31.8%Language:Shell 0.2%