DaloyJS OpenAPI spec
DaloyJS emits a clean OpenAPI 3.1 document straight from your route definitions, no plugins, no separate decorators. Validation, types, and the spec all share one source of truth.
One line: auto-mount /docs, /openapi.json, /openapi.yaml
FastAPI-style. Pass docs: "auto" (or docs: true) to the App constructor and DaloyJS registers GET /openapi.json + GET /openapi.yaml (the live spec in both formats) and GET /docs (a Scalar API reference UI) for you.
docs: "auto" mounts only when the App is not in production (the create-daloy templates default to this, so a deployed API does not publish its schema), or leave it off (the default) and mount manually with the helpers below. Customize paths, UI, and tags via the object form. Since 1.5.5 the object form behaves like "auto" unless you set enabled, so customizing the UI never publishes your route map in production by accident; set enabled: true (or docs: true) for a public API that should serve its docs in production:
Security: a published spec is a complete map of your attack surface: every path, parameter, auth scheme, and error shape. Withdocs: trueorenabled: truein production, DaloyJS logsdocs.public_in_productionand serves the spec to anyone who can reach it. That is right for a public API. For anything else, keep"auto", or put the docs behind authentication (an app-levelbearerAuth()without anexcept()for/docsand/openapi.jsoncovers them). Mark admin, cron, and debugging routesinternal: true. They are then left out of the generated document and answer404to network traffic, whileapp.inject()can still call them. See Secure admin panels.
Pick a UI: Scalar, Swagger UI, or Redoc
Set ui to "scalar" (default), "swagger", or "redoc". All three render the same live spec, mount on the same paths, and ship the same strict CSP and CDN-hosted assets, so switching is a one-word change. Scalar and Swagger UI include a developer request console for protected routes. Redoc is a read-only reference UI: it displays security requirements, but it does not ship a built-in Try it console.
Swagger UI keeps authorizations from its Authorize dialog across reloads by default. It does this by storing the pasted token in the browser's localStorage, as does Scalar with persistAuth: true. Any script running on the docs origin can read it, and it survives closing the tab. Paste short-lived, least-privilege test tokens only, never production or admin credentials, and serve docs from a separate origin from any first-party web app. Pass swagger: { persistAuthorization: false } to keep tokens in memory only. Scalar automatically selects the first configured OpenAPI security scheme, so the auth form is ready for developers to paste a bearer token or API key. If you want a different Scalar default, set it explicitly:
If your docs need a visible login entry point, add docs.auth. The same button is rendered for Scalar, Swagger UI, and Redoc. Point it at a local login form, Better Auth route, Clerk sign-in URL, or an OIDC authorization URL from Auth0, Okta, Microsoft Entra ID, Cognito, Keycloak, or another provider:
The redoc object is forwarded verbatim to Redoc.init(specUrl, configuration, element). Because Redoc builds its search index in a blob: Web Worker, the auto-mounted /docs route automatically widens that page's CSP with worker-src 'self' blob: for ui: "redoc" only. Scalar and Swagger UI keep the tighter policy. The scalar option is ignored unless ui is "scalar", and likewise for swagger and redoc.
The scalar object is forwarded to Scalar's HTML API as JSON configuration while Daloy keeps the live openapiPath as the source. Use it for themes, custom CSS, layout, auth defaults, and client visibility without copying the docs HTML.
Advanced: generate the spec manually
Need the raw spec object (for codegen, contract tests, or a custom route)? Call generateOpenAPI(app, options) directly:
Advanced: serve docs from your own route
A hand-rolled docs route is an ordinary route, so docs: "auto" gating does not apply to it. Decide yourself whether it should exist in production and whether it needs auth. The same applies to any /openapi.json route you serve by hand with generateOpenAPI():
swaggerUiHtml, scalarHtml, and redocHtml all return self-contained HTML pages that load their assets from jsDelivr with a strict CSP allowing only that origin. When you hand-roll the route with redocHtml, pass allowBlobWorkers: true to htmlResponse (or docsContentSecurityPolicy) so Redoc's blob: search worker is allowed by the CSP:
If you want to test your docs UX against a much larger contract, see the large fake REST demo. It is a better benchmark than a toy CRUD sample when you need to validate search, grouping, and render performance.
Dump to disk for codegen
What gets emitted
- One
operationIdper route, duplicates throw at registration. - Path params
:idnormalized to{id}. - Schema bodies converted via
schema.toJSONSchema?.()when supported, or a structural fallback. - Reusable
components.schemas.Problemfor RFC 9457 errors. - Schemas tagged with an id (
.meta({ id: "Book" })) are hoisted intocomponents.schemasand referenced by$ref(see Named schemas). tags,summary,description, and per-statusdescription.- Routes marked
internal: trueare omitted unless you passincludeInternal: true(for example, an internal-only contract for your own tooling). Never publish that document.
Named schemas and components.schemas
By default every request and response schema is inlined into the operation that uses it. That is correct, but a generated client then names the types after the operation (GetBookResponse, ListBooksResponse[number]), and the same shape is repeated in every operation that returns it.
Give a schema an id and DaloyJS emits it once under components.schemas, referencing it by $ref everywhere it appears, including inside other named schemas, path and query parameter schemas, webhooks, and callbacks:
Hey API (and every other OpenAPI client generator) now produces a Book and an Author type you can import directly, instead of a per-operation anonymous shape, so there is no need to hand-maintain a models.ts that renames generated types.
Rules:
- Ids must be unique per shape. Two structurally different schemas sharing one id make
generateOpenAPI()throw, because the second would silently replace the first in every generated client. Reuse the same schema instance, or give the second one its own id. Problemis reserved for the built-in RFC 9457 error schema.- Zod 4's own
$defs/$schemakeys never reach the document: the dialect is already set document-wide by OpenAPI 3.1, and#/$defs/...pointers are rewritten to#/components/schemas/...so no reference dangles. - Schemas without an id keep being inlined, exactly as before.
Zod is the reference implementation here (.meta({ id }) is what makes toJSONSchema() emit $defs). Any Standard Schema library whose toJSONSchema() returns the same { $ref, $defs } shape is hoisted the same way.
Webhooks
OpenAPI 3.1 lets a producer publish top-level webhooks, operations a consumer is expected to implement. Pass webhooks to generateOpenAPI and DaloyJS emits them under the document's top-level webhooks map.
The webhooks map only describes the operation; it does not deliver or secure anything. For real deliveries, sign each payload so receivers can verify both origin and integrity, and include a timestamp so they can reject replays. A static bearer token alone proves neither. createWebhookSender() signs, timestamps, retries, and routes every outbound request through fetchGuard(). Receivers verify with verifyWebhookSignature(). Document the signature headers in the webhook's description.
Callbacks
Callbacks describe out-of-band requests that an operation may trigger on the consumer (e.g. a subscription endpoint that later POSTs to the URL the caller supplied). Attach a callbacks map directly to a route or webhook.
Security: a caller-supplied callback URL is a Server-Side Request Forgery sink. Schema validation is not enough.z.url()on its own acceptshttp://169.254.169.254/(cloud metadata),http://localhost:6379/, andfile:/gopher:URLs, and evenz.url({ protocol: /^https$/ })still acceptshttps://169.254.169.254/or a hostname that resolves to a private address. Send every callback throughsafeFetchfromfetchGuard(), which checks the resolved address and every redirect hop. Better still, deliver withcreateWebhookSender(), which also signs the payload. Consider verifying ownership of the URL, for example with a challenge request, before you store it.
Each callback name maps to one or more runtime expression keys (e.g. "{$request.body#/callbackUrl}"), each of which maps to one or more operations keyed by HTTP method. Empty maps and empty arrays are skipped, passing an empty callback never produces a malformed spec.
Discriminated unions
OpenAPI 3.1's discriminator is the canonical way to describe tagged unions. DaloyJS ships two helpers from @daloyjs/core/openapi (and the root package):
discriminator(propertyName, mapping?): the bare spec builder. Use it when you already have a hand-rolled JSON Schema and want to attach the field cleanly.discriminatedUnion(propertyName, variants, opts?): a Standard-Schema- compatible wrapper that both validates at runtime (dispatching on the discriminator value) and exposes.toJSONSchema()so the OpenAPI generator emits{ oneOf, discriminator }automatically.
At runtime the wrapper rejects non-objects, missing or non-string discriminators, and unknown discriminator values (including inherited names such as __proto__, constructor, and toString) with a clear Standard Schema issue, then defers to the matching variant's validator for everything else.