Operation-Based API
Use semantic operation names instead of path templates.
If your OpenAPI spec has operationId on endpoints, use api.op. It's the default recommendation — readable call sites, autocomplete that lists verbs not URLs, and the same { data, error } contract as the path client.
No operationId? No api.op method. Generation skips it and logs a warning. Path-based calls still work. Fix the spec if you want the ergonomic layer.
Types for request/response shapes live in api.contracts.ts (UserDto, CreateUserBody, …). Import those in your query layer; don't re-derive from list responses. See The Query Layer for the full stack.
Basic usage
import { api } from "./app/services/api/api.client";
// Works, but you're reading a template string:
const { data, error } = await api.get("/users/{id}", { id: "123" });
// Prefer this:
const { data, error } = await api.op.getUserById({ id: "123" });Both return Result<T>. Neither throws.
How it works
The CLI reads operationId from your spec:
paths:
/users/{id}:
get:
operationId: getUserById
parameters:
- name: id
in: path
required: true
responses:
200:
description: User foundThat becomes a thin wrapper in api.operations.ts:
export const createOperations = (apiClient: any) => ({
/**
* Get user by ID
* @operationId getUserById
* @method GET
* @path /users/{id}
*/
getUserById: (
pathParams: { id: string | number },
config?: RequestConfig<"/users/{id}", "get">
): Promise<Result<ResponseData<"/users/{id}", "get">>> =>
apiClient.get("/users/{id}", pathParams, config),
});Under the hood it's still the path client. You're just not typing the path.
Operation signatures
Parameters match what the endpoint needs — path params, query via config.params, body for writes.
GET with path params
const { data, error } = await api.op.getUserById({ id: "123" });GET with query params
const { data, error } = await api.op.listUsers({
params: { limit: 10, offset: 0 },
});POST with body
const { data, error } = await api.op.createUser({
name: "John Doe",
email: "john@example.com",
});POST with body and path params
const { data, error } = await api.op.createUserPost(
{ title: "Hello", content: "World" },
{ id: "123" }
);DELETE with path params
const { data, error } = await api.op.deleteUser({ id: "123" });JSDoc from the spec
Generated operations carry JSDoc from OpenAPI — summary, @operationId, @method, @path. Your IDE shows them inline. Low effort documentation that actually stays in sync.
/**
* Get a user by their unique identifier
*
* @operationId getUserById
* @method GET
* @path /users/{id}
*/
getUserById: (pathParams: { id: string | number }) => ...When to use which
Use api.op when:
- Your spec has consistent
operationIdnaming - You're building anything beyond a one-off script
- You want The Query Layer shape (contracts → op → exec → hooks)
await api.op.createUser({ name: "John" });
await api.op.getUserById({ id: "123" });
await api.op.deleteUser({ id: "123" });Use path-based when:
- The endpoint has no
operationId(yet) - You're spelunking an unfamiliar API and want the URL visible
- You're debugging generation and need to match the spec literally
await api.post("/users", { name: "John" });
await api.get("/users/{id}", { id: "123" });
await api.delete("/users/{id}", { id: "123" });Missing operationId
Skipped during generation. Not silent — you'll see:
⚠ Skipping operation without operationId (method=GET, path=/users)paths:
/users:
get:
# No operationId — no api.op.listUsers
summary: List usersPath client still works:
await api.get("/users");Run chowbea-axios validate to find offenders before they surprise you in code review.
Listing available operations
Open api.operations.ts or let autocomplete do the work:
api.op. // every operationId from your specexport const createOperations = (apiClient: any) => ({
listUsers: (...) => ...,
getUserById: (...) => ...,
createUser: (...) => ...,
updateUser: (...) => ...,
deleteUser: (...) => ...,
listPosts: (...) => ...,
getPostById: (...) => ...,
});Real-world example
This is the shape your query class should call — not components directly.
import { api } from "./api/api.client";
export const userService = {
async getById(id: string) {
const { data, error } = await api.op.getUserById({ id });
if (error) throw new Error(error.message);
return data;
},
async create(input: { name: string; email: string }) {
const { data, error } = await api.op.createUser(input);
if (error) throw new Error(error.message);
return data;
},
async list(options?: { limit?: number }) {
const { data, error } = await api.op.listUsers({ params: options });
if (error) throw new Error(error.message);
return data;
},
async delete(id: string) {
const { error } = await api.op.deleteUser({ id });
if (error) throw new Error(error.message);
},
};Better: wrap this in a BaseQuery with exec() and expose TanStack options. The Query Layer shows how.