Package Exports
- @zayne-labs/callapi
Readme
CallApi
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/callapiThen 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() etcEasy error handling via using async/await
CallApi provides a unified error handling through an error object, which captures both HTTP errors (errors coming as a response from the api) and standard JavaScript errors.
The error object contains the following properties:
errorName: A string indicating the type of error (e.g., 'TypeError', 'SyntaxError', 'HTTPError').message: The error message describing what went wrong.errorData: The error data, which can be an error response from the API or a standard JavaScript error object.
For HTTP errors:
errorNameis set to "HTTPError"- An additional
errorDataproperty contains the error response data from the API.
For non-HTTP errors (e.g., TypeError, SyntaxError):
errorNamereflects the specific JavaScript error type (i.e., 'TypeError', 'SyntaxError')- The
errorDataproperty contains the original JavaScript Error object.
This structure allows you to easily identify and handle different types of errors that may occur during API calls.
const { data, error } = await callApi("some-url");
console.log(error.errorName);
console.log(error.message);
// Will reference the Error object if the error 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,
callApiinternals 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,
callApiautomatically 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
callApiis 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 tocallApi(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¶m2=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
beareroption if you want to generate a Bearer Auth Header. - Use
tokenif 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
optionsthat can be passed tocallApican also be passed tocallApi.create. - Any options passed to
callApi.createwill be applied to all requests made with the instance. - If you pass a similar
optionsproperty 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.okproperty will befalse. - The
response.statusproperty 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
onRequestErrorinterceptor.
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 Timeout409- Conflict425- Too Early429- Too Many Requests500- Internal Server Error502- Bad Gateway503- Service Unavailable504- 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
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 ofcallApiwith 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 HTTPErrorisHTTPErrorInstance: Type guard for if an error is an instance of HTTPError. Useful for whenthrowAllErrorsoption is set totruetoQueryString: Converts an object to a URL query string
Acknowledgements
- Credits to
ofetchby unjs for some of the ideas for the features in the library like the function-based interceptors, retries etc - Credits to
zl-fetchfetch wrapper as well for the inspiration behind a few features in this library as well