chowbea-axios
Usage

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:

OpenAPI spec
paths:
  /users/{id}:
    get:
      operationId: getUserById
      parameters:
        - name: id
          in: path
          required: true
      responses:
        200:
          description: User found

That becomes a thin wrapper in api.operations.ts:

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 operationId naming
  • 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 users

Path 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 spec
api.operations.ts
export 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.

User service with operations
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.

Next Steps

On this page