@setu-ts/openapi-plugin

OpenAPI 3.1 generation from registered routes, plus Swagger UI. Registers an IOpenApiService under CAPABILITIES.OPENAPI ('openapi').

Zod version support

The supported Zod range is >=4.6.6 <5. The plugin does not import Zod: each schema is recognized by its public toJSONSchema method. pnpm run check:compat exercises Zod 4.

  • Zod v4 is converted wholesale through schema.toJSONSchema() (JSON Schema draft 2020-12, which OpenAPI 3.1 speaks natively) and adapted: the dialect $schema key is dropped, reused schemas land in components/schemas with their pointers rewritten, and a recursive schema’s root-cycle pointer forces the schema into components so no bare # ref survives.

  • A type union is always anyOf, on every supported zod v4. Draft 2020-12 permits two spellings and zod changed which one it emits across v4 releases — 4.5 collapses a union of bare types to "type": ["string", "null"] where 4.4 emitted anyOf — so the transformer normalizes the collapsed form back. One schema therefore yields one document whichever in-range zod the application installs, and the result still converts down to OpenAPI 3.0, where a type array is not legal.

  • Unrepresentable nodes degrade, never throw. A type zod cannot represent in JSON Schema (z.date(), z.bigint(), …) becomes an empty schema, and the operation that owns it carries a machine-readable x-setu-unrepresentable extension naming the operation and the reason:

    {
      "x-setu-unrepresentable": [
        { "at": "post-events", "reason": "zod v4 type 'date' has no JSON Schema representation" }
      ]
    }

    The extension is absent when every schema is representable, so valid documents are unchanged. Nothing is logged when a node degrades: the document is built on the first getSpec() / /openapi.json request, not at startup, so inspect it (or test for x-setu-unrepresentable) in CI. For dates, z.iso.datetime() represents a date-time in JSON Schema.

  • Schemas without toJSONSchema, including Zod 3 schemas, are unrecognized. They degrade to {} with the existing not a recognized zod schema diagnostic in x-setu-unrepresentable. Upgrade Zod to >=4.6.6 <5 before regenerating documents.

Zod schemas are transformed to OpenAPI schema objects, and a schema reused across request and response bodies — including one nested inside another — is hoisted into components and referenced by $ref from each of those sites.

Reuse means the same schema object, not an equal one: two separately constructed but structurally identical schemas are two schemas and each stays where it is. Only object, array, composition and enum shapes are hoisted — a reused primitive is smaller inline than as a $ref. Parameter schemas are never hoisted, because a parameter list is built by destructuring the transformed object’s properties, and a $ref has none.

A route’s schemas come from its declared schema field or are derived from the validation middleware guarding it, so a route already carrying validateBody(...) does not repeat itself.

Installation

import { OpenApiPlugin } from '@setu-ts/openapi-plugin';

Usage

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { OpenApiPlugin } from '@setu-ts/openapi-plugin';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    OpenApiPlugin({
      title: 'Orders API',
      version: '1.0.0',
      swagger: true,
      endpoint: '/docs',
      specEndpoint: '/openapi.json',
    }),
  ],
});
await app.start({ port: 3000 });
// GET /openapi.json → the spec
// GET /docs         → Swagger UI

Options

Swagger defaults to self-hosting. Install swagger-ui-dist (supported range ^5) in the application and grant filesystem read access on Deno. On Deno, add "swagger-ui-dist": "npm:swagger-ui-dist@5.33.3" to the imports of the app’s deno.json (in a workspace, the member’s — as generated members do — or the root’s), run deno install from the workspace root, and keep --allow-read on the start task: the optional import resolves from the installed node_modules, so an entry with no install still fails at startup. Registration awaits the tiny swagger-ui-dist/absolute-path.js helper and reads only the three fixed distribution files once. The routes /docs/swagger-ui.css, /docs/swagger-ui-bundle.js, and /docs/swagger-ui-standalone-preset.js serve the package bytes with CSS/JavaScript content types and Cache-Control: public, max-age=3600; changing endpoint moves all four UI routes. Delivery routes are excluded from the generated spec; in the external modes no asset route exists, so only the page endpoint is.

