rhythmjs

Search documentation

Search guides, the tutorial and every package.

On this page

Generate the document#

generate(router, config, options?) walks router.entries in registration order (middleware entries contribute inherited fragments, route entries become operations) and emits an OpenAPI object (openapi: "3.1.2" unless overridden). Router paths convert to OpenAPI templates: /users/:id becomes /users/{id} and a bare * segment becomes {wildcard}. Undocumented routes are included with a default 200 unless includeUndocumented: false.

TypeScript
import { generate } from "@rhythmjs/openapi/generate";

const document = await generate(users, config);
// { openapi: "3.1.2", info, paths: { "/users/{id}": { get: {...} } }, components }

Schema resolution#

Standard Schema values resolve through ~standard.jsonSchema, targeting JSON Schema draft 2020-12. Request-side schemas (bodies, parameters) resolve with the schema's input projection and response-side schemas with its output projection, so defaults and transforms document correctly on each side. Types JSON Schema cannot express (File, Date, …) are documented as {} via the vendor's unrepresentable: "any" escape hatch. A schema whose vendor does not implement the interface throws with a pointed message: use zod ^4.2.0 or pass a raw JSON Schema object, which is always passed through untouched.

Shared schemas and $defs hoisting#

When a resolved schema carries $defs (zod emits them for registered, reused schemas), each definition is hoisted into components.schemas and every #/$defs/… reference is rewritten to #/components/schemas/…. Name collisions are safe: an identical definition is deduplicated, a different one under the same name is renamed with a numeric suffix and its references follow. Shared zod schemas therefore become shared component refs across the whole document.

Document configuration#

The document-level half comes from defineDocument({...}) (an identity helper for typing): required info, plus servers, tags, externalDocs, document-level security, webhooks, jsonSchemaDialect, x- extensions, and components. securitySchemes is a top-level convenience merged into components.securitySchemes; generated schemas merge into components.schemas alongside anything you declared.

TypeScript
import { defineDocument } from "@rhythmjs/openapi/document";

const config = defineDocument({
  info: { title: "Users API", version: "1.0.0" },
  servers: [{ url: "https://api.example.com" }],
  securitySchemes: { bearer: { type: "http", scheme: "bearer" } },
});

Serve the document#

openapiModule.forRoot({ document, path?, openapi?, includeUndocumented? }) is a kernel module, in the spirit of NestJS's SwaggerModule. Register it once and mount your routers as usual: it reads the sources of the app it is registered in, including nested modules, for routers, so nothing router-shaped is passed to it. It answers GET /openapi.json (an exact path match, mounted with @rhythmjs/http/mount; everything else falls through) with the generated document, and path moves it. The document is generated lazily on the first request and cached as a promise; a failed generation is evicted so the next request retries instead of caching the error. The module also provides openapiService.document() to the app.

A document covers the app its module is registered in, including everything registered inside that app. Register one openapiModule at the root for a single document of the whole API, or one inside each group module for a separate document per group, each with its own path; the router use() middleware of one group never leaks into another. A UI registered at the top of the app lists all the documents through its sources option, so the UI does not need to be repeated per group. The module learns its app from its parent, which register() and use(module.middleware()) both set. A module never added to an app fails on the first request instead of serving an empty document.

TypeScript
const apiModule = new Rhythm<RhythmHttpContext>({ name: "api", type: "module" })
  .register(openapiModule.forRoot({ document: apiDocument, path: "/api/v1/openapi.json" }))
  .use(apiController.middleware());

const platformModule = new Rhythm<RhythmHttpContext>({ name: "platform", type: "module" })
  .register(openapiModule.forRoot({ document: platformDocument, path: "/api/platform/openapi.json" }))
  .use(platformController.middleware());

const app = new Rhythm<RhythmHttpContext>()
  .register(apiModule)
  .register(platformModule)
  .register(
    scalarModule.forRoot({
      sources: [
        { url: "/api/v1/openapi.json", title: "Public API" },
        { url: "/api/platform/openapi.json", title: "Platform API" },
      ],
    }),
  );

The module serves the document and nothing else. It is the input for any consumer: the Scalar and Swagger UI integrations render it, and so can a code generator, a gateway, or a curl.

TypeScript
import { defineDocument } from "@rhythmjs/openapi/document";
import { openapiModule } from "@rhythmjs/openapi/module";
import { scalarModule } from "@rhythmjs/scalar";

const app = new Rhythm<RhythmHttpContext>()
  .register(openapiModule.forRoot({ document: config }))
  .register(scalarModule.forRoot({ pageTitle: "Users API", theme: "purple" }))
  .use(users.middleware());
// GET /openapi.json => the document; GET /docs => Scalar UI loading it