# Title: Advanced Options
Description: Advanced configuration options for fine-tuned control
URL: /docs/advanced-options
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/advanced-options.mdx
This page covers advanced CallApi configuration options for specialized use cases and fine-tuned control over request/response handling.
Response Cloning [#response-cloning]
Enable `cloneResponse` to read the response multiple times in different places (e.g., in hooks and main code).
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const callApi = createFetchClient({
cloneResponse: true,
onSuccess: async ({ response }) => {
const data = await response.json();
console.log(data);
},
});
```
Automatically enabled when using
`dedupeStrategy: "defer"`
.
Custom Fetch Implementation [#custom-fetch-implementation]
Replace the default fetch function for testing or using alternative HTTP clients.
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
// Mock fetch for testing
const mockFetch = async (url: string | Request | URL, init?: RequestInit) => {
return Response.json(
{ mocked: true },
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
};
const callApi = createFetchClient({
customFetchImpl: mockFetch,
});
```
Skip Auto-Merge [#skip-auto-merge]
Control which configuration parts skip automatic merging between base and instance configs.
**How it works:**
By default, CallApi automatically merges base config with instance config. When you set `skipAutoMergeFor`, CallApi stops merging that part - **you become responsible for manually spreading the skipped object to preserve instance values**.
This is useful when you need to manually spread instance options and then selectively override specific nested properties with defaults.
**Available options:**
* `"options"` - Skips auto-merge of extra options (plugins, hooks, meta, etc.). You must manually spread `ctx.options`.
* `"request"` - Skips auto-merge of request options (headers, body, method, etc.). You must manually spread `ctx.request`.
* `"all"` - Skips auto-merge of both. You must manually spread both `ctx.options` and `ctx.request`.
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient((ctx) => ({
baseURL: "https://api.example.com",
plugins: [authPlugin()],
skipAutoMergeFor: "options",
// Spread instance options first
...(ctx.options as object),
// Then provide defaults for nested properties
meta: {
...ctx.options.meta,
auth: {
signInRoute: "/auth/signin",
// Instance values override these defaults
...ctx.options.meta?.auth,
},
},
}));
// Instance can override nested auth properties
await client("/protected", {
meta: {
auth: {
redirectOnError: false,
},
},
});
```
**Why use `skipAutoMergeFor: "options"`?**
Without it, CallApi automatically merges `ctx.options` with your base config, which means you can't provide defaults for nested properties that can be overridden.
With `skipAutoMergeFor: "options"`:
1. CallApi stops automatically merging `ctx.options`
2. **You must manually spread `ctx.options`** to preserve instance values: `...(ctx.options as object)`
3. Then you can provide defaults for nested properties
4. Instance-provided nested values override your defaults because they're spread last
If you don't manually spread the skipped object, instance values will be lost!
Body Serialization [#body-serialization]
Customize how plain object request bodies are serialized into strings.
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const callApi = createFetchClient({
bodySerializer: (body) => JSON.stringify(body, null, 2),
});
```
Body Transformation [#body-transformation]
Transform request bodies into another Fetch-compatible body type. `bodyTransformer` takes precedence over `bodySerializer`.
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const callApi = createFetchClient({
bodyTransformer: ({ body }) => {
const formData = new FormData();
Object.entries(body).forEach(([key, value]) => {
formData.append(key, String(value));
});
return formData;
},
});
```
Response Parsing [#response-parsing]
Customize how response strings are parsed.
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
// Parse XML responses
const xmlClient = createFetchClient({
responseParser: (responseString) => {
const parser = new DOMParser();
const doc = parser.parseFromString(responseString, "text/xml");
return xmlToObject(doc);
},
});
// Custom JSON parser with error handling
const customClient = createFetchClient({
responseParser: (responseString) => {
try {
return JSON.parse(responseString);
} catch {
return { error: "Invalid JSON", raw: responseString };
}
},
});
```
Default HTTP Error Messages [#default-http-error-messages]
Customize the default error message when the server doesn't provide one.
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
defaultHTTPErrorMessage: ({ response }) => {
switch (response.status) {
case 401: {
return "Authentication required";
}
case 403: {
return "Access denied";
}
case 404: {
return "Resource not found";
}
default: {
return `Request failed with status ${response.status}`;
}
}
},
});
```
Meta Field [#meta-field]
Associate metadata with requests for logging, tracing, or custom handling in hooks and middleware.
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const callApi = createFetchClient({
onError: ({ options, error }) => {
// Access metadata passed with the request
logError({
userId: options.meta?.userId,
requestId: options.meta?.requestId,
error,
});
},
});
await callApi("/api/data", {
meta: {
userId: currentUser.id,
requestId: generateId(),
},
});
```
Types [#types]
For complete type information on all options, see:
* [Extra Options](/docs/extra-options) - Instance-level options
* [Base Extra Options](/docs/base-extra-options) - Base client options
* [Request Options](/docs/request-options) - Fetch API options
# Title: Authorization
Description: Learn how to use convenience authorization helpers callApi provides
URL: /docs/authorization
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/authorization.mdx
CallApi provides convenient authorization helpers via the `auth` property, making it easy to add authentication headers to your requests without manually constructing Authorization headers.
Bearer [#bearer]
Since Bearer is the most common authorization type, passing a string to `auth` automatically generates a `Bearer` Authorization header. You can also use an object with a `bearer` property.
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
// Passing a string
const callBackendApi = createFetchClient({
baseURL: "http://localhost:3000",
auth: "my-token", // [!code highlight]
});
// Passing an object
const result = await callBackendApi("/users/123", {
auth: {
type: "Bearer",
value: "my-token",
},
});
```
The above is equivalent to writing the following with Fetch:
```ts
fetch("http://localhost:3000/users/123", {
headers: {
Authorization: `Bearer my-token`,
},
});
```
You can also pass a function that returns a string or a promise that resolves to a string. This is useful for retrieving tokens dynamically:
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const callBackendApi = createFetchClient({
baseURL: "http://localhost:3000",
});
const result = callBackendApi("/users/123", {
auth: {
type: "Bearer",
value: () => authStore.getToken(),
},
});
```
The function will be called only once when the request is made. If it returns undefined or null, the
header will not be added to the request. This allows for conditional authentication.
Token [#token]
Similar to Bearer authorization, but uses `Token` as the header prefix instead of `Bearer`.
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const callBackendApi = createFetchClient({
baseURL: "http://localhost:3000",
auth: {
type: "Token",
value: "my-token",
},
});
const result = await callBackendApi("/users/123");
```
The above is equivalent to writing the following with Fetch:
```ts
fetch("http://localhost:3000/users/123", {
headers: {
Authorization: `Token my-token`,
},
});
```
Basic [#basic]
Basic authentication adds username and password to the `Authorization` header, automatically base64 encoded.
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const callBackendApi = createFetchClient({
baseURL: "http://localhost:3000",
auth: {
type: "Basic",
username: "my-username",
password: "my-password",
},
});
```
You can also pass async getter functions for the username and password fields.
```ts
const { data } = await callApi("/api/data", {
auth: {
type: "Basic",
username: async () => await getUsername(),
password: async () => await getPassword(),
},
});
```
Custom [#custom]
For custom authorization schemes not supported by default, use the `Custom` type with `prefix` and `value` properties.
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const callBackendApi = createFetchClient({
baseURL: "http://localhost:3000",
auth: {
type: "Custom",
prefix: "SomePrefix",
value: "my-token",
},
});
const result = await callBackendApi("/users/123");
```
The above is equivalent to writing the following with Fetch:
```ts
fetch("http://localhost:3000/users/123", {
headers: { Authorization: `SomePrefix my-token` },
});
```
Advanced Examples [#advanced-examples]
Refreshing Tokens [#refreshing-tokens]
A common use case for `auth` is combining it with error hooks to implement automatic session recovery or token refreshing.
Since the `auth` function is called right before every request, and manual `refetch()` preservation is built-in, you can handle `401 Unauthorized` errors easily:
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const api = createFetchClient({
baseURL: "https://api.example.com",
auth: () => getAccessToken(),
onResponseError: async ({ response, options }) => {
if (response.status === 401) {
const newToken = await refreshToken();
localStorage.setItem("token", newToken);
options.refetch();
}
},
});
```
Conditional Auth [#conditional-auth]
Conditionally trigger authentication by returning `null` or `undefined` from your auth function when the user is not authenticated:
```ts title="api.ts"
const client = createFetchClient({
baseURL: "https://api.example.com",
auth: async () => {
// Only add auth if user is logged in
const isLoggedIn = await checkAuthStatus();
// Returning null means no auth header will be added
return isLoggedIn ? await getToken() : null;
},
});
// Or dynamically per-request
async function makeRequest(endpoint: string, requiresAuth: boolean) {
return callApi(endpoint, {
auth: requiresAuth ? () => getToken() : undefined,
});
}
```
Global Auth with Override [#global-auth-with-override]
Define a global authentication strategy when creating a client and override or disable it for specific requests:
```ts title="api.ts"
const client = createFetchClient({
baseURL: "https://api.example.com",
auth: () => getSessionToken(),
});
// Uses the global auth token
const userData = await client("/api/user");
// Override with a different token for a specific request
const adminData = await client("/api/admin", {
auth: () => getAdminToken(),
});
// Disable auth entirely for a specific request
const publicData = await client("/api/public", {
auth: undefined,
});
```
# Title: Comparisons
Description: How is CallApi different from other existing fetching libraries?
URL: /docs/comparisons
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/comparisons.mdx
CallApi is not a React Query, SWR, Apollo, or framework loader replacement. Those handle caching and server state.
CallApi sits lower. It is for the HTTP call itself which covers:
* Building the URL
* Sending the request
* Parsing the response
* Handling errors
* Retrying
* Dedupe
* Validating
* Running hooks, etc
Philosophy [#philosophy]
CallApi is built around four core principles:
1. **Lightweight:** Under 6KB. Zero dependencies. Pure ESM.
2. **Simple:** Based on Fetch API, and close enough to `fetch` that it does not feel like learning another client from scratch.
3. **Type-safe:** Types can come from schemas, validators, or manual generics.
4. **Extensible:** Hooks and plugins are there when the base client is not enough.
Pain Points CallApi Covers [#pain-points-callapi-covers]
These are the things that usually make a small fetch helper keep growing.
| Problem | What usually happens | What CallApi gives you |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------- |
| Repeated `try/catch`, `response.ok`, and `response.json()` code | Every API file grows its own version | `{ data, error, response }` by default |
| Different error conventions | Fetch does not throw for HTTP errors, Axios/Ky/Ofetch do | Return errors by default, throw when you ask for it |
| Request dedupe added late | You add maps, abort controllers, cache keys, or custom request registries | Built-in request dedupe |
| Validation separated from fetching | You fetch first, then manually parse with Zod/Valibot/etc. | Schema validation on the request, with inferred data types |
| Messy URL building | Template strings and `URLSearchParams` spread around the app | `params`, `query`, and method prefixes |
| Auth/defaults drift | Different helpers set headers in slightly different ways | Base client options, auth helpers, hooks, and plugins |
| Wrapper code keeps growing | Retries, timeouts, parsing, hooks, validation, and error shaping get rebuilt per app | Those pieces are already in the client |
Comparison Table [#comparison-table]
Approximate minified + gzipped sizes.
| Feature | Raw fetch | Axios | Ky | Ofetch | CallApi |
| ----------------------------------- | ------------------ | ------------------ | ----------------------- | ---------------------- | ------------------------------ |
| Size | 0KB | \~13KB | \~5KB | \~8KB | \<6KB |
| API style | Platform primitive | Custom HTTP client | Chainable fetch wrapper | `$fetch` style wrapper | Fetch-like client |
| Fetch-based | Yes | No | Yes | Yes | Yes |
| Default result shape | Response | Throws/response | Throws/body helpers | Throws/body | `{ data, error, response }` |
| Content-type aware response parsing | Manual | Partial | Yes | Yes | Yes |
| Request timeout | Manual | Yes | Yes | Yes | Yes |
| Retries | Manual | No | Yes | Yes | Yes |
| Request dedupe | Manual | No | No | No | Yes |
| Schema validation | Manual | No | No | No | Yes |
| Type inference from schemas | Manual | No | No | No | Yes |
| Hooks/interceptors | Manual | Yes | Yes | Yes | Yes |
| Plugin system | Manual | No | No | No | Yes |
| URL params/query helpers | Manual | Params only | Prefix/query helpers | Query helpers | Params, query, method prefixes |
Raw Fetch [#raw-fetch]
Raw fetch is fine until the same request code starts showing up everywhere.
```ts
const baseURL = process.env.NEXT_PUBLIC_API_URL;
const response = await fetch(`${baseURL}/api/users`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message);
}
const users = await response.json();
```
With CallApi:
```ts
import { callApi } from "@zayne-labs/callapi";
const { data: users, error } = await callApi("/api/users", {
baseURL: process.env.NEXT_PUBLIC_API_URL,
});
if (error) {
console.error(error.message, error.errorData);
}
```
Keep raw fetch if the wrapper stays tiny. Use CallApi when that wrapper starts growing retries, parsing, validation, auth, dedupe, and shared error handling.
Axios [#axios]
Axios is still fine. It just has its own request and response model, while CallApi stays closer to fetch.
What CallApi Adds Here [#what-callapi-adds-here]
* Retry logic and dedupe live in the client instead of wrapper code.
* Schema validation can live on the request instead of after the request.
* `params`, `query`, and method prefixes reduce URL string building.
* The default result object lets you handle expected API errors without wrapping every call in `try/catch`.
API Mapping [#api-mapping]
| Axios | CallApi |
| ----------------------------- | -------------------------------------------------------- |
| `axios.create({ baseURL })` | `createFetchClient({ baseURL })` |
| `axios.get("/users")` | `callApi("/users")` or `callApi("@get/users")` |
| `axios.post("/users", body)` | `callApi("@post/users", { body })` |
| `response.data` | `result.data` |
| `AxiosError` | `result.error` or thrown `HTTPError` with `throwOnError` |
| Request/response interceptors | Lifecycle hooks and plugins |
```ts
import axios from "axios";
const api = axios.create({
baseURL: "https://api.example.com",
timeout: 10_000,
});
api.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${getToken()}`;
return config;
});
const response = await api.post("/users", {
name: "Ada",
});
const user = response.data;
```
```ts
import { createFetchClient } from "@zayne-labs/callapi";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
timeout: 10_000,
onRequest: ({ options }) => {
options.auth = getToken();
},
});
const { data: user, error } = await callApi("@post/users", {
body: { name: "Ada" },
});
```
Stick with Axios if the app is deeply built around it. CallApi makes more sense when you want fetch plus the request-layer pieces you would otherwise add yourself.
Ky [#ky]
Ky is the closest comparison if you only care about size and fetch support. It is also a smaller mental model: chain the request, call `.json()`, move on.
What CallApi Adds Here [#what-callapi-adds-here-1]
* Ky's `.json()` is a type assertion. CallApi can infer from a schema and validate at runtime.
* Request dedupe, plugins, URL params, and result modes are in CallApi.
* CallApi fits better when the request layer needs shared behavior, not just nicer syntax.
```ts
import ky from "ky";
const api = ky.create({
prefixUrl: "https://api.example.com",
retry: 2,
});
const user = await api.get("users/1").json();
```
```ts
import { createFetchClient } from "@zayne-labs/callapi";
import { z } from "zod";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
retryAttempts: 2,
});
const { data: user } = await callApi("/users/:id", {
params: { id: 1 },
schema: {
data: z.object({
id: z.number(),
name: z.string(),
}),
},
});
```
Use Ky when you want the smaller tool. Use CallApi when you want the request lifecycle handled in one place.
Ofetch [#ofetch]
Ofetch and CallApi are close. Ofetch fits especially well if you already use Nuxt or other UnJS tools.
What CallApi Adds Here [#what-callapi-adds-here-2]
* Validation, dedupe, and plugin-style extension are not the main things Ofetch is solving.
* Ofetch throws by default. CallApi defaults to a result object, which is easier for expected API errors like validation failures.
* CallApi puts params, query, schemas, hooks, result modes, and request defaults on the same request model.
| Area | Ofetch | CallApi |
| ----------------------- | ------------------ | ------------------------------------------- |
| Basic fetch wrapper | Yes | Yes |
| Nuxt/UnJS ecosystem fit | Strong | Neutral |
| Default error behavior | Throws | Returns structured result unless configured |
| Schema validation | Manual | Built in |
| Dedupe | Manual | Built in |
| Plugins | Manual composition | Plugin API |
```ts
import { ofetch } from "ofetch";
const api = ofetch.create({
baseURL: "https://api.example.com",
retry: 2,
onRequest: ({ options }) => {
options.headers = {
...options.headers,
Authorization: `Bearer ${token}`,
};
},
});
const users = await api("/users");
```
```ts
import { createFetchClient } from "@zayne-labs/callapi";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
retryAttempts: 2,
onRequest: ({ options }) => {
options.auth = token;
},
});
const { data: users } = await callApi("/users");
```
# Title: Error Handling
Description: Learn more about how to handle errors in CallApi
URL: /docs/error-handling
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/error-handling.mdx
It's prevalent knowledge that making network requests is inherently risky. Things can go wrong for many reasons:
* The server might be down.
* The server might respond with an error status like 404 Not Found (the resource doesn't exist) or 500 Internal Server Error (something broke on the server).
* There might be a network issue.
* The request might timeout.
* The response data might not be in the format you expected (e.g., not valid JSON).
When using the standard browser fetch API, handling these failures can sometimes be a bit clunky due to the following reasons:
* Network errors throw one type of error
* Non-2xx HTTP responses don't throw errors by default (you have to check response.ok)
* Parsing errors might throw yet another type
This can lead to complex if/else chains and unwieldy try...catch blocks just to figure out what went wrong.
CallApi aims to make dealing with these failures much more predictable and convenient.
Structure of the `error` property [#structure-of-the-error-property]
As introduced in the [Getting Started](/docs/getting-started) guide, CallApi wraps responses in a result object with three key properties: `data`, `error`, and `response`.
When something goes wrong, the `error` property contains a structured object with:
1. **name**: A string identifying the type of error (e.g., `'HTTPError'`, `'ValidationError'`, `'TypeError'`, `'TimeoutError'`, ...etc).
2. **message**: A brief description of what went wrong:
* For HTTP errors: The error message from the server, or if not provided, falls back to the `defaultHTTPErrorMessage` option
* For validation errors: A formatted error message derived from the validation issues array
* For non-HTTP errors: The error message from the JavaScript error object that caused the error
3. **errorData**: The detailed error information:
* For HTTP errors: It is set to the `parsed error response from the API`
* For validation errors: It is set to the `validation issues array`
* For non-HTTP errors: It is set to `false`
4. **originalError**: The original error object that caused the error:
* For HTTP errors: `HTTPError`
* For validation errors: `ValidationError`
* For non-HTTP errors: The underlying javascript error object (e.g., `TypeError`, `DOMException`, etc.)
```ts twoslash title="api.ts"
import { callApi } from "@zayne-labs/callapi";
const { error } = await callApi("https://my-api.com/api/v1/session");
// @annotate: Hover over the error object to see the type
```
Handling HTTP Errors [#handling-http-errors]
One of the most common types of errors you'll encounter is when the server responds with a status code outside the 200-299 range (like 400, 401, 403, 404, 500, 503, etc.). Standard `fetch` doesn't throw an error for these responses.
CallApi, by default, wraps these responses in an `HTTPError`.
You can customize the error response data type by providing a second generic type argument to callApi.
```ts twoslash title="api.ts"
import { callApi } from "@zayne-labs/callapi";
type ErrorData = {
errors: Array;
message: string;
};
const { error } = await callApi("/api/endpoint");
if (error) {
console.log(error.errorData);
}
```
Since the `error` property is a discriminated union, you can use the `isHTTPError` utility from `@zayne-labs/callapi/utils` to check if it's an HTTP error:
```ts twoslash title="api.ts"
import { callApi } from "@zayne-labs/callapi";
import { isHTTPError } from "@zayne-labs/callapi/utils";
type UserData = {
completed: boolean;
id: number;
title: string;
userId: string;
};
type ErrorResponseData = {
errors?: Array;
message?: string;
};
const { data, error } = await callApi("https://my-api.com/api/v1/session");
if (isHTTPError(error)) {
console.error(error);
console.error(error.name); // 'HTTPError'
console.error(error.message);
console.error(error.errorData); // Will be set to the error response data
}
```
Handling Validation Errors [#handling-validation-errors]
When schema validation fails, CallApi wraps the failure in a `ValidationError`. See the [Validation](/docs/validation) section for details.
You can use the `isValidationError` utility to check specifically for this error type:
```ts twoslash title="api.ts"
import { callApi } from "@zayne-labs/callapi";
import { isValidationError } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const dataSchema = z.object({
id: z.number(),
userId: z.string(),
title: z.string(),
completed: z.boolean(),
});
const { data, error } = await callApi("https://my-api.com/api/v1/session", {
schema: {
data: dataSchema,
},
});
if (isValidationError(error)) {
console.error(error.name); // 'ValidationError'
console.error(error.errorData); // Validation issues array
}
```
Throwing Errors [#throwing-errors]
Set `throwOnError: true` to throw errors instead of returning them—useful for libraries expecting promise rejection:
```ts twoslash title="api.ts"
import { callApi } from "@zayne-labs/callapi";
import { isHTTPErrorInstance, isValidationErrorInstance } from "@zayne-labs/callapi/utils";
type UserData = {
completed: boolean;
id: number;
title: string;
userId: string;
};
type ErrorResponseData = {
errors?: Array;
message?: string;
};
try {
const { data } = await callApi("https://my-api.com/api/v1/session", {
throwOnError: true,
});
} catch (error) {
if (isHTTPErrorInstance(error)) {
console.error(error.errorData);
}
if (isValidationErrorInstance(error)) {
console.error(error.errorData);
}
}
```
**Conditional throwing:**
You can also pass a function to `throwOnError` for conditional throwing based on the error context:
```ts twoslash title="api.ts"
import { callApi } from "@zayne-labs/callapi";
import { isHTTPError, isValidationError } from "@zayne-labs/callapi/utils";
// Only throw for authentication errors
const resultOne = await callApi("https://my-api.com/api/v1/session", {
throwOnError: ({ response }) => response?.status === 401,
});
// Throw for client errors (user mistakes) but not server errors (temporary issues)
const resultTwo = await callApi("https://my-api.com/api/users", {
throwOnError: ({ response }) => {
if (!response) {
return false;
}
return response.status >= 400 && response.status < 500;
},
});
// Complex conditional logic based on error type and context
const resultThree = await callApi("https://my-api.com/api/sensitive", {
throwOnError: ({ error, response, options }) => {
// Always throw validation errors - data integrity is critical
if (isValidationError(error)) {
return true;
}
// Throw HTTP errors for sensitive endpoints
if (isHTTPError(error) && response?.status === 403 && options.initURL?.includes("/sensitive")) {
return true;
}
// Throw rate limiting errors during business hours (handle differently off-hours)
if (response?.status === 429) {
const hour = new Date().getHours();
return hour >= 9 && hour <= 17;
}
// Return other errors in result object
return false;
},
});
```
Type Narrowing [#type-narrowing]
The `data` and `error` properties form a discriminated union—if one is present, the other is null. TypeScript automatically narrows types after error checks:
```ts twoslash title="api.ts"
import { callApi } from "@zayne-labs/callapi";
import { isHTTPError } from "@zayne-labs/callapi/utils";
type UserData = {
completed: boolean;
id: number;
title: string;
userId: string;
};
type ErrorResponseData = {
errors?: Array;
message?: string;
};
const { data, error } = await callApi("https://my-api.com/api/v1/session");
if (isHTTPError(error)) {
console.error(error);
} else if (error) {
console.error(error);
} else {
console.log(data); // TypeScript knows data is not null
}
```
Error Recovery with `refetch` [#error-recovery-with-refetch]
Sometimes you want to silently recover from an error and retry the request (e.g., refreshing an expired auth token). CallApi provides a `refetch()` function within the `options` object of your error hooks for this purpose.
For common recovery patterns like token refreshing, see the [Authorization guide](/docs/authorization#refreshing-tokens).
# Title: Getting Started
Description: How to get started with CallApi
URL: /docs/getting-started
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/getting-started.mdx
Installation [#installation]
npm
pnpm
yarn
bun
```bash
npm install @zayne-labs/callapi
```
```bash
pnpm add @zayne-labs/callapi
```
```bash
yarn add @zayne-labs/callapi
```
```bash
bun add @zayne-labs/callapi
```
Or via CDN:
```html
```
Quick Start [#quick-start]
To get started with callApi, simply import the function and make your first request:
```ts twoslash title="api.ts"
import { callApi } from "@zayne-labs/callapi";
type Todo = {
completed: boolean;
id: number;
title: string;
userId: string;
};
// @annotate: Hover over the data or error object to see the type
const { data, error, response } = await callApi("https://jsonplaceholder.typicode.com/todos/1");
```
As shown in the example, callApi returns a result object containing:
* `data`: The response data
* `error`: An error object containing info about any error that occurred during the lifecycle of the request
* `response`: The Response object from the underlying fetch API
You can specify the data type and error type via TypeScript generics, or using a [Validation Schema](/docs/getting-started#request-and-response-validation) to validate and infer the types automatically.
```ts twoslash title="api.ts"
import { callApi } from "@zayne-labs/callapi";
import { z } from "zod";
type Todo = {
completed: boolean;
id: number;
title: string;
userId: string;
};
// Via TypeScript generics
const { data, error, response } = await callApi("https://jsonplaceholder.typicode.com/todos/1");
// Via Validation Schemas (automatic type inference)
const dataSchema = z.object({
userId: z.string(),
id: z.number(),
title: z.string(),
completed: z.boolean(),
});
const { data: validatedData } = await callApi("https://jsonplaceholder.typicode.com/todos/1", {
schema: {
data: dataSchema,
},
});
// @annotate: Hover over any of the properties to see the type
```
The result object format can also be customized using the [`resultMode`](/docs/request-and-response-helpers#result-management) option.
```ts twoslash title="api.ts"
import { callApi } from "@zayne-labs/callapi";
type Todo = {
completed: boolean;
id: number;
title: string;
userId: string;
};
// @annotate: Hover over the data to see the type
const data = await callApi("https://jsonplaceholder.typicode.com/todos/1", {
resultMode: "onlyData",
});
```
Creating a Configured Client [#creating-a-configured-client]
Create a reusable client with base configuration using `createFetchClient`. Instance options override base defaults.
```ts twoslash title="callBackendApi.ts"
import { createFetchClient } from "@zayne-labs/callapi";
export const callBackendApi = createFetchClient({
baseURL: "https://jsonplaceholder.typicode.com",
retryAttempts: 3,
credentials: "same-origin",
timeout: 10000,
});
type Todo = {
completed: boolean;
id: number;
title: string;
userId: string;
};
const resultOne = await callBackendApi("/todos/10");
const resultTwo = await callBackendApi("/todos/5", {
// Override timeout and retry attempts
retryAttempts: 2,
timeout: 5000,
});
```
Request and Response Validation [#request-and-response-validation]
CallApi supports runtime validation using the [Standard Schema specification](https://github.com/standard-schema/standard-schema), compatible with Zod, Valibot, ArkType, and more.
```ts twoslash title="api.ts"
import { callApi } from "@zayne-labs/callapi";
import { z } from "zod";
const dataSchema = z.object({
id: z.number(),
title: z.string(),
completed: z.boolean(),
});
const errorDataSchema = z.object({
message: z.string(),
errors: z.array(
z.object({
field: z.string(),
message: z.string(),
})
),
});
const { data, error } = await callApi("/todos/1", {
schema: {
data: dataSchema, // Validates successful response data
errorData: errorDataSchema, // Validate error response data from the server
},
});
```
See the [Validation Guide](/docs/validation) for comprehensive details on validation strategies and best practices.
Throwing Errors [#throwing-errors]
You can throw errors instead of returning them by passing the `throwOnError` option.
If you set the `throwOnError` option to `true`, the `callApi` function will throw the error.
If set it to a function instead, it will be passed the error context object, and it should return a boolean indicating whether to throw the error or not.
```ts twoslash title="callBackendApi.ts"
import { createFetchClient } from "@zayne-labs/callapi";
export const callBackendApi = createFetchClient({
baseURL: "https://jsonplaceholder.typicode.com",
throwOnError: true,
});
// @error: This will throw an error if the request fails or there is an error response
const { data } = await callBackendApi<{ userId: number }>("https://jsonplaceholder.typicode.com/todos/1");
```
Learn more about handling errors in the [Error Handling](/docs/error-handling) section.
# Title: Hooks
Description: Learn how to use hooks in CallApi
URL: /docs/hooks
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/hooks.mdx
Hooks are callback functions that let you observe and react to events during the request lifecycle. They're perfect for logging, metrics, error handling, and other side effects that don't need to control the request flow.
**Hooks vs Middleware:** Hooks observe events, so naturally their return values are ignored. Middleware
on the other hand, controls execution flow and can modify or replace operations. Use hooks for side
effects, middleware for control flow. See [Middleware](/docs/middlewares) for details.
You can configure hook execution using `hooksExecutionMode` (parallel vs sequential). Plugin hooks
always execute before main hooks.
```ts twoslash title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const callApi = createFetchClient({
baseURL: "http://localhost:3000",
onRequest: (ctx) => {
// Do something with context object
},
onRequestReady: (ctx) => {
// Do something with context object
},
onRequestError: (ctx) => {
// Do something with context object
},
onResponse: (ctx) => {
// Do something with context object
},
onResponseError: (ctx) => {
// Do something with context object
},
onValidationError: (ctx) => {
// Do something with context object
},
onError: (ctx) => {
// Do something with context object
},
onSuccess: (ctx) => {
// Do something with context object
},
onRetry: (ctx) => {
// Do something with context object
},
onRequestStream: (ctx) => {
// Do something with context object
},
onResponseStream: (ctx) => {
// Do something with context object
},
});
callApi("/api/data", {
onRequest: (ctx) => {},
onRequestReady: (ctx) => {},
onRequestError: (ctx) => {},
onResponse: (ctx) => {},
onResponseError: (ctx) => {},
onValidationError: (ctx) => {},
onError: (ctx) => {},
onSuccess: (ctx) => {},
onRetry: (ctx) => {},
onRequestStream: (ctx) => {},
onResponseStream: (ctx) => {},
});
```
What hooks are available and when do they run? [#what-hooks-are-available-and-when-do-they-run]
Request Phase Hooks [#request-phase-hooks]
onRequest [#onrequest]
This hook is called before the final request processing (body serialization, authentication, etc.) but after URL resolution. You can use this to add headers, handle authentication, or modify request configuration.
```ts twoslash title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
onRequest: ({ request, options }) => {
// Add auth header
options.auth = localStorage.getItem("token");
// Add custom headers
request.headers["X-Custom-ID"] = "123";
// Add environment header based on baseURL
if (options.fullURL?.includes("api.dev")) {
request.headers["X-Environment"] = "development";
}
},
});
```
onRequestReady [#onrequestready]
This hook is called just before the HTTP request is sent and after the request has been fully processed internally (including auth headers and body serialization). Use this when you need to inspect or log the final request state.
```ts twoslash title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
onRequestReady: ({ request }) => {
// Final check of the request object
console.log("Final Headers:", request.headers);
console.log("Final Body:", request.body);
},
});
```
onRequestStream [#onrequeststream]
This hook is called during request body streaming, useful for tracking upload progress.
```ts title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
onRequestStream: ({ event }) => {
// Access stream progress information
console.log(`Upload progress: ${event.progress}%`);
console.log(`Bytes transferred: ${event.transferredBytes}`);
console.log(`Total bytes: ${event.totalBytes}`);
// Access the current chunk being streamed
// event.chunk is a Uint8Array
},
});
// Example: Uploading a large file
const fileInput = document.querySelector('input[type="file"]');
const file = fileInput?.files?.[0];
if (file) {
await client("/api/upload", {
method: "POST",
body: file,
onRequestStream: ({ event }) => {
updateUploadProgress(event.progress);
},
});
}
```
onRequestError [#onrequesterror]
This hook is called when the request fails before reaching the server. You can use it to handle network errors, timeouts, etc.
```ts twoslash title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
onRequestError: ({ error, request, options }) => {
if (error.name === "TimeoutError") {
console.error(`Request timeout: ${options.initURL}`);
return;
}
console.error(`Network error: ${error.message}`);
},
});
```
Response Phase Hooks [#response-phase-hooks]
onResponse [#onresponse]
This hook is called for every response from the server, regardless of the status code. You can use it to log all API calls, handle specific status codes, etc.
```ts twoslash title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
onResponse: ({ data, error, request, response, options }) => {
// Log all API calls
console.log(`${request.method} ${options.initURL} - ${response?.status}`);
// Handle specific status codes
if (response?.status === 207) {
console.warn("Partial success:", data);
}
},
});
```
onResponseStream [#onresponsestream]
This hook is called during response body streaming, perfect for tracking download progress.
```ts title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
onResponseStream: ({ event }) => {
// Access stream progress information
console.log(`Download progress: ${event.progress}%`);
console.log(`Bytes received: ${event.transferredBytes}`);
console.log(`Total bytes: ${event.totalBytes}`);
// Process the current chunk
// event.chunk is a Uint8Array
},
});
// Example: Downloading a large file
const { data } = await client("/api/download-video", {
responseType: "stream",
onResponseStream: ({ event }) => {
updateDownloadProgress(event.progress);
},
});
```
onSuccess [#onsuccess]
This hook is called only for successful responses. You can use it to handle successful responses, cache data, etc.
```ts twoslash title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
type User = {
email: string;
id: string;
name: string;
};
const userCache = new Map();
const client = createFetchClient<{ Data: User[] }>({
onSuccess: ({ data, response, request, options }) => {
// Cache user data
data.forEach((user) => userCache.set(user.id, user));
},
});
```
onResponseError [#onresponseerror]
This hook is called for error responses (response.ok === false). You can use it to handle specific status codes, etc.
```ts twoslash title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
onResponseError: ({ error, response, options }) => {
switch (response.status) {
case 401: {
// Handle Token expired
break;
}
case 403: {
// Handle User not authorized
break;
}
case 404: {
// Handle Resource not found
break;
}
case 429: {
// Handle Rate limited
break;
}
default: {
// Handle other errors
break;
}
}
},
});
```
onError [#onerror]
This hook is called for any error. It's basically a combination of onRequestError and onResponseError. It's perfect for global error handling.
```ts twoslash title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
onError: ({ error, response, request, options }) => {
// Send to error tracking
// errorTracker.capture({
// type: error.name,
// message: error.message,
// url: request.url,
// status: response?.status,
// });
// Show user-friendly messages
if (!response) {
// showNetworkError();
} else if (response.status >= 500) {
// showServerError();
} else if (response.status === 400) {
// showValidationErrors(error.errorData);
}
},
});
```
Retry Phase Hooks [#retry-phase-hooks]
onRetry [#onretry]
This hook is called before retrying a failed request. You can use it to handle stuff before retrying.
```ts twoslash title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
// Advanced retry configuration
retryAttempts: 3,
retryStrategy: "exponential",
retryStatusCodes: [408, 429, 500, 502, 503, 504],
onRetry: ({ response }) => {
// Handle stuff...
},
});
```
Validation Phase Hooks [#validation-phase-hooks]
onValidationError [#onvalidationerror]
This hook is called when request or response validation fails via the `schema` option.
```ts twoslash title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
onValidationError: ({ error, response, request, options }) => {
// Handle stuff...
},
});
```
Manual Refetching [#manual-refetching]
The error-related hooks all receive an `options` object that contains a `refetch` function.
This allows you to manually trigger a retry of the original request directly from an error hook. It's particularly useful for recovery flows like authentication token refreshes.
```ts title="api.ts"
const client = createFetchClient({
onResponseError: async ({ response, options }) => {
if (response.status === 401) {
// Refresh session logic
const newToken = await refreshToken();
localStorage.setItem("token", newToken);
// Retry request with new token
options.refetch();
}
},
});
```
For a detailed walkthrough on using `refetch()` for token refreshing, see the [Authorization guide](/docs/authorization#refreshing-tokens).
To prevent infinite loops, manual refetches are limited by the `refetchAttempts` option (defaults to
`1`). If the limit is reached, `refetch()` will return `null` and log an error to the console instead of
retrying recursively.
Ways in which hooks can be provided [#ways-in-which-hooks-can-be-provided]
Hooks can be provided at three levels:
1. **The Plugin Level**: (covered in [`plugins`](/docs/plugins))
2. **The Base Client Level**: (`createFetchClient`)
3. **The Instance Level**: (`callApi`)
And each hook can be provided, as:
* A single callback function.
* An array of callback functions.
```ts title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const callApi = createFetchClient({
onRequest: [
(ctx) => addAuthHeader(ctx.request),
(ctx) => addTrackingHeader(ctx.request),
(ctx) => addVersionHeader(ctx.request),
],
});
const result = await callApi("/api/data", {
onRequest: (ctx) => {},
});
```
Hook Execution Order [#hook-execution-order]
Hooks execute in the following order:
**Plugin Hooks → Base Client Hooks → Instance Hooks**
```ts title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { definePlugin } from "@zayne-labs/callapi/utils";
// Plugin hooks execute first
const loggingPlugin = definePlugin({
id: "logger",
name: "Logger Plugin",
hooks: {
onRequest: () => console.log("1. Plugin onRequest"),
onResponse: () => console.log("1. Plugin onResponse"),
},
});
// Base client hooks execute second
const callMainApi = createFetchClient({
plugins: [loggingPlugin],
onRequest: () => console.log("2. Base onRequest"),
onResponse: () => console.log("2. Base onResponse"),
});
// Instance hooks execute last
await callMainApi("/api/data", {
onRequest: () => console.log("3. Instance onRequest"),
onResponse: () => console.log("3. Instance onResponse"),
});
// With parallel execution (default), all hooks at each level run simultaneously
// With sequential execution, they run in order: 1 → 2 → 3
```
Use `hooksExecutionMode` to control whether hooks run in parallel or sequentially. Plugin hooks always execute before base and instance hooks.
Hook Configuration Options [#hook-configuration-options]
hooksExecutionMode [#hooksexecutionmode]
Controls whether all hooks (plugin + main + instance) execute in parallel or sequentially.
* **`"parallel"`** (default): All hooks execute simultaneously via `Promise.all()` for better performance
* **`"sequential"`**: All hooks execute one by one in registration order via `await` in a loop
```ts title="Hook Execution Mode Examples"
import { createFetchClient } from "@zayne-labs/callapi";
// Parallel execution (default) - all hooks run simultaneously
const parallelClient = createFetchClient({
hooksExecutionMode: "parallel", // Default
onRequest: [
async (ctx) => await addAuthToken(ctx.request), // Runs in parallel
async (ctx) => await logRequest(ctx.request), // Runs in parallel
async (ctx) => await addTrackingId(ctx.request), // Runs in parallel
],
});
// Sequential execution - hooks run one after another
const sequentialClient = createFetchClient({
hooksExecutionMode: "sequential",
onRequest: [
async (ctx) => await validateAuth(ctx.request), // Runs first
async (ctx) => await transformRequest(ctx.request), // Runs second
async (ctx) => await logRequest(ctx.request), // Runs third
],
});
// Use case: Hooks have dependencies and must run in order
const dependentClient = createFetchClient({
hooksExecutionMode: "sequential",
onError: [
(ctx) => logError(ctx.error), // Log first
(ctx) => reportError(ctx.error), // Then report
(ctx) => cleanupResources(ctx), // Finally cleanup
],
});
```
Hook Overriding And Merging [#hook-overriding-and-merging]
1. **Plugin Hooks**: These run first by default and can't be overridden by either instance or base client hooks, making them perfect for must-have functionality.
2. **Instance Hooks**: Instance-level hooks generally **override** base client hooks if both are single functions.
3. **Base Client Hooks**: When the base client hook is an array, instance hooks are **merged** into that array instead of replacing it, allowing you to add additional functionality without overriding existing logic.
This merging behavior for array-type base client hooks is mostly just a convenience. If you need more
sophisticated control or guaranteed execution, defining your logic within a dedicated
[plugin](/docs/plugins) is often the better approach.
```ts title="Hook Order and Merging Example"
import { createFetchClient } from "@zayne-labs/callapi";
import { definePlugin } from "@zayne-labs/callapi/utils";
// 1. Plugin Hook (Runs first)
const examplePlugin = definePlugin({
id: "example-plugin",
name: "ExamplePlugin",
hooks: {
onRequest: (ctx) => console.log("1.1 Plugin onRequest -- (cannot be overridden)"),
onResponse: (ctx) => console.log("1.2 Plugin onResponse -- (cannot be overridden)"),
},
});
// 2. Base Client Hooks (Runs second)
const callBackendApi = createFetchClient({
plugins: [examplePlugin],
// Single base hook - will be overridden by instance hook
onRequest: (ctx) =>
console.log("2.1 Base onRequest -- (will be overridden by instance onRequest hook (3.1))"),
// Array base hook - instance hook will be merged
onResponse: [
(ctx) => console.log("2.2 Base onResponse (part of array)"),
(ctx) => console.log("2.3 Base onResponse (part of array)"),
],
});
// 3. Instance Hooks (Runs last, overrides or merges)
const result = await callBackendApi("/data", {
// Overrides the single base onRequest hook
onRequest: (ctx) => console.log("3.1 Instance onRequest (overrides base onRequest (2.1))"),
// Merges with the base onResponse array
onResponse: (ctx) => console.log("3.2 Instance onResponse (merged with base onResponse (2.2, 2.3))"),
});
```
**Explanation:**
* The plugin's `onRequest` and `onResponse` run first.
* The base client's `onRequest` is a single function, and the instance provides its own `onRequest`. The instance hook **replaces** the base hook for this specific call.
* The base client's `onResponse` is an **array**. The instance `onResponse` is **added** to this array. The combined array `[2.2 Base, 2.3 Base, 3.2 Instance]` is then executed.
Async Hooks [#async-hooks]
All hooks can be async or return a Promise. When this is the case, the hook will be awaited internally:
```ts
onRequest: async ({ request }) => {
const token = await getAuthToken();
request.headers.Authorization = `Bearer ${token}`;
};
```
Type Safety [#type-safety]
All hooks are fully typed based on the response type you specify when creating the client. This ensures you get proper type inference and autocompletion for the data and error objects in all your hooks.
```ts twoslash title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
type User = {
id: number;
name: string;
};
const client = createFetchClient<{ Data: User }>({
onSuccess: ({ data }) => {
console.log(data.name);
},
});
const { error } = await client("/api/data", {
onSuccess: ({ data }) => {
console.log(data.name);
},
});
// @annotate: Hover over the data object to see the inferred type
```
Streaming [#streaming]
Both stream hooks (`onRequestStream` and `onResponseStream`) receive a context object with an `event` property of type `StreamProgressEvent` that contains:
* `chunk`: Current chunk of data being streamed (Uint8Array)
* `progress`: Progress percentage (0-100)
* `totalBytes`: Total size of data in bytes
* `transferredBytes`: Amount of data transferred so far
Types [#types]
# Title: Introduction
Description: Understanding what CallApi is all about
URL: /docs
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/index.mdx
What is CallApi? [#what-is-callapi]
CallApi is a modern [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) wrapper that adds essential features for real-world HTTP requests. If you know Fetch, you already know CallApi as it's a drop-in replacement with the same API, meaning you can use it anywhere Fetch works.
Built-in features include request deduplication, automatic retries, structured error handling, lifecycle hooks, schema validation, and a plugin system. Works everywhere: browsers, Node.js 18+, Deno, Bun, and edge workers.
Why CallApi? [#why-callapi]
Most HTTP clients either lack critical features or ship with bloated bundles. CallApi addresses common pain points while staying lightweight:
* **Modern standards**: Built on Fetch API, not legacy XMLHttpRequest
* **Complete feature set**: Deduplication, retries, interceptors, validation—all included out of the box
* **Intuitive API**: Simple, consistent interface without verbose configuration
* **Full TypeScript support**: Automatic type inference from validation schemas
* **Lightweight**: Less than 6KB minified and gzipped with zero dependencies
* **Extensible**: Plugin system for custom functionality
Features [#features]
CallApi aims to be the most comprehensive as well as intuitive fetching library out there. It provides a wide range of features out of the box and still allows you to extend it with plugins. Here are some of the features:
# Title: Middlewares
Description: Intercept and wrap internal operations in CallApi
URL: /docs/middlewares
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/middlewares.mdx
Middleware wraps and intercepts internal operations, giving you full control over execution flow. Currently CallApi supports fetch middleware for intercepting network requests at the lowest level, with more middleware types planned for future releases.
**Middleware vs Hooks:** Middleware controls execution (can return custom responses, short-circuit
requests). Hooks observe events (return values ignored). Use middleware for control flow, hooks for side
effects. See [Hooks](/docs/hooks) for details.
When a given middleware is defined at multiple levels (plugins, base config, per-request), they compose automatically in reverse order—the last middleware added wraps all previous ones.
Fetch Middleware [#fetch-middleware]
Fetch middleware wraps the underlying fetch implementation, giving you full control over when and how requests execute. Use it to cache responses, track progress, handle offline mode, add logging, or short-circuit requests.
```ts
type FetchMiddleware = (context: RequestContext & { fetchImpl: FetchImpl }) => FetchImpl;
```
Each middleware receives a context object (containing the fetch implementation and request context) and returns a new fetch function.
Using Fetch Middleware [#using-fetch-middleware]
Base Config [#base-config]
Apply fetch middleware to all requests by adding it to your client configuration:
```ts twoslash title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
baseURL: "https://api.example.com",
fetchMiddleware: (ctx) => async (input, init) => {
console.log("Request:", input);
const response = await ctx.fetchImpl(input, init);
console.log("Response:", response.status);
return response;
},
});
```
Per-Request [#per-request]
Add fetch middleware to individual requests for one-off modifications:
```ts twoslash title="api.ts"
import { callApi } from "@zayne-labs/callapi";
const { data, error } = await callApi("/users", {
fetchMiddleware: (ctx) => async (input, init) => {
const start = Date.now();
const response = await ctx.fetchImpl(input, init);
console.log(`Took ${Date.now() - start}ms`);
return response;
},
});
```
In Plugins [#in-plugins]
Plugins can define fetch middleware to add reusable functionality. See [Plugins](/docs/plugins) for details.
```ts twoslash title="plugins.ts"
import { definePlugin } from "@zayne-labs/callapi/utils";
const loggingPlugin = definePlugin({
id: "logging",
name: "Logging Plugin",
middlewares: {
fetchMiddleware: (ctx) => async (input, init) => {
console.log("→", init?.method || "GET", input);
const response = await ctx.fetchImpl(input, init);
console.log("←", response.status, input);
return response;
},
},
});
```
Fetch Middleware Composition [#fetch-middleware-composition]
When multiple fetch middlewares are defined, they compose in order, with each wrapping the previous one like onion layers. The last middleware added gets the outermost position:
**Execution flow: Instance → Base → Plugins (last to first) → customFetchImpl/fetch**
```ts title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
plugins: [cachingPlugin, loggingPlugin],
fetchMiddleware: (ctx) => async (input, init) => {
console.log("Base middleware");
return ctx.fetchImpl(input, init);
},
});
await client("/users", {
fetchMiddleware: (ctx) => async (input, init) => {
console.log("Instance middleware");
return ctx.fetchImpl(input, init);
},
});
// Execution order: Instance → Base → loggingPlugin → cachingPlugin → fetch
```
This reverse composition means the last middleware added gets the first chance to intercept the request.
Examples [#examples]
Response Caching [#response-caching]
```ts twoslash title="caching-plugin.ts"
import { createFetchClient, type PluginSetupContext } from "@zayne-labs/callapi";
import { definePlugin } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const CacheOptionsSchema = z.object({
cacheLifetime: z.int().positive().optional(),
cachePolicy: z.literal(["cache-first", "no-cache"]).optional(),
});
export const cachingPlugin = () => {
const cache = new Map();
return definePlugin({
id: "caching-plugin",
name: "Caching Plugin",
defineExtraOptions: () => CacheOptionsSchema,
middlewares: ({
options,
}: PluginSetupContext<{ InferredExtraOptions: typeof CacheOptionsSchema }>) => {
const { cacheLifetime = 60_000, cachePolicy = "cache-first" } = options;
return {
fetchMiddleware: (ctx) => async (input, init) => {
if (cachePolicy === "no-cache") {
return ctx.fetchImpl(input, init);
}
const cacheKey = input instanceof Request ? input.url : input.toString();
const cachedEntry = cache.get(cacheKey);
const fetchAndCache = async () => {
const response = await ctx.fetchImpl(input, init);
cache.set(cacheKey, { data: response.clone(), timestamp: Date.now() });
return response;
};
if (!cachedEntry) {
console.info(`[Caching Plugin] Cache miss: ${cacheKey}`);
return fetchAndCache();
}
const isCacheExpired = Date.now() - cachedEntry.timestamp > cacheLifetime;
if (isCacheExpired) {
console.info(`[Caching Plugin] Cache miss (expired): ${cacheKey}`);
cache.delete(cacheKey);
return fetchAndCache();
}
console.info(`[Caching Plugin] Cache hit: ${cacheKey}`);
return cachedEntry.data.clone();
},
};
},
});
};
const callBackendApi = createFetchClient({
baseURL: "https://api.example.com",
plugins: [cachingPlugin()],
cachePolicy: "cache-first",
cacheLifetime: 2 * 60 * 1000, // 2 minutes
});
await callBackendApi("/users");
await callBackendApi("/users/:id", {
cachePolicy: "no-cache", // Skip cache for this request
});
```
Offline Detection [#offline-detection]
```ts title="offline.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
fetchMiddleware: (ctx) => async (input, init) => {
if (!navigator.onLine) {
return Response.json(
{ error: "No internet connection" },
{
status: 503,
headers: { "Content-Type": "application/json" },
}
);
}
return ctx.fetchImpl(input, init);
},
});
```
Request Timing [#request-timing]
```ts title="timing.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
fetchMiddleware: (ctx) => async (input, init) => {
const start = performance.now();
try {
const response = await ctx.fetchImpl(input, init);
const duration = performance.now() - start;
console.log(`${init?.method || "GET"} ${input} - ${duration.toFixed(2)}ms`);
return response;
} catch (error) {
const duration = performance.now() - start;
console.error(`${init?.method || "GET"} ${input} - Failed after ${duration.toFixed(2)}ms`);
throw error;
}
},
});
```
Error Handling in Middleware [#error-handling-in-middleware]
Middleware can catch and handle errors or transform them before they reach your application:
```ts title="error-handling.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const client = createFetchClient({
fetchMiddleware: (ctx) => async (input, init) => {
try {
const response = await ctx.fetchImpl(input, init);
if (response.status === 503) {
return Response.json(
{ cached: true },
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
return response;
} catch (error) {
if (error instanceof TypeError && error.message.includes("fetch")) {
return Response.json(
{ error: "Network error" },
{
status: 0,
headers: { "Content-Type": "application/json" },
}
);
}
throw error;
}
},
});
```
Mock Responses for Testing [#mock-responses-for-testing]
```ts title="mock.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const mockData = {
"/users/1": { id: 1, name: "John" },
"/users/2": { id: 2, name: "Jane" },
};
const client = createFetchClient({
fetchMiddleware: (ctx) => async (input, init) => {
const url = input.toString();
// Return mock data without calling fetch
if (url in mockData) {
return Response.json(mockData[url as keyof typeof mockData], {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// Fall through to real fetch for unmocked URLs
return ctx.fetchImpl(input, init);
},
});
```
Middleware vs Hooks [#middleware-vs-hooks]
**Use fetch middleware when you need to:**
* Control whether fetch is called (short-circuit, return cached responses)
* Replace the fetch implementation (use XHR, mock responses, alternative clients)
* Transform the response (return a different Response object)
* Wrap the network call (add retry logic, circuit breakers, request queuing)
**Use hooks when you need to:**
* Observe lifecycle events without controlling flow
* Log or track metrics
* Modify request options by mutation
* Handle errors after they occur
* Trigger side effects (update UI, analytics)
**Key distinction:** Middleware wraps functions and controls what gets called and returned. Hooks receive context and can mutate objects, but their return values are ignored.
# Title: Migration Guide
Description: Migrate to CallApi from Axios, Ky, Ofetch, or raw fetch
URL: /docs/migration-guide
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/migration-guide.mdx
Most migrations are mechanical. The main thing is deciding how you want errors to work.
* Axios, Ky, and Ofetch usually throw for HTTP errors.
* Raw fetch does not throw for HTTP errors.
* CallApi returns `{ data, error, response }` by default.
* If you want throwing behavior, use `throwOnError: true`.
Install [#install]
npm
pnpm
yarn
bun
```bash
npm install @zayne-labs/callapi
```
```bash
pnpm add @zayne-labs/callapi
```
```bash
yarn add @zayne-labs/callapi
```
```bash
bun add @zayne-labs/callapi
```
What Usually Changes [#what-usually-changes]
| Before | CallApi |
| ----------------------------------------------------------- | --------------------------------------------------------------- |
| `axios.create(...)`, `ky.create(...)`, `ofetch.create(...)` | `createFetchClient(...)` |
| `.get("/users")` | `callApi("/users")` or `callApi("@get/users")` |
| `.post("/users", body)` | `callApi("@post/users", { body })` |
| `response.data` | `result.data` |
| `.json()` | `schema` or manual generic |
| `try/catch` for HTTP errors | `result.error` by default |
| Interceptors/hooks | `onRequest`, `onSuccess`, `onResponseError`, `onError`, plugins |
| Manual path interpolation | `params` |
| Manual query strings | `query` |
Before You Touch Every Call [#before-you-touch-every-call]
Set up a base client first. Move the shared bits there: `baseURL`, timeout, retries, headers, auth, and hooks.
```ts
import { createFetchClient } from "@zayne-labs/callapi";
export const callApi = createFetchClient({
baseURL: "https://api.example.com",
timeout: 10_000,
retryAttempts: 2,
onRequest: ({ options }) => {
options.auth = getToken();
},
});
```
After that, most call sites should only care about the path, body, params, query, and schema.
From Axios [#from-axios]
Axios migration mostly means replacing method helpers and deciding whether you want result objects or thrown errors.
Client Setup [#client-setup]
```ts
import axios from "axios";
export const api = axios.create({
baseURL: "https://api.example.com",
timeout: 10_000,
headers: {
"X-App": "dashboard",
},
});
```
```ts
import { createFetchClient } from "@zayne-labs/callapi";
export const callApi = createFetchClient({
baseURL: "https://api.example.com",
timeout: 10_000,
headers: {
"X-App": "dashboard",
},
});
```
Requests [#requests]
```ts
const usersResponse = await api.get("/users");
const users = usersResponse.data;
const userResponse = await api.post("/users", {
name: "Ada",
});
const user = userResponse.data;
```
```ts
const { data: users } = await callApi("/users");
const { data: user } = await callApi("@post/users", {
body: { name: "Ada" },
});
```
Interceptors to Hooks [#interceptors-to-hooks]
```ts
api.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${getToken()}`;
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
redirectToLogin();
}
return Promise.reject(error);
}
);
```
```ts
export const callApi = createFetchClient({
baseURL: "https://api.example.com",
onRequest: ({ options }) => {
options.auth = getToken();
},
onResponseError: ({ response }) => {
if (response.status === 401) {
redirectToLogin();
}
},
});
```
Error Handling [#error-handling]
```ts
const getUsers = async () => {
try {
const response = await api.get("/users");
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response?.data);
}
}
};
```
```ts
const getUsers = async () => {
const { data, error } = await callApi("/users");
if (error) {
console.error(error.errorData);
}
return data;
};
```
```ts
const getUsers = async () => {
try {
const data = await callApi("/users", {
resultMode: "onlyData",
throwOnError: true,
});
return data;
} catch (error) {
console.error(error);
}
};
```
From Ky [#from-ky]
Ky is already fetch-based. The main change is moving away from chains like `.json()` and into CallApi's result object.
```ts
import ky from "ky";
const api = ky.create({
prefixUrl: "https://api.example.com",
retry: 2,
timeout: 10_000,
});
const user = await api
.post("users", {
json: { name: "Ada" },
})
.json();
```
```ts
import { createFetchClient } from "@zayne-labs/callapi";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
retryAttempts: 2,
timeout: 10_000,
});
const { data: user } = await callApi("@post/users", {
body: { name: "Ada" },
});
```
Ky Hooks [#ky-hooks]
```ts
const api = ky.create({
hooks: {
beforeRequest: [
(request) => {
request.headers.set("Authorization", `Bearer ${token}`);
},
],
afterResponse: [
(_request, _options, response) => {
console.log(response.status);
},
],
},
});
```
```ts
const callApi = createFetchClient({
onRequest: ({ options }) => {
options.auth = token;
},
onSuccess: ({ response }) => {
console.log(response.status);
},
});
```
From Ofetch [#from-ofetch]
Ofetch is close to CallApi. The biggest difference is that Ofetch throws by default, while CallApi returns a result object by default.
```ts
import { ofetch } from "ofetch";
const api = ofetch.create({
baseURL: "https://api.example.com",
retry: 2,
retryDelay: 1000,
});
const user = await api("/users/1");
```
```ts
import { createFetchClient } from "@zayne-labs/callapi";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
retryAttempts: 2,
retryDelay: 1000,
});
const { data: user } = await callApi("/users/1");
```
Ofetch Hooks [#ofetch-hooks]
```ts
const api = ofetch.create({
onRequest: ({ options }) => {
options.headers = {
...options.headers,
Authorization: `Bearer ${token}`,
};
},
onResponseError: ({ response }) => {
console.error(response.status);
},
});
```
```ts
const callApi = createFetchClient({
onRequest: ({ options }) => {
options.auth = token;
},
onResponseError: ({ response }) => {
console.error(response.status);
},
});
```
From Raw Fetch [#from-raw-fetch]
Raw fetch migration is mostly deleting the wrapper code you keep repeating.
```ts
const createUser = async () => {
const response = await fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Ada" }),
});
const payload = await response.json();
if (!response.ok) {
return { data: null, error: payload };
}
return { data: payload, error: null };
};
```
```ts
import { callApi } from "@zayne-labs/callapi";
const createUser = async () => {
const result = await callApi("/api/users", {
method: "POST",
body: { name: "Ada" },
});
return result;
};
```
Add Validation Where It Matters [#add-validation-where-it-matters]
Do not add schemas everywhere on day one. Start with places where bad data is actually painful: auth, permissions, payments, user profiles, and shared API clients.
```ts
import { z } from "zod";
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
const { data: user, error } = await callApi("/users/:id", {
params: { id: 1 },
schema: {
data: userSchema,
},
});
```
Common Refactors [#common-refactors]
Query Parameters [#query-parameters]
```ts
const { data } = await callApi("/users", {
query: {
role: "admin",
active: true,
status: ["active", "invited"],
},
});
```
Arrays become repeated keys. Objects are JSON-stringified. `URLSearchParams` is also accepted.
URL Parameters [#url-parameters]
```ts
const { data } = await callApi("/users/:id/posts/{postId}", {
params: {
id: 1,
postId: 10,
},
});
```
Both `:param` and `{param}` placeholders work. CallApi URL-encodes each value before adding it to the
path.
File Uploads [#file-uploads]
```ts
const formData = new FormData();
formData.append("avatar", file);
const { data } = await callApi("@post/users/:id/avatar", {
body: formData,
params: { id: 1 },
});
```
Final Considerations/Checklist [#final-considerationschecklist]
* [ ] Replace imports.
* [ ] Replace client creation.
* [ ] Replace method helpers.
* [ ] Convert interceptors/hooks.
* [ ] Decide if each call should return `{ data, error }` or throw.
* [ ] Set an absolute `baseURL` for relative requests that can run during SSR or in Node.js.
* [ ] Add schemas where runtime validation is worth it.
* [ ] Use `params` and `query` instead of manual string building.
* [ ] Keep server-state caching in TanStack Query, SWR, or your framework. Use CallApi underneath as the request client.
# Title: Plugins
Description: Extend CallApi's functionality with plugins
URL: /docs/plugins
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/plugins.mdx
Plugins extend CallApi with reusable functionality like authentication, logging, caching, or custom request/response handling. They can modify requests before they're sent, intercept fetch calls at the network layer, and hook into various points in the request lifecycle.
Creating a Plugin [#creating-a-plugin]
Use the `definePlugin` helper for type-safe plugin creation:
```ts twoslash title="plugins.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { definePlugin } from "@zayne-labs/callapi/utils";
const envPlugin = definePlugin({
id: "env-plugin",
name: "Environment Plugin",
description: "Adds environment-specific headers to requests",
version: "1.0.0",
setup: ({ request, options, initURL }) => {
const env = process.env.NODE_ENV || "development";
const updatedRequest = {
...request,
headers: { ...request.headers, "X-Environment": env },
};
const updatedOptions = {
...options,
meta: {
...options.meta,
env,
},
};
const updatedInitURL = initURL.replace("http://localhost:3000", "http://localhost:3001");
return {
initURL: updatedInitURL,
options: updatedOptions,
request: updatedRequest,
};
},
});
const callBackendApi = createFetchClient({
baseURL: "http://localhost:3000",
plugins: [envPlugin],
});
```
Or alternatively, you can also use TypeScript's `satisfies` keyword together with `CallApiPlugin` type to achieve the same effect:
```ts twoslash title="plugins.ts"
import type { CallApiPlugin } from "@zayne-labs/callapi";
const envPlugin = {
id: "env-plugin",
name: "Environment Plugin",
description: "Adds environment-specific headers to requests",
version: "1.0.0",
setup: ({ request }) => {
const env = process.env.NODE_ENV || "development";
const updatedRequest = {
...request,
headers: { ...request.headers, "X-Environment": env },
};
return {
request: updatedRequest,
};
},
} satisfies CallApiPlugin;
```
Using Plugins [#using-plugins]
Base Plugins [#base-plugins]
Add plugins when creating a client to apply them to all requests:
```ts title="api.ts"
const callBackendApi = createFetchClient({
plugins: [
envPlugin, // Handle environment-specific configurations
loggingPlugin, // Log request/response details
],
});
```
Per-Request Plugins [#per-request-plugins]
Add plugins to individual requests for specific calls:
```ts title="api.ts"
const { data } = await callBackendApi("/users", {
plugins: [metricsPlugin],
});
```
By default, passing `plugins` to a request replaces base plugins. To keep base plugins and add new ones,
use a callback.
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { envPlugin, loggingPlugin, metricsPlugin } from "./plugins";
const callBackendApi = createFetchClient({
plugins: [envPlugin, loggingPlugin],
});
const { data } = await callBackendApi("/users", {
plugins: ({ basePlugins }) => [...basePlugins, metricsPlugin], // Add metrics plugin while keeping base plugins
});
```
Plugin Anatomy [#plugin-anatomy]
Setup Function [#setup-function]
The `setup` function runs before any request processing begins. It receives the initial URL, options, and request, and can return modified versions of these values. This is useful for transforming requests before CallApi's internal processing.
```ts twoslash title="plugins.ts"
import { definePlugin } from "@zayne-labs/callapi/utils";
const envPlugin = definePlugin({
id: "env-plugin",
name: "Environment Plugin",
description: "A plugin that adds environment-specific headers to requests",
version: "1.0.0",
setup: ({ request }) => {
const env = process.env.NODE_ENV ?? "development";
const platform = globalThis.window !== undefined ? "browser" : "node";
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const updatedRequest = {
...request,
headers: {
...request.headers,
"X-Environment": env,
"X-Client-Platform": platform,
"X-Client-Timezone": timezone,
},
};
return {
request: updatedRequest,
};
},
});
```
Hooks [#hooks]
Plugins can define hooks that run at different stages of the request lifecycle. Hooks can be an object or a function that returns an object (useful for accessing setup context). See [Hooks](/docs/hooks) for detailed information about available hooks.
```ts twoslash title="plugins.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { definePlugin } from "@zayne-labs/callapi/utils";
const myPlugin = definePlugin({
id: "my-plugin",
name: "My Plugin",
description: "A plugin that does something",
version: "1.0.0",
hooks: {
onError: (ctx) => {
// Do something with context object
},
onSuccess: (ctx) => {
// Do something with context object
},
// More hooks can be added here
},
});
const callBackendApi = createFetchClient({
baseURL: "http://localhost:3000",
plugins: [myPlugin],
});
```
**Dynamic Hooks:**
Hooks can also be a function that receives the plugin setup context:
```ts twoslash title="plugins.ts"
import { definePlugin } from "@zayne-labs/callapi/utils";
const dynamicPlugin = definePlugin({
id: "dynamic-plugin",
name: "Dynamic Plugin",
hooks: (context) => {
const startTime = Date.now();
return {
onSuccess: () => {
console.log(`Request to ${context.initURL} took ${Date.now() - startTime}ms`);
},
};
},
});
```
When multiple plugins are registered, their `setup` functions and hooks execute in the order they appear
in the plugins array.
Middleware [#middleware]
Plugins can define middleware to wrap internal operations. Currently, CallApi supports fetch middleware for intercepting requests at the network layer. See [Middleware](/docs/middlewares) for detailed information.
Define middleware using the `middlewares` property. Like hooks, middlewares can be an object or a function that receives the setup context.
**Static Middleware:**
```ts title="plugins.ts"
import { definePlugin } from "@zayne-labs/callapi/utils";
const loggingPlugin = definePlugin({
id: "logging",
name: "Logging Plugin",
version: "1.0.0",
middlewares: {
fetchMiddleware: (ctx) => async (input, init) => {
console.log("→", init?.method || "GET", input);
const response = await ctx.fetchImpl(input, init);
console.log("←", response.status, input);
return response;
},
},
});
```
**Dynamic Middleware:**
Middlewares can also be a function that receives the plugin setup context:
```ts twoslash title="plugins.ts"
import { type PluginSetupContext } from "@zayne-labs/callapi";
import { definePlugin } from "@zayne-labs/callapi/utils";
const cachingPlugin = definePlugin({
id: "caching",
name: "Caching Plugin",
version: "1.0.0",
middlewares: (context: PluginSetupContext) => {
const cache = new Map();
return {
fetchMiddleware: (ctx) => async (input, init) => {
const key = input.toString();
if (cache.has(key)) {
return cache.get(key)!.clone();
}
const response = await ctx.fetchImpl(input, init);
cache.set(key, response.clone());
return response;
},
};
},
});
```
Defining Extra Options [#defining-extra-options]
Plugins can define custom options that users can pass to `callApi`. Use `defineExtraOptions` to return a validation schema (like Zod) that defines these options.
Here's a plugin that adds an `apiVersion` option to automatically set the API version header:
```ts twoslash title="plugins.ts"
import { createFetchClient, type PluginHooks, type PluginSetupContext } from "@zayne-labs/callapi";
import { definePlugin } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const apiVersionSchema = z.object({
apiVersion: z.literal(["v1", "v2", "v3"]).optional(),
});
const apiVersionPlugin = definePlugin({
id: "api-version-plugin",
name: "API Version Plugin",
description: "Adds API version header to requests",
version: "1.0.0",
defineExtraOptions: () => apiVersionSchema,
hooks: {
onRequest: (ctx) => {
const version = ctx.options.apiVersion ?? "v1";
console.log("API Version:", version);
},
} satisfies PluginHooks<{
InferredExtraOptions: typeof apiVersionSchema;
}>,
setup: (ctx: PluginSetupContext<{ InferredExtraOptions: typeof apiVersionSchema }>) => {
const version = ctx.options.apiVersion ?? "v1";
return {
request: {
...ctx.request,
headers: { ...ctx.request.headers, "X-API-Version": version },
},
};
},
});
const callBackendApi = createFetchClient({
baseURL: "https://api.example.com",
plugins: [apiVersionPlugin],
apiVersion: "v2", // Default for all requests
});
// Use default v2
const { data: users } = await callBackendApi("/users");
// Override to v3 for this request
const { data: posts } = await callBackendApi("/posts", {
apiVersion: "v3",
});
```
You can use the Zod schema to validates the `apiVersion` option and hence make the type available within at the base or instance level of `callApi`.
To ensure your plugin has proper TypeScript support, you can use generic types like `PluginHook`, `PluginMiddlewares`, or `PluginSetupContext`. Simply pass your extra options schema (or its inferred type) to the `InferredExtraOptions` type parameter, like shown in the example above. This makes your custom options available with full type safety throughout the plugin.
Alternatively, you can apply the `CallApiPlugin` type with the `InferredExtraOptions` to your entire plugin object using the `satisfies` operator. This provides the inferred type across the entire plugin without needing to specify it for each individual section. When using this pattern, the `definePlugin` helper becomes optional since TypeScript will enforce type safety through the `satisfies` operator, as explained in the [Creating a Plugin](#creating-a-plugin) section.
```ts twoslash title="plugins.ts"
import { createFetchClient, type CallApiPlugin } from "@zayne-labs/callapi";
import { z } from "zod";
const apiVersionSchema = z.object({
apiVersion: z.literal(["v1", "v2", "v3"]).optional(),
});
const apiVersionPlugin = {
id: "api-version-plugin",
name: "API Version Plugin",
description: "Adds API version header to requests",
version: "1.0.0",
defineExtraOptions: () => apiVersionSchema,
hooks: {
onRequest: (ctx) => {
const version = ctx.options.apiVersion ?? "v1";
console.log("API Version:", version);
},
},
// Look ma! No need to type the extra options individually for each section!
setup: (ctx) => {
const version = ctx.options.apiVersion ?? "v1";
return {
request: {
...ctx.request,
headers: { ...ctx.request.headers, "X-API-Version": version },
},
};
},
} satisfies CallApiPlugin<{ InferredExtraOptions: typeof apiVersionSchema }>;
```
Example: Metrics Plugin [#example-metrics-plugin]
Here's a complete example of a plugin that tracks API metrics:
```ts title="plugins.ts"
import { definePlugin } from "@zayne-labs/callapi/utils";
declare module "@zayne-labs/callapi" {
interface Register {
meta: {
startTime: number;
};
}
}
const metricsPlugin = definePlugin({
id: "metrics",
name: "Metrics Plugin",
description: "Tracks API response times and success rates",
setup: ({ initURL, options }) => {
console.info(`Starting request to ${initURL}`);
const startTime = performance.now();
return {
options: {
...options,
meta: { startTime },
},
};
},
hooks: {
onSuccess: ({ options }) => {
const startTime = options.meta?.startTime ?? 0;
const duration = performance.now() - startTime;
console.info(`Request completed in ${duration}ms`);
},
onError: ({ error, options }) => {
const startTime = options.meta?.startTime ?? 0;
const duration = performance.now() - startTime;
console.error(`Request failed after ${duration}ms:`, error);
},
},
});
```
Types [#types]
# Title: Request & Response Helpers
Description: Content type handling, response conversion, and request body processing
URL: /docs/request-and-response-helpers
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/request-and-response-helpers.mdx
CallApi automatically handles content types and response parsing with utilities for common data formats.
Automatic Content-Type Detection and Assignment [#automatic-content-type-detection-and-assignment]
Request bodies are automatically assigned the correct `Content-Type` header:
* **Objects** → `application/json`
* **Query strings** → `application/x-www-form-urlencoded`
* **FormData** → `multipart/form-data` (browser handled)
```ts title="content-types.ts"
import { callApi } from "@zayne-labs/callapi";
// Automatically sets Content-Type: application/json
await callApi("/api/users", {
method: "POST",
body: { name: "John", age: 30 },
});
// Automatically sets Content-Type: application/x-www-form-urlencoded
await callApi("/api/form", {
method: "POST",
body: "name=John&age=30",
});
// Override when needed
await callApi("/api/custom", {
method: "POST",
body: data,
headers: { "Content-Type": "application/custom+json" },
});
```
Smart Response Parsing [#smart-response-parsing]
Responses are automatically parsed based on their `Content-Type` header:
* **JSON types** (`application/json`, `application/vnd.api+json`) → Parsed as JSON
* **Text types** (`text/*`, `application/xml`) → Parsed as text
* **Everything else** → Parsed as blob
```ts title="response-parsing.ts"
import { callApi } from "@zayne-labs/callapi";
// Automatically parsed based on Content-Type response header
const { data: user } = await callApi("/api/user"); // JSON
const { data: html } = await callApi("/page.html"); // Text
const { data: image } = await callApi("/avatar.png"); // Blob
```
Manual Response Type Override [#manual-response-type-override]
You can still manually specify the response type if needed:
Available response types include:
* All [response types](https://developer.mozilla.org/en-US/docs/Web/API/Response#instance_methods) from the Fetch API:
* `json()` (default fallback)
* `text()`
* `blob()`
* `arrayBuffer()`
* `formData()`
* `stream` - Returns the direct [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/Response/body)
```ts title="api.ts"
import { callApi } from "@zayne-labs/callapi";
const { data: imageBlob } = await callApi("/image", {
responseType: "blob",
});
const { data: rawText } = await callApi("/data.json", {
responseType: "text", // Get JSON as raw text
});
const { data: buffer } = await callApi("/binary", {
responseType: "arrayBuffer",
});
const { data: stream } = await callApi("/large-file", {
responseType: "stream", // ReadableStream for progressive processing
});
```
Custom Response Parser [#custom-response-parser]
Use a custom parser function:
```ts title="custom-parser.ts"
import { callApi } from "@zayne-labs/callapi";
const { data } = await callApi("/api/data", {
responseParser: (responseString) => customParser(responseString),
});
```
Result Modes [#result-modes]
The `resultMode` option dictates how CallApi processes and returns the final result:
* **"all"** (default): Returns `{ data, error, response }`. Standard lifecycle.
* **"onlyData"**: Returns only the data from the response.
* **"onlyResponse"**: Returns only the `Response` object.
* **"fetchApi"**: Also returns only the `Response` object, but also skips parsing of the response body internally and data/errorData schema validation.
* **"withoutResponse"**: Returns `{ data, error }`. Standard lifecycle, but omits the `response` property.
The `fetchApi` Mode [#the-fetchapi-mode]
The `fetchApi` mode is designed for scenarios where you want most of the library's benefits (URL resolution, plugins, hooks) don't want any internal parsing of the response body to occur, just like the `Fetch Api`.
When set to `fetchApi`:
1. **No Parsing**: The library will not attempt to read or parse the response body.
2. **No Validation**: Both data error-data validation are skipped.
By default, simplified modes (`"onlyData"`, `"onlyResponse"`, `"fetchApi"`) do not throw errors.
Success/failure should be handled via hooks or by checking the return value (e.g., `if (data)` or `if
(response?.ok)`).
To force an exception, set `throwOnError: true`.
Request Body Utilities [#request-body-utilities]
Object Bodies [#object-bodies]
Objects are automatically JSON stringified:
```ts title="object-bodies.ts"
import { callApi } from "@zayne-labs/callapi";
// CallApi handles this automatically
await callApi("/api/user", {
method: "POST",
body: { name: "John", age: 30 },
});
// Equivalent to manual fetch:
// fetch("/api/user", {
// method: "POST",
// headers: { "Content-Type": "application/json" },
// body: JSON.stringify({ name: "John", age: 30 }),
// });
```
Custom Body Serializer [#custom-body-serializer]
Override the default object-to-string serialization:
```ts title="custom-serializer.ts"
import { callApi } from "@zayne-labs/callapi";
await callApi("/api/data", {
method: "POST",
body: { name: "John", age: 30 },
bodySerializer: (body) => customSerialize(body),
});
```
Custom Body Transformer [#custom-body-transformer]
Transform the request body into another Fetch-compatible body type:
```ts title="custom-transformer.ts"
import { callApi } from "@zayne-labs/callapi";
await callApi("/api/upload", {
method: "POST",
body: { avatar: imageFile, name: "John" },
bodyTransformer: ({ body }) => {
const formData = new FormData();
Object.entries(body).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
},
});
```
Query String Bodies [#query-string-bodies]
Convert objects to URL-encoded strings:
```ts title="query-string-bodies.ts"
import { callApi } from "@zayne-labs/callapi";
import { toQueryString } from "@zayne-labs/callapi/utils";
await callApi("/api/search", {
method: "POST",
body: toQueryString({ name: "John", age: 30 }),
});
// Body: "name=John&age=30"
// Content-Type: application/x-www-form-urlencoded
```
FormData Bodies [#formdata-bodies]
Convert objects to FormData with intelligent type handling:
```ts title="formdata-bodies.ts"
import { callApi } from "@zayne-labs/callapi";
import { toFormData } from "@zayne-labs/callapi/utils";
await callApi("/api/upload", {
method: "POST",
body: toFormData({
avatar: imageFile, // Files/blobs added directly
tags: ["dev", "designer"], // Arrays become multiple entries
metadata: { role: "admin" }, // Objects are JSON stringified
name: "John", // Primitives added as-is
}),
});
```
**How toFormData handles different types:**
* **Files/Blobs** → Added directly
* **Arrays** → Multiple entries with same key
* **Objects** → JSON stringified
* **Primitives** → Added as-is
# Title: Request Deduplication
Description: Optimize API calls by automatically handling duplicate concurrent requests
URL: /docs/request-dedupe
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/request-dedupe.mdx
Request deduplication prevents redundant API calls by identifying identical requests made while another is already running (**in-flight**).
This saves bandwidth and prevents race conditions where overlapping requests finish out of order.
How it works [#how-it-works]
Deduplication only applies to **active** requests. Once a request completes, it is removed from the internal tracking cache.
1. **Task Queueing:** Every request enters a tiny non-zero timeout slot. This ensures parallel calls (like `Promise.all`) execute sequentially so they can correctly detect each other in the cache.
2. **Keying:** A unique `dedupeKey` is generated (customizable).
3. **Active Check:** If a request with the same key is already running, CallApi applies the configured strategy.
4. **Cleanup:** The key is automatically removed from the cache as soon as the request resolves or rejects.
Deduplication is **client-instance based** by default. Requests from different `callApi` instances won't
deduplicate unless [Global Scope](#cache-scope) is enabled.
Usage [#usage]
Configure deduplication globally during client creation or override it per request.
1\. Cancel Strategy (Default) [#1-cancel-strategy-default]
Aborts the **previous** in-flight request and starts the new one. Best for search-as-you-type or rapid navigation where only the latest query is relevant.
```ts title="search.ts"
const handleSearch = (query: string) => {
return callApi("/api/search", {
query: { q: query },
dedupeStrategy: "cancel",
dedupeKey: "site-search",
});
};
handleSearch("h");
handleSearch("he");
handleSearch("hel");
handleSearch("hello");
// Result: "h", "he", and "hel" are aborted. Only "hello" executes fully.
```
2\. Defer Strategy [#2-defer-strategy]
Instead of starting a new fetch, the new call **waits** for the existing in-flight request and shares its promise. Ideal for loading initial config or data shared across multiple components.
```ts title="api.ts"
const [response1, response2] = await Promise.all([
callApi("/api/app-config", { dedupeStrategy: "defer" }),
callApi("/api/app-config", { dedupeStrategy: "defer" }),
]);
// Result: Only one network request is sent. Both calls receive the same response data.
```
3\. None Strategy [#3-none-strategy]
Disables tracking entirely. Every call triggers an independent request.
```ts title="logs.ts"
const { data } = await callApi("/api/logs", {
dedupeStrategy: "none",
});
```
Custom Deduplication Key [#custom-deduplication-key]
The default `dedupeKey` is generated from the **URL** plus a stable hash of the **Method**, **Body**, and **explicitly provided Headers**.
Static Key [#static-key]
Force unrelated requests to be treated as duplicates.
```ts title="api.ts"
const { data } = await callApi("/api/resource", {
dedupeKey: "singleton-request",
});
```
Dynamic Key Callback [#dynamic-key-callback]
Use a function to return a key based on the request context.
```ts title="api.ts"
await callApi("/api/user/123", {
dedupeKey: (ctx) => {
const { method, fullURL } = ctx.options;
return `${method}:${fullURL}`;
},
});
```
Cache Scope [#cache-scope]
Controls whether the deduplication cache is isolated to a single client or shared globally.
Local Scope (Default) [#local-scope-default]
Deduplication state is isolated to the client instance. Identical requests made from two different client instances will not deduplicate each other.
```ts title="api.ts"
const userClient = createFetchClient({ baseURL: "/users" });
const postClient = createFetchClient({ baseURL: "/posts" });
// These will not deduplicate with each other.
```
Global Scope [#global-scope]
Multiple client instances share the same deduplication tracker. This allows requests from different client instances to deduplicate each other if they share the same keys.
```ts title="api.ts"
const userClient = createFetchClient({
baseURL: "/api/users",
dedupeCacheScope: "global",
});
const profileClient = createFetchClient({
baseURL: "/api/profiles",
dedupeCacheScope: "global",
});
// These will share deduplication state if they hit the same keys.
```
Scope Namespacing [#scope-namespacing]
Group specific clients together within the global scope using `dedupeCacheScopeKey`.
```ts title="api.ts"
const sharedScopeKey = "identity";
const authClient = createFetchClient({
dedupeCacheScope: "global",
dedupeCacheScopeKey: sharedScopeKey,
});
const accountClient = createFetchClient({
dedupeCacheScope: "global",
dedupeCacheScopeKey: sharedScopeKey, // Shares cache with authClient
});
```
Recommendations [#recommendations]
* Use `cancel` when you only need the most recent request (most common)
* Use `defer` when multiple parts of your app need the same data simultaneously
* Use `none` when requests must be independent (polling, etc.)
Types [#types]
# Title: Timeout and Retries
Description: Configure automatic retries and request timeouts
URL: /docs/timeout-and-retries
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/timeout-and-retries.mdx
Timeout [#timeout]
Set a maximum time limit for requests using the `timeout` option (in milliseconds). If a request takes longer than the specified timeout, it will be aborted and a `TimeoutError` will be returned:
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const callBackendApi = createFetchClient({
baseURL: "http://localhost:3000",
timeout: 5000,
});
const result = await callBackendApi("/api/users", {
timeout: 10000,
});
```
Auto Retry [#auto-retry]
CallApi can automatically retry failed requests. For most use cases, you only need to specify the number of retry attempts:
```ts title="api.ts"
const result = await callApi("/api/users", {
retryAttempts: 3,
});
```
Advanced Retry Options [#advanced-retry-options]
CallApi provides flexible retry mechanisms with both linear and exponential backoff strategies.
Linear Retry Strategy [#linear-retry-strategy]
Waits a fixed amount of time between retries:
```ts title="api.ts"
const result = await callApi("/api/users", {
retryStrategy: "linear",
retryAttempts: 3,
retryDelay: 1000,
});
```
Exponential Retry Strategy [#exponential-retry-strategy]
Increases the delay between retries exponentially. This is the recommended strategy for most APIs to prevent overwhelming servers.
```ts title="api.ts"
const result = await callApi("/api/users", {
retryStrategy: "exponential",
retryAttempts: 5, // Retry up to 5 times
retryDelay: 1000, // Start with 1 second delay
retryMaxDelay: 10000, // Cap the delay at 10 seconds
// Retry delays will be: 1s, 2s, 4s, 8s, 10s (capped at maxDelay)
});
```
Dynamic Retry Delay [#dynamic-retry-delay]
Pass a function to `retryDelay` to dynamically calculate the delay based on the current attempt count:
```ts title="api.ts"
const result = await callApi("/api/data", {
retryAttempts: 5,
retryDelay: (attemptCount) => {
// Example: Add random jitter to prevent thundering herd
const baseDelay = 1000 * 2 ** attemptCount;
const jitter = Math.random() * 1000;
return Math.min(baseDelay + jitter, 10000);
},
});
```
Retry Methods and Status Codes [#retry-methods-and-status-codes]
Customize when to retry a request with `retryMethods` and `retryStatusCodes`:
1. **Retry Methods**: Specifies which HTTP methods should be retried. Defaults to `["GET", "POST"]`. Be careful when configuring this to retry `PUT` or `DELETE` requests unless they are idempotent.
2. **Retry Status Codes**: Specifies which HTTP status codes should be retried. If not specified, all error status codes are eligible for retry.
```ts title="api.ts"
const result = await callApi("/api/users", {
retryAttempts: 3,
retryDelay: 1000,
retryMethods: ["GET", "POST"],
// Only retry on rate limits and server errors
retryStatusCodes: [429, 500, 502, 503, 504],
});
```
Custom Retry Condition [#custom-retry-condition]
Use `retryCondition` to implement custom retry logic. This function receives the error context and returns a boolean (or promise) indicating whether to retry:
```ts title="api.ts"
const result = await callApi("/api/users", {
retryAttempts: 3,
retryCondition: ({ error, response }) => {
return response?.status === 429;
},
});
```
The onRetry hook [#the-onretry-hook]
Listen to retry attempts using the `onRetry` hook:
```ts title="api.ts"
const result = await callApi("/todos/1", {
retryAttempts: 3,
onRetry: ({ retryAttemptCount, error }) => {
console.log(`Retrying request (attempt ${retryAttemptCount}). Reason: ${error.message}`);
},
});
```
Manual Refetching [#manual-refetching]
While `retryAttempts` handles automatic retries for common scenarios, sometimes you need more control—like refreshing an expired authentication token before retrying.
In these cases, you can use the `refetch()` function available in most error-related hooks. See the [Manual Refetching](/docs/hooks#manual-refetching) section in the Hooks documentation for more details.
Types [#types]
Timeout [#timeout-1]
Retry [#retry]
# Title: URL helpers
Description: Learn about various convenient ways to build request URLs in CallApi
URL: /docs/url-helpers
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/url-helpers.mdx
Base URL [#base-url]
Set a base URL for requests using the `baseURL` option:
```ts twoslash title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
const callBackendApi = createFetchClient({
baseURL: "https://api.example.com",
});
const { data } = await callBackendApi("/users/123/posts");
// @annotate: The resolved URL will be: "https://api.example.com/users/123/posts"
```
CallApi handles the joining slash. Absolute URLs ignore `baseURL`.
Set an absolute
`baseURL`
for requests that can run on the server.
Dynamic Parameters [#dynamic-parameters]
Many URLs contain dynamic parts representing specific resources, like an ID. For example, `/users/123` fetches user 123, and `/posts/456` fetches post 456.
Instead of building the URL yourself, use `:param` or `{param}` placeholders. For example,
`/users/:userId` and `/users/{userId}` both accept a `userId` value from `params`.
Provide values for these placeholders using the `params` option. CallApi replaces each placeholder
with its matching value.
The `params` option accepts either an object or an array:
* **Object**: Keys match the parameter names without `:` or braces. For example,
`/users/:userId/posts/{postId}` with `params: { userId: 123, postId: 456 }` resolves to
`/users/123/posts/456`.
* **Array**: The values replace the parameters in the order they appear in the URL. For example, if your URL is `/users/:userId/posts/:postId`, passing `params: ['123', '456']` will result in `/users/123/posts/456`.
Using an object is generally recommended as it's clearer which value goes with which parameter.
```ts twoslash title="api.ts"
import { callApi } from "@zayne-labs/callapi";
const { data } = await callApi("https://api.example.com/users/:userId/posts/{postId}", {
params: {
userId: 123,
postId: 456,
},
});
const { data: userData } = await callApi("https://api.example.com/users/:userId/posts/:postId", {
params: [123, 456],
});
// @annotate: The resolved URL for both cases will be: "https://api.example.com/users/123/posts/456"
```
Parameter values are URL-encoded before being inserted. `.` and `..` are rejected because they are
reserved path segments.
Query Parameters [#query-parameters]
Include query parameters in the URL using the `query` option:
```ts twoslash title="api.ts"
import { callApi } from "@zayne-labs/callapi";
const { data } = await callApi("https://api.example.com/users/123/posts", {
query: {
page: 1,
limit: 10,
sort: "latest",
tag: ["typescript", "fetch"],
filter: { published: true },
},
});
// @annotate: Arrays become repeated keys and objects are JSON-stringified.
```
Top-level nullish values are omitted. `URLSearchParams` is also accepted. `query` replaces matching
keys already in the URL.
Method Prefixes [#method-prefixes]
CallApi provides a convenient way to specify HTTP methods directly in the URL using the `@method/` prefix. This allows you to:
* Write more concise API calls by embedding the HTTP method in the URL
* Make your code more readable by keeping the HTTP method close to the endpoint
Usage [#usage]
```ts title="api.ts"
import { callApi } from "@zayne-labs/callapi";
// Using method prefix
const result = await callApi("@delete/users/123");
// Equivalent to:
const result2 = await callApi("users/123", {
method: "DELETE",
});
```
How It Works [#how-it-works]
When you prefix a URL with `@method/` (e.g., `@get/users`):
1. The method (e.g., `get`, `post`, `put`, etc.) is extracted from the URL
2. The extracted method is automatically set as the request method
3. The remaining part of the URL is used as the endpoint
Supported Methods [#supported-methods]
CallApi supports the following HTTP methods via URL prefixes:
* `@get/` → GET requests
* `@post/` → POST requests
* `@put/` → PUT requests
* `@delete/` → DELETE requests
* `@patch/` → PATCH requests
Any other method prefix (like `@head/`, `@options/`, `@trace/`) will be ignored and the URL will fall back to the default GET method.
{/* prettier-ignore */}
* Always include a forward slash after the method prefix (e.g., `@get/` not `@get`)
* If both a method prefix and explicit `method` option are provided, the explicit method will be used
Types [#types]
# Title: Validation
Description: Define validation schemas for your requests details and response data
URL: /docs/validation
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/validation.mdx
Validation schemas let you pre-define URL paths and validate the shape of request and response data. This validation happens at both the type-level (TypeScript) and at runtime, helping you catch errors early, ensure data integrity, and document your API structure.
CallApi uses Standard Schema internally, allowing you to bring your own Standard Schema-compliant validator like Zod, Valibot, or ArkType.
Basic Usage [#basic-usage]
To create a validation schema, you need to import the `defineSchema` function from `@zayne-labs/callapi`.
```ts twoslash title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
export const baseSchema = defineSchema({
// [!code highlight]
"/path": {
// [!code highlight]
body: z.object({
// [!code highlight]
userId: z.string(), // [!code highlight]
id: z.number(), // [!code highlight]
title: z.string(), // [!code highlight]
completed: z.boolean(), // [!code highlight]
}), // [!code highlight]
// [!code highlight]
data: z.object({
// [!code highlight]
userId: z.string(), // [!code highlight]
id: z.number(), // [!code highlight]
title: z.string(), // [!code highlight]
completed: z.boolean(), // [!code highlight]
}), // [!code highlight]
}, // [!code highlight]
});
const callApi = createFetchClient({
baseURL: "https://jsonplaceholder.typicode.com",
schema: baseSchema, // [!code highlight]
});
```
Validation Schema [#validation-schema]
The validation schema is a map of paths to their validation rules. Each path can define validation for different parts of the request and response.
**Response Validation:**
* `data` - Validates successful response data
* `errorData` - Validates error response data
When validation fails, CallApi throws a `ValidationError` with details about what went wrong.
```ts twoslash title="client.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
schema: defineSchema({
"/products/:id": {
data: z.object({
id: z.number(),
title: z.string(),
price: z.number(),
}),
errorData: z.object({
code: z.string(),
message: z.string(),
}),
},
}),
});
const { data, error } = await callApi("/products/:id", {
params: {
id: 100,
},
});
// @annotate: data will be typed as { id: number; title: string; price: number }
// @annotate: errorData for HTTP errors will be typed as { code: string; message: string }
```
**Request Validation:**
* `body` - Validates request body data before sending
* `headers` - Ensures required headers are present and correctly formatted
* `method` - Validates or enforces specific HTTP methods (GET, POST, etc.)
* `params` - Validates URL parameters (`:param`)
* `query` - Validates query string parameters before adding to the URL
```ts title="client.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
schema: defineSchema({
"/users/:userId": {
query: z.object({
id: z.string(),
}),
params: z.object({
userId: z.string(),
}),
},
}),
});
```
Body Validation [#body-validation]
The `body` key validates request body data and provides type safety for the request body:
```ts twoslash title="client.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
schema: defineSchema({
"/products": {
body: z.object({
title: z.string(),
price: z.number(),
category: z.string(),
}),
},
}),
});
// @errors: 2739
const { data } = await callApi("/products", {
body: {},
});
```
Headers Validation [#headers-validation]
The `headers` key validates request headers, useful for enforcing required headers or validating header formats:
```ts twoslash title="client.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
schema: defineSchema({
"/products": {
headers: z.object({
"x-api-key": z.string(),
"content-type": z.literal("application/json"),
authorization: z.string().startsWith("Bearer "),
}),
},
}),
});
// @errors: 2322
const { data } = await callApi("/products", {
headers: {},
});
```
Meta Validation [#meta-validation]
The `meta` key validates the meta option, which passes arbitrary metadata through the request lifecycle:
```ts twoslash title="client.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
schema: defineSchema({
"/analytics": {
meta: z.object({
trackingId: z.string(),
userId: z.string().optional(),
}),
},
}),
});
// @errors: 2741
const { data } = await callApi("/analytics", {
meta: {},
});
```
Query Parameters [#query-parameters]
The `query` schema validates query parameters before they're added to the URL:
```ts twoslash title="client.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
schema: defineSchema({
"/products": {
query: z.object({
category: z.string(),
page: z.number(),
limit: z.number(),
}),
},
}),
});
// @errors: 2739
const { data } = await callApi("/products", {
query: {},
});
```
Dynamic Path Parameters [#dynamic-path-parameters]
The `params` schema validates URL parameters. You can define dynamic parameters in two ways:
1. Using colon syntax in the schema path (`:paramName`) - Enforces types at the type level only
2. Using the `params` validator schema - Validates at both type level and runtime (takes precedence over colon syntax)
```ts twoslash title="client.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
schema: defineSchema({
// Using colon syntax
"/products/:id": {
data: z.object({
name: z.string(),
price: z.string(),
}),
},
// Using params schema
"/products": {
data: z.object({
name: z.string(),
price: z.number(),
}),
params: z.object({
version: z.string(),
}),
},
// Using both colon syntax and params schema (the params schema takes precedence and ensures validation both at type level and runtime)
"/products/:id/:category": {
data: z.object({
name: z.string(),
price: z.number(),
}),
params: z.object({
id: z.number(),
category: z.string(),
}),
},
}),
});
const response1 = await callApi("/products/:id", {
params: {
id: 20,
},
});
const response2 = await callApi("/products", {
params: {
version: "v1",
},
});
const response3 = await callApi("/products/:id/:category", {
params: {
id: 20,
category: "electronics",
},
});
```
HTTP Method Modifiers [#http-method-modifiers]
You can specify the HTTP method in two ways:
1. Using the `method` validator schema
2. Prefixing the path with `@method-name` (supported: `@get/`, `@post/`, `@put/`, `@patch/`, `@delete/`)
When using the `@method-name/` prefix, it's automatically added to request options. You can override it by explicitly passing the `method` option to `callApi`.
```ts title="client.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
schema: defineSchema({
// Using method prefix
"@post/products": {
body: z.object({
title: z.string(),
price: z.number(),
}),
},
// Using method validator
"products/:id": {
method: z.literal("DELETE"),
},
}),
});
```
Validation Schema Per Instance [#validation-schema-per-instance]
You can define a validation schema for a specific request instead of globally on `createFetchClient`:
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { z } from "zod";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
});
const { data, error } = await callApi("/user", {
schema: {
data: z.object({
userId: z.string(),
id: z.number(),
title: z.string(),
completed: z.boolean(),
}),
},
});
```
Custom Validators [#custom-validators]
Instead of using Zod schemas, you can also provide custom validator functions for any schema field.
These functions receive the input value and can perform custom validation or transformation. They can also be `async` if need be.
```ts twoslash title="client.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { defineSchema } from "@zayne-labs/callapi/utils";
// Simulation: Get allowed domains from config/API
const waitUntil = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const getAllowedDomains = async () => {
await waitUntil(1000);
return ["example.com", "company.com"];
};
const callApi = createFetchClient({
baseURL: "https://api.example.com",
schema: defineSchema({
"/users": {
// Async body validator with custom validation
body: async (body) => {
if (!body || typeof body !== "object") {
throw new Error("Invalid request body");
}
// Required fields
if (!("name" in body) || typeof body.name !== "string") {
throw new Error("Name is required");
}
if (!("email" in body) || typeof body.email !== "string" || !body.email.includes("@")) {
throw new Error("Valid email required");
}
// Validate domain against allowed list
const domain = body.email.split("@")[1] ?? "";
const allowed = await getAllowedDomains();
if (!allowed.includes(domain)) {
throw new Error(`Email domain ${domain} not allowed`);
}
return {
email: body.email.toLowerCase(),
name: body.name.trim(),
};
},
// Response data validator
data: (data) => {
if (
!data
|| typeof data !== "object"
|| !("id" in data)
|| !("name" in data)
|| !("email" in data)
) {
throw new Error("Invalid response data");
}
return data; // Type will be narrowed to { id: number; name: string; email: string }
},
},
}),
});
// @annotate: Types are inferred from validator return types
const { data } = await callApi("/users", {
body: {
email: "JOHN@example.com",
name: " John ", // Will be trimmed & lowercased.
},
});
```
Custom validators allow you to:
1. Accept raw input data to validate
2. Run sync or async validation logic
3. Transform data if needed (e.g., normalize, sanitize)
4. Can throw errors for invalid data
5. Return the validated data (From which TypeScript infers the return type)
Overriding the base schema for a specific path [#overriding-the-base-schema-for-a-specific-path]
You can override the base schema by passing a schema to the `schema` option:
```ts twoslash title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
schema: defineSchema({
"/user": {
data: z.object({
name: z.string(),
id: z.number(),
}),
},
}),
});
const { data, error } = await callApi("/user", {
// @annotate: This will override the base schema for this specific path
schema: {
data: z.object({
id: z.number(),
address: z.string(),
isVerified: z.boolean(),
}),
},
});
```
Extending the base schema for a specific path [#extending-the-base-schema-for-a-specific-path]
In case you want to extend the base schema instead of overriding it, you can pass a callback to schema option of the instance, which will be called with the base schema and the specific schema for current path:
```ts twoslash title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const callApi = createFetchClient({
baseURL: "https://api.example.com",
schema: defineSchema({
"/user": {
data: z.object({
userId: z.string(),
id: z.number(),
title: z.string(),
completed: z.boolean(),
}),
},
}),
});
const { data, error } = await callApi("/user", {
// @annotate: This will extend the base schema for this specific path
schema: ({ currentRouteSchema }) => ({
...currentRouteSchema,
errorData: z.object({
code: z.string(),
message: z.string(),
}),
}),
});
```
Fallback Route Schema [#fallback-route-schema]
You can define a fallback schema that applies to all routes not explicitly defined in your schema using the special `@default` key (or use the exported `fallBackRouteSchemaKey` constant):
```ts twoslash title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const callApi = createFetchClient({
schema: defineSchema({
// Fallback schema for all routes
"@default": {
headers: z.object({
"x-api-key": z.string(),
}),
},
// Specific route schema (takes precedence over fallback)
"/users": {
data: z.object({
id: z.number(),
name: z.string(),
}),
},
}),
});
// This will use the fallback schema (requires x-api-key header)
// @errors: 2554
await callApi("/posts");
// This will use both the fallback and specific schema
// @errors: 2554
await callApi("/users");
```
Alternatively, use the exported constant for better maintainability:
```ts twoslash title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { fallBackRouteSchemaKey } from "@zayne-labs/callapi/constants";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const callApi = createFetchClient({
schema: defineSchema({
[fallBackRouteSchemaKey]: {
headers: z.object({
"x-api-key": z.string(),
}),
},
"/users": {
data: z.object({
id: z.number(),
name: z.string(),
}),
},
}),
});
```
Strict Mode [#strict-mode]
By default, CallAPI allows requests to paths not defined in the schema. Enable strict mode to prevent usage of undefined paths:
```ts twoslash title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const callApi = createFetchClient({
schema: defineSchema(
{
"/user": {
data: z.object({
userId: z.string(),
id: z.number(),
title: z.string(),
completed: z.boolean(),
}),
},
},
{
// [!code highlight]
strict: true, // [!code highlight]
}
),
});
// @errors: 2345
const { data, error } = await callApi("/invalid-path");
```
Validation Error Handling [#validation-error-handling]
CallAPI throws a `ValidationError` when validation fails. The error includes detailed information about what failed:
```ts title="client.ts"
import { isValidationError } from "@zayne-labs/callapi/utils";
const { data, error } = await callApi("/products/:id", {
params: { id: "invalid" }, // Should be a number
});
if (isValidationError(error)) {
console.log(error.errorData); // Validation issues array
console.log(error.issueCause); // Which schema key caused the validation error
}
```
Types [#types]
CallApiSchema [#callapischema]
CallApiSchemaConfig [#callapischemaconfig]
# Title: Base Extra Options
Description: Options that can be passed to the createFetchClient function
URL: /docs/api-reference/base-extra-options
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/api-reference/base-extra-options.mdx
This page documents the configuration options specific to `createFetchClient`. These options define the base behavior for all requests made with the created client instance.
# Title: Extra Options
Description: Options that can be passed to a CallApi instance
URL: /docs/api-reference/extra-options
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/api-reference/extra-options.mdx
This page documents the configuration options specific to CallApi.
# Title: Request Options
Description: All options shared with the Fetch API
URL: /docs/api-reference/request-options
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/api-reference/request-options.mdx
This page documents the configuration options that callApi shares with the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit).
# Title: Integrations
Description: Learn how to use CallApi with various libraries and frameworks
URL: /docs/integrations
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/integrations/index.mdx
CallApi works seamlessly with any library that expects a Promise-based HTTP client, since it's built on the native Fetch API.
Choose an integration guide from the sidebar to learn more about specific details and best practices.
Common Integration Pattern [#common-integration-pattern]
Most integrations follow this pattern:
1. Create a CallApi instance with your base configuration
2. Use the instance in your data fetching hooks or functions
3. Handle errors using CallApi's error handling utilities
Example:
```tsx title="example.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { isHTTPErrorInstance } from "@zayne-labs/callapi/utils";
const callMainApi = createFetchClient({
baseURL: "https://api.example.com",
resultMode: "onlyData", // Return just data, not { data, error, response }
throwOnError: true, // Libraries like React Query expect thrown errors
});
export default function App() {
// React Query
const queryResult = useQuery({
queryKey: ["user", userId],
queryFn: () => callMainApi(`/users/${userId}`),
});
// SWR
const swrResult = useSWR(`/users/${userId}`, () => callMainApi(`/users/${userId}`));
return (
{queryResult.data.name}
{swrResult.data.name}
);
}
```
# Title: React Query
Description: Learn how to use CallApi with React Query
URL: /docs/integrations/react-query
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/integrations/react-query.mdx
This guide covers essential patterns and best practices for using CallApi with [React Query](https://tanstack.com/query/v5/docs/framework/react), combining CallApi's features with React Query's caching and synchronization capabilities.
Quick Start [#quick-start]
By default, CallApi returns errors as values in the result object instead of throwing them, which is great for explicit error handling. However, React Query expects query functions to either return data on success or throw an error on failure.
To make CallApi work seamlessly with React Query, configure it to throw errors and return only the data:
```ts title="api/todos.ts"
import { useQuery } from "@tanstack/react-query";
import { callApi } from "@zayne-labs/callapi";
type Todo = {
completed: boolean;
id: number;
title: string;
};
export const useTodos = () => {
return useQuery({
queryKey: ["todos"],
queryFn: () => {
return callApi("/todos", {
throwOnError: true,
resultMode: "onlyData",
});
},
});
};
```
Configuration Options [#configuration-options]
Key options for React Query integration:
* **`throwOnError: true`** - Makes CallApi throw errors instead of returning them
* **`resultMode: "onlyData"`** - Returns just the data property, perfect for React Query
These settings ensure CallApi behaves exactly like React Query expects: throwing errors for failures and
returning clean data for successes.
Centralized Configuration [#centralized-configuration]
```ts title="api/client.ts"
import { createFetchClient } from "@zayne-labs/callapi";
export const callApiForQuery = createFetchClient({
baseURL: "https://api.example.com",
// Default to React Query compatible settings
throwOnError: true,
resultMode: "onlyData",
});
// Use the configured client in queries
export const useTodos = () => {
return useQuery({
queryKey: ["todos"],
queryFn: () => callApiForQuery("/todos"),
});
};
```
Data Inference via typescript [#data-inference-via-typescript]
Option 1: Schema Validation (Recommended) [#option-1-schema-validation-recommended]
Using validation libraries like Zod provides both runtime safety and automatic type inference:
```ts title="hooks/useTodos.ts"
import { useQuery } from "@tanstack/react-query";
import { callApi } from "@zayne-labs/callapi";
import { z } from "zod";
const todosSchema = z.array(
z.object({
id: z.number(),
title: z.string(),
completed: z.boolean(),
})
);
export const useTodos = () => {
return useQuery({
queryKey: ["todos"],
queryFn: () => {
return callApi("/todos", {
schema: { data: todosSchema },
throwOnError: true,
resultMode: "onlyData",
});
},
});
};
```
Option 2: Manual Type Specification [#option-2-manual-type-specification]
```ts title="hooks/useTodos.ts"
import { useQuery } from "@tanstack/react-query";
import { callApi } from "@zayne-labs/callapi";
type Todo = {
completed: boolean;
id: number;
title: string;
};
export const useTodos = () => {
return useQuery({
queryKey: ["todos"],
queryFn: () => {
// Pass `false` as second generic to signal errors will be thrown allow callApi to return the expected type
// This is needed due to TypeScript limitations with partial generic inference
// See: https://github.com/microsoft/TypeScript/issues/26242
return callApi("@get/todos", {
throwOnError: true,
resultMode: "onlyData",
});
},
});
};
```
# Title: Runtime Helpers
Description: Helper functions and utilities for working with CallApi
URL: /docs/utilities/runtime-helpers
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/utilities/runtime-helpers.mdx
CallApi provides utility functions for schemas, plugins, error handling, and data processing.
Schema & Plugin Utilities [#schema--plugin-utilities]
defineSchema [#defineschema]
Creates type-safe schema configurations:
```ts title="api-schema.ts"
import { fallBackRouteSchemaKey } from "@zayne-labs/callapi/constants";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
export const apiSchema = defineSchema({
[fallBackRouteSchemaKey]: {
headers: z.object({
"x-api-key": z.string(),
}),
},
"/users/:id": {
data: z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
}),
params: z.object({
id: z.number(),
}),
},
});
```
definePlugin [#defineplugin]
Creates type-safe plugins:
```ts title="auth-plugin.ts"
import { definePlugin } from "@zayne-labs/callapi/utils";
import { z } from "zod";
export const authPlugin = definePlugin({
id: "auth-plugin",
name: "Authentication Plugin",
defineExtraOptions: () =>
z.object({
apiKey: z.string(),
refreshToken: z.string().optional(),
}),
setup: ({ options, request }) => ({
request: {
...request,
headers: {
...request.headers,
Authorization: `Bearer ${options.apiKey}`,
},
},
}),
});
```
Data Processing Utilities [#data-processing-utilities]
toFormData [#toformdata]
Converts objects to FormData with intelligent type handling:
```ts title="form-data.ts"
import { toFormData } from "@zayne-labs/callapi/utils";
const formData = toFormData({
name: "John",
age: 30,
tags: ["javascript", "typescript"], // Arrays become multiple entries
avatar: fileBlob, // Files and blobs handled correctly
settings: { theme: "dark" }, // Objects are JSON stringified
});
```
toQueryString [#toquerystring]
Converts objects to URL query strings:
```ts title="query-string.ts"
import { toQueryString } from "@zayne-labs/callapi/utils";
const query = toQueryString({
page: 1,
tags: ["js", "ts"], // Arrays become multiple params
search: "javascript",
active: null, // Null/undefined values are skipped
});
// Result: "page=1&tags=js&tags=ts&search=javascript"
```
Error Handling Utilities [#error-handling-utilities]
Error Type Guards [#error-type-guards]
Check error types in result objects:
```ts title="error-guards.ts"
import { isHTTPError, isJavascriptError, isValidationError } from "@zayne-labs/callapi/utils";
function handleError(error: unknown) {
if (isHTTPError(error)) {
return `HTTP ${error.originalError.response.status}: ${error.message}`;
}
if (isValidationError(error)) {
return `Validation failed: ${error.message}`;
}
// Any error that basically isn't the above two
if (isJavascriptError(error)) {
return `JavaScript error: ${error.message}`;
}
}
```
Error Instance Guards [#error-instance-guards]
Check CallApi-specific error instances when using `throwOnError: true`:
```ts title="error-instances.ts"
import { isHTTPErrorInstance, isValidationErrorInstance } from "@zayne-labs/callapi/utils";
try {
const { data } = await apiClient("/users", { throwOnError: true });
} catch (error) {
if (isHTTPErrorInstance(error)) {
console.error(`HTTP ${error.response.status}: ${error.message}`);
}
if (isValidationErrorInstance(error)) {
console.error(`Validation failed: ${error.message}`);
}
}
```
Constants [#constants]
fallBackRouteSchemaKey [#fallbackrouteschemakey]
Constant for defining fallback schemas:
```ts title="fallback-schema.ts"
import { fallBackRouteSchemaKey } from "@zayne-labs/callapi/constants";
import { defineSchema } from "@zayne-labs/callapi/utils";
import { z } from "zod";
const schema = defineSchema({
[fallBackRouteSchemaKey]: {
headers: z.object({
"x-api-key": z.string(),
}),
},
});
```
# Title: Type Helpers
Description: Advanced type helpers for CallApi
URL: /docs/utilities/type-helpers
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/utilities/type-helpers.mdx
CallApi provides type helpers for complex type scenarios and better type safety.
Client Context Types [#client-context-types]
GetCallApiContext [#getcallapicontext]
Provides type safety and IntelliSense when defining context types:
```ts title="custom-context.ts"
import type { GetCallApiContext } from "@zayne-labs/callapi";
type MyAppContext = GetCallApiContext<{
Data: {
email: string;
id: number;
name: string;
};
ErrorData: {
code: string;
details?: Record;
message: string;
};
Meta: {
feature?: string;
requestId: string;
userId: string;
};
ResultMode: "all";
}>;
// Use with createFetchClientWithContext
const createAppClient = createFetchClientWithContext();
```
Benefits: validates context structure at compile time, provides autocomplete, and catches typos.
createFetchClientWithContext [#createfetchclientwithcontext]
Create clients with custom context types:
```ts title="typed-client.ts"
import { createFetchClientWithContext, GetCallApiContext } from "@zayne-labs/callapi";
type AppContext = GetCallApiContext<{
Data: { id: number; name: string };
ErrorData: { code: string; message: string };
Meta: { requestId: string; userId: string };
}>;
const callBackendApi = createFetchClientWithContext()({
baseURL: "https://api.example.com",
meta: {
userId: "123", // Fully typed based on AppContext.Meta
requestId: "req-456",
},
});
const { data } = await callBackendApi("/users/1");
// data is typed as AppContext.Data: { id: number; name: string }
```
Global Meta Registration [#global-meta-registration]
Register global meta types using module augmentation:
```ts title="types/callapi.d.ts"
declare module "@zayne-labs/callapi" {
interface Register {
meta: {
feature?: string;
priority?: "high" | "low" | "normal";
requestId?: string;
source?: "background" | "system" | "user-action";
userId?: string;
};
}
}
```
Hook Context Types [#hook-context-types]
Each hook has a corresponding context type:
```ts title="hook-types.ts"
import type { ErrorContext, RequestContext, SuccessContext } from "@zayne-labs/callapi";
function createLoggingHooks() {
return {
onRequest: (ctx: RequestContext) => {
console.log(`Request: ${ctx.options.initURL}`);
},
onSuccess: (ctx: SuccessContext<{ Data: TData }>) => {
console.log(`Success: ${ctx.data}`);
},
onError: (ctx: ErrorContext<{ ErrorData: TErrorData }>) => {
console.log(`Error: ${ctx.error.message}`);
},
};
}
```
Plugin Types [#plugin-types]
CallApiPlugin [#callapiplugin]
Type for defining plugins:
```ts title="plugin-types.ts"
import type { CallApiPlugin } from "@zayne-labs/callapi";
import { z } from "zod";
const myOptionsSchema = z.object({
apiKey: z.string(),
debug: z.boolean().default(false),
});
type MyPluginContext = GetCallApiContext<{
InferredExtraOptions: typeof myOptionsSchema;
}>;
const myPlugin = {
id: "my-plugin",
name: "My Plugin",
setup: ({ options }) => {
console.log(`API Key: ${options.apiKey}, Debug: ${options.debug}`);
return {};
},
hooks: {
onRequest: ({ options }) => {
if (options.debug) {
console.log("Debug mode enabled");
}
},
},
} satisfies CallApiPlugin;
```
These type utilities provide advanced TypeScript support while maintaining type safety throughout your application.
# Title: Logger
Description: Comprehensive HTTP request/response logging with beautiful console output
URL: /docs/utilities/plugins/logger
Source: https://raw.githubusercontent.com/zayne-labs/callapi/refs/heads/main/apps/docs/content/docs/utilities/plugins/logger.mdx
The logger plugin provides detailed, structured logging for all HTTP request/response lifecycle events. Built on [consola](https://github.com/unjs/consola).
Features [#features]
* Logs all HTTP requests and responses
* Tracks errors and retries
* Color-coded console output
* Customizable logging options
Installation [#installation]
npm
pnpm
yarn
bun
```bash
npm install @zayne-labs/callapi-plugins
```
```bash
pnpm add @zayne-labs/callapi-plugins
```
```bash
yarn add @zayne-labs/callapi-plugins
```
```bash
bun add @zayne-labs/callapi-plugins
```
Quick Start [#quick-start]
```ts title="api.ts"
import { createFetchClient } from "@zayne-labs/callapi";
import { loggerPlugin } from "@zayne-labs/callapi-plugins";
const callMainApi = createFetchClient({
baseURL: "https://api.example.com",
plugins: [
loggerPlugin({
enabled: process.env.NODE_ENV === "development",
mode: process.env.DEBUG === "true" ? "verbose" : "basic",
}),
],
});
```
Configuration [#configuration]
`enabled` [#enabled]
Type: `boolean | { onError?: boolean; onRequest?: boolean; onRequestError?: boolean; onResponse?: boolean; onResponseError?: boolean; onRetry?: boolean; onSuccess?: boolean; onValidationError?: boolean; }`
Default: `true`
Toggle logging on/off. Can be a boolean for all logging, or an object for granular control over specific events.
```ts
// Simple boolean toggle
export const client1 = createFetchClient({
plugins: [
loggerPlugin({
enabled: process.env.NODE_ENV === "development",
}),
],
});
// Granular control
export const client2 = createFetchClient({
plugins: [
loggerPlugin({
enabled: {
onRequest: true,
onSuccess: true,
onError: true, // Enable all error logging by default
onValidationError: false, // Disable validation error logging specifically
},
}),
],
});
// Alternative: Enable only specific error types
export const client3 = createFetchClient({
plugins: [
loggerPlugin({
enabled: {
onRequest: true,
onSuccess: true,
onRequestError: true, // Only network/request errors
onResponseError: false, // Disable HTTP error logging
onValidationError: false,
},
}),
],
});
```
`consoleObject` [#consoleobject]
Type: `ConsoleLikeObject`
Default: A pre-configured `consola` instance
Provide a custom console-like object implementing this interface:
```ts
interface ConsoleLikeObject {
error: (...args: any[]) => void;
fail?: (...args: any[]) => void;
log: (...args: any[]) => void;
success?: (...args: any[]) => void;
warn?: (...args: any[]) => void;
}
```
Example:
```ts title="api.ts"
import { loggerPlugin, type ConsoleLikeObject } from "@zayne-labs/callapi-plugins";
const customLogger: ConsoleLikeObject = {
log: (...args) => console.log("[API]", ...args),
error: (...args) => console.error("[API ERROR]", ...args),
warn: (...args) => console.warn("[API WARN]", ...args),
success: (...args) => console.log("[API SUCCESS]", ...args),
};
export const client = createFetchClient({
plugins: [loggerPlugin({ consoleObject: customLogger })],
});
```
`mode` [#mode]
Type: `"basic" | "verbose"`
Default: `"basic"`
Controls the verbosity of logging output:
* **`"basic"`**: Standard logging with essential information
* **`"verbose"`**: Detailed debugging with full payloads, error data, and additional context
```ts
export const client = createFetchClient({
plugins: [loggerPlugin({ mode: "verbose" })],
});
```
Logged Events [#logged-events]
The logger plugin logs these events:
* **Request Started**: When a request is initiated
* **Request Error**: When a request fails to be sent
* **Response Error**: When a response has an error status
* **Retry**: When a request is being retried (with attempt count)
* **Success**: When a request completes successfully
* **Validation Error**: When request/response validation fails (via schema validation)