SwaggerOptions is { readonly assets?: 'self' | 'cdn' | { readonly baseUrl: string } }. true and {} select 'self'; false disables the UI. Missing swagger-ui-dist fails startup with OptionalDependencyUnavailableError: install it or set swagger: { assets: 'cdn' }. Runtimes without runtime.fs, including Workers, must choose 'cdn' or { baseUrl }.

The 'cdn' and { baseUrl } modes add Subresource Integrity hashes computed for swagger-ui-dist@5.33.3, so a { baseUrl } that serves your own copy must serve exactly that release; any other version’s files fail the hash check and the page renders blank (V10-36). A project generated by setu installs 5.33.3 exactly (M119c). 'self' adds no hashes and works with any 5.x release.

swagger-ui-dist depends on @scarf/scarf, whose npm postinstall sends install analytics (platform, Node version, dependency data) unless the root package.json sets "scarfSettings": { "enabled": false }. Scarf reads only the package.json npm was run from, so in a workspace the setting belongs at the root; setu new sets it on a standalone project and on an npm workspace root.

CDN mode uses https://unpkg.com/swagger-ui-dist@5.33.3 with SHA-384 integrity and crossorigin="anonymous" on all three tags. A custom base must be printable ASCII with no backslash, ? or # (the file names are appended to it), and either an absolute HTTP(S) URL already in its serialized form or a path starting with a single /. The base is written into the page as given, so a URL carrying credentials (https://user:password@host) is refused. It must serve the unmodified 5.33.3 files, since it uses the same hashes. Invalid modes and base URLs throw TypeError at construction. SwaggerUiOptions.assets selects the same mode for swaggerUiHtml; its endpoint supplies the local prefix (default /docs).

OptionTypeDefaultDescription
swaggerboolean | SwaggerOptionstrueServe Swagger UI, self-hosted by default.
endpointstring'/docs'Path for the Swagger UI page.
specEndpointstring'/openapi.json'Path for the JSON spec.
securityreadonly SecurityRequirement[]—Document-level requirement inherited by every operation.
deriveSecurity{ scheme: string }—Derive each operation’s requirement from its route guards.
excludereadonly string[]—Router paths to omit; matches the resolved pattern.
excludeOwnersreadonly string[]['health-plugin', 'metrics-plugin']Plugin names whose routes are omitted, by RouteInfo.owner.
deriveRequestSchemasbooleantrueFill requestBody/parameters from validation middleware.

The remaining options come from OpenApiGeneratorOptions (title, version, servers, securitySchemes, and the rest of the document metadata).

exclude matches the fully-resolved router pattern — router-style (/todos/:id, not /todos/{id}) and including any router.group() prefix, so a route registered as get('/metrics') inside group('/internal', …) is matched only by '/internal/metrics'. An entry matching no route is silently ignored.

The match is exact: there is no prefix or glob form. The exclusion set is read once when the plugin is constructed; mutating the input array later has no effect. To omit an ordinary router.group(), list each of its resolved paths in exclude. excludeOwners does not match a group prefix: it matches RouteInfo.owner, so it only omits routes a plugin registered.

A security requirement naming a scheme absent from securitySchemes is refused at register(), because emitting it produces a document that is invalid per the specification and nothing downstream can detect that.

The plugin’s own specEndpoint and endpoint are never documented as operations — a spec listing /openapi.json and /docs describes its own delivery mechanism, and those entries flow into every generated client. They are still served; only the document entries are omitted.

excludeOwners drops operational routes by the plugin that registered them rather than by path, because those paths are configuration: HealthPlugin({ endpoints }) and MetricsPlugin({ endpoint }) both accept one, so a static path list silently stops excluding a renamed endpoint. Without it, /health, /live, /ready and /metrics reach every generated client as getHealth, getLive, getReady and getMetrics. Pass [] to document them.

Deriving request schemas from validation middleware

import { z } from 'zod';
import { validateBody, validateQuery } from '@setu-ts/validation-plugin';

const PlaceOrderSchema = z.object({ sku: z.string(), qty: z.number().int() });
const ListQuerySchema = z.object({ page: z.number().int().optional() });

app.router.post('/orders', {
  middleware: [validateBody(PlaceOrderSchema), validateQuery(ListQuerySchema)],
  handler,
});

