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 +301 -0
- package/package.json +39 -0
- package/scripts/build.mjs +34 -0
- package/scripts/clean.mjs +12 -0
- package/scripts/watch.mjs +5 -0
- package/src/controllers.mts +26 -0
- package/src/decorators.mts +705 -0
- package/src/openapi.mts +122 -0
- package/src/symbol-metadata-polyfill.mts +1 -0
- package/tsconfig.json +114 -0
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,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
|
+
}
|