express-openapi-decorators 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,301 @@
1
+ # express-openapi-decorators
2
+
3
+ Decorator-based Express controllers with OpenAPI specification generation.
4
+
5
+ This library is **experimental**.
6
+
7
+ 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.
8
+
9
+ ## Features
10
+
11
+ - Class/method decorators to define Express routes:
12
+ `@path()`, `@method()`, `@middleware()`
13
+ - OpenAPI decorators:
14
+ `@tag()`, `@summary()`, `@description()`,
15
+ `@operationId()`, `@requestBody()`, `@response()`
16
+ - Register controllers on an Express `app` or `router` via metadata
17
+ - Generate OpenAPI document (`openapi.json`) from the same metadata
18
+ - Optional schema generation for `components.schemas` using `ts-json-schema-generator`
19
+ - Express-style `/:param` to OpenAPI `/{param}` path conversion
20
+ - Supports `/:id(<pattern>)` patterns
21
+
22
+ ## Non-goals (for now)
23
+
24
+ - Automatic inference of request/response types from handler signatures
25
+ - Full OpenAPI surface area (security schemes, callbacks, links, deep parameter modeling, etc.)
26
+ - Advanced param sources (query/header/cookie) beyond basic path-parameter emission
27
+ - Runtime validation (this is routing + docs generation, not a validator)
28
+
29
+ ## Requirements
30
+
31
+ - TypeScript 5.3+
32
+ - Decorator metadata support (`Symbol.metadata`)
33
+ - If your runtime doesn’t provide it, you can use the included polyfill.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ npm i express-openapi-decorators
39
+ ```
40
+
41
+ ## Quick start
42
+
43
+ ### 1) Add the metadata polyfill (if needed)
44
+
45
+ Import it once, before loading any decorated classes.
46
+
47
+ ```ts
48
+ import 'express-openapi-decorators/symbol-metadata-polyfill.mjs';
49
+ ```
50
+
51
+ ### 2) Create a controller
52
+
53
+ A method becomes a route handler only if it has at least one method-level `@path()`.
54
+
55
+ ```ts
56
+ import type express from 'express';
57
+ import { controller, path, method, middleware, tag, summary, description, requestBody, response } from 'express-openapi-decorators';
58
+
59
+ @controller()
60
+ @path('/users')
61
+ @tag('users')
62
+ @middleware((req, _res, next) => {
63
+ req.headers['x-example'] = '1';
64
+ next();
65
+ })
66
+ export class UserController {
67
+ @method('GET')
68
+ @path('/:id([0-9]+)')
69
+ @summary('Get user by id')
70
+ @description('Returns a user by id.')
71
+ @response(200, 'User')
72
+ @response(404)
73
+ async getUserById(req: express.Request, res: express.Response) {
74
+ res.json({ id: req.params.id });
75
+ }
76
+
77
+ @method('POST')
78
+ @path('/')
79
+ @summary('Create a new user')
80
+ @requestBody('CreateUserRequest')
81
+ @response(200, 'CreateUserResponse')
82
+ @response(400)
83
+ @response(500)
84
+ async createUser(req: express.Request, res: express.Response) {
85
+ // ...
86
+ }
87
+ }
88
+ ```
89
+
90
+ ### 3) Register controllers on Express
91
+
92
+ ```ts
93
+ import express from 'express';
94
+ import 'express-openapi-decorators/symbol-metadata-polyfill.mjs';
95
+ import { OpenAPI } from 'express-openapi-decorators';
96
+ import { UserController } from './UserController.mjs';
97
+
98
+ const app = express();
99
+
100
+ const router = await new OpenAPI().initialize({
101
+ controllersGlob: 'build/**/*Controller.mjs',
102
+ schemaComponentsGlob: 'src/**/http-dto/*.d.mts',
103
+ baseOpenAPISchema: {
104
+ openapi: '3.0.0',
105
+ info: {
106
+ title: 'REST API DEMO',
107
+ version: '1.0.0',
108
+ description: 'REST API documentation example app.',
109
+ },
110
+ servers: [
111
+ { url: 'http://localhost/api' },
112
+ { url: 'https://test.example.com/api' },
113
+ { url: 'https://example.com/api' },
114
+ ],
115
+ },
116
+ });
117
+
118
+ app.use('/api', router);
119
+
120
+ app.listen(80, () => {
121
+ console.log(`HTTP Server running on port 80`);
122
+ });
123
+ ```
124
+
125
+ ## OpenAPI generation
126
+
127
+ The generator builds an OpenAPI document by walking decorator metadata on controller instances.
128
+
129
+ ### Base schema
130
+
131
+ You provide a base OpenAPI document (the generator clones it and merges `paths` and optional `components.schemas`).
132
+
133
+ ```ts
134
+ import { getOpenAPISchema } from 'express-openapi-decorators';
135
+ import type { oas31 } from 'openapi3-ts';
136
+
137
+ const baseOpenAPISchema: oas31.OpenAPIObject = {
138
+ openapi: '3.1.0',
139
+ info: {
140
+ title: 'My API',
141
+ version: '1.0.0',
142
+ },
143
+ servers: [
144
+ { url: 'http://localhost:3000' },
145
+ ],
146
+ };
147
+ ```
148
+
149
+ ### Generate `openapi.json`
150
+
151
+ Using the high-level `OpenAPI.initialize()` method, an `openapi.json` is automatically
152
+ generated when you start your server with `--generate-openapi` command-line argument:
153
+
154
+ ```sh
155
+ node server.mjs --generate-openapi
156
+ ```
157
+
158
+ When the file is generated the server exits. This step usually required only once per build/deploy.
159
+
160
+ * `openapi.json` will be written to the current working directory
161
+ * if you enabled auto-serving, `GET /openapi.json` can serve it
162
+
163
+ ## Decorators
164
+
165
+ ### `@path(path: string)`
166
+
167
+ * Class-level: base path prefix(es)
168
+ * Method-level: route path(s) relative to the class base path
169
+ * Can be applied multiple times (registers multiple endpoints)
170
+
171
+ ```ts
172
+ @path('/v1')
173
+ @path('/v2')
174
+ class UserController {
175
+ @path('/login')
176
+ @path('/auth')
177
+ login(req: express.Request, res: express.Response) {}
178
+ }
179
+ ```
180
+
181
+ ### `@method(method: 'GET' | 'POST' | ...)`
182
+
183
+ * Class-level: default method for handlers without method-level `@method`
184
+ * Method-level: per-handler verb
185
+
186
+ ### `@middleware(...handlers: express.RequestHandler[])`
187
+
188
+ * Class-level middleware runs before method-level middleware
189
+ * Effective chain: `[...classMiddlewares, ...methodMiddlewares]`
190
+
191
+ ### `@tag(...tags: string[])`
192
+
193
+ * Class-level tags are applied to all operations
194
+ * Method-level tags are appended
195
+ * Deduped with `Set`
196
+
197
+ ### `@summary(text: string)`
198
+
199
+ * Method only
200
+ * Sets OpenAPI `summary`
201
+
202
+ ### `@description(text: string)`
203
+
204
+ * Method only
205
+ * Sets OpenAPI `description`
206
+
207
+ ### `@operationId(id: string)`
208
+
209
+ * Method only
210
+ * Sets OpenAPI `operationId`
211
+ * If omitted, the method name is used (when available)
212
+
213
+ ### `@requestBody(body: RequestBodyObject | string)`
214
+
215
+ * Method only
216
+ * `string` shorthand resolves to `#/components/schemas/<name>`
217
+ * Supports `Name[]` for array bodies
218
+
219
+ ```ts
220
+ @requestBody('CreateNotebookRequest')
221
+ @requestBody('Notebook[]')
222
+ ```
223
+
224
+ ### `@response(code: number, content?, description?, headers?)`
225
+
226
+ * Method or class
227
+ * Method-level responses are combined with class-level defaults
228
+ * `content` forms:
229
+
230
+ * `string` → shorthand for `application/json` schema ref
231
+ * `Record<contentType, schemaName>` → shorthand map
232
+ * `ContentObject` → full OpenAPI content
233
+
234
+ Examples:
235
+
236
+ ```ts
237
+ @response(200, 'Notebook')
238
+ @response(200, { 'application/json': 'Notebook' })
239
+ @response(201, {
240
+ 'application/json': { schema: { $ref: '#/components/schemas/Notebook' } },
241
+ }, 'Created')
242
+ @response(204)
243
+ @response(404)
244
+ ```
245
+
246
+ Default descriptions/content exist for some common codes (200/204/400/401/403/404/500) when you omit `content`.
247
+
248
+ ## Schema components generation (`components.schemas`)
249
+
250
+ If you provide `schemaComponentsGlob`, the generator will attempt to build schemas using `ts-json-schema-generator`.
251
+
252
+ Convention used by the included implementation:
253
+
254
+ * one schema per declaration file
255
+ * filename (without extension) is the exported symbol name used as the root type
256
+
257
+ Example layout:
258
+
259
+ ```
260
+ src/user/http-dto/User.d.mts
261
+ src/user/http-dto/CreateUserRequest.d.mts
262
+ ```
263
+
264
+ Then:
265
+
266
+ ```ts
267
+ getOpenAPISchema(baseOpenAPISchema, controllers, 'src/**/http-dto/*.d.mts');
268
+ ```
269
+
270
+ This will merge into:
271
+
272
+ * `openapi.components.schemas.User`
273
+ * `openapi.components.schemas.CreateUserRequest`
274
+
275
+ Notes:
276
+
277
+ * The current implementation rewrites `const` to `enum` and inlines internal `#/definitions/*` refs.
278
+ * This is best-effort; complex TS types may need tweaks.
279
+
280
+ ## How routing is discovered
281
+
282
+ A class method is registered as a route handler only if:
283
+
284
+ * it has at least one method-level `@path(...)`
285
+
286
+ Resolution rules:
287
+
288
+ * `path` = `<each class @path>` + `<each method @path>`
289
+ * `method` = `<method @method>` else `<class @method>` else `GET`
290
+ * `middlewares` = `[...class @middleware, ...method @middleware]`
291
+
292
+ ## Express param pattern support
293
+
294
+ Express route params like:
295
+
296
+ * `/:id` → `/{id}`
297
+ * `/:name(a|b|c)` → `enum: ['a','b','c']` (when pattern looks like a pipe-delimited list)
298
+ * `/:id([0-9]+)` → `pattern: '[0-9]+'`
299
+
300
+ ## License
301
+ MIT
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "express-openapi-decorators",
3
+ "version": "0.1.0",
4
+ "description": "Decorator-based Express controllers with OpenAPI specification generation.",
5
+ "keywords": [
6
+ "express",
7
+ "openapi",
8
+ "swagger",
9
+ "controller",
10
+ "generator",
11
+ "decorator"
12
+ ],
13
+ "homepage": "https://github.com/lionel87/express-openapi-decorators#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/lionel87/express-openapi-decorators/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/lionel87/express-openapi-decorators.git"
20
+ },
21
+ "license": "MIT",
22
+ "author": "László BULIK",
23
+ "type": "commonjs",
24
+ "main": "index.js",
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "prepublishOnly": "echo \"Please use npm run publish to publish this package to npm.\"; exit 1"
28
+ },
29
+ "dependencies": {
30
+ "glob": "^13.0.1",
31
+ "ts-json-schema-generator": "^2.5.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^25.2.1",
35
+ "chokidar": "^5.0.0",
36
+ "openapi3-ts": "^4.5.0",
37
+ "typescript": "^5.9.3"
38
+ }
39
+ }
@@ -0,0 +1,34 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { rmSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { normalize } from 'node:path';
4
+ import esbuild from 'esbuild';
5
+
6
+ try {
7
+ rmSync('build', { force: true, recursive: true });
8
+ execSync(normalize('./node_modules/.bin/tsc'), { stdio: 'inherit' });
9
+ renameSync('build/index.d.mts', 'index.d.mts');
10
+ const dmts = readFileSync('index.d.mts', 'utf-8');
11
+ const dcts = dmts
12
+ .replace('export default function buildTree', 'declare function buildTree')
13
+ .replace('export {};', 'declare const _default: { default: typeof buildTree };\nexport = _default;');
14
+ writeFileSync('index.d.cts', dcts);
15
+ } catch (error) {
16
+ console.error(error);
17
+ console.error('Build failed.');
18
+ } finally {
19
+ rmSync('build', { force: true, recursive: true });
20
+ }
21
+
22
+ esbuild.buildSync({
23
+ format: 'esm',
24
+ outdir: '.',
25
+ outExtension: { '.js': '.mjs' },
26
+ entryPoints: ['src/index.mts'],
27
+ });
28
+
29
+ esbuild.buildSync({
30
+ format: 'cjs',
31
+ outdir: '.',
32
+ outExtension: { '.js': '.cjs' },
33
+ entryPoints: ['src/index.mts'],
34
+ });
@@ -0,0 +1,12 @@
1
+ import { rmSync, readdirSync } from 'node:fs';
2
+
3
+ const rm = (dir) => rmSync(dir, { force: true, recursive: true });
4
+
5
+ rm('coverage');
6
+ rm('build');
7
+
8
+ for (const file of readdirSync('.')) {
9
+ if (!file.startsWith('.') && ['.js', '.d.ts', '.cjs', '.d.cts', '.mjs', '.d.mts'].some(x => file.endsWith(x))) {
10
+ rm(file);
11
+ }
12
+ }
@@ -0,0 +1,5 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { watch } from 'chokidar';
3
+
4
+ watch('src', { ignoreInitial: false })
5
+ .addListener('all', () => execSync('node scripts/build.js', { stdio: 'inherit' }));
@@ -0,0 +1,26 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import { globSync } from 'glob';
3
+
4
+ const CONTROLLER_REGISTRY = new Set<(new (...args: any[]) => any)>();
5
+
6
+ export function controller() {
7
+ return function (target: new (...args: any[]) => any, context: ClassDecoratorContext) {
8
+ if (context.kind !== 'class') throw new Error('This decorator can only be used on classes.');
9
+ CONTROLLER_REGISTRY.add(target);
10
+ };
11
+ }
12
+
13
+ export function getControllerClasses(): (new (...args: any[]) => any)[] {
14
+ return Array.from(CONTROLLER_REGISTRY);
15
+ }
16
+
17
+ export async function scanControllerClasses(patterns: string | string[]): Promise<(new (...args: any[]) => any)[]> {
18
+ const files = globSync(patterns, { absolute: true });
19
+
20
+ for (const file of files) {
21
+ const url = pathToFileURL(file).href;
22
+ await import(url);
23
+ }
24
+
25
+ return getControllerClasses();
26
+ }