The operation is documented with a requestBody from PlaceOrderSchema, query parameters from ListQuerySchema, and a 400 — which is what the middleware actually answers. No schema.body is needed, and nothing is written twice.

Every helper @setu-ts/validation-plugin ships brands the middleware it returns with RouteValidationMetadata from @setu-ts/common, and this plugin reads the brand off the route. Neither package imports the other.

Rules and limits:

  • A value declared on the route’s own schema always wins, per field.
  • The last brand for a target wins when a route carries two, because that is the value the handler receives: each middleware writes validated:<target> as it passes, so the final writer’s is the one in ctx.state. The request must still satisfy every brand — any of them can answer 400 — so the documented shape is what the handler sees, not that conjunction.
  • A cookies brand derives nothing: RouteSchema has no cookies field, and @setu-ts/sdk’s client generator refuses an in: 'cookie' parameter outright, so emitting one would turn a working document into a codegen failure for its consumers.
  • The derived 400 carries a description and no schema — the body shape depends on the validation plugin’s configured errorFormat, which this plugin cannot see.
  • deriveRequestSchemas: false disables derivation only. It does NOT restore the rest of the pre-derivation document: owner exclusion, the operationId format and schema deduplication are unconditional. To document the operational routes again, pass excludeOwners: [].

Operation ids

When no id is declared, an id is derived from the method and the path with placeholders unwrapped: GET /orders/{id} becomes get-orders-by-id. Braces are URL-unsafe — Redocly’s recommended ruleset flags them — and tools that put an operationId in an anchor, a filename or a URL are entitled to break on them.

For example, GET /api/v1/invoices produces get-api-v1-invoices; the SDK generator names its method getApiV1Invoices when no id is declared. Declare schema: { operationId: 'listInvoices', description: 'Lists invoices.' } on a programmatic route, or use @ApiOperation({ operationId: 'listInvoices', description: 'Lists invoices.' }). A declared id is used verbatim and description is emitted on the operation alongside summary and tags.

Declared ids are reserved before derived ones, regardless of registration order. A derived get-users becomes get-users-2 if another included route declares get-users. A blank id (empty or whitespace-only) or duplicate declared id throws exported OperationIdError with reason: 'blank' | 'duplicate' at app.start(); direct OpenApiGenerator.generate() calls throw the same error. Its message names the id and routes as METHOD path. Excluded paths and owners neither claim nor collide. A route added after bootstrap is validated when the document is first built; invalid declarations then produce a request-time 500. The startup scan does not build or cache the document.

Distinct ids can still sanitize to the same SDK method: declared getUsers beside undeclared GET /users (derived get-users) is valid OpenAPI, but @setu-ts/sdk refuses it with Duplicate operation name. Choose ids that also yield distinct TypeScript names when generating clients. See the upgrade guide for generated-name changes.

Documenting authentication

Declaring securitySchemes is what gives Swagger UI its Authorize button; without it a protected route cannot be exercised from the page. Pair it with security for the default, and let a route opt out with an empty array:

OpenApiPlugin({
  securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } },
  security: [{ bearerAuth: [] }],
});

// Public, overriding the document-level requirement.
app.router.post('/login', { schema: { security: [] }, handler });

// Inherits the document requirement.
app.router.get('/todos/:id', { middleware: [requireAuth()], handler });

Deriving it from the guards instead

Declaring security per route makes the document a second source of truth that can drift from what actually enforces. deriveSecurity closes that: every guard @setu-ts/auth-plugin ships is branded with RouteSecurityMetadata, and the generator reads the brand off the route’s middleware.

import { publicRoute } from '@setu-ts/auth-plugin';

OpenApiPlugin({
  securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } },
  deriveSecurity: { scheme: 'bearerAuth' },
});

app.router.get('/todos/:id', { middleware: [requireAuth()], handler }); // → requires bearerAuth
app.router.post('/login', { middleware: [publicRoute()], handler }); // → public

A declared schema.security always wins, so this changes no document that already declares. Three limits: only route-level middleware is inspected (app.middleware.add() is invisible to a route, which is correct for authMiddleware() — it populates rather than enforces); roles and permissions cannot be expressed, since a requirement names a scheme and none can be inferred from 'admin'; and the scheme name is configured rather than inferred, because a guard cannot know what your document calls it.

