The Generated Client
chowbea-axios turns the API OpenAPI spec into typed operations, and the Axios instance injects auth automatically.
You never write an HTTP call by hand in this app. There is no fetch('/api/v1/campus') anywhere.
Instead, a tool called chowbea-axios reads the backend's OpenAPI spec — a machine-readable description of every endpoint it has — and generates a typed client from it. You get one named function per endpoint, with the request body, the URL parameters, and the response all typed for you.
So adding an endpoint on the backend and regenerating makes a new function appear in your editor's autocomplete, correctly typed, without anyone writing frontend code for it.
The rule this sets up: the backend's spec is the truth, and the frontend client is a build artifact generated from it. You never describe the API by hand and hope the two match.
The config
Everything the generator needs lives in one file at the repo root. It says where to find the spec, where to write the output, and how the runtime client should behave:
api_endpoint = "http://localhost:8000/docs/swagger/json"
poll_interval_ms = 20000
[output]
folder = "src/services/api"
[instance]
base_url_env = "VITE_API_URL"
token_key = "auth-token"
auth_mode = "custom"
with_credentials = true
timeout = 30000Three of these are worth understanding:
| Setting | What it does |
|---|---|
api_endpoint | Where to read the spec from. It is a URL, not a file — so the backend must be running when you regenerate. |
token_key | The localStorage key the auth token is read from. |
auth_mode = "custom" | Tells the generator not to include its own login handling. Instead it gives you api.instance.ts, a file you own and can edit — which is where the Bearer token and the refresh logic live. |
The API must be running to regenerate
Because api_endpoint is a URL, api:generate fetches the spec from localhost:8000. If the backend is down you'll get a fetch error. There's a commented spec_file option in the config for pointing at a local openapi.json instead — handy when you're offline.
The commands
The generator is driven through api:* scripts in package.json. You'll mostly use two:
api:generate # one-shot regenerate from the current spec
api:watch # poll the spec, regenerate whenever it changes
api:status # is the local client in sync with the spec?
api:validate # check the spec is well-formed
api:diff # show what would change before regeneratingIn practice you run none of them. bun dev:all starts api:watch alongside the Vite dev server, so the client regenerates by itself whenever the backend's spec changes.
That means when a teammate adds an endpoint, it appears in your autocomplete without you doing anything.
What gets generated
The output lands in src/services/api/_generated. Three files, each with a single job:
api.types.ts— the raw type unions pulled straight from the spec: everypath, every reusablecomponentschema. This is the vocabulary everything else is built from.api.contracts.ts— the request and response body types, named per operation:CreateCampusBody,ListCampusesResponse, and so on.api.operations.ts— the part you actually call. Every endpoint becomes a named function, so you calllistCampuses()instead of having to remember that it isGET /api/v1/campus.
Here's the shape of that factory — each operation is a one-liner that maps a semantic name onto a path and method:
export const createOperations = (apiClient: ApiClient) => ({
// @operationId listCampuses
listCampuses: (config?: AxiosRequestConfig): Promise<Result<ListCampusesResponse>> =>
apiClient.get("/api/v1/campus", config),
// @operationId createCampus
createCampus: (data: CreateCampusBody, config?: AxiosRequestConfig): Promise<Result<CreateCampusResponse>> =>
apiClient.post("/api/v1/campus", data, config),
// ...
})Notice the return type: every operation resolves to a Result<T>, never a bare value and never a thrown error. That's deliberate, and it's the subject of The Result Pattern — the short version is that these functions don't throw.
Never edit _generated by hand
Anything under src/services/api/_generated is overwritten on every regenerate — your edits will silently vanish. If a type or operation is wrong, the fix lives on the backend: change the API, let the spec update, then regenerate. Types that are not on a REST body (websocket payloads, Action / Resource) use a second pipe — the type bus — and land in _generated/bus instead of api.contracts.ts.
Using an operation
You don't touch createOperations yourself. The client wraps it behind a single api object, exposed through an op accessor. So a real call site reads like plain English:
import { api } from "@/services/api/api.client"
const result = await api.op.listCampuses()
if (result.success) {
console.log(result.data) // typed ListCampusesResponse
}
await api.op.createCampus({ name: "Lugazi", code: "LGZ" })The argument to createCampus is type-checked against CreateCampusBody, and result.data is typed as ListCampusesResponse — both straight from the spec. In practice you almost never call api.op directly inside a component; it gets wrapped in a TanStack Query options factory. That end-to-end wiring is covered in A Feature, End to End.
Auth comes for free
Here's the quietly important part: you never attach a token by hand. The generated api.instance.ts owns one shared Axios instance, and a request interceptor reads the stored token and stamps it onto every outgoing request:
axiosInstance.interceptors.request.use((config) => {
const { token } = getStoredAuth()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})getStoredAuth() reads the Zustand-persisted auth state out of localStorage (the auth-token key from the config). Every operation rides through this instance, so every request is authenticated automatically — there's nothing to remember and nothing to wire up at the call site.
There's a matching response interceptor too: when the backend answers 401, it silently refreshes the token and replays the original request, queuing any concurrent calls so they don't all fire a refresh at once. That race is subtle enough to deserve its own page — see The Refresh Queue.
Where to go next
The Result Pattern
Why operations return a Result and never throw — and how you read it.
A Feature, End to End
From api.op call to rendered screen, the full data-fetching path.
The Refresh Queue
How the 401 interceptor refreshes tokens without firing a stampede.
Keys and Caching
How operations get cached, keyed, and invalidated with TanStack Query.
Using Chowbea
The type bus for websocket payloads and domain enums Swagger cannot carry.