skills/openapi-fetch/SKILL.md
Type-safe fetch client for OpenAPI schemas using openapi-fetch and openapi-typescript. Use when writing, reviewing, or refactoring code that makes API calls with openapi-fetch, creates API clients from OpenAPI schemas, or uses openapi-typescript generated types. Triggers on imports of openapi-fetch or createClient usage with OpenAPI paths types.
npx skillsauth add prescottprue/agent-skills openapi-fetchInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Comprehensive guide for openapi-fetch — a type-safe fetch client that uses OpenAPI schemas for zero-overhead type checking at build time.
Reference these guidelines when:
npm i openapi-fetch
npm i -D openapi-typescript typescript
Generate TypeScript types from your OpenAPI schema:
npx openapi-typescript ./path/to/api/v1.yaml -o ./src/lib/api/v1.d.ts
This produces a paths type export used to type the client.
Recommended: Enable noUncheckedIndexedAccess in tsconfig.json for enhanced type safety.
import createClient from "openapi-fetch";
import type { paths } from "./my-openapi-3-schema";
const client = createClient<paths>({
baseUrl: "https://myapi.dev/v1/",
});
createClient Options| Option | Type | Description |
|--------|------|-------------|
| baseUrl | string | Prefix all fetch URLs |
| fetch | (input: Request) => Promise<Response> | Custom fetch instance (default: globalThis.fetch) |
| Request | typeof Request | Custom Request constructor |
| querySerializer | QuerySerializer \| QuerySerializerOptions | Global query param serializer |
| bodySerializer | BodySerializer | Global body serializer |
| pathSerializer | PathSerializer | Global path param serializer |
| headers | HeadersOptions | Default headers for all requests |
| requestInitExt | Record<string, unknown> | Extension object passed as 2nd argument to fetch |
| (any fetch option) | | Any valid RequestInit option (mode, cache, credentials, signal, etc.) |
All HTTP methods are available: GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD, TRACE.
// GET request
const { data, error, response } = await client.GET("/blogposts/{post_id}", {
params: {
path: { post_id: "123" },
query: { format: "json" },
},
});
// POST request
const { data, error } = await client.POST("/blogposts", {
body: {
title: "My Post",
content: "Hello world",
},
});
// PUT request
const { data, error } = await client.PUT("/blogposts/{post_id}", {
params: { path: { post_id: "123" } },
body: { title: "Updated Title" },
});
// DELETE request
const { data, error } = await client.DELETE("/blogposts/{post_id}", {
params: { path: { post_id: "123" } },
});
Every method returns an object with:
data — Present only on 2xx responses. Typed from the schema's success response.error — Present only on 4xx/5xx responses. Typed from the schema's error response.response — The raw Response object with status, headers, etc.data and error are mutually exclusive — check one to narrow the type:
const { data, error } = await client.GET("/blogposts/{post_id}", {
params: { path: { post_id: "123" } },
});
if (error) {
// error is typed, data is undefined
console.error(error);
return;
}
// data is typed, error is undefined
console.log(data.title);
| Option | Type | Description |
|--------|------|-------------|
| params | { path?, query?, header?, cookie? } | Path, query, header, and cookie parameters |
| body | object | Request body (typed from schema) |
| parseAs | "json" \| "text" \| "arrayBuffer" \| "blob" \| "stream" | Response parsing method (default: "json") |
| querySerializer | QuerySerializer \| QuerySerializerOptions | Per-request query serializer |
| bodySerializer | BodySerializer | Per-request body serializer |
| pathSerializer | PathSerializer | Per-request path serializer |
| baseUrl | string | Override base URL for this request |
| fetch | fetch | Override fetch for this request |
| headers | HeadersOptions | Per-request headers (merged with client defaults) |
| middleware | Middleware[] | Per-request middleware |
| (any fetch option) | | Any valid RequestInit option |
request MethodFor dynamic HTTP methods:
const { data, error } = await client.request("GET", "/blogposts/{post_id}", {
params: { path: { post_id: "123" } },
});
parseAs OptionControl how the response body is parsed:
// Get response as text
const { data } = await client.GET("/file", { parseAs: "text" });
// Get response as blob
const { data } = await client.GET("/image", { parseAs: "blob" });
// Get response as stream
const { data } = await client.GET("/large-file", { parseAs: "stream" });
// Get response as arrayBuffer
const { data } = await client.GET("/binary", { parseAs: "arrayBuffer" });
Middleware intercepts requests and responses for all fetches. Use for auth, logging, caching, error handling, etc.
import type { Middleware } from "openapi-fetch";
const myMiddleware: Middleware = {
async onRequest({ request, schemaPath, params, id, options }) {
// Modify or return a new Request
// Return a Response to short-circuit (skip fetch + remaining onRequest handlers)
// Return undefined to skip this middleware
return request;
},
async onResponse({ request, response, schemaPath, params, id, options }) {
// Modify or return a new Response
// Return undefined to skip
return response;
},
async onError({ request, error, schemaPath, params, id, options }) {
// Return nothing: logs without re-throwing
// Return an Error: throws the modified error
// Return a Response: treats the request as successful
},
};
| Name | Type | Available in | Description |
|------|------|-------------|-------------|
| request | Request | all | Current Request object |
| response | Response | onResponse | Response from endpoint |
| error | unknown | onError | Error thrown by fetch |
| schemaPath | string | all | Original OpenAPI path (e.g., "/blogposts/{post_id}") |
| params | object | all | Original params object (query, header, path, cookie) |
| id | string | all | Unique ID for this request |
| options | MergedOptions | all | Read-only client options |
client.use(myMiddleware); // Register
client.use(mw1, mw2, mw3); // Register multiple
client.eject(myMiddleware); // Unregister
onRequest handlers run in registration orderonResponse handlers run in reverse orderimport type { Middleware } from "openapi-fetch";
let accessToken: string | undefined = undefined;
const authMiddleware: Middleware = {
async onRequest({ request }) {
if (!accessToken) {
const authRes = await someAuthFunc();
accessToken = authRes.accessToken;
}
request.headers.set("Authorization", `Bearer ${accessToken}`);
return request;
},
};
client.use(authMiddleware);
const UNPROTECTED_ROUTES = ["/v1/login", "/v1/logout", "/v1/public/"];
const authMiddleware: Middleware = {
onRequest({ schemaPath, request }) {
if (UNPROTECTED_ROUTES.some((path) => schemaPath.startsWith(path))) {
return undefined; // Skip this middleware
}
request.headers.set("Authorization", `Bearer ${accessToken}`);
return request;
},
};
const cache = new Map<string, Response>();
const getCacheKey = (request: Request) => `${request.method}:${request.url}`;
const cacheMiddleware: Middleware = {
onRequest({ request }) {
const cached = cache.get(getCacheKey(request));
if (cached) {
return cached.clone(); // Return Response to short-circuit
}
},
onResponse({ request, response }) {
if (response.ok) {
cache.set(getCacheKey(request), response.clone());
}
},
};
By default, openapi-fetch does not throw on 4xx/5xx — it returns { error }. To throw instead:
const throwOnError: Middleware = {
onResponse({ response }) {
if (!response.ok) {
throw new Error(`${response.url}: ${response.status} ${response.statusText}`);
}
},
};
Request/Response bodies are stateful (can only be read once). When reading without modification, clone first:
const loggingMiddleware: Middleware = {
async onResponse({ response }) {
const data = await response.clone().json(); // Clone before reading
console.log(data);
return undefined; // Don't modify
},
};
const client = createClient<paths>({
baseUrl: "https://myapi.dev/v1/",
querySerializer: {
array: {
style: "pipeDelimited", // "form" (default) | "spaceDelimited" | "pipeDelimited"
explode: false, // default: true
},
object: {
style: "form", // "form" | "deepObject" (default)
explode: true, // default: true
},
allowReserved: false, // default: false
},
});
const client = createClient<paths>({
baseUrl: "https://myapi.dev/v1/",
querySerializer(queryParams) {
let s = "";
for (const [k, v] of Object.entries(queryParams)) {
if (v !== undefined) {
s += `${s ? "&" : ""}${k}=${encodeURIComponent(String(v))}`;
}
}
return s;
},
});
const { data } = await client.GET("/search", {
params: { query: { tags: ["a", "b", "c"] } },
querySerializer: {
array: { style: "pipeDelimited", explode: false },
},
});
const { data } = await client.POST("/upload", {
body: { file: myFile, name: "photo" },
bodySerializer(body) {
const fd = new FormData();
for (const [name, value] of Object.entries(body)) {
fd.append(name, value);
}
return fd;
},
});
Provide the content-type header and pass body as an object:
const { data } = await client.POST("/form", {
body: { username: "user", password: "pass" },
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
Custom path parameter formatting:
const client = createClient<paths>({
baseUrl: "https://myapi.dev/v1/",
pathSerializer(pathname, pathParams) {
let result = pathname;
for (const [key, value] of Object.entries(pathParams)) {
result = result.replace(`{${key}}`, encodeURIComponent(String(value)));
}
return result;
},
});
Alternative API where methods are accessed via path:
import { createPathBasedClient, wrapAsPathBasedClient } from "openapi-fetch";
import type { paths } from "./my-schema";
// Option 1: Create directly (no middleware support)
const client = createPathBasedClient<paths>({ baseUrl: "https://myapi.dev/v1/" });
const { data } = await client["/blogposts/{post_id}"].GET({
params: { path: { post_id: "123" } },
});
// Option 2: Wrap existing client (preserves middleware)
const baseClient = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });
baseClient.use(authMiddleware);
const pathClient = wrapAsPathBasedClient(baseClient);
const { data } = await pathClient["/blogposts"].GET();
Note: createPathBasedClient does not support middleware. Use wrapAsPathBasedClient with an existing client if you need middleware.
Supply a mock fetch to the client:
import createClient from "openapi-fetch";
import { expect, test, vi } from "vitest";
import type { paths } from "./my-schema";
test("my request", async () => {
const mockFetch = vi.fn();
const client = createClient<paths>({
baseUrl: "https://my-site.com/api/v1/",
fetch: mockFetch,
});
const reqBody = { name: "test" };
await client.PUT("/tag", { body: reqBody });
const req = mockFetch.mock.calls[0][0];
expect(req.url).toBe("/tag");
expect(await req.json()).toEqual(reqBody);
});
Mock Service Worker is recommended for mocking API responses:
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import createClient from "openapi-fetch";
import { afterEach, beforeAll, afterAll, expect, test } from "vitest";
import type { paths } from "./my-schema";
const server = setupServer();
beforeAll(() => {
server.listen({
onUnhandledRequest: (request) => {
throw new Error(`No request handler found for ${request.method} ${request.url}`);
},
});
});
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("my API call", async () => {
const rawData = { test: { data: "foo" } };
const BASE_URL = "https://my-site.com";
server.use(
http.get(`${BASE_URL}/api/v1/foo`, () =>
HttpResponse.json(rawData, { status: 200 }),
),
);
const client = createClient<paths>({ baseUrl: BASE_URL });
const { data, error } = await client.GET("/api/v1/foo");
expect(data).toEqual(rawData);
expect(error).toBeUndefined();
});
Important: server.listen() must be called before createClient is used so MSW can intercept requests.
Key types available from openapi-fetch:
import type {
Client,
ClientOptions,
Middleware,
FetchOptions,
FetchResponse,
MaybeOptionalInit,
ParseAs,
QuerySerializer,
QuerySerializerOptions,
BodySerializer,
PathSerializer,
HeadersOptions,
MethodResponse,
ClientPathsWithMethod,
PathBasedClient,
} from "openapi-fetch";
MethodResponse — Extract Response Data Typeimport type { MethodResponse } from "openapi-fetch";
// Extract the data type for a specific endpoint
type BlogPost = MethodResponse<typeof client, "get", "/blogposts/{post_id}">;
ClientPathsWithMethod — Get All Paths for a Methodimport type { ClientPathsWithMethod } from "openapi-fetch";
type GettablePaths = ClientPathsWithMethod<typeof client, "get">;
MaybeOptionalInit — Init Type for a Path/MethodDetermines whether the init parameter is required or optional based on the schema:
import type { MaybeOptionalInit } from "openapi-fetch";
import type { paths } from "./my-schema";
type BlogPostInit = MaybeOptionalInit<paths["/blogposts/{post_id}"], "get">;
Helper functions for custom serialization:
import {
createQuerySerializer,
defaultPathSerializer,
defaultBodySerializer,
createFinalURL,
mergeHeaders,
serializePrimitiveParam,
serializeObjectParam,
serializeArrayParam,
removeTrailingSlash,
} from "openapi-fetch";
tools
React data fetching with swr-openapi - type-safe SWR hooks generated from OpenAPI schemas. Use when writing, reviewing, or refactoring React components that fetch data using swr-openapi, openapi-fetch, or openapi-typescript. Triggers on tasks involving React data fetching with OpenAPI clients.
tools
Use when work should span one or more detached tasks but still behave like one job with a single owner context. TaskFlow is the durable flow substrate under authoring layers like Lobster, ACPX, plugins, or plain code. Keep conditional logic in the caller; use TaskFlow for flow identity, child-task linkage, waiting state, revision-checked mutations, and user-facing emergence.
tools
# Lobster Lobster executes multi-step workflows with approval checkpoints. Use it when: - User wants a repeatable automation (triage, monitor, sync) - Actions need human approval before executing (send, post, delete) - Multiple tool calls should run as one deterministic operation ## When to use Lobster | User intent | Use Lobster? | | ------------------------------------------------------ | --------------------------
tools
# Lobster Lobster executes multi-step workflows with approval checkpoints. Use it when: - User wants a repeatable automation (triage, monitor, sync) - Actions need human approval before executing (send, post, delete) - Multiple tool calls should run as one deterministic operation ## When to use Lobster | User intent | Use Lobster? | | ------------------------------------------------------ | --------------------------