On a decorated controller, @Public produces the same empty security array, so the opt-out is available without writing a schema:

import { Controller, Post, Public } from '@setu-ts/decorator-plugin';

@Controller('/auth')
class AuthController {
  @Public()
  @Post('/login')
  login() {
    /* ... */
  }
}

RouteSchema.security enforces nothing — authentication is enforced by middleware and guards. It describes the route for readers and for generated clients.

Contributions

At registration the plugin drains CAPABILITIES.OPENAPI_SCHEMA contributions, so other plugins — and the @ApiTags/@ApiOperation/@ApiResponse decorators from @setu-ts/decorator-plugin — can enrich the document.

Exports

ExportKind
OpenApiPluginfunction
swaggerUiHtmlfunction
zodToOpenApifunction
OpenApiGeneratorclass
OpenApiServiceclass
OperationIdErrorclass
ZodToOpenApiclass
IOpenApiServiceinterface
OpenApiDocumentinterface
OpenApiGeneratorOptionsinterface
OpenApiOperationinterface
OpenApiParameterinterface
OpenApiPluginOptionsinterface
OpenApiRequestBodyinterface
OpenApiResponseinterface
OpenApiSchemaObjectinterface
OpenApiServiceOptionsinterface
SwaggerUiOptionsinterface
SchemaIotype
SchemaNodeHooktype
SwaggerOptionstype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export and option is documented in PUBLIC_API.md.

API reference

OpenAPI (@setu-ts/openapi-plugin)

OpenApiPluginOptions.swagger?: boolean | SwaggerOptions defaults to self-hosted Swagger UI. Install optional peer swagger-ui-dist (^5) and provide runtime.fs (Deno needs --allow-read). true and {} select self-hosting; false disables UI. Registration loads the guarded CommonJS swagger-ui-dist/absolute-path.js helper through its default export, then reads three fixed files once. /docs/swagger-ui.css, /docs/swagger-ui-bundle.js, and /docs/swagger-ui-standalone-preset.js serve CSS/JavaScript bytes with Cache-Control: public, max-age=3600; endpoint moves their prefix. These routes, the UI and the spec endpoint never appear as API operations. A missing package throws OptionalDependencyUnavailableError, naming installation or swagger: { assets: 'cdn' }. No filesystem refuses self-hosting and names CDN and custom-base remedies.

ExportKindContract
OperationIdErrorclassRefuses blank or duplicate declared ids; readonly reason: 'duplicate' | 'blank'
SwaggerOptionstype{ readonly assets?: 'self' | 'cdn' | { readonly baseUrl: string } }
SwaggerUiOptionsinterfacespecUrl, optional title, assets with the same modes, and local endpoint (default /docs) for swaggerUiHtml

CDN mode uses https://unpkg.com/swagger-ui-dist@5.33.3 with SHA-384 SRI and crossorigin="anonymous" on all three asset tags. { baseUrl } uses the same hashes and requires the unmodified 5.33.3 files. The base must be printable ASCII with no backslash, ? or #, and either an absolute HTTP(S) URL already in its serialized form or a path starting with a single /; it is written into the page as given, so a URL carrying credentials is refused. Invalid modes or bases throw TypeError at construction. Workers needs an external mode. The REST, microservice and full-stack starters select 'cdn' on Workers when no Swagger asset mode is chosen, and keep any explicit choice. CLI projects declare the package in npm dependencies, or the Deno import map, and set "scarfSettings": { "enabled": false } in a generated standalone or npm workspace-root package.json (Scarf ignores a member’s setting), opting out of the install-time analytics hook swagger-ui-dist depends on (@scarf/scarf). Workers templates select CDN automatically and do not declare the package.

Provides automatic OpenAPI documentation.

Registration

import { OpenApiPlugin } from '@setu-ts/openapi-plugin';

