# 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. ``` Cancel Strategy Visualization 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. ``` Defer Strategy Visualization 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)