Every route a module exposes must appear in the generated OpenAPI spec. This is enforced at review: a new
*.routes.tsthat ships routes withoutdescribeRouteis rejected.
The API keeps its plain new Hono<AppEnv>() route factories. OpenAPI is
layered on with hono-openapi
middleware plus the shared helpers in
apps/api/src/shared/lib/openapi.ts.
The spec is served at /api/openapi.json and the Scalar UI at /api/docs
(see modules/docs).
Reference implementations: system.routes.ts
(public + token + non-envelope responses) and
audit.routes.ts
(admin + query/param validation).
| Concern | Tool | Notes |
|---|---|---|
| Make the route visible + document responses | describeRoute({ tags, summary, security, responses }) |
Required on every route. First middleware in the chain. |
| Validate + document the request | validator("query" | "json" | "param" | "header", schema) |
Use only where a Zod schema already validated input. Replaces schema.parse(...). |
A route with no describeRoute is invisible to the spec — the walker
that builds /openapi.json only collects routes carrying describeRoute.
-
Every route gets
describeRoutefirst, before auth/validation middleware:import { describeRoute, errors, jsonOk, SECURITY, TAGS, validator } from "@/shared/lib/openapi"; router.get( "/things/:id", describeRoute({ tags: [TAGS.Document], summary: "Get a thing", security: SECURITY.session, responses: { ...jsonOk(thingSchema), ...errors(401, 403, 404) }, }), authRequired, validator("param", z.object({ id: z.string() })), async (c) => { const { id } = c.req.valid("param"); // ... }, );
-
Validation: migrate
schema.parse(...)→validator(...). Where a route already validated input with a Zod schema, drop the manual parse and read the typed value withc.req.valid(target):// before const body = createThingSchema.parse(await c.req.json()); // after — add `validator("json", createThingSchema)` to the chain, then: const body = c.req.valid("json");
validatorpreserves the project's 422VALIDATION_ERRORenvelope (it throwsValidationErrorvia a shared hook), so the contract is unchanged. -
Do NOT force
validatoronto bespoke parsing. Routes that hand-parse for security/timing reasons (e.g.account/authlogin: malformed JSON →400, bad credentials →400, constant-time compare) keep their handler unchanged — add onlydescribeRoute. Forcingvalidatorthere would change status codes and break the flow. -
Tag per module. Use the module's
TAGS.*constant. Add a new entry toTAGSinshared/lib/openapi.tswhen you add a module (one line). -
Declare auth. Add
security: SECURITY.session(cookie) orSECURITY.serviceToken(bearer) on protected routes; omit on public ones. -
Responses. Build the
responsesmap from the envelope helpers so the shape matches the liveApiResponse<T>envelope:Helper Emits jsonOk(data?, desc?)200 { success: true, data }jsonCreated(data?, desc?)201 { success: true, data }jsonPaged(item, desc?)200 { success: true, data: item[], meta }noContent(desc?)204raw(status, mediaType, desc)non-JSON (text/binary/redirect) errors(...codes)error envelope per status (400/401/403/404/409/413/422/429/500/503) datadefaults toz.unknown()— pass a small Zod schema (often mirroring the Drizzle row) when the precise shape is worth documenting. Define it once at the top of the routes file and reuse it for list + detail.
-
describeRouteis the first middleware, withtags+summary. -
securityset on protected routes. -
responsesincludes the success helper and the realistic error codes. - Input schema validated via
validator(...)(or, for bespoke parsing, left as-is). - Handler reads validated input via
c.req.valid(target). - Route appears in
/api/openapi.jsonand renders in/api/docs.
- Spec + UI:
apps/api/src/modules/docs/docs.routes.ts(mountDocs). - Mounted before the route modules in
app.tsso docs stay outside every module'suse("*")auth/unlock guards (Hono middleware only applies to routes registered after it). - Scalar loads a CDN bundle; its strict-CSP exception is scoped to
/api/docsviadocsCspRelax(registered beforesecureHeaders).