app.register(
  OpenApiPlugin({
    // OpenAPI spec metadata
    title: 'My API',
    version: '1.0.0',
    description: 'A sample API built with Setu-TS',
    servers: [
      { url: 'https://api.myapp.com', description: 'Production' },
      { url: 'http://localhost:3000', description: 'Local' },
    ],
    securitySchemes: {
      bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
      apiKey: { type: 'apiKey', in: 'header', name: 'X-API-Key' },
    },
    // Document-level requirement, inherited by every operation that does not
    // declare its own. Omit it and no operation is marked as protected.
    security: [{ bearerAuth: [] }],
    // Or derive each operation's requirement from the guards on its route, so
    // the document tracks what actually enforces instead of a second
    // declaration that can drift from it.
    deriveSecurity: { scheme: 'bearerAuth' },
    // Router paths to leave out of the document. Matched exactly against the
    // fully-resolved router pattern: router-style (`/todos/:id`, not the
    // template `/todos/{id}`) and including any `router.group()` prefix.
    exclude: ['/internal/debug'],
    // Plugin names whose routes are left out, matched against `RouteInfo.owner`.
    // Defaults to ['health-plugin', 'metrics-plugin']; pass [] to document them.
    excludeOwners: ['health-plugin', 'metrics-plugin'],
    // Fill requestBody and parameters from the validation middleware guarding
    // each route. On by default; pass false for the pre-derivation document.
    deriveRequestSchemas: true,
    // Document each operation's success status from what its handler declared
    // (`@HttpCode` / `@Redirect`) instead of assuming 200. On by default.
    deriveResponseStatus: true,
    // Endpoint configuration
    endpoint: '/docs', // Path for Swagger UI HTML (default: '/docs')
    specEndpoint: '/openapi.json', // Path for OpenAPI JSON spec (default: '/openapi.json')
    swagger: true, // Whether to serve Swagger UI (default: true)
  }),
);

Operation ids and descriptions

Optional RouteSchema.operationId and RouteSchema.description (since 0.10.0) carry programmatic declarations and @ApiOperation({ operationId, description }) metadata. The id is used verbatim; without it, ids keep the derived method/path format (GET /users/:id → get-users-by-id). The description is emitted when declared, including an empty string.

Declared ids are claimed before derived ones, regardless of route order: a derived get-users becomes get-users-2 if another included route declares get-users. Excluded paths and owners neither claim nor collide. OpenApiGenerator.generate(routes) throws OperationIdError for blank (empty or whitespace-only) or duplicate declared ids. OpenApiPlugin performs the same route scan at bootstrap, failing start() without building or caching a document. A route added after bootstrap is checked when the document is first built; a failure then answers 500.

OperationIdError extends Error is exported from @setu-ts/openapi-plugin (since 0.10.0). Its constructor is (reason: 'duplicate' | 'blank', message: string); readonly reason names the refusal, name is 'OperationIdError', and the diagnostic names the declared id and affected routes as METHOD path. The allocation helper is internal; no service or generator method is added.

Component names and x-setu-unrepresentable[].at follow the chosen id. SDK method and type names therefore change when an existing declaration starts taking effect; see Upgrading. Distinct OpenAPI ids can still collide in SDK codegen: declared getUsers beside derived get-users is valid OpenAPI but is refused by the SDK with Duplicate operation name. Choose ids with distinct sanitized TypeScript names.

Documenting Authentication

Declaring securitySchemes is what gives Swagger UI its Authorize button; without it a protected route cannot be exercised from the page at all. Pair it with security to state that operations require authentication by default, and let an individual route opt out:

app.router.post('/login', {
  // An EMPTY array marks the operation public, overriding the document-level
  // requirement. Omitting `security` entirely would leave `/login` documented
  // as needing the token it issues.
  schema: { security: [] },
  handler: async (ctx) => {
    /* ... */
  },
});

app.router.delete('/todos/:id', {
  middleware: [requireAuth()],
  // Narrower than the document default — this operation needs a scope.
  schema: { security: [{ oauth2: ['write:todos'] }] },
  handler: async (ctx) => {
    /* ... */
  },
});

RouteSchema.security enforces nothing. Authentication is enforced by authMiddleware and the requireXxx guards; this describes the route for readers and for generated clients.

Deriving Request Schemas From Validation Middleware

A route carrying validateBody(schema) already states its request shape, from a first-party plugin, on the route. deriveRequestSchemas (default true) reads that schema and fills the operation’s requestBody and parameters, so the shape is not written twice:

app.router.post('/orders', {
  middleware: [validateBody(PlaceOrderSchema), validateQuery(ListQuerySchema)],
  handler,
});
// -> requestBody from PlaceOrderSchema, query parameters from ListQuerySchema,
//    and a documented 400 (which is what the middleware answers).

