express-openapi-decorators
v0.1.5
Published
Decorator-based Express controllers with OpenAPI specification generation.
Maintainers
Readme
express-openapi-decorators
Decorator-based Express controllers with OpenAPI specification generation.
This library is experimental.
It targets modern Node.js + TypeScript setups (ESM) and relies on decorator metadata (Symbol.metadata). Some OpenAPI features are not covered yet. If there is interest in the package, I’ll extend it.
Features
- Class/method decorators to define Express routes:
@path(),@method(),@middleware() - OpenAPI decorators:
@tag(),@summary(),@description(),@operationId(),@deprecated(),@requestBody(),@parameter(),@query(),@header(),@cookie(),@response() - Register controllers on an Express
apporroutervia metadata - Generate OpenAPI document (
openapi.json) from the same metadata - Optional schema generation for
components.schemasusingts-json-schema-generator - Express-style
/:paramto OpenAPI/{param}path conversion- Supports
/:id(<pattern>)patterns
- Supports
Non-goals (for now)
- Automatic inference of request/response types from handler signatures
- Full OpenAPI surface area (security schemes, callbacks, links, deep parameter modeling, etc.)
- Automatic inference for advanced param sources beyond explicit decorator-based metadata
- Runtime validation (this is routing + docs generation, not a validator)
Requirements
- TypeScript 5.3+
- Decorator metadata support (
Symbol.metadata)- If your runtime doesn’t provide it, you can use the included polyfill.
Install
npm i express-openapi-decoratorsQuick start
1) Add the metadata polyfill (if needed)
Import it once, before loading any decorated classes.
import 'express-openapi-decorators/symbol-metadata-polyfill.mjs';2) Create a controller
A method becomes a route handler only if it has at least one method-level @path().
import type express from 'express';
import { controller, path, method, middleware, tag, summary, description, deprecated, requestBody, query, header, cookie, response } from 'express-openapi-decorators';
@controller()
@path('/users')
@tag('users')
@middleware((req, _res, next) => {
req.headers['x-example'] = '1';
next();
})
export class UserController {
@method('GET')
@path('/:id([0-9]+)')
@summary('Get user by id')
@description('Returns a user by id.')
@deprecated()
@query('includePosts', { type: 'boolean' }, 'Include authored posts')
@header('x-request-id', { type: 'string' }, 'Request correlation id')
@cookie('session', { type: 'string' }, 'Session token')
@response(200, 'User')
@response(404)
async getUserById(req: express.Request, res: express.Response) {
res.json({ id: req.params.id });
}
@method('POST')
@path('/')
@summary('Create a new user')
@requestBody('CreateUserRequest')
@response(200, 'CreateUserResponse')
@response(400)
@response(500)
async createUser(req: express.Request, res: express.Response) {
// ...
}
}3) Register controllers on Express
import express from 'express';
import 'express-openapi-decorators/symbol-metadata-polyfill.mjs';
import { OpenAPI } from 'express-openapi-decorators';
const app = express();
const router = await new OpenAPI().initialize({
autoscanControllersGlob: 'build/**/*Controller.mjs',
schemaComponentsGlob: 'src/**/http-dto/*.d.mts',
baseOpenAPISchema: {
openapi: '3.0.0',
info: {
title: 'REST API DEMO',
version: '1.0.0',
description: 'REST API documentation example app.',
},
servers: [
{ url: 'http://localhost/api' },
{ url: 'https://test.example.com/api' },
{ url: 'https://example.com/api' },
],
},
});
app.use('/api', router);
app.listen(80, () => {
console.log(`HTTP Server running on port 80`);
});initialize() registers controllers onto the provided registrar, or creates and returns an internal express.Router() when registrar is omitted.
OpenAPI.initialize() options
OpenAPI.initialize() accepts these options:
autoscanControllersGlob?: string | string[]Imports matching controller modules and collects@controller()-decorated classes.autoloadControllers?: booleanCollects already-loaded@controller()classes without importing modules.controllers?: object[]Explicit controller instances to register.controllerClasses?: (new (...args: any[]) => any)[]Explicit controller classes to instantiate and register.controllerFactoryMap?: Map<(new (...args: any[]) => any), (Cls: new (...args: any[]) => any) => any>Optional factories for custom controller instantiation.schemaComponentsGlob?: string | string[]Glob(s) for schema component modules used during OpenAPI generation.registrar?: express.Application | express.RouterExpress app/router to register routes on.autoregGetOpenApiSpecOp?: booleanWhentrue(default), also registersGET /openapi.json.baseOpenAPISchema: oas31.OpenAPIObjectBase OpenAPI document to extend.silent?: booleanSuppresses registration and generation logs.
Typical usage patterns:
- Use
autoscanControllersGlobwhen controller modules should be imported automatically. - Use
autoloadControllerswhen modules are already imported elsewhere and you only want to collect decorated classes. - Use
controllers,controllerClasses, andcontrollerFactoryMapwhen you want explicit control over registration and instantiation.
OpenAPI generation
The generator builds an OpenAPI document by walking decorator metadata on controller instances.
Base schema
You provide a base OpenAPI document (the generator clones it and merges paths and optional components.schemas).
import { getOpenAPISchema } from 'express-openapi-decorators';
import type { oas31 } from 'openapi3-ts';
const baseOpenAPISchema: oas31.OpenAPIObject = {
openapi: '3.1.0',
info: {
title: 'My API',
version: '1.0.0',
},
servers: [
{ url: 'http://localhost:3000' },
],
};Generate openapi.json
Using the high-level OpenAPI.initialize() method, an openapi.json is generated when you start your server with the --generate-openapi command-line argument:
node server.mjs --generate-openapiWhen this flag is present, the library generates openapi.json from the configured controllers and base schema, then exits the process. This step is usually needed only once per build/deploy.
openapi.jsonwill be written to the current working directory- if
autoregGetOpenApiSpecOpis enabled,GET /openapi.jsonserves that generated file - if the file does not exist yet, the endpoint responds with
500
Decorators
@path(path: string)
- Class-level: base path prefix(es)
- Method-level: route path(s) relative to the class base path
- Can be applied multiple times (registers multiple endpoints)
@path('/v1')
@path('/v2')
class UserController {
@path('/login')
@path('/auth')
login(req: express.Request, res: express.Response) {}
}@method(method: 'GET' | 'POST' | ...)
- Class-level: default method for handlers without method-level
@method - Method-level: per-handler verb
@middleware(...handlers: express.RequestHandler[])
- Class-level middleware runs before method-level middleware
- Effective chain:
[...classMiddlewares, ...methodMiddlewares]
@tag(...tags: string[])
- Class-level tags are applied to all operations
- Method-level tags are appended
- Deduped with
Set
@summary(text: string)
- Method only
- Sets OpenAPI
summary
@description(text: string)
- Method only
- Sets OpenAPI
description
@operationId(id: string)
- Method only
- Sets OpenAPI
operationId - If omitted, the method name is used (when available)
@deprecated()
- Class or method
- Emits OpenAPI
deprecated: trueon generated operations - On a class, applies to every operation in the controller
@deprecated()
class LegacyController {
@path('/old-endpoint')
oldEndpoint(req: express.Request, res: express.Response) {}
}@requestBody(body: RequestBodyObject | string)
- Method only
stringshorthand resolves to#/components/schemas/<name>- Supports
Name[]for array bodies
@requestBody('CreateNotebookRequest')
@requestBody('Notebook[]')@query(name: string, schemaOrRef: SchemaObject | ReferenceObject | string, description?: string, required = false)
- Method or class
- Class-level query parameters are applied to all operations in the class
- Method-level query parameters override class-level ones with the same name
stringshorthand resolves to#/components/schemas/<name>
@query('limit', { type: 'integer', minimum: 1, maximum: 100 }, 'Page size')
@query('filter', 'UserFilter', 'Optional filter')@header(name: string, schemaOrRef: SchemaObject | ReferenceObject | string, description?: string, required = false)
- Method or class
- Emits an OpenAPI parameter with
in: 'header' - Method-level header parameters override class-level ones with the same name
@header('x-request-id', { type: 'string' }, 'Request correlation id', true)@cookie(name: string, schemaOrRef: SchemaObject | ReferenceObject | string, description?: string, required = false)
- Method or class
- Emits an OpenAPI parameter with
in: 'cookie' - Method-level cookie parameters override class-level ones with the same name
@cookie('session', { type: 'string' }, 'Session token')@parameter(param: ParameterObject)
- Method or class
- Accepts a full OpenAPI
ParameterObject - Method-level parameters override class-level ones with the same
(in, name)pair
@parameter({
name: 'traceId',
in: 'header',
required: false,
description: 'Trace identifier',
schema: { type: 'string' },
})@response(code: number, content?, description?, headers?)
Method or class
Method-level responses are combined with class-level defaults
contentforms:string→ shorthand forapplication/jsonschema refRecord<contentType, schemaName>→ shorthand mapContentObject→ full OpenAPI content
Examples:
@response(200, 'Notebook')
@response(200, { 'application/json': 'Notebook' })
@response(201, {
'application/json': { schema: { $ref: '#/components/schemas/Notebook' } },
}, 'Created')
@response(204)
@response(404)Default descriptions/content exist for some common codes (200/204/400/401/403/404/500) when you omit content.
Schema components generation (components.schemas)
If you provide schemaComponentsGlob, the generator will attempt to build schemas using ts-json-schema-generator.
Convention used by the included implementation:
- one schema per declaration file
- filename (without extension) is the exported symbol name used as the root type
Example layout:
src/user/http-dto/User.d.mts
src/user/http-dto/CreateUserRequest.d.mtsThen:
getOpenAPISchema(baseOpenAPISchema, controllers, 'src/**/http-dto/*.d.mts');This will merge into:
openapi.components.schemas.Useropenapi.components.schemas.CreateUserRequest
Notes:
- The current implementation rewrites
consttoenumand inlines internal#/definitions/*refs, except self-references which stay as component$refs. - This is best-effort; complex TS types may need tweaks.
How routing is discovered
A class method is registered as a route handler only if:
- it has at least one method-level
@path(...)
Resolution rules:
path=<each class @path>+<each method @path>method=<method @method>else<class @method>elseGETmiddlewares=[...class @middleware, ...method @middleware]
Express param pattern support
Express route params like:
/:id→/{id}/:name(a|b|c)→enum: ['a','b','c'](when pattern looks like a pipe-delimited list)/:id([0-9]+)→pattern: '[0-9]+'
License
MIT
