API contract & OpenAPI
How endpoints declare themselves, how the OpenAPI spec is generated, and when you must regenerate.
How an endpoint registers
Every route declares its OpenAPI shape via apiRoute(...) from apps/server/src/infrastructure/http/openapi.ts:
import { apiRoute } from '@/infrastructure/http/openapi.js';
import { user } from '@app/contracts';
apiRoute({
method: 'post',
path: '/users',
tags: ['users'],
summary: 'Create a user',
body: user.CreateUserInput,
responses: {
201: { description: 'Created', schema: user.UserDTO },
409: { description: 'Email already exists' },
},
security: 'session',
});apiRoute registers the path with a Zod-to-OpenAPI registry. A 422 validation-failure response is added by default to every route, with the standard { code, message, issues } shape — don't redeclare it.
The same Zod schema validates the request via validateRequest({ body, params, query }) middleware. Schema drift is impossible: there is one schema.
Generating the spec
bun run generate:apiThis runs three steps:
- Boots the server's container in spec-only mode and walks the
openApiRegistryto emitapps/server/openapi.json. - Runs
openapi-typescriptto produceapps/client/lib/api/openapi-types.ts. - Formats both with prettier.
Both files are checked into git. The CI job api-types-fresh re-runs the generator and fails the PR if either file would change. This is a hard gate, not a warning.
When you must regenerate
The CLAUDE.md rule:
- Adding, removing, or renaming a route.
- Changing a request or response Zod schema (including adding/removing fields).
- Modifying permission/auth requirements (the OpenAPI security scheme reflects these).
- Adding/removing values to enums that flow through the API surface — e.g. permission strings in
packages/shared/src/permissions, status enums on aggregates exposed via DTOs.
Skip only if the change is pure infrastructure with no controller-visible effect (a Prisma index, a private helper). When in doubt: regenerate. It's idempotent.
Workflow
- Make the endpoint or schema change.
- From the repo root:
bun run generate:api. git diff apps/server/openapi.json apps/client/lib/api/openapi-types.ts— confirm the changes are scoped to your edit. Surprise drift means you're regenerating against a dirty tree; clean it up before committing.- Commit both regenerated files in the same commit (or as a
chore(<scope>): regenerate OpenAPIcommit attached to the same PR). - Push. CI re-runs the freshness check.
Consuming the typed client
The frontend calls the server through openapi-fetch:
import createClient from 'openapi-fetch';
import type { paths } from '@/lib/api/openapi-types';
export const api = createClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL,
credentials: 'include', // cookies — see /docs/architecture/contracts
});
const { data, error } = await api.GET('/api/users/{id}', {
params: { path: { id: userId } },
});If the server changes the path or the body shape, this call fails to compile.
Swagger UI
/docs (on the server) serves Swagger UI against the live spec. Useful for poking at endpoints during development; not a replacement for the typed client in app code.