Every helper @setu-ts/validation-plugin ships brands the middleware it returns with RouteValidationMetadata (@setu-ts/common), and so does IValidationService.middleware(...) — both entry points, identically. No plugin imports another; the Symbol.for-keyed brand in common is the whole channel, exactly as RouteSecurityMetadata is for guards.

Rules and limits, stated rather than left to discovery:

  • A declared schema field always wins, per field. Declaring schema.body and carrying validateQuery(...) gives the declared body and the derived query.
  • The LAST brand for a target wins when a route carries two, because that is the value the handler receives: each validation middleware writes validated:<target> as it passes, so the final writer’s parsed value is the one in ctx.state by the time the handler runs. The request must still satisfy EVERY brand, since any of them can short-circuit with a 400; the document shows the shape the handler sees, not that conjunction.
  • cookies derives nothing. RouteSchema has no cookies field, so there is no declared counterpart — and @setu-ts/sdk’s client generator refuses an in: 'cookie' parameter outright, so emitting one would turn a working document into a hard codegen failure for its consumers.
  • A derived route gains 400: { description: 'Bad request' } unless it declares its own. The status is real; no body schema is emitted, because the shape depends on the plugin’s configured errorFormat, which the generator cannot see.
  • deriveRequestSchemas: false disables derivation only. Owner exclusion, the derived operationId format for routes without a declared id and schema deduplication are unconditional, so this does not restore the whole pre-derivation document; pass excludeOwners: [] to document the operational routes again.

Unlike deriveSecurity this is ON by default, because nothing has to be configured for it: a security requirement names a scheme that cannot be inferred from a guard, while the schema on the route IS the schema the document wants.

Deriving The Success Status From The Handler

With no schema.response, an operation is documented as 200: { description: 'Successful response' } — which is wrong for every route that answers something else, and the wrongness is load-bearing: @setu-ts/sdk’s generator types the success body under that key, so a 201 route’s generated client types its success as 200 and a caller reading the created record gets nothing.

deriveResponseStatus (default true) reads the status the handler DECLARED. Every route @setu-ts/decorator-plugin’s @HttpCode(status) or @Redirect(url, status?) produces brands its handler with RouteResponseMetadata (@setu-ts/common), and the generator reads that brand off RouteInfo.definition.handler. No plugin imports another; the Symbol.for-keyed brand in common is the whole channel, exactly as RouteSecurityMetadata is for guards and RouteValidationMetadata is for request shapes.

@Controller('/orders')
class OrderController {
  @HttpCode(201)
  @Post('/')
  create() {
    return { id: 'o-1' };
  }
}
// -> responses: { '201': { description: 'Resource created' } }

Rules and limits, stated rather than left to discovery:

  • A DECLARED schema.response always wins, tested !== undefined rather than by length: an empty map is a caller saying “no documented responses”. A route carrying @ApiResponse(...) is therefore untouched by the derivation.
  • The HANDLER carries the brand, not a middleware. A guard and a validator ARE middleware; a success status is a property of the handler, and a decorated route may carry no middleware at all.
  • Only the status is derived. @ResponseHeader contributes nothing — an OpenAPI response-header entry needs a schema and a description the declaration does not carry, so deriving one would put an under-specified headers object into every document that used the decorator.
  • A @Redirect route is documented under its 3xx.
  • deriveResponseStatus: false restores the assumed 200 for every branded route. Like deriveRequestSchemas: false it disables this derivation only; owner exclusion, the derived operationId format for routes without a declared id and schema deduplication are unconditional.

Like deriveRequestSchemas and unlike deriveSecurity, this is ON by default, because nothing has to be configured for it. It is safe to default on because the brand is new surface: no handler written before @HttpCode existed carries one, so an application that changes nothing gets a byte-identical document. What DOES change the document is adopting the decorator — which is the point of it, and which moves that operation’s generated success type from 200 to the declared status.

Excluding Operational Routes

excludeOwners (default ['health-plugin', 'metrics-plugin']) drops routes by the plugin that registered them, read from RouteInfo.owner. Without it, /health, /live, /ready and /metrics are documented and flow into every generated client as getHealth, getLive, getReady and getMetrics alongside the real API.

