API client
The generated TypeScript client the frontend uses - types, a typed fetch wrapper, and typed React Query hooks.
The frontend never hand-writes API types or fetch calls. Everything under src/api is generated
straight from the frozen OpenAPI seam, end to end: types, a typed
fetch client, typed React Query hooks, and MSW mocks with fixtures. This is the frontend's real
equivalent of a codegen'd SDK - a published, standalone SDK package is deferred to a later milestone,
so today the generated client lives inside the frontend repo itself.
The pipeline
openapi-typescript -> openapi-fetch -> openapi-react-query
(types) (client) (typed hooks)
+ openapi-msw + msw + fixtures (mocks)Pinned versions: openapi-fetch ^0.17.0, openapi-react-query ^0.5.4, openapi-msw ^2.0.0,
openapi-typescript ^7.13.0.
Regenerating the client from the contract is one script:
pnpm api:generate # openapi-typescript ../botanary-be/openapi/botanary-v1.yaml -o src/api/schema.d.tssrc/api holds exactly two files afterward: the hand-written client.ts and the generated
schema.d.ts - nothing else.
Constructing the client
import createFetchClient from 'openapi-fetch';
import createReactQueryClient from 'openapi-react-query';
import type { paths } from './schema';
export const fetchClient = createFetchClient<paths>({ baseUrl: API_BASE_URL });
export const $api = createReactQueryClient(fetchClient);API_BASE_URL defaults to http://localhost:3000/v1 unless overridden by an environment variable,
and a separate flag switches the whole app into MSW mock mode - see
Quickstart for both. The bearer-attaching middleware on
fetchClient is covered in Authentication.
Using it
Every call site follows the same two shapes - a typed query, or a typed mutation, both keyed by HTTP method and OpenAPI path string:
// query
const rules = $api.useQuery('get', '/account/rules');
// mutation
const buildRules = $api.useMutation('post', '/account/rules/build');That pattern shows up across the app's real feature surfaces - device management reads
GET /account/devices, the settings page mutates POST /account/rules/build, notification handling
mutates POST /notifications/read-all and the per-notification read endpoint, and so on. When a
component needs a schema type directly rather than through a hook, it pulls it straight off the
generated types:
type Notification = components['schemas']['Notification'];MSW's own request typing mirrors the same paths type, via createOpenApiHttp<paths>({ baseUrl: API_BASE_URL }) - so a mock handler and a real request are checked against the same contract.
What's compile-time only, for now
Today, safety against the contract is compile-time only: if the generated types and your code disagree, TypeScript catches it, but nothing currently checks an actual HTTP response against the schema at runtime. A runtime-validation layer - generating Zod schemas from the same contract and failing loudly in development if a real response drifts from it - is a documented but not yet implemented plan. Until then, contract drift between a running backend and a stale generated client is a TypeScript-time problem, not a runtime one.