@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$schemakey is dropped, reused schemas land incomponents/schemaswith their pointers rewritten, and a recursive schema’s root-cycle pointer forces the schema intocomponentsso 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 emittedanyOf— 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 atypearray 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-readablex-setu-unrepresentableextension 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.jsonrequest, not at startup, so inspect it (or test forx-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 existingnot a recognized zod schemadiagnostic inx-setu-unrepresentable. Upgrade Zod to>=4.6.6 <5before 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).
| Option | Type | Default | Description |
|---|---|---|---|
swagger | boolean | SwaggerOptions | true | Serve Swagger UI, self-hosted by default. |
endpoint | string | '/docs' | Path for the Swagger UI page. |
specEndpoint | string | '/openapi.json' | Path for the JSON spec. |
security | readonly SecurityRequirement[] | — | Document-level requirement inherited by every operation. |
deriveSecurity | { scheme: string } | — | Derive each operation’s requirement from its route guards. |
exclude | readonly string[] | — | Router paths to omit; matches the resolved pattern. |
excludeOwners | readonly string[] | ['health-plugin', 'metrics-plugin'] | Plugin names whose routes are omitted, by RouteInfo.owner. |
deriveRequestSchemas | boolean | true | Fill 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
schemaalways 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 inctx.state. The request must still satisfy every brand — any of them can answer400— so the documented shape is what the handler sees, not that conjunction. - A
cookiesbrand derives nothing:RouteSchemahas nocookiesfield, and@setu-ts/sdk’s client generator refuses anin: 'cookie'parameter outright, so emitting one would turn a working document into a codegen failure for its consumers. - The derived
400carries a description and no schema — the body shape depends on the validation plugin’s configurederrorFormat, which this plugin cannot see. deriveRequestSchemas: falsedisables derivation only. It does NOT restore the rest of the pre-derivation document: owner exclusion, theoperationIdformat and schema deduplication are unconditional. To document the operational routes again, passexcludeOwners: [].
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
| Export | Kind |
|---|---|
OpenApiPlugin | function |
swaggerUiHtml | function |
zodToOpenApi | function |
OpenApiGenerator | class |
OpenApiService | class |
OperationIdError | class |
ZodToOpenApi | class |
IOpenApiService | interface |
OpenApiDocument | interface |
OpenApiGeneratorOptions | interface |
OpenApiOperation | interface |
OpenApiParameter | interface |
OpenApiPluginOptions | interface |
OpenApiRequestBody | interface |
OpenApiResponse | interface |
OpenApiSchemaObject | interface |
OpenApiServiceOptions | interface |
SwaggerUiOptions | interface |
SchemaIo | type |
SchemaNodeHook | type |
SwaggerOptions | type |
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.
| Export | Kind | Contract |
|---|---|---|
OperationIdError | class | Refuses blank or duplicate declared ids; readonly reason: 'duplicate' | 'blank' |
SwaggerOptions | type | { readonly assets?: 'self' | 'cdn' | { readonly baseUrl: string } } |
SwaggerUiOptions | interface | specUrl, 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
schemafield always wins, per field. Declaringschema.bodyand carryingvalidateQuery(...)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 inctx.stateby the time the handler runs. The request must still satisfy EVERY brand, since any of them can short-circuit with a400; the document shows the shape the handler sees, not that conjunction. cookiesderives nothing.RouteSchemahas nocookiesfield, so there is no declared counterpart — and@setu-ts/sdk’s client generator refuses anin: '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 configurederrorFormat, which the generator cannot see. deriveRequestSchemas: falsedisables derivation only. Owner exclusion, the derivedoperationIdformat for routes without a declared id and schema deduplication are unconditional, so this does not restore the whole pre-derivation document; passexcludeOwners: []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.responsealways wins, tested!== undefinedrather 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.
@ResponseHeadercontributes nothing — an OpenAPI response-header entry needs a schema and a description the declaration does not carry, so deriving one would put an under-specifiedheadersobject into every document that used the decorator. - A
@Redirectroute is documented under its3xx. deriveResponseStatus: falserestores the assumed200for every branded route. LikederiveRequestSchemas: falseit disables this derivation only; owner exclusion, the derivedoperationIdformat 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 forauthMiddleware(), 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', sorequireRole('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
bearerAuthorjwt. An undeclared name is refused atregister().
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$schemakey is dropped, formats and constraints pass through verbatim (format,pattern,contentEncoding, …), reused schemas are hoisted intocomponents/schemaswith their pointers rewritten, and a recursive schema’s root-cycle pointer forces that schema intocomponentsso 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 emittedanyOf: [{ "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 atypearray is not legal). Sibling keywords stay beside theanyOfrather 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 thenullarm. Only schema positions are rewritten: adefault,const,enum, example or vendor-extension value is instance DATA and is preserved verbatim, even when it happens to carry its owntypekey. And where a node already assertsanyOf, the normalized union is conjoined throughallOfrather than taking theanyOfkey, since the two are independent assertions over the same instance. A union whose arms carry anything beyond a bare type (a constraint, aformat, aconst, anitems) is emitted asanyOfby 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" } ] }atnames 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 firstgetSpec()//openapi.jsonrequest, not at startup, so inspect it (or test forx-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 existingnot a recognized zod schemadiagnostic throughx-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:
| schema | unknown key at runtime | request body documents | response documents |
|---|---|---|---|
z.object | accepted, stripped | (no additionalProperties) | additionalProperties:false |
z.strictObject | rejected | additionalProperties:false | additionalProperties:false |
z.looseObject | accepted, kept | additionalProperties:{} | 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
RouteSchemaposition the generator reads becomes part of the operation:bodybecomes theapplication/jsonrequest body,responsebecomes the responses map,tags/summary/operationId/descriptionbecome the operation metadata, andparams,query, andheadersbecomeparameterswithin: 'path',in: 'query', andin: 'header'respectively. Path parameters are alwaysrequired: true(they come from the path template); query and header parameters take theirrequiredflag from the schema. Header parameters are emitted verbatim — per OpenAPI 3.1, tooling ignores definitions namedAccept,Content-Type, andAuthorization, so the generator does not filter them out. - A path parameter the
paramsschema 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 emitunknown. A declaredparamsentry always wins, per parameter. - The plugin’s own
specEndpointandendpointare never documented as operations — a spec that lists/openapi.jsonand/docsdescribes 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 inexclude, which matches the fully-resolved router pattern — a route registered insiderouter.group('/internal', …)is matched by its prefixed path, and an entry matching no route is silently ignored. - A
securityrequirement naming a scheme absent fromsecuritySchemesis refused atregister(). 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 answers200. - An unrestricted decorated route marked
@Publicis documented with an emptysecurityarray, so it opts out of a document-level requirement.@Publicnever exempts a route from an enforced@Roles/@Permissionsdeclaration; 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();