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
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { createGenerator } from 'ts-json-schema-generator';
|
|
3
|
+
import { globSync } from 'glob';
|
|
4
|
+
import type express from 'express';
|
|
5
|
+
import type { oas31 } from 'openapi3-ts';
|
|
6
|
+
|
|
7
|
+
// key for class level metadata maps
|
|
8
|
+
const CLASS_METADATA = Symbol('class-metadata');
|
|
9
|
+
|
|
10
|
+
/* ---------------- decorators ---------------- */
|
|
11
|
+
|
|
12
|
+
type MethodMetadata =
|
|
13
|
+
| 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD'
|
|
14
|
+
| 'OPTIONS' | 'PATCH' | 'TRACE' /* | 'CONNECT' */;
|
|
15
|
+
const METHOD = Symbol('method');
|
|
16
|
+
/**
|
|
17
|
+
* Sets the HTTP method metadata for routing.
|
|
18
|
+
*
|
|
19
|
+
* - When applied to a method: defines the HTTP verb for that route handler.
|
|
20
|
+
* - When applied to a class: defines the default HTTP verb for handlers that don't specify one explicitly.
|
|
21
|
+
*/
|
|
22
|
+
export function method(method: MethodMetadata) {
|
|
23
|
+
return function (
|
|
24
|
+
target: (new (...args: any[]) => any) | ((req: express.Request, res: express.Response) => void),
|
|
25
|
+
context: ClassDecoratorContext | ClassMethodDecoratorContext
|
|
26
|
+
) {
|
|
27
|
+
if (context.kind !== 'class' && context.kind !== 'method') throw new Error('This decorator can only be used on classes and class methods.');
|
|
28
|
+
if (!context.metadata) throw new Error('This decorator does not work without decorator metadata support.');
|
|
29
|
+
|
|
30
|
+
context.metadata[METHOD] ??= new Map<string | symbol, unknown>();
|
|
31
|
+
const map = context.metadata[METHOD] as Map<string | symbol, unknown>;
|
|
32
|
+
const key = context.kind === 'class' ? CLASS_METADATA : context.name;
|
|
33
|
+
|
|
34
|
+
if (map.has(key)) throw new Error('This decorator may be applied at most once per method.');
|
|
35
|
+
|
|
36
|
+
map.set(key, method);
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type PathMetadata = string;
|
|
41
|
+
const PATH = Symbol('path');
|
|
42
|
+
/**
|
|
43
|
+
* Sets the route path metadata.
|
|
44
|
+
* Usually it is a good idea to start your path with a `/` character or the path may not work correctly.
|
|
45
|
+
*
|
|
46
|
+
* - When applied to a method: defines the route path relative to the controller/base path.
|
|
47
|
+
* - When applied to a class: defines the controller/base path prefix for all handlers in the class.
|
|
48
|
+
*
|
|
49
|
+
* Note: multiple `@path(...)` decorators may be used to register the same handler on multiple URLs.
|
|
50
|
+
*
|
|
51
|
+
* Example:
|
|
52
|
+
* ```ts
|
|
53
|
+
* \@path('/v1')
|
|
54
|
+
* \@path('/v2')
|
|
55
|
+
* class User {
|
|
56
|
+
* \@path('/login')
|
|
57
|
+
* \@path('/auth')
|
|
58
|
+
* loginFn(...) { ... }
|
|
59
|
+
* }
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* The `loginFn` handler will be available at 4 endpoints:
|
|
63
|
+
* - `/v1/login`
|
|
64
|
+
* - `/v1/auth`
|
|
65
|
+
* - `/v2/login`
|
|
66
|
+
* - `/v2/auth`
|
|
67
|
+
*/
|
|
68
|
+
export function path(path: PathMetadata) {
|
|
69
|
+
return function (
|
|
70
|
+
target: (new (...args: any[]) => any) | ((req: express.Request, res: express.Response) => void),
|
|
71
|
+
context: ClassDecoratorContext | ClassMethodDecoratorContext
|
|
72
|
+
) {
|
|
73
|
+
if (context.kind !== 'class' && context.kind !== 'method') throw new Error('This decorator can only be used on classes and class methods.');
|
|
74
|
+
if (!context.metadata) throw new Error('This decorator does not work without decorator metadata support.');
|
|
75
|
+
|
|
76
|
+
context.metadata[PATH] ??= new Map<string | symbol, unknown>();
|
|
77
|
+
const map = context.metadata[PATH] as Map<string | symbol, unknown>;
|
|
78
|
+
const key = context.kind === 'class' ? CLASS_METADATA : context.name;
|
|
79
|
+
|
|
80
|
+
let list = map.get(key) as PathMetadata[] | undefined;
|
|
81
|
+
if (!list) map.set(key, list = []);
|
|
82
|
+
list.push(path);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type MiddlewareMetadata = express.RequestHandler;
|
|
87
|
+
const MIDDLEWARE = Symbol('middleware');
|
|
88
|
+
/**
|
|
89
|
+
* Registers middleware for the decorated target.
|
|
90
|
+
*
|
|
91
|
+
* - When applied to a method: middleware runs for that specific handler.
|
|
92
|
+
* - When applied to a class: middleware runs for every handler in the class (in addition to any method-level middleware).
|
|
93
|
+
*
|
|
94
|
+
* Class middlewares precedes the middlewares on methods.
|
|
95
|
+
*/
|
|
96
|
+
export function middleware(...middlewares: MiddlewareMetadata[]) {
|
|
97
|
+
return function (
|
|
98
|
+
target: (new (...args: any[]) => any) | ((req: express.Request, res: express.Response) => void),
|
|
99
|
+
context: ClassDecoratorContext | ClassMethodDecoratorContext
|
|
100
|
+
) {
|
|
101
|
+
if (context.kind !== 'class' && context.kind !== 'method') throw new Error('This decorator can only be used on classes and class methods.');
|
|
102
|
+
if (!context.metadata) throw new Error('This decorator does not work without decorator metadata support.');
|
|
103
|
+
|
|
104
|
+
context.metadata[MIDDLEWARE] ??= new Map<string | symbol, unknown>();
|
|
105
|
+
const map = context.metadata[MIDDLEWARE] as Map<string | symbol, unknown>;
|
|
106
|
+
const key = context.kind === 'class' ? CLASS_METADATA : context.name;
|
|
107
|
+
|
|
108
|
+
let list = map.get(key) as MiddlewareMetadata[] | undefined;
|
|
109
|
+
if (!list) map.set(key, list = []);
|
|
110
|
+
list.push(...middlewares);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type TagMetadata = string;
|
|
115
|
+
const TAG = Symbol('tag');
|
|
116
|
+
/**
|
|
117
|
+
* Adds an OpenAPI tag to the operation.
|
|
118
|
+
*
|
|
119
|
+
* - When applied to a method: appends the tag to that operation.
|
|
120
|
+
* - When applied to a class: applies the tag to all operations in the class.
|
|
121
|
+
*/
|
|
122
|
+
export function tag(...tags: TagMetadata[]) {
|
|
123
|
+
return function (
|
|
124
|
+
target: (new (...args: any[]) => any) | ((req: express.Request, res: express.Response) => void),
|
|
125
|
+
context: ClassDecoratorContext | ClassMethodDecoratorContext
|
|
126
|
+
) {
|
|
127
|
+
if (context.kind !== 'class' && context.kind !== 'method') throw new Error('This decorator can only be used on classes and class methods.');
|
|
128
|
+
if (!context.metadata) throw new Error('This decorator does not work without decorator metadata support.');
|
|
129
|
+
|
|
130
|
+
context.metadata[TAG] ??= new Map<string | symbol, unknown>();
|
|
131
|
+
const map = context.metadata[TAG] as Map<string | symbol, unknown>;
|
|
132
|
+
const key = context.kind === 'class' ? CLASS_METADATA : context.name;
|
|
133
|
+
|
|
134
|
+
let list = map.get(key) as TagMetadata[] | undefined;
|
|
135
|
+
if (!list) map.set(key, list = []);
|
|
136
|
+
list.push(...tags);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
type SummaryMetadata = string;
|
|
141
|
+
const SUMMARY = Symbol('summary');
|
|
142
|
+
/**
|
|
143
|
+
* Sets the OpenAPI `summary` for the decorated route handler.
|
|
144
|
+
*
|
|
145
|
+
* This decorator is valid on class methods only. Use it for a short, one-line description
|
|
146
|
+
* of what the operation does.
|
|
147
|
+
*/
|
|
148
|
+
export function summary(summary: SummaryMetadata) {
|
|
149
|
+
return function (
|
|
150
|
+
target: (req: express.Request, res: express.Response) => void,
|
|
151
|
+
context: ClassMethodDecoratorContext
|
|
152
|
+
) {
|
|
153
|
+
if (context.kind !== 'method') throw new Error('This decorator can only be used on class methods.');
|
|
154
|
+
if (!context.metadata) throw new Error('This decorator does not work without decorator metadata support.');
|
|
155
|
+
|
|
156
|
+
context.metadata[SUMMARY] ??= new Map<string | symbol, unknown>();
|
|
157
|
+
const map = context.metadata[SUMMARY] as Map<string | symbol, unknown>;
|
|
158
|
+
|
|
159
|
+
if (map.has(context.name)) throw new Error('This decorator may be applied at most once per method.');
|
|
160
|
+
|
|
161
|
+
map.set(context.name, summary);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type DescriptionMetadata = string;
|
|
166
|
+
const DESCRIPTION = Symbol('description');
|
|
167
|
+
/**
|
|
168
|
+
* Sets the OpenAPI `description` for the decorated route handler.
|
|
169
|
+
*
|
|
170
|
+
* This decorator is valid on class methods only. Use it for a detailed explanation of the
|
|
171
|
+
* operation's behavior, constraints, and edge cases.
|
|
172
|
+
*/
|
|
173
|
+
export function description(description: DescriptionMetadata) {
|
|
174
|
+
return function (
|
|
175
|
+
target: (req: express.Request, res: express.Response) => void,
|
|
176
|
+
context: ClassMethodDecoratorContext
|
|
177
|
+
) {
|
|
178
|
+
if (context.kind !== 'method') throw new Error('This decorator can only be used on class methods.');
|
|
179
|
+
if (!context.metadata) throw new Error('This decorator does not work without decorator metadata support.');
|
|
180
|
+
|
|
181
|
+
context.metadata[DESCRIPTION] ??= new Map<string | symbol, unknown>();
|
|
182
|
+
const map = context.metadata[DESCRIPTION] as Map<string | symbol, unknown>;
|
|
183
|
+
|
|
184
|
+
if (map.has(context.name)) throw new Error('This decorator may be applied at most once per method.');
|
|
185
|
+
|
|
186
|
+
map.set(context.name, description);
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
type OperationIdMetadata = string;
|
|
191
|
+
const OPERATION_ID = Symbol('operationId');
|
|
192
|
+
/**
|
|
193
|
+
* Sets the OpenAPI `operationId` for the decorated route handler.
|
|
194
|
+
*
|
|
195
|
+
* This decorator is valid on class methods only. Use it to assign a stable, unique identifier
|
|
196
|
+
* for the operation (e.g. for SDK generation, client method names, and tooling references).
|
|
197
|
+
*/
|
|
198
|
+
export function operationId(operationId: OperationIdMetadata) {
|
|
199
|
+
return function (
|
|
200
|
+
target: (req: express.Request, res: express.Response) => void,
|
|
201
|
+
context: ClassMethodDecoratorContext
|
|
202
|
+
) {
|
|
203
|
+
if (context.kind !== 'method') throw new Error('This decorator can only be used on class methods.');
|
|
204
|
+
if (!context.metadata) throw new Error('This decorator does not work without decorator metadata support.');
|
|
205
|
+
|
|
206
|
+
context.metadata[OPERATION_ID] ??= new Map<string | symbol, unknown>();
|
|
207
|
+
const map = context.metadata[OPERATION_ID] as Map<string | symbol, unknown>;
|
|
208
|
+
|
|
209
|
+
if (map.has(context.name)) throw new Error('This decorator may be applied at most once per method.');
|
|
210
|
+
|
|
211
|
+
map.set(context.name, operationId);
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
type RequestBodyMetadata = oas31.RequestBodyObject | string;
|
|
216
|
+
const REQUEST_BODY = Symbol('requestBody');
|
|
217
|
+
/**
|
|
218
|
+
* Defines the OpenAPI request body for the decorated route handler.
|
|
219
|
+
*
|
|
220
|
+
* This decorator is valid on class methods only.
|
|
221
|
+
*
|
|
222
|
+
* Accepts either:
|
|
223
|
+
* - an inline OpenAPI 3.1 `RequestBodyObject`, or
|
|
224
|
+
* - a schema name as `string`, which is resolved to a `$ref` under `#/components/schemas/<name>`.
|
|
225
|
+
*
|
|
226
|
+
* If the schema name ends with `[]`, it is interpreted as an array of that schema
|
|
227
|
+
* (i.e. `#/components/schemas/<name-without-brackets>`).
|
|
228
|
+
*/
|
|
229
|
+
export function requestBody(requestBody: RequestBodyMetadata) {
|
|
230
|
+
return function (
|
|
231
|
+
target: (req: express.Request, res: express.Response) => void,
|
|
232
|
+
context: ClassMethodDecoratorContext
|
|
233
|
+
) {
|
|
234
|
+
if (context.kind !== 'method') throw new Error('This decorator can only be used on class methods.');
|
|
235
|
+
if (!context.metadata) throw new Error('This decorator does not work without decorator metadata support.');
|
|
236
|
+
|
|
237
|
+
context.metadata[REQUEST_BODY] ??= new Map<string | symbol, unknown>();
|
|
238
|
+
const map = context.metadata[REQUEST_BODY] as Map<string | symbol, unknown>;
|
|
239
|
+
|
|
240
|
+
if (map.has(context.name)) throw new Error('This decorator may be applied at most once per method.');
|
|
241
|
+
|
|
242
|
+
map.set(context.name, requestBody);
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
type ResponseCodeMetadata = number;
|
|
247
|
+
type ResponseContentMetadata = oas31.ContentObject | Record<string, string> | string;
|
|
248
|
+
type ResponseDescriptionMetadata = string;
|
|
249
|
+
type ResponseHeadersMetadata = oas31.HeadersObject;
|
|
250
|
+
type ResponseMetadata = {
|
|
251
|
+
code: ResponseCodeMetadata;
|
|
252
|
+
content?: ResponseContentMetadata;
|
|
253
|
+
description?: ResponseDescriptionMetadata;
|
|
254
|
+
headers?: ResponseHeadersMetadata;
|
|
255
|
+
};
|
|
256
|
+
const RESPONSE = Symbol('response');
|
|
257
|
+
/**
|
|
258
|
+
* Defines an OpenAPI response for the decorated route handler.
|
|
259
|
+
*
|
|
260
|
+
* - When applied to a method: registers a response entry (status code + schema/content/description).
|
|
261
|
+
* - When applied to a class: registers default responses applied to operations that don't specify them explicitly.
|
|
262
|
+
*
|
|
263
|
+
* Parameters:
|
|
264
|
+
* - `code` is the HTTP status code.
|
|
265
|
+
* - `description` is the human-readable response description (optional).
|
|
266
|
+
* - `headers` defines OpenAPI response headers (optional).
|
|
267
|
+
*
|
|
268
|
+
* `content` can be provided in multiple forms:
|
|
269
|
+
* - `oas31.ContentObject`: full, explicit OpenAPI content definition.
|
|
270
|
+
* - `Record<string, string>`: a map where the key is the content type (e.g. `application/json`)
|
|
271
|
+
* and the value is a schema reference shorthand resolved under `#/components/schemas/<name>`.
|
|
272
|
+
* If the schema name ends with `[]`, it is interpreted as an array of that schema.
|
|
273
|
+
* - `string`: shorthand for an `application/json` response whose schema is resolved under
|
|
274
|
+
* `#/components/schemas/<name>` (with the same `[]` array rule).
|
|
275
|
+
*
|
|
276
|
+
* Note: multiple `@response(...)` decorators may be used to describe multiple status codes/content types.
|
|
277
|
+
*/
|
|
278
|
+
export function response(
|
|
279
|
+
code: ResponseCodeMetadata,
|
|
280
|
+
content?: ResponseContentMetadata,
|
|
281
|
+
description?: ResponseDescriptionMetadata,
|
|
282
|
+
headers?: ResponseHeadersMetadata
|
|
283
|
+
) {
|
|
284
|
+
return function (
|
|
285
|
+
target: (new (...args: any[]) => any) | ((req: express.Request, res: express.Response) => void),
|
|
286
|
+
context: ClassDecoratorContext | ClassMethodDecoratorContext
|
|
287
|
+
) {
|
|
288
|
+
if (context.kind !== 'class' && context.kind !== 'method') throw new Error('This decorator can only be used on classes and class methods.');
|
|
289
|
+
if (!context.metadata) throw new Error('This decorator does not work without decorator metadata support.');
|
|
290
|
+
|
|
291
|
+
context.metadata[RESPONSE] ??= new Map<string | symbol, unknown>();
|
|
292
|
+
const map = context.metadata[RESPONSE] as Map<string | symbol, unknown>;
|
|
293
|
+
const key = context.kind === 'class' ? CLASS_METADATA : context.name;
|
|
294
|
+
|
|
295
|
+
let list = map.get(key) as ResponseMetadata[] | undefined;
|
|
296
|
+
if (!list) map.set(key, list = []);
|
|
297
|
+
list.push({ code, content, description, headers });
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/* ---------------- registration and schema generation ---------------- */
|
|
302
|
+
|
|
303
|
+
export type RegisteredControllerInfo = {
|
|
304
|
+
method: MethodMetadata;
|
|
305
|
+
path: PathMetadata;
|
|
306
|
+
middlewares: MiddlewareMetadata[];
|
|
307
|
+
handler: express.RequestHandler;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Registers one or more controller instances on an Express `Application` or `Router` using decorator metadata.
|
|
312
|
+
*
|
|
313
|
+
* Route discovery:
|
|
314
|
+
* - A class method is considered a route handler only if it has at least one method-level `@path(...)` decorator.
|
|
315
|
+
*
|
|
316
|
+
* Route configuration:
|
|
317
|
+
* - Class-level `@path(...)` values act as base path prefixes. Each method-level path is combined with each class-level path.
|
|
318
|
+
* - HTTP method is resolved from method-level `@method(...)`, otherwise falls back to class-level `@method(...)`,
|
|
319
|
+
* otherwise defaults to `GET`.
|
|
320
|
+
* - Middlewares are resolved as: `[...classMiddlewares, ...methodMiddlewares]`, preserving that order.
|
|
321
|
+
*
|
|
322
|
+
* Requirements:
|
|
323
|
+
* - Requires decorator metadata support (`Symbol.metadata`). If not available, an error is thrown.
|
|
324
|
+
* - The function expects `controllers` to be class instances (not constructors).
|
|
325
|
+
*
|
|
326
|
+
* @param registrar Express `Application` or `Router` to register the resolved routes on.
|
|
327
|
+
* @param controllers A controller instance or an array of controller instances.
|
|
328
|
+
* @returns A list of resolved route registrations (method, path, middlewares, handler) in the order they were registered.
|
|
329
|
+
*/
|
|
330
|
+
export function registerControllers(
|
|
331
|
+
registrar: express.Application | express.Router,
|
|
332
|
+
controllers: Object | Object[],
|
|
333
|
+
) {
|
|
334
|
+
if (!('metadata' in Symbol) || typeof Symbol.metadata !== 'symbol') throw new Error('Decorator metadata is not available: Symbol.metadata is missing.');
|
|
335
|
+
|
|
336
|
+
const info: RegisteredControllerInfo[] = [];
|
|
337
|
+
|
|
338
|
+
for (const controller of Array.isArray(controllers) ? controllers : [controllers]) {
|
|
339
|
+
const md = (controller.constructor as any)?.[Symbol.metadata] as DecoratorMetadataObject | undefined;
|
|
340
|
+
if (!md) continue;
|
|
341
|
+
|
|
342
|
+
const pathMap = md[PATH] as Map<string | symbol, PathMetadata[]> | undefined;
|
|
343
|
+
if (!pathMap?.size) continue; // no @path anywhere => nothing to register
|
|
344
|
+
const methodMap = md[METHOD] as Map<string | symbol, MethodMetadata> | undefined;
|
|
345
|
+
const middlewareMap = md[MIDDLEWARE] as Map<string | symbol, MiddlewareMetadata[]> | undefined;
|
|
346
|
+
|
|
347
|
+
const classPaths = pathMap?.get(CLASS_METADATA) ?? [''] as PathMetadata[];
|
|
348
|
+
const classMethod = methodMap?.get(CLASS_METADATA) ?? 'GET' as MethodMetadata;
|
|
349
|
+
const classMiddlewares = middlewareMap?.get(CLASS_METADATA) ?? [] as MiddlewareMetadata[];
|
|
350
|
+
|
|
351
|
+
for (const [handlerName, methodPaths] of pathMap?.entries() ?? []) {
|
|
352
|
+
if (handlerName === CLASS_METADATA) continue;
|
|
353
|
+
for (const classPath of classPaths) {
|
|
354
|
+
for (const methodPath of methodPaths) {
|
|
355
|
+
const path = classPath + methodPath;
|
|
356
|
+
const method = methodMap?.get(handlerName) ?? classMethod ?? 'GET';
|
|
357
|
+
const handler = ((controller as any)[handlerName] as Function).bind(controller);
|
|
358
|
+
const middlewares = [
|
|
359
|
+
...classMiddlewares,
|
|
360
|
+
...(middlewareMap?.get(handlerName) ?? [])
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
info.push({ method, path, middlewares, handler });
|
|
364
|
+
|
|
365
|
+
const verb = method.toLowerCase() as Lowercase<typeof method>;
|
|
366
|
+
(registrar as express.Application)?.[verb](path, ...middlewares, handler);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return info;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Generates an OpenAPI document for the given controllers using decorator metadata.
|
|
376
|
+
*
|
|
377
|
+
* Paths and operations:
|
|
378
|
+
* - A class method is considered an operation only if it has at least one method-level `@path(...)` decorator.
|
|
379
|
+
* - Class-level `@path(...)` values act as base path prefixes. Each method-level path is combined with each class-level path.
|
|
380
|
+
* - HTTP method is resolved from method-level `@method(...)`, otherwise falls back to class-level `@method(...)`,
|
|
381
|
+
* otherwise defaults to `GET`.
|
|
382
|
+
*
|
|
383
|
+
* Express-style path conversion:
|
|
384
|
+
* - Express parameters like `/:id` or `/:id(<pattern>)` are converted to OpenAPI templated paths: `/{id}`.
|
|
385
|
+
* - `/:name(<a|b|c>)` is emitted as an enum when the pattern looks like a pipe-delimited value list.
|
|
386
|
+
* - Otherwise the pattern is emitted as a string `pattern` (with escapes normalized).
|
|
387
|
+
*
|
|
388
|
+
* Components generation:
|
|
389
|
+
* - The returned document is a clone of `baseOpenAPI` with generated `paths` merged in.
|
|
390
|
+
* - If `componentsPattern` is provided, schema definitions are generated and merged into `components.schemas`.
|
|
391
|
+
* - If generation fails, an error is thrown.
|
|
392
|
+
*
|
|
393
|
+
* Requirements:
|
|
394
|
+
* - Requires decorator metadata support (`Symbol.metadata`). If not available, an error is thrown.
|
|
395
|
+
*
|
|
396
|
+
* @param baseOpenAPISchema Base OpenAPI 3.1 document to clone and extend.
|
|
397
|
+
* @param controllers A controller instance or an array of controller instances.
|
|
398
|
+
* @param componentsPattern Optional glob/pattern(s) pointing to type definitions used to generate `components.schemas`.
|
|
399
|
+
* @returns A fully formed OpenAPI 3.1 document containing generated `paths` and optional `components.schemas`.
|
|
400
|
+
*/
|
|
401
|
+
export function getOpenAPISchema(
|
|
402
|
+
baseOpenAPISchema: oas31.OpenAPIObject,
|
|
403
|
+
controllers: Object | Object[],
|
|
404
|
+
componentsPattern?: string | string[]
|
|
405
|
+
) {
|
|
406
|
+
if (!('metadata' in Symbol) || typeof Symbol.metadata !== 'symbol') throw new Error('Decorator metadata is not available: Symbol.metadata is missing.');
|
|
407
|
+
|
|
408
|
+
const openapi: oas31.OpenAPIObject = structuredClone(baseOpenAPISchema);
|
|
409
|
+
if (!openapi.paths) {
|
|
410
|
+
openapi.paths = {};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
for (const controller of Array.isArray(controllers) ? controllers : [controllers]) {
|
|
414
|
+
const md = (controller.constructor as any)?.[Symbol.metadata] as DecoratorMetadataObject | undefined;
|
|
415
|
+
if (!md) continue;
|
|
416
|
+
|
|
417
|
+
const pathMap = md[PATH] as Map<string | symbol, PathMetadata[]> | undefined;
|
|
418
|
+
if (!pathMap?.size) continue; // no @path anywhere => nothing to register
|
|
419
|
+
const methodMap = md[METHOD] as Map<string | symbol, MethodMetadata> | undefined;
|
|
420
|
+
const tagMap = md[TAG] as Map<string | symbol, TagMetadata[]> | undefined;
|
|
421
|
+
const operationIdMap = md[OPERATION_ID] as Map<string | symbol, OperationIdMetadata> | undefined;
|
|
422
|
+
const summaryMap = md[SUMMARY] as Map<string | symbol, SummaryMetadata> | undefined;
|
|
423
|
+
const descriptionMap = md[DESCRIPTION] as Map<string | symbol, DescriptionMetadata> | undefined;
|
|
424
|
+
const requestBodyMap = md[REQUEST_BODY] as Map<string | symbol, RequestBodyMetadata> | undefined;
|
|
425
|
+
const responseMap = md[RESPONSE] as Map<string | symbol, ResponseMetadata[]> | undefined;
|
|
426
|
+
|
|
427
|
+
const classPaths = pathMap?.get(CLASS_METADATA) ?? [''] as PathMetadata[];
|
|
428
|
+
const classMethod = methodMap?.get(CLASS_METADATA) ?? 'GET' as MethodMetadata;
|
|
429
|
+
const classTags = tagMap?.get(CLASS_METADATA) ?? [] as TagMetadata[];
|
|
430
|
+
const classRequestBody = requestBodyMap?.get(CLASS_METADATA) as RequestBodyMetadata | undefined;
|
|
431
|
+
const classReponses = responseMap?.get(CLASS_METADATA) ?? [] as ResponseMetadata[];
|
|
432
|
+
|
|
433
|
+
for (const [handlerName, methodPaths] of pathMap?.entries() ?? []) {
|
|
434
|
+
if (handlerName === CLASS_METADATA) continue;
|
|
435
|
+
for (const classPath of classPaths) {
|
|
436
|
+
for (const methodPath of methodPaths) {
|
|
437
|
+
const path = (classPath + methodPath)
|
|
438
|
+
const method = (methodMap?.get(handlerName) ?? classMethod ?? 'GET').toLowerCase() as Lowercase<typeof classMethod>;
|
|
439
|
+
|
|
440
|
+
// replace /some/:path/:segments(regex) to /some/{path}/{segments}
|
|
441
|
+
const oaPath = path.replace(/:([a-zA-Z0-9_]+)(?:\((?:\\\)|[^)])+\))?/g, '{$1}');
|
|
442
|
+
|
|
443
|
+
let oaPathItem = openapi.paths[oaPath];
|
|
444
|
+
if (!oaPathItem) {
|
|
445
|
+
openapi.paths[oaPath] = oaPathItem = {};
|
|
446
|
+
|
|
447
|
+
const parameters: oas31.ParameterObject[] = [];
|
|
448
|
+
const paramRegex = /:([a-zA-Z0-9_]+)(?:\(((?:\\\)|[^)])+)\))?/g;
|
|
449
|
+
const enumRegex = /^[a-zA-Z0-9_|]+$/;
|
|
450
|
+
let match;
|
|
451
|
+
while ((match = paramRegex.exec(path)) !== null) {
|
|
452
|
+
const [, name, pattern] = match;
|
|
453
|
+
parameters.push({
|
|
454
|
+
name,
|
|
455
|
+
in: 'path',
|
|
456
|
+
required: true,
|
|
457
|
+
schema: pattern
|
|
458
|
+
? (pattern.match(enumRegex)
|
|
459
|
+
? { type: 'string', enum: pattern.split('|') }
|
|
460
|
+
: { type: 'string', pattern: pattern }
|
|
461
|
+
)
|
|
462
|
+
: { type: 'string' },
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
if (parameters.length) {
|
|
466
|
+
oaPathItem.parameters = parameters;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (oaPathItem[method]) throw new Error(`Duplicate path definition found for '${method} ${oaPath}'`);
|
|
471
|
+
|
|
472
|
+
oaPathItem[method] = {};
|
|
473
|
+
const oaOperation = oaPathItem[method];
|
|
474
|
+
|
|
475
|
+
const tags = [...new Set([...classTags, ...(tagMap?.get(handlerName) ?? [])])];
|
|
476
|
+
if (tags.length) oaOperation.tags = tags;
|
|
477
|
+
|
|
478
|
+
const summary = summaryMap?.get(handlerName);
|
|
479
|
+
if (summary) oaOperation.summary = summary;
|
|
480
|
+
|
|
481
|
+
const description = descriptionMap?.get(handlerName);
|
|
482
|
+
if (description) oaOperation.description = description;
|
|
483
|
+
|
|
484
|
+
const methodOperationId = operationIdMap?.get(handlerName);
|
|
485
|
+
if (methodOperationId) oaOperation.operationId = methodOperationId;
|
|
486
|
+
else if (typeof handlerName === 'string') oaOperation.operationId = handlerName;
|
|
487
|
+
|
|
488
|
+
const requestBody = requestBodyMap?.get(handlerName) ?? classRequestBody;
|
|
489
|
+
if (typeof requestBody === 'string') {
|
|
490
|
+
oaOperation.requestBody = {
|
|
491
|
+
content: {
|
|
492
|
+
'application/json': { schema: nameToSchemaRef(requestBody) },
|
|
493
|
+
},
|
|
494
|
+
required: true,
|
|
495
|
+
};
|
|
496
|
+
} else if (requestBody) {
|
|
497
|
+
oaOperation.requestBody = requestBody;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const responses = [...classReponses, ...(responseMap?.get(handlerName) ?? [])];
|
|
501
|
+
if (responses.length) {
|
|
502
|
+
const oaResponses: oas31.ResponsesObject = {};
|
|
503
|
+
for (const { code, content, description, headers } of responses) {
|
|
504
|
+
|
|
505
|
+
const codeStr = String(code) as keyof typeof defaultResponses;
|
|
506
|
+
let userContent: any = content
|
|
507
|
+
?? ('content' in defaultResponses[codeStr]
|
|
508
|
+
? defaultResponses[codeStr].content
|
|
509
|
+
: undefined
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
if (typeof userContent === 'string') {
|
|
513
|
+
userContent = {
|
|
514
|
+
'application/json': { schema: nameToSchemaRef(userContent) },
|
|
515
|
+
};
|
|
516
|
+
} else if (userContent) {
|
|
517
|
+
userContent = structuredClone(userContent);
|
|
518
|
+
for (const mediaType of Object.keys(userContent)) {
|
|
519
|
+
if (typeof userContent[mediaType] === 'string') {
|
|
520
|
+
userContent[mediaType] = { schema: nameToSchemaRef(userContent[mediaType]) };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
oaResponses[codeStr] = {
|
|
525
|
+
description: description ?? defaultResponses[codeStr]?.description ?? 'Response',
|
|
526
|
+
content: userContent,
|
|
527
|
+
headers: headers,
|
|
528
|
+
// TODO: links
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
}
|
|
532
|
+
oaOperation.responses = oaResponses;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (componentsPattern) {
|
|
539
|
+
try {
|
|
540
|
+
const componentSchemas = generateSchemaDefinitions(componentsPattern);
|
|
541
|
+
if (Object.keys(componentSchemas).length) {
|
|
542
|
+
if (!openapi.components) {
|
|
543
|
+
openapi.components = {};
|
|
544
|
+
}
|
|
545
|
+
if (!openapi.components.schemas) {
|
|
546
|
+
openapi.components.schemas = {};
|
|
547
|
+
}
|
|
548
|
+
Object.assign(openapi.components.schemas, componentSchemas);
|
|
549
|
+
}
|
|
550
|
+
} catch (error) {
|
|
551
|
+
throw new Error('Could not generate components.schemas from the given type definitions.\n' + String(error));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return openapi;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/* ---------------- helpers ---------------- */
|
|
558
|
+
|
|
559
|
+
export function nameToSchemaRef(name: string): oas31.SchemaObject | oas31.ReferenceObject {
|
|
560
|
+
if (name.endsWith('[]')) {
|
|
561
|
+
return {
|
|
562
|
+
type: 'array',
|
|
563
|
+
'items': {
|
|
564
|
+
'$ref': '#/components/schemas/' + name.substring(0, name.length - 2)
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
'$ref': '#/components/schemas/' + name
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function generateSchemaDefinitions(pattern: string | string[]): oas31.SchemasObject {
|
|
574
|
+
const fixSchema = (obj: object, defs: any) => {
|
|
575
|
+
if (Array.isArray(obj)) {
|
|
576
|
+
for (let i = 0; i < obj.length; i++) {
|
|
577
|
+
const target = obj[i];
|
|
578
|
+
if (typeof target === 'object' && target !== null) {
|
|
579
|
+
if (target.$ref?.startsWith?.('#/definitions/')) {
|
|
580
|
+
const ref = defs[target.$ref.replace('#/definitions/', '')];
|
|
581
|
+
obj.splice(i, 1, ref);
|
|
582
|
+
} else {
|
|
583
|
+
if ('const' in target) {
|
|
584
|
+
target.enum = [target.const];
|
|
585
|
+
delete target.const;
|
|
586
|
+
}
|
|
587
|
+
fixSchema(target, defs);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
} else {
|
|
592
|
+
for (const key of Object.keys(obj)) {
|
|
593
|
+
const target = (obj as any)[key];
|
|
594
|
+
if (typeof target === 'object' && target !== null) {
|
|
595
|
+
if (target.$ref?.startsWith?.('#/definitions/')) {
|
|
596
|
+
const ref = defs[target.$ref.replace('#/definitions/', '')];
|
|
597
|
+
(obj as any)[key] = ref;
|
|
598
|
+
} else {
|
|
599
|
+
if ('const' in target) {
|
|
600
|
+
target.enum = [target.const];
|
|
601
|
+
delete target.const;
|
|
602
|
+
}
|
|
603
|
+
fixSchema(target, defs);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const combinedSchemas: any = {};
|
|
611
|
+
|
|
612
|
+
const declarationFiles = globSync(pattern, { absolute: true });
|
|
613
|
+
for (const file of declarationFiles) {
|
|
614
|
+
const filename = basename(file);
|
|
615
|
+
const dotPos = filename.indexOf('.');
|
|
616
|
+
const filenameWithoutExt = dotPos < 0 ? filename : filename.substring(0, dotPos);
|
|
617
|
+
|
|
618
|
+
const schema = createGenerator({
|
|
619
|
+
path: file,
|
|
620
|
+
type: filenameWithoutExt,
|
|
621
|
+
topRef: false,
|
|
622
|
+
}).createSchema(filenameWithoutExt);
|
|
623
|
+
|
|
624
|
+
if (schema) {
|
|
625
|
+
if (schema.definitions) {
|
|
626
|
+
fixSchema(schema, schema.definitions);
|
|
627
|
+
delete schema.definitions;
|
|
628
|
+
}
|
|
629
|
+
delete schema.$schema;
|
|
630
|
+
combinedSchemas[filenameWithoutExt] = schema;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return combinedSchemas;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const defaultResponses = {
|
|
637
|
+
'200': {
|
|
638
|
+
description: 'Successful request',
|
|
639
|
+
content: {
|
|
640
|
+
'text/plain': {
|
|
641
|
+
schema: {
|
|
642
|
+
type: 'string'
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
'204': {
|
|
648
|
+
description: 'Successful request, no content to return',
|
|
649
|
+
},
|
|
650
|
+
'400': {
|
|
651
|
+
description: 'Bad request',
|
|
652
|
+
content: {
|
|
653
|
+
'text/plain': {
|
|
654
|
+
schema: {
|
|
655
|
+
type: 'string',
|
|
656
|
+
example: 'Bad Request',
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
'401': {
|
|
662
|
+
description: 'Unauthorized',
|
|
663
|
+
content: {
|
|
664
|
+
'text/plain': {
|
|
665
|
+
schema: {
|
|
666
|
+
type: 'string',
|
|
667
|
+
example: 'Unauthorized',
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
'403': {
|
|
673
|
+
description: 'Forbidden',
|
|
674
|
+
content: {
|
|
675
|
+
'text/plain': {
|
|
676
|
+
schema: {
|
|
677
|
+
type: 'string',
|
|
678
|
+
example: 'Forbidden',
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
},
|
|
683
|
+
'404': {
|
|
684
|
+
description: 'Not found',
|
|
685
|
+
content: {
|
|
686
|
+
'text/plain': {
|
|
687
|
+
schema: {
|
|
688
|
+
type: 'string',
|
|
689
|
+
example: 'Not Found',
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
'500': {
|
|
695
|
+
description: 'Internal server error',
|
|
696
|
+
content: {
|
|
697
|
+
'text/plain': {
|
|
698
|
+
schema: {
|
|
699
|
+
type: 'string',
|
|
700
|
+
example: 'Internal Server Error',
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
},
|
|
704
|
+
},
|
|
705
|
+
};
|