OpenAPI 3.1 export
Drop the spec into Scalar, Redoc, Swagger UI, or any spec-driven tool.
Every adapter serves an OpenAPI 3.1
document at <docsPath>/openapi.json and the docs UI ships with a one-click
Export to OpenAPI 3.1 button next to Export to Postman. The same
schemas that drive DocTreen's own UI — Zod, Pydantic, or the s builder — also drive the spec —
no extra annotations, no separate file.
# Once the docs UI is running:
curl https://your-api.example.com/docs/openapi.json > openapi.jsonDrop the file (or paste it) into Scalar, Redoc, Swagger UI, editor.swagger.io, or any other tool that consumes the spec.
One spec, three languages
The Python and PHP packages export the same document: the Python
exporter's output is verified byte-for-byte against the Node reference (same
values, same key order) by a shared
conformance suite
that runs on every push, and the PHP exporter is parity-tested against the npm
exporter's output — including $ref dedup, callbacks, and webhooks. A
polyglot backend gets one spec format everywhere, so downstream tooling
(codegen, mock server, linters) never cares which language produced it.
What's in the spec
- GET / POST / PUT / PATCH / DELETE operations grouped by tag (per-route
tagsoverride, otherwise first path segment) - Path parameters (
/users/:id→/users/{id}), query parameters fromrequest.query, request headers asparameters[].in = header - JSON request body for POST/PUT/PATCH with
requiredarrays derived from the schema - 200 / 201 success responses (201 for POST) plus every declared error response with its own schema
- Status-keyed responses v1.15 — a
responsemap documents each declared status with its own schema - Typed path parameters v1.15 — path parameters typed from
request.paramsinstead of defaulting tostring DoctreenValidationError422 v1.15 — routes with validators (Zod / Pydantic) auto-document a422referencing a named schemacomponents.schemaswith$refdedup v1.11 — named schemas fromdefineSchemaand repeated anonymous shapes are promoted automaticallycallbacksper-operation and document-levelwebhooksv1.11- Multi-example bodies and responses via
defineRoute({ examples })v1.11 - Top-level
tagsmetadata with descriptions and external docs v1.11 securitySchemes, per-routesecurity,hiddenroutes v1.8
Emit the spec offline (no server) v1.15
You no longer need a running server to produce the OpenAPI document. Every
adapter exports getOpenApiDocument(app, config), which returns the spec object
directly:
import { getOpenApiDocument } from 'doctreen/express';
const doc = getOpenApiDocument(app, { meta: { title: 'My API', version: '1.0.0' } });
fs.writeFileSync('openapi.json', JSON.stringify(doc, null, 2));- Koa — pass the
@koa/routerinstance rather than the app. - Fastify — call it after
await fastify.ready(). - NestJS — call it after
NestFactory.create().
Or skip the script entirely with the CLI:
doctreen emit-openapi --adapter <express|fastify|hono|koa|nest> --app ./app.js --out openapi.jsonOn PHP, the bundled CLI does the same without a running server:
vendor/bin/doctreen emit-openapi --app . --out openapi.jsonOn Python, build the document directly:
from doctreen import RouteRegistry, normalize_config
from doctreen.exporters.openapi import build_openapi_document
doc = build_openapi_document(
registry.get_visible(),
normalize_config({"meta": {"title": "My API", "version": "1.0.0"}}),
)Handy in CI, and it pairs with doctreen codegen --from ./openapi.json. See
codegen for the full CLI reference.
Status-keyed responses v1.15
A route's response can be a status-keyed map instead of a single schema. Each
declared status is exported as its own responses[N] entry with its own schema:
app.post('/users', defineRoute(handler, {
request: { body: CreateUser },
response: {
201: User, // created
200: ExistingUser, // already existed — returned as-is
},
}));The plain response: schema form still maps to a single success status
(201 for POST, 200 otherwise), so existing routes are unchanged.
Typed path parameters v1.15
When a route declares request.params, the exported path parameters are typed
from that schema instead of defaulting to string:
app.get('/users/:id', defineRoute(handler, {
request: { params: s.object({ id: s.number() }) },
// /users/{id} exports `schema: { type: 'integer' }` instead of string
}));Validation errors: DoctreenValidationError v1.15
Any route wired up with validators (Zod on Node, Pydantic on Python, S:: metadata on PHP) automatically gets a documented 422
response that references a named components.schemas.DoctreenValidationError
(shape { error, issues[] }). If the route declares its own 422, that one
wins and the automatic entry is skipped. See
Error responses for the stable envelope shape.
The openapi.* config block in every language
The openapi config keys below (tags, servers, securitySchemes,
security, webhooks) are identical across languages — a JS object on Node,
a camelCase dict on Python, the 'openapi' array in config/doctreen.php on
Laravel. The JS examples on this page translate mechanically.
Per-route tags + top-level metadata v1.11
expressAdapter(app, {
openapi: {
tags: [
{ name: 'users', description: 'User account management' },
{ name: 'billing', description: 'Invoices + payment methods',
externalDocs: { url: 'https://docs.example.com/billing' } },
],
},
});
app.post('/users', defineRoute(handler, {
tags: ['users', 'public'], // overrides the path-segment default
// ...
}));Tags used by routes but not declared at the top level are still emitted — just
without descriptions. The lint openapi command warns about undescribed tags
so you notice.
$ref schema deduplication v1.11
Wrap a schema with defineSchema('Name', ...) and every route that references
the same object will share a single $ref: '#/components/schemas/Name' entry
in the exported spec. See Named schemas for the full
walkthrough.
Callbacks and webhooks v1.11
OpenAPI 3.1 distinguishes between callbacks (per-operation, expressed inline
with a runtime expression like {$request.body#/callbackUrl}) and webhooks
(document-level events the server emits).
Per-operation callback
app.post('/payments', defineRoute(handler, {
description: 'Create a payment',
callbacks: {
onPaymentSucceeded: {
url: '{$request.body#/callbackUrl}',
method: 'POST',
summary: 'Notify the caller when the payment clears',
request: { body: s.object({ paymentId: s.string() }) },
response: s.object({ ok: s.boolean() }),
},
},
}));Document-level webhook
expressAdapter(app, {
openapi: {
webhooks: {
userDeleted: {
method: 'POST',
summary: 'Fired when a user closes their account',
request: { body: s.object({ userId: s.number(), deletedAt: s.string() }) },
response: s.object({ ok: s.boolean() }),
},
},
},
});Both reuse the same schema pipeline as routes — Zod schemas are accepted,
$ref dedup applies.
Multi-example bodies and responses v1.11
app.post('/users', defineRoute(handler, {
request: { body: User },
response: User,
errors: { 422: 'Validation failed' },
examples: {
request: {
basic: { value: { name: 'Ada', email: 'ada@example.com' }, summary: 'Minimum' },
admin: { value: { name: 'Boss', email: 'boss@x.com', role: 'admin' }, summary: 'With role' },
},
response: { id: 1, name: 'Ada', email: 'ada@example.com' },
responses: { 422: { value: { errors: ['email is required'] } } },
},
}));Single values render as OpenAPI example; named maps render as examples.
Aliases: body → request, success → response.
Linting the spec v1.11
Available from npm (npx doctreen lint openapi) and from Composer
(vendor/bin/doctreen lint openapi) — both read any OpenAPI 3.x document over
HTTP or from disk, so either binary can lint a spec produced by any of the
three packages.
# Live URL
npx doctreen lint openapi --url http://localhost:3000/docs
# Local file
npx doctreen lint openapi --file ./build/openapi.json --fail-on warning
# CI-friendly JSON
npx doctreen lint openapi --url https://api.example.com/docs --json
# Hide the noisy `info` items in the table
npx doctreen lint openapi --url http://localhost:3000/docs --no-infoNine rules across three severities:
- error — duplicate operationIds, missing
info.title/info.version, undeclared path parameters, operations withoutresponses - warning — missing operation
summary, missing 4xx response, undescribed tags - info — untagged operations, unused
components.schemasentries
--fail-on error (default) → exit 1 when any error is present. --fail-on warning
→ exit 1 on errors or warnings. info never affects exit code. Use --no-info
to suppress info-level rows from the table without changing the exit semantics.
Servers, security schemes, per-route security v1.8
Point the spec at real environments and declare auth schemes once — DocTreen
will attach the right security block to each operation and strip the
redundant Authorization header parameter:
expressAdapter(app, {
openapi: {
servers: [
{ url: 'https://api.example.com', description: 'Production' },
{ url: 'https://staging.api.example.com', description: 'Staging' },
],
securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
apiKey: { type: 'apiKey', in: 'header', name: 'x-api-key' },
},
security: [{ bearerAuth: [] }], // global default — applies to every operation
},
});Per-route overrides:
// Override with a different scheme
app.get('/admin/stats', defineRoute(handler, {
security: [{ adminAuth: [] }],
// ...
}));
// Mark this route explicitly public, ignoring the global default
app.post('/auth/login', defineRoute(handler, {
security: [],
// ...
}));When a route has any effective security requirement (per-route or inherited),
DocTreen automatically strips the Authorization header from parameters[] —
the security scheme is the single source of truth, and Redocly's
security-defined rule passes cleanly.
Inject custom HTML into the docs <head> v1.9
Pass headHtml to drop analytics scripts, custom CSS, favicons, OG tags, or
web fonts into the docs UI without forking DocTreen:
expressAdapter(app, {
meta: { title: 'My API', version: '1.0.0' },
headHtml: [
'<script defer src="/_vercel/insights/script.js"></script>',
'<script defer src="/_vercel/speed-insights/script.js"></script>',
'<link rel="icon" href="/favicon.ico" />',
'<meta name="theme-color" content="#0f1117">',
].join('\n'),
});The string is appended as-is to the generated <head>, after DocTreen's
built-in styles and before </head>. Trusted input — DocTreen does not
sanitise — so do not pass anything derived from user-submitted data.
Hide a route from the docs v1.8
Some endpoints serve traffic but should not appear in the docs UI or the OpenAPI export — internal admin tools, experimental features, deprecated routes you can't remove yet. Mark them per-route:
app.get('/internal/metrics', defineRoute(handler, {
hidden: true, // removed from docs UI and openapi.json — still serves 200s
description: 'Internal metrics endpoint',
}));// NestJS — full bag
@Get('flags') @DocRoute({ hidden: true }) flags() { /* ... */ }
// NestJS — shorthand decorator
@Get('flags') @DocHidden() flags() { /* ... */ }Hidden routes are filtered out by RouteRegistry.getVisible() (used by both
the docs UI and the OpenAPI exporter), so the runtime route remains fully
reachable.
CI hooks
npx @redocly/cli lint https://your-api.example.com/docs/openapi.json
npx @apidevtools/swagger-cli validate https://your-api.example.com/docs/openapi.json