Owners rather than paths, because those endpoints are configuration: HealthPlugin({ endpoints }) and MetricsPlugin({ endpoint }) both accept a path, so a static path list silently stops excluding a renamed one. Pass excludeOwners: [] to document them again.

Deriving Authentication From Guards

Declaring security per route makes the document a second source of truth that can drift from the guards. deriveSecurity closes that: every guard @setu-ts/auth-plugin ships is branded with RouteSecurityMetadata, and the generator reads the brand off the route’s middleware.

app.register(
  OpenApiPlugin({
    securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } },
    deriveSecurity: { scheme: 'bearerAuth' },
  }),
);

// Documented as requiring bearerAuth — no `schema.security` needed.
app.router.get('/todos/:id', { middleware: [requireAuth()], handler });

// Documented as public.
app.router.post('/login', { middleware: [publicRoute()], handler });

Rules, in precedence order: a requirement declared on schema.security always wins; otherwise a route carrying at least one branded guard is derived (authenticated: true beats false, matching what the middleware chain does); otherwise the operation carries no key and inherits the document-level security.

Three limits, stated rather than left to discovery:

  • Only route-level middleware is inspected. Middleware added with app.middleware.add() is not on the route and is not consulted — correct for authMiddleware(), which populates the principal and never rejects.
  • Roles and permissions are not expressible. An OpenAPI requirement names a scheme, and no declared scheme can be inferred from 'admin', so requireRole('admin') documents only that authentication is required. A 403 remains a surprise the document cannot warn about.
  • The scheme name is configured, never inferred. A guard cannot know whether the document calls its scheme bearerAuth or jwt. An undeclared name is refused at register().

Defining Route Schemas

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});

const CreateUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
});

const ErrorSchema = z.object({
  error: z.string(),
  message: z.string(),
});

app.router.post('/users', {
  schema: {
    body: CreateUserSchema,
    response: {
      201: UserSchema,
      400: ErrorSchema,
      409: ErrorSchema,
    },
    tags: ['Users'],
    summary: 'Create a new user',
  },
  handler: async (ctx) => {
    /* ... */
  },
});

Zod Version Support

The supported Zod range is >=4.6.6 <5. Schemas are recognized by the public toJSONSchema method; the plugin does not import Zod.

  • Zod v4 is converted through schema.toJSONSchema() and adapted from JSON Schema draft 2020-12 to OpenAPI 3.1: the dialect $schema key is dropped, formats and constraints pass through verbatim (format, pattern, contentEncoding, …), reused schemas are hoisted into components/schemas with their pointers rewritten, and a recursive schema’s root-cycle pointer forces that schema into components so no bare # ref survives in the document.

  • A type union is always spelled anyOf, on every supported zod v4. Draft 2020-12 permits two spellings, and zod switched between them across v4 releases: through 4.4 a union of bare types emitted anyOf: [{ "type": "string" }, { "type": "null" }], and from 4.5 it collapses to "type": ["string", "null"]. The transformer normalizes the collapsed form back, so one schema produces one document whichever in-range zod the application installs, and the document stays convertible to OpenAPI 3.0 (where a type array is not legal). Sibling keywords stay beside the anyOf rather than being distributed into its arms — { "type": ["string", "null"], "minLength": 2 } becomes { "minLength": 2, "anyOf": [{ "type": "string" }, { "type": "null" }] }, which asserts the same thing without applying a string constraint to the null arm. Only schema positions are rewritten: a default, const, enum, example or vendor-extension value is instance DATA and is preserved verbatim, even when it happens to carry its own type key. And where a node already asserts anyOf, the normalized union is conjoined through allOf rather than taking the anyOf key, since the two are independent assertions over the same instance. A union whose arms carry anything beyond a bare type (a constraint, a format, a const, an items) is emitted as anyOf by zod itself and passes through untouched.

  • Unrepresentable nodes degrade, never throw. A type zod cannot represent in JSON Schema (z.date(), z.bigint()) still becomes an empty schema, but the operation owning it now carries a machine-readable vendor extension:

    {
      "x-setu-unrepresentable": [
        { "at": "post-events", "reason": "zod v4 type 'date' has no JSON Schema representation" }
      ]
    }

    at names the operationId, so the affected route is identifiable without diffing documents. The extension is absent when every schema is representable. Nothing is logged when a node degrades: the document is built on the first getSpec() / /openapi.json request, not at startup, so inspect it (or test for x-setu-unrepresentable) in CI. For dates, z.iso.datetime() represents a date-time in JSON Schema.

  • Schemas without toJSONSchema, including Zod 3 schemas, are unrecognized. They degrade to {} and report the existing not a recognized zod schema diagnostic through x-setu-unrepresentable. Upgrade Zod to at least 4.6.6 before regenerating documents.

