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 field | Source |
|---|---|
info | openapi.documentation.info |
paths['/...'].<method>.summary / description / tags | RouteOptions.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.securitySchemes | openapi.documentation.components.securitySchemes ∪ authentication.openApiSecuritySchemes |
security | openapi.documentation.security (document-wide) |
| which document(s) contain an operation | RouteOptions.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:
openapi.documentation.components.securitySchemes— anything you'd write by hand into the OpenAPI document.authentication.openApiSecuritySchemes— bound to yourAuthenticationConfigso the framework knows which scheme covers which principal kind and can attach the rightsecurityblock 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:
| Document | Routes | JSON | Scalar |
|---|---|---|---|
default | routes without a group | /docs/openapi.json | /docs |
admin | routes 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'sgroup, thendefault. - Without
openapi.documents, a single document includes every route regardless of group. Adding agroupnever hides a route until you opt in todocuments. - Each document includes the groups listed in
groups, which defaults to the document's own key. One document can combine groups:{ groups: ['default', 'admin'] }. infoandsecuritycan be overridden per document.components— including security schemes — are shared by all documents.
Paths
| Key | documentPath default | referencePath default |
|---|---|---|
default | openapi.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.