Naalya Handbook
The Data Layer

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.config.toml
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 = 30000

Three of these are worth understanding:

SettingWhat it does
api_endpointWhere to read the spec from. It is a URL, not a file — so the backend must be running when you regenerate.
token_keyThe 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:

package.json (scripts)
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 regenerating

In 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: every path, every reusable component schema. 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 call listCampuses() instead of having to remember that it is GET /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:

src/services/api/_generated/api.operations.ts
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:

example call site
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:

src/services/api/api.instance.ts
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

On this page