exjs-controllers

OpenAPI

Generate an OpenAPI 3.1 document from controllers, DTOs and authorization metadata.

exjs-controllers generates an OpenAPI 3.1.0 document directly from the metadata you declare on controllers and DTOs. There is no extra annotation step — inputClass, outputClass, @Authorized, @Field and parameter decorators are all the source of truth.

Built-in serving

Pass openapi.documentation.info to configureApplication and the document is served at /docs/openapi.json (configurable via openapi.documentPath).

await configureApplication(app, {
  controllers,
  openapi: {
    documentPath: '/openapi.json',
    documentation: {
      info: { title: 'My API', version: '1.0.0' }
    }
  }
})

Programmatic generation

generateOpenApiDocument(controllers, options) returns the document as a plain object — useful for build-time spec generation:

import { writeFile } from 'node:fs/promises'
import { generateOpenApiDocument } from 'exjs-controllers/openapi/generateOpenApiDocument'

const document = generateOpenApiDocument([UsersController, OrdersController], {
  openapi: { documentation: { info: { title: 'My API', version: '1.0.0' } } }
})

await writeFile('openapi.json', JSON.stringify(document, null, 2))

Where each part comes from

Spec fieldSource
infoopenapi.documentation.info
paths['/...'].<method>.summary / description / tagsRouteOptions.summary / description / tags
paths['/...'].<method>.parameters@Param, @QueryParam, @HeaderParam (each with schema: { type: 'string' })
paths['/...'].<method>.requestBody@Body(Class) or RouteOptions.inputClass → Zod → JSON Schema
paths['/...'].<method>.responses['200']RouteOptions.outputClass (wrapped in an array when outputIsArray is true)
paths['/...'].<method>.security@Authorized permissions + non-optional @CurrentUser/@CurrentApiKey kinds
components.securitySchemesopenapi.documentation.components.securitySchemes ∪ authentication.openApiSecuritySchemes
securityopenapi.documentation.security (document-wide)
which document(s) contain an operationRouteOptions.group → @Controller(prefix, { group }) → default, matched against openapi.documents

Path parameters use Express syntax (:id) in your code; the generator rewrites them to OpenAPI syntax ({id}).

Schemas from DTOs

The generator passes each inputClass / outputClass through classToZod and then through Zod's z.toJSONSchema(schema, { target: 'openapi-3.0', unrepresentable: 'any' }). This handles unions, refinements, defaults and discriminated unions out of the box — anything Zod can model.

class CreateUserInput extends BaseSchema {
  @Field(z.string().min(1))
  name!: string

  @Field(z.string().email())
  email!: string

  @Field(z.array(z.string()).optional())
  tags?: string[]
}

class UserOutput extends BaseSchema {
  @Field(z.string())
  id!: string

  @Field(z.string())
  name!: string
}

@Post('/', { inputClass: CreateUserInput, outputClass: UserOutput })
create(@Body(CreateUserInput) input: CreateUserInput) { /* ... */ }

The resulting operation will document a JSON request body (with the right required fields) and a JSON 200 response.

Documenting security schemes

There are two complementary ways to declare schemes:

  1. openapi.documentation.components.securitySchemes — anything you'd write by hand into the OpenAPI document.
  2. authentication.openApiSecuritySchemes — bound to your AuthenticationConfig so the framework knows which scheme covers which principal kind and can attach the right security block to each operation.

The two are merged, with authentication.openApiSecuritySchemes taking precedence for collisions.

authentication: {
  // ...
  openApiSecuritySchemes: {
    bearerAuth: {
      scheme: {
        type: 'http',
        scheme: 'bearer',
        bearerFormat: 'JWT'
      },
      kinds: ['user']
    },
    apiKey: {
      scheme: {
        type: 'apiKey',
        in: 'header',
        name: 'X-API-Key'
      },
      kinds: ['api-key']
    }
  }
}

A route declared as @Authorized({ kinds: ['user'], permissions: ['orders:read'] }) will then get:

{ "security": [{ "bearerAuth": ["orders:read"] }] }

Multiple documents

By default every route ends up in a single document. When one API serves different audiences — a public API and an admin API, for example — declare a group on controllers or routes and configure one document per group with openapi.documents.

@JsonController('/users')
class UsersController { /* no group → "default" */ }

@JsonController('/admin/users', { group: 'admin' })
class AdminUsersController {
  @Get('/') list() { /* group "admin" */ }

  @Post('/reindex', { group: 'internal' }) // route-level override
  reindex() { /* group "internal" */ }
}

await configureApplication(app, {
  controllers: [UsersController, AdminUsersController],
  openapi: {
    documentation: { info: { title: 'My API', version: '1.0.0' } },
    documents: {
      default: {},
      admin: { info: { title: 'My API — Admin', version: '1.0.0' } }
    }
  },
  enableScalar: true
})

This serves two documents, each with its own Scalar UI:

DocumentRoutesJSONScalar
defaultroutes without a group/docs/openapi.json/docs
adminroutes in group admin/docs/admin/openapi.json/docs/admin

The internal group is not referenced by any document, so POST /admin/users/reindex is not exposed in either. The route itself keeps working — groups only affect documentation.

Resolution rules

  • A route's group is RouteOptions.group, then the controller's group, then default.
  • Without openapi.documents, a single document includes every route regardless of group. Adding a group never hides a route until you opt in to documents.
  • Each document includes the groups listed in groups, which defaults to the document's own key. One document can combine groups: { groups: ['default', 'admin'] }.
  • info and security can be overridden per document. components — including security schemes — are shared by all documents.

Paths

KeydocumentPath defaultreferencePath default
defaultopenapi.documentPath (/docs/openapi.json)scalar.referencePath (/docs)
any other<referencePath>/openapi.json<scalar.referencePath>/<key>

Both can be set explicitly per document. Duplicate paths across documents throw at bootstrap.

Programmatic generation per group

generateOpenApiDocument accepts a third argument with the same groups, info and security fields:

const adminDocument = generateOpenApiDocument(controllers, options, {
  groups: ['admin'],
  info: { title: 'My API — Admin', version: '1.0.0' }
})

Inspecting the spec

Once the server is running, the spec is at GET /docs/openapi.json (or your custom path). Pair it with the Scalar UI for an interactive reference.

On this page