Request and response are documented from different sides

A zod schema has two shapes: what a client may SEND, and what the server holds after parsing. They differ for anything carrying a .default(), a .transform(), a .coerce, or the ordinary key-stripping of z.object. Every request-side position — body, params, query, headers — is documented from the input side; response is documented from the output side.

This is what stops a document contradicting the server it describes. A field with .default('free') is optional for a client (that is what a default is for) and always present in a response, so it is absent from the request body’s required and present in the response’s. A .transform() field documents its SOURCE type on a request rather than the unrepresentable {} its result produces. And on the three object modes, measured against zod 4.4:

schemaunknown key at runtimerequest body documentsresponse documents
z.objectaccepted, stripped(no additionalProperties)additionalProperties:false
z.strictObjectrejectedadditionalProperties:falseadditionalProperties:false
z.looseObjectaccepted, keptadditionalProperties:{}additionalProperties:{}

ZodToOpenApi.transform(schema, io?) takes the side as an optional second argument, defaulting to 'output', so a direct call uses the response view.

Deduplication follows the side. A schema whose two views are identical — any v4 schema with no default, transform, coercion or object-mode difference — is hoisted into ONE component shared by every site, exactly as before. A schema whose views differ and that is used on both sides yields one component per side, because the two sites do not describe the same shape. A schema registered through addSchema('Name', …) keeps that name for the output side and gains a NameInput twin when a request site reaches it, so the contributor’s chosen name still appears on both sides. The twin is identified by SCHEMA, never by name alone — registering both Address and an unrelated AddressInput is legal, and the twin then takes the next free suffixed name rather than adopting the unrelated component. It belongs to the document that needed it: a later generate() with no request site for that schema drops it, exactly as it drops every other per-document component.

Notes

  • Every RouteSchema position the generator reads becomes part of the operation: body becomes the application/json request body, response becomes the responses map, tags/summary/operationId/description become the operation metadata, and params, query, and headers become parameters with in: 'path', in: 'query', and in: 'header' respectively. Path parameters are always required: true (they come from the path template); query and header parameters take their required flag from the schema. Header parameters are emitted verbatim — per OpenAPI 3.1, tooling ignores definitions named Accept, Content-Type, and Authorization, so the generator does not filter them out.
  • A path parameter the params schema does not describe is emitted as { type: 'string' } rather than the empty schema, which OpenAPI reads as “any type”. Every path segment arrives as a string, so the empty form made Swagger UI render an untyped box and client generators emit unknown. A declared params entry always wins, per parameter.
  • The plugin’s own specEndpoint and endpoint are never documented as operations — a spec that lists /openapi.json and /docs describes its own delivery mechanism, and those entries flow into every generated client. The routes are still served; only the document entries are omitted. Custom endpoint paths are honored. Anything else the document should omit goes in exclude, which matches the fully-resolved router pattern — a route registered inside router.group('/internal', …) is matched by its prefixed path, and an entry matching no route is silently ignored.
  • A security requirement naming a scheme absent from securitySchemes is refused at register(). Emitting it would produce a document that is invalid per the specification — Swagger UI renders a lock on every operation with no Authorize button — and nothing downstream can detect it, since the spec endpoint still answers 200.
  • An unrestricted decorated route marked @Public is documented with an empty security array, so it opts out of a document-level requirement. @Public never exempts a route from an enforced @Roles/@Permissions declaration; when one is present, the public marker is omitted and the middleware’s derived requirement documents the protection truthfully.

Accessing the Spec

// The spec is available at /openapi.json
// The Swagger UI is available at /docs

// Programmatic access
const openapi = ctx.services.get<IOpenApiService>('openapi');
const spec = openapi.getSpec();

Agent card · TypeDoc API reference