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 Zod-or-s-builder schemas that drive DocTreen's own UI 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.

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 Zod schema
  • 200 / 201 success responses (201 for POST) plus every declared error response with its own 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

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

# 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