DocTreen
Features

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.json

Drop 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 tags override, otherwise first path segment)
  • Path parameters (/users/:id/users/{id}), query parameters from request.query, request headers as parameters[].in = header
  • JSON request body for POST/PUT/PATCH with required arrays 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 response map documents each declared status with its own schema
  • Typed path parameters v1.15 — path parameters typed from request.params instead of defaulting to string
  • DoctreenValidationError 422 v1.15 — routes with validators (Zod / Pydantic) auto-document a 422 referencing a named schema
  • components.schemas with $ref dedup v1.11 — named schemas from defineSchema and repeated anonymous shapes are promoted automatically
  • callbacks per-operation and document-level webhooks v1.11
  • Multi-example bodies and responses via defineRoute({ examples }) v1.11
  • Top-level tags metadata with descriptions and external docs v1.11
  • securitySchemes, per-route security, hidden routes 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/router instance 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.json

On PHP, the bundled CLI does the same without a running server:

vendor/bin/doctreen emit-openapi --app . --out openapi.json

On 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: bodyrequest, successresponse.

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-info

Nine rules across three severities:

  • error — duplicate operationIds, missing info.title / info.version, undeclared path parameters, operations without responses
  • warning — missing operation summary, missing 4xx response, undescribed tags
  • info — untagged operations, unused components.schemas entries

--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

On this page