OpenAPI@rhythmjs/openapi
Describe the routes
Small middlewares document (and, for request parts, validate) each route, so the router stays the single source of truth.
On this page
Install the package#
@rhythmjs/openapi exports every module on its own subpath: /body, /response, /module, and so on; there is no root barrel export. Schemas are Standard Schema v1; conversion to JSON Schema goes through the Standard JSON Schema interface (~standard.jsonSchema), which zod 4.2+ implements natively. Raw JSON Schema objects pass through untouched, so zod is an optional peer. Turning the described routers into a served document is the Generate & serve page.
bun add @rhythmjs/openapi @rhythmjs/http @rhythmjs/rhythm @rhythmjs/routerMetadata rides on middleware#
Every api* helper returns an ordinary Rhythm middleware carrying an operation fragment, a slice of an OpenAPI operation, under the well-known symbol Symbol.for("rhythmjs.openapi"). Two kinds exist: doc-only fragments (apiOperation, apiTags, apiResponse, the security helpers) whose middleware just calls next(), and validating fragments (apiBody, apiQuery, apiParam, apiHeader, apiCookie) that also check the request at runtime. Because the documentation rides on the middlewares themselves, the router is the single source of truth: what is documented is what runs.
import { apiBody, type Validated } from "@rhythmjs/openapi/body";
import { apiOperation } from "@rhythmjs/openapi/operation";
import { apiResponse } from "@rhythmjs/openapi/response";
import { apiTags } from "@rhythmjs/openapi/tags";
import { apiBearerAuth } from "@rhythmjs/openapi/security";
import { z } from "zod";
const User = z.object({ id: z.string(), name: z.string() });
const CreateUser = z.object({ name: z.string().min(1) });
const users = new RhythmRouter({ prefix: "/users" })
.use(apiTags("users")) // router-level: inherited by later routes
.post<Validated<"body", typeof CreateUser>>(
"/",
apiBody(CreateUser),
apiOperation({ summary: "Create user", operationId: "createUser" }),
apiBearerAuth(),
apiResponse(201, { description: "Created", schema: User }),
(ctx) => {
ctx.json({ id: "1", ...ctx.valid.body }, 201); // typed
},
);Validate and type the request#
The five request validators extract their part of the request, run the schema, and on success store the output at ctx.valid[target] before calling next(); chained validators merge into one ctx.valid. The Validated<Target, Schema> type plugs into a route handler's extra-context parameter so ctx.valid.body is fully typed. On failure the chain short-circuits with status 400 and a JSON ValidationFailure body: { "success": false, "target": "body", "issues": [...] }, each issue carrying a message and an optional path.
Extraction follows the declared content type. apiBody clones the request and parses JSON (malformed JSON is itself a 400), form-data (repeated fields become arrays, files stay Files), urlencoded, or text. apiQuery reads search params with repeated keys as arrays; apiParam reads the router's matched params; apiHeader hands the schema every header, keyed by the lowercase names the Headers class stores; apiCookie parses and percent-decodes the Cookie header. Parameter helpers accept per-field overrides (description, style, examples, required) that surface in the document.
Describe operations#
Doc-only helpers cover the rest of the operation object: apiOperation for summary, description, operationId, deprecation, per-operation servers and external docs; apiResponse(status, spec) per status code: a number, "default", or a range such as "4XX", with a schema shorthand or a full per-media-type content map, response headers, and links; apiTags for grouping; apiCallback for callback objects; apiExtension("x-…", value) for specification extensions (a non-x- name throws); and apiExclude() to hide a route from the document entirely.
Security composes declaratively: apiSecurity(name, scopes?) and the shorthands apiBearerAuth, apiBasicAuth, apiCookieAuth, apiKeyAuth, apiOAuth2(scopes) add requirements (deduplicated), while apiNoSecurity() clears everything accumulated so far and emits security: [], the OpenAPI idiom for "this route is public despite a document-level default".
Inheritance and merging#
Fragments attached with router.use() are inherited by every route registered after them, in order; a route's own fragments merge over the inherited ones. Merging is field-wise: operation fields overwrite, tags and security requirements append with deduplication, parameters keyed by location:name so a later fragment can override one parameter, the last requestBody wins, and responses merge by status code. A fragment with exclude anywhere in the chain removes the operation. Routes with no fragments at all still appear with a default 200 response unless generation runs with includeUndocumented: false.