@fluojs/openapi 1.0.0-beta.1
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/LICENSE +21 -0
- package/README.ko.md +106 -0
- package/README.md +106 -0
- package/dist/decorators.d.ts +192 -0
- package/dist/decorators.d.ts.map +1 -0
- package/dist/decorators.js +400 -0
- package/dist/handler-registry.d.ts +20 -0
- package/dist/handler-registry.d.ts.map +1 -0
- package/dist/handler-registry.js +24 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/openapi-module.d.ts +63 -0
- package/dist/openapi-module.d.ts.map +1 -0
- package/dist/openapi-module.js +180 -0
- package/dist/schema-builder.d.ts +187 -0
- package/dist/schema-builder.d.ts.map +1 -0
- package/dist/schema-builder.js +877 -0
- package/package.json +54 -0
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
import { getDtoBindingSchema, getDtoValidationSchema } from '@fluojs/core/internal';
|
|
2
|
+
import { getControllerTags, getMethodApiMetadata } from './decorators.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OpenAPI `info` object emitted in the generated document.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* OpenAPI response object emitted for an operation status code.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* OpenAPI security requirement object emitted on an operation.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* JSON Schema-compatible OpenAPI schema object used across parameters, bodies, and components.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* OpenAPI parameter object emitted for path, query, header, or cookie bindings.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* OpenAPI media-type object wrapping a schema.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* OpenAPI request-body object emitted for handler body bindings.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* OpenAPI security scheme object registered in `components.securitySchemes`.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* OpenAPI components object emitted for shared schemas and security schemes.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* OpenAPI operation object emitted for a single HTTP method on a path.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* OpenAPI path-item object containing one or more HTTP method operations.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Root OpenAPI 3.1.0 document produced by `buildOpenApiDocument(...)`.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Input used by `buildOpenApiDocument(...)` to assemble an OpenAPI document.
|
|
54
|
+
*
|
|
55
|
+
* @remarks
|
|
56
|
+
* Most applications should prefer `OpenApiModule.forRoot(...)`. Use this lower
|
|
57
|
+
* level builder when tests or custom tooling need a document without mounting
|
|
58
|
+
* the OpenAPI runtime module.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Controls whether common framework error responses are injected automatically.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
function expressPathToOpenApi(path) {
|
|
66
|
+
return path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}');
|
|
67
|
+
}
|
|
68
|
+
function resolveControllerTags(descriptor) {
|
|
69
|
+
const decorated = getControllerTags(descriptor.controllerToken);
|
|
70
|
+
if (decorated && decorated.length > 0) {
|
|
71
|
+
return decorated;
|
|
72
|
+
}
|
|
73
|
+
return [descriptor.controllerToken.name || 'Controller'];
|
|
74
|
+
}
|
|
75
|
+
function normalizeOperationId(descriptor) {
|
|
76
|
+
const tag = resolveControllerTags(descriptor)[0] ?? 'Controller';
|
|
77
|
+
const path = expressPathToOpenApi(descriptor.route.path).replaceAll('/', '_').replaceAll('{', '').replaceAll('}', '').replaceAll('-', '_');
|
|
78
|
+
return `${tag}_${descriptor.methodName}_${descriptor.route.method.toLowerCase()}${path}`;
|
|
79
|
+
}
|
|
80
|
+
function propertyName(propertyKey) {
|
|
81
|
+
return typeof propertyKey === 'string' ? propertyKey : String(propertyKey);
|
|
82
|
+
}
|
|
83
|
+
function collectDtoEntries(dto, context) {
|
|
84
|
+
const cachedEntries = context.dtoEntries.get(dto);
|
|
85
|
+
if (cachedEntries) {
|
|
86
|
+
return cachedEntries;
|
|
87
|
+
}
|
|
88
|
+
const bindingEntries = getDtoBindingSchema(dto);
|
|
89
|
+
const validationEntries = getDtoValidationSchema(dto);
|
|
90
|
+
const bindingMap = new Map(bindingEntries.map(entry => [entry.propertyKey, entry]));
|
|
91
|
+
const validationMap = new Map(validationEntries.map(entry => [entry.propertyKey, entry]));
|
|
92
|
+
const propertyKeys = new Set([...Array.from(bindingMap.keys()), ...Array.from(validationMap.keys())]);
|
|
93
|
+
const entries = Array.from(propertyKeys).map(propertyKey => ({
|
|
94
|
+
binding: bindingMap.get(propertyKey),
|
|
95
|
+
name: propertyName(propertyKey),
|
|
96
|
+
validation: validationMap.get(propertyKey)
|
|
97
|
+
}));
|
|
98
|
+
context.dtoEntries.set(dto, entries);
|
|
99
|
+
return entries;
|
|
100
|
+
}
|
|
101
|
+
function getDtoSchemaName(dto, context, suffix = '') {
|
|
102
|
+
let perDto = context.dtoSchemaNames.get(dto);
|
|
103
|
+
if (!perDto) {
|
|
104
|
+
perDto = new Map();
|
|
105
|
+
context.dtoSchemaNames.set(dto, perDto);
|
|
106
|
+
}
|
|
107
|
+
const cached = perDto.get(suffix);
|
|
108
|
+
if (cached) {
|
|
109
|
+
return cached;
|
|
110
|
+
}
|
|
111
|
+
const baseName = `${dto.name || 'AnonymousDto'}${suffix}`;
|
|
112
|
+
let candidate = baseName;
|
|
113
|
+
let index = 2;
|
|
114
|
+
while (context.usedSchemaNames.has(candidate)) {
|
|
115
|
+
candidate = `${baseName}_${String(index)}`;
|
|
116
|
+
index++;
|
|
117
|
+
}
|
|
118
|
+
context.usedSchemaNames.add(candidate);
|
|
119
|
+
perDto.set(suffix, candidate);
|
|
120
|
+
return candidate;
|
|
121
|
+
}
|
|
122
|
+
function createSchemaRef(name) {
|
|
123
|
+
return {
|
|
124
|
+
$ref: `#/components/schemas/${name}`
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function resolveNestedDto(dto) {
|
|
128
|
+
if (typeof dto === 'function' && 'prototype' in dto && dto.prototype) {
|
|
129
|
+
return dto;
|
|
130
|
+
}
|
|
131
|
+
return dto();
|
|
132
|
+
}
|
|
133
|
+
function inferEnumValueType(value) {
|
|
134
|
+
if (typeof value === 'number') {
|
|
135
|
+
return 'number';
|
|
136
|
+
}
|
|
137
|
+
if (typeof value === 'boolean') {
|
|
138
|
+
return 'boolean';
|
|
139
|
+
}
|
|
140
|
+
return 'string';
|
|
141
|
+
}
|
|
142
|
+
function createEnumSchema(values) {
|
|
143
|
+
const typeSet = new Set(values.map(value => inferEnumValueType(value)));
|
|
144
|
+
return {
|
|
145
|
+
enum: [...values],
|
|
146
|
+
...(typeSet.size === 1 && values.length > 0 ? {
|
|
147
|
+
type: inferEnumValueType(values[0])
|
|
148
|
+
} : {})
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function inferNestedSchema(nestedRule, context) {
|
|
152
|
+
if (!nestedRule) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const resolvedDto = resolveNestedDto(nestedRule.dto);
|
|
156
|
+
const schemaName = getDtoSchemaName(resolvedDto, context);
|
|
157
|
+
if (nestedRule.each) {
|
|
158
|
+
return {
|
|
159
|
+
items: createSchemaRef(schemaName),
|
|
160
|
+
type: 'array'
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return createSchemaRef(schemaName);
|
|
164
|
+
}
|
|
165
|
+
const ruleProfileCache = new WeakMap();
|
|
166
|
+
function resolveValidatorStringFormat(validatorRule) {
|
|
167
|
+
if (validatorRule.validator === 'email') {
|
|
168
|
+
return 'email';
|
|
169
|
+
}
|
|
170
|
+
if (validatorRule.validator === 'uuid') {
|
|
171
|
+
return 'uuid';
|
|
172
|
+
}
|
|
173
|
+
if (validatorRule.validator === 'url') {
|
|
174
|
+
return 'uri';
|
|
175
|
+
}
|
|
176
|
+
if (validatorRule.validator === 'dateString' || validatorRule.validator === 'iso8601') {
|
|
177
|
+
return 'date-time';
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
function createRuleProfile() {
|
|
182
|
+
return {
|
|
183
|
+
enumEachRule: undefined,
|
|
184
|
+
enumRule: undefined,
|
|
185
|
+
hasArrayRule: false,
|
|
186
|
+
hasBooleanRule: false,
|
|
187
|
+
hasDateRule: false,
|
|
188
|
+
hasEachBooleanRule: false,
|
|
189
|
+
hasEachIntRule: false,
|
|
190
|
+
hasEachNumberRule: false,
|
|
191
|
+
hasIntRule: false,
|
|
192
|
+
hasNumberRule: false,
|
|
193
|
+
hasObjectRule: false,
|
|
194
|
+
hasStringRule: false,
|
|
195
|
+
hasStringRuleForEach: false,
|
|
196
|
+
maxLength: undefined,
|
|
197
|
+
maximum: undefined,
|
|
198
|
+
minLength: undefined,
|
|
199
|
+
minimum: undefined,
|
|
200
|
+
nestedEachRule: undefined,
|
|
201
|
+
nestedRule: undefined,
|
|
202
|
+
stringFormat: undefined
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function applyRuleToProfile(profile, rule) {
|
|
206
|
+
if (rule.kind === 'nested') {
|
|
207
|
+
profile.nestedRule ??= rule;
|
|
208
|
+
if (rule.each) {
|
|
209
|
+
profile.nestedEachRule ??= rule;
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (rule.kind === 'array') {
|
|
214
|
+
profile.hasArrayRule = true;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (rule.kind === 'enum') {
|
|
218
|
+
profile.enumRule ??= rule;
|
|
219
|
+
if (rule.each) {
|
|
220
|
+
profile.enumEachRule ??= rule;
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (rule.kind === 'int') {
|
|
225
|
+
profile.hasIntRule = true;
|
|
226
|
+
if (rule.each) {
|
|
227
|
+
profile.hasEachIntRule = true;
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (rule.kind === 'number') {
|
|
232
|
+
profile.hasNumberRule = true;
|
|
233
|
+
if (rule.each) {
|
|
234
|
+
profile.hasEachNumberRule = true;
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (rule.kind === 'boolean') {
|
|
239
|
+
profile.hasBooleanRule = true;
|
|
240
|
+
if (rule.each) {
|
|
241
|
+
profile.hasEachBooleanRule = true;
|
|
242
|
+
}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (rule.kind === 'date') {
|
|
246
|
+
profile.hasDateRule = true;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (rule.kind === 'object') {
|
|
250
|
+
profile.hasObjectRule = true;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (rule.kind === 'string') {
|
|
254
|
+
profile.hasStringRule = true;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (rule.kind === 'minLength') {
|
|
258
|
+
if (rule.each) {
|
|
259
|
+
profile.hasStringRuleForEach = true;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
profile.minLength = rule.value;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (rule.kind === 'maxLength') {
|
|
266
|
+
if (rule.each) {
|
|
267
|
+
profile.hasStringRuleForEach = true;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
profile.maxLength = rule.value;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (rule.kind === 'min' && !rule.each) {
|
|
274
|
+
profile.minimum = rule.value;
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (rule.kind === 'max' && !rule.each) {
|
|
278
|
+
profile.maximum = rule.value;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (rule.kind === 'validatorjs') {
|
|
282
|
+
const nextFormat = resolveValidatorStringFormat(rule);
|
|
283
|
+
if (nextFormat) {
|
|
284
|
+
profile.stringFormat = nextFormat;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function getRuleProfile(rules) {
|
|
289
|
+
const cached = ruleProfileCache.get(rules);
|
|
290
|
+
if (cached) {
|
|
291
|
+
return cached;
|
|
292
|
+
}
|
|
293
|
+
const profile = createRuleProfile();
|
|
294
|
+
for (const rule of rules) {
|
|
295
|
+
applyRuleToProfile(profile, rule);
|
|
296
|
+
}
|
|
297
|
+
ruleProfileCache.set(rules, profile);
|
|
298
|
+
return profile;
|
|
299
|
+
}
|
|
300
|
+
function inferPrimitiveTypeFromRules(rules, context) {
|
|
301
|
+
const profile = getRuleProfile(rules);
|
|
302
|
+
const nestedSchema = inferNestedSchema(profile.nestedRule, context);
|
|
303
|
+
if (nestedSchema) {
|
|
304
|
+
return nestedSchema;
|
|
305
|
+
}
|
|
306
|
+
if (profile.hasArrayRule) {
|
|
307
|
+
return {
|
|
308
|
+
items: inferEachItemSchema(rules, context, profile) ?? {},
|
|
309
|
+
type: 'array'
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (profile.enumRule) {
|
|
313
|
+
return createEnumSchema(profile.enumRule.values);
|
|
314
|
+
}
|
|
315
|
+
if (profile.hasIntRule) {
|
|
316
|
+
return {
|
|
317
|
+
type: 'integer'
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (profile.hasNumberRule) {
|
|
321
|
+
return {
|
|
322
|
+
type: 'number'
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (profile.hasBooleanRule) {
|
|
326
|
+
return {
|
|
327
|
+
type: 'boolean'
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (profile.hasDateRule) {
|
|
331
|
+
return {
|
|
332
|
+
format: 'date-time',
|
|
333
|
+
type: 'string'
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
if (profile.hasObjectRule) {
|
|
337
|
+
return {
|
|
338
|
+
additionalProperties: true,
|
|
339
|
+
type: 'object'
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
if (profile.hasStringRule) {
|
|
343
|
+
return {
|
|
344
|
+
type: 'string'
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
function inferEachItemSchema(rules, context, profile = getRuleProfile(rules)) {
|
|
350
|
+
if (profile.nestedEachRule) {
|
|
351
|
+
const resolvedDto = resolveNestedDto(profile.nestedEachRule.dto);
|
|
352
|
+
return createSchemaRef(getDtoSchemaName(resolvedDto, context));
|
|
353
|
+
}
|
|
354
|
+
if (profile.enumEachRule) {
|
|
355
|
+
return createEnumSchema(profile.enumEachRule.values);
|
|
356
|
+
}
|
|
357
|
+
if (profile.hasStringRule || profile.hasStringRuleForEach) {
|
|
358
|
+
return {
|
|
359
|
+
type: 'string'
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
if (profile.hasEachIntRule) {
|
|
363
|
+
return {
|
|
364
|
+
type: 'integer'
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
if (profile.hasEachNumberRule) {
|
|
368
|
+
return {
|
|
369
|
+
type: 'number'
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (profile.hasEachBooleanRule) {
|
|
373
|
+
return {
|
|
374
|
+
type: 'boolean'
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
function applyValidationConstraints(schema, rules) {
|
|
380
|
+
const nextSchema = {
|
|
381
|
+
...schema
|
|
382
|
+
};
|
|
383
|
+
const profile = getRuleProfile(rules);
|
|
384
|
+
if (nextSchema.type === 'string') {
|
|
385
|
+
if (profile.minLength !== undefined) {
|
|
386
|
+
nextSchema.minLength = profile.minLength;
|
|
387
|
+
}
|
|
388
|
+
if (profile.maxLength !== undefined) {
|
|
389
|
+
nextSchema.maxLength = profile.maxLength;
|
|
390
|
+
}
|
|
391
|
+
if (profile.stringFormat !== undefined) {
|
|
392
|
+
nextSchema.format = profile.stringFormat;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (nextSchema.type === 'number' || nextSchema.type === 'integer') {
|
|
396
|
+
if (profile.minimum !== undefined) {
|
|
397
|
+
nextSchema.minimum = profile.minimum;
|
|
398
|
+
}
|
|
399
|
+
if (profile.maximum !== undefined) {
|
|
400
|
+
nextSchema.maximum = profile.maximum;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return nextSchema;
|
|
404
|
+
}
|
|
405
|
+
function isPropertyRequired(binding, validation) {
|
|
406
|
+
if (binding?.metadata.optional) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
if (validation?.rules.some(rule => rule.kind === 'optional')) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
function ensureComponentSchemaFromEntries(schemaName, entries, componentSchemas, context) {
|
|
415
|
+
if (componentSchemas[schemaName]) {
|
|
416
|
+
return createSchemaRef(schemaName);
|
|
417
|
+
}
|
|
418
|
+
componentSchemas[schemaName] = {
|
|
419
|
+
additionalProperties: false,
|
|
420
|
+
properties: {},
|
|
421
|
+
type: 'object'
|
|
422
|
+
};
|
|
423
|
+
const {
|
|
424
|
+
properties,
|
|
425
|
+
required
|
|
426
|
+
} = buildComponentSchemaShape(entries, componentSchemas, context);
|
|
427
|
+
componentSchemas[schemaName] = {
|
|
428
|
+
additionalProperties: false,
|
|
429
|
+
properties,
|
|
430
|
+
...(required.length > 0 && {
|
|
431
|
+
required
|
|
432
|
+
}),
|
|
433
|
+
type: 'object'
|
|
434
|
+
};
|
|
435
|
+
return createSchemaRef(schemaName);
|
|
436
|
+
}
|
|
437
|
+
function ensureNestedSchemasFromRules(rules, componentSchemas, context) {
|
|
438
|
+
for (const rule of rules) {
|
|
439
|
+
if (rule.kind === 'nested') {
|
|
440
|
+
ensureComponentSchema(resolveNestedDto(rule.dto), componentSchemas, context);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function buildComponentSchemaShape(entries, componentSchemas, context) {
|
|
445
|
+
const properties = {};
|
|
446
|
+
const required = [];
|
|
447
|
+
for (const entry of entries) {
|
|
448
|
+
const rules = entry.validation?.rules ?? [];
|
|
449
|
+
ensureNestedSchemasFromRules(rules, componentSchemas, context);
|
|
450
|
+
const inferred = inferPrimitiveTypeFromRules(rules, context) ?? {};
|
|
451
|
+
properties[entry.name] = applyValidationConstraints(inferred, rules);
|
|
452
|
+
if (isPropertyRequired(entry.binding, entry.validation)) {
|
|
453
|
+
required.push(entry.name);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
properties,
|
|
458
|
+
required
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
function ensureComponentSchema(dto, componentSchemas, context) {
|
|
462
|
+
const schemaName = getDtoSchemaName(dto, context);
|
|
463
|
+
return ensureComponentSchemaFromEntries(schemaName, collectDtoEntries(dto, context), componentSchemas, context);
|
|
464
|
+
}
|
|
465
|
+
function createParameters(dto, context) {
|
|
466
|
+
if (!dto) {
|
|
467
|
+
return [];
|
|
468
|
+
}
|
|
469
|
+
const entries = collectDtoEntries(dto, context).filter(entry => entry.binding?.metadata.source === 'path' || entry.binding?.metadata.source === 'query' || entry.binding?.metadata.source === 'header' || entry.binding?.metadata.source === 'cookie');
|
|
470
|
+
return entries.map(entry => {
|
|
471
|
+
const source = entry.binding.metadata.source;
|
|
472
|
+
const rules = entry.validation?.rules ?? [];
|
|
473
|
+
const inferred = inferPrimitiveTypeFromRules(rules, context) ?? {};
|
|
474
|
+
const schema = alignParameterSchemaWithRuntimeBindingContract(applyValidationConstraints(inferred, rules), source);
|
|
475
|
+
const isRequired = source === 'path' ? true : isPropertyRequired(entry.binding, entry.validation);
|
|
476
|
+
return {
|
|
477
|
+
in: source,
|
|
478
|
+
name: entry.binding.metadata.key ?? entry.name,
|
|
479
|
+
required: isRequired,
|
|
480
|
+
schema
|
|
481
|
+
};
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
function ensureErrorResponseSchema(componentSchemas) {
|
|
485
|
+
const schemaName = 'ErrorResponse';
|
|
486
|
+
if (!componentSchemas[schemaName]) {
|
|
487
|
+
componentSchemas[schemaName] = {
|
|
488
|
+
additionalProperties: false,
|
|
489
|
+
properties: {
|
|
490
|
+
error: {
|
|
491
|
+
additionalProperties: false,
|
|
492
|
+
properties: {
|
|
493
|
+
code: {
|
|
494
|
+
type: 'string'
|
|
495
|
+
},
|
|
496
|
+
details: {
|
|
497
|
+
items: {
|
|
498
|
+
additionalProperties: false,
|
|
499
|
+
properties: {
|
|
500
|
+
code: {
|
|
501
|
+
type: 'string'
|
|
502
|
+
},
|
|
503
|
+
field: {
|
|
504
|
+
type: 'string'
|
|
505
|
+
},
|
|
506
|
+
message: {
|
|
507
|
+
type: 'string'
|
|
508
|
+
},
|
|
509
|
+
source: {
|
|
510
|
+
enum: ['path', 'query', 'header', 'cookie', 'body'],
|
|
511
|
+
type: 'string'
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
required: ['code', 'message'],
|
|
515
|
+
type: 'object'
|
|
516
|
+
},
|
|
517
|
+
type: 'array'
|
|
518
|
+
},
|
|
519
|
+
message: {
|
|
520
|
+
type: 'string'
|
|
521
|
+
},
|
|
522
|
+
meta: {
|
|
523
|
+
additionalProperties: true,
|
|
524
|
+
type: 'object'
|
|
525
|
+
},
|
|
526
|
+
requestId: {
|
|
527
|
+
type: 'string'
|
|
528
|
+
},
|
|
529
|
+
status: {
|
|
530
|
+
type: 'integer'
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
required: ['code', 'status', 'message'],
|
|
534
|
+
type: 'object'
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
required: ['error'],
|
|
538
|
+
type: 'object'
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
return createSchemaRef(schemaName);
|
|
542
|
+
}
|
|
543
|
+
function addDefaultErrorResponses(responses, componentSchemas) {
|
|
544
|
+
const errorSchema = ensureErrorResponseSchema(componentSchemas);
|
|
545
|
+
const defaultErrorResponses = {
|
|
546
|
+
'400': 'Bad Request',
|
|
547
|
+
'401': 'Unauthorized',
|
|
548
|
+
'403': 'Forbidden',
|
|
549
|
+
'404': 'Not Found',
|
|
550
|
+
'500': 'Internal Server Error'
|
|
551
|
+
};
|
|
552
|
+
for (const [status, description] of Object.entries(defaultErrorResponses)) {
|
|
553
|
+
if (responses[status]) {
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
responses[status] = {
|
|
557
|
+
content: {
|
|
558
|
+
'application/json': {
|
|
559
|
+
schema: errorSchema
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
description
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
function createRequestBody(dto, componentSchemas, context) {
|
|
567
|
+
if (!dto) {
|
|
568
|
+
return undefined;
|
|
569
|
+
}
|
|
570
|
+
const dtoEntries = collectDtoEntries(dto, context);
|
|
571
|
+
const entries = dtoEntries.filter(entry => entry.binding?.metadata.source === 'body');
|
|
572
|
+
if (entries.length === 0) {
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
const schemaName = entries.length === dtoEntries.length ? getDtoSchemaName(dto, context) : getDtoSchemaName(dto, context, 'RequestBody');
|
|
576
|
+
ensureComponentSchemaFromEntries(schemaName, entries, componentSchemas, context);
|
|
577
|
+
return {
|
|
578
|
+
content: {
|
|
579
|
+
'application/json': {
|
|
580
|
+
schema: createSchemaRef(schemaName)
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
...(entries.some(entry => isPropertyRequired(entry.binding, entry.validation)) ? {
|
|
584
|
+
required: true
|
|
585
|
+
} : {})
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function createExplicitRequestBody(methodMeta) {
|
|
589
|
+
const requestBodyMeta = methodMeta?.requestBody;
|
|
590
|
+
if (!requestBodyMeta) {
|
|
591
|
+
return undefined;
|
|
592
|
+
}
|
|
593
|
+
const content = requestBodyMeta.content ? requestBodyMeta.content : requestBodyMeta.schema ? {
|
|
594
|
+
'application/json': {
|
|
595
|
+
schema: requestBodyMeta.schema
|
|
596
|
+
}
|
|
597
|
+
} : undefined;
|
|
598
|
+
if (!content) {
|
|
599
|
+
return undefined;
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
content,
|
|
603
|
+
...(requestBodyMeta.description !== undefined ? {
|
|
604
|
+
description: requestBodyMeta.description
|
|
605
|
+
} : {}),
|
|
606
|
+
...(requestBodyMeta.required !== undefined ? {
|
|
607
|
+
required: requestBodyMeta.required
|
|
608
|
+
} : {})
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
function scalarizeArraySchemaItems(items) {
|
|
612
|
+
if (!items || items.$ref !== undefined || items.type === undefined || items.type === 'array' || items.type === 'object') {
|
|
613
|
+
return {
|
|
614
|
+
type: 'string'
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
return {
|
|
618
|
+
type: items.type,
|
|
619
|
+
...(items.format !== undefined && {
|
|
620
|
+
format: items.format
|
|
621
|
+
}),
|
|
622
|
+
...(items.enum !== undefined && {
|
|
623
|
+
enum: items.enum
|
|
624
|
+
})
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
function alignParameterSchemaWithRuntimeBindingContract(schema, source) {
|
|
628
|
+
let aligned = schema;
|
|
629
|
+
if (aligned.$ref !== undefined) {
|
|
630
|
+
aligned = {
|
|
631
|
+
type: 'string'
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
if (aligned.type === 'object') {
|
|
635
|
+
aligned = {
|
|
636
|
+
type: 'string'
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
if (aligned.type === 'array' && aligned.items) {
|
|
640
|
+
if (aligned.items.$ref !== undefined || aligned.items.type === 'object') {
|
|
641
|
+
aligned = {
|
|
642
|
+
...aligned,
|
|
643
|
+
items: {
|
|
644
|
+
type: 'string'
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if ((source === 'path' || source === 'cookie') && aligned.type === 'array') {
|
|
650
|
+
return scalarizeArraySchemaItems(aligned.items);
|
|
651
|
+
}
|
|
652
|
+
return aligned;
|
|
653
|
+
}
|
|
654
|
+
function createExplicitParameter(parameter) {
|
|
655
|
+
const source = parameter.in;
|
|
656
|
+
const schema = alignParameterSchemaWithRuntimeBindingContract(parameter.schema ?? {
|
|
657
|
+
type: 'string'
|
|
658
|
+
}, source);
|
|
659
|
+
const isRequired = source === 'path' ? true : parameter.required;
|
|
660
|
+
return {
|
|
661
|
+
in: source,
|
|
662
|
+
name: parameter.name,
|
|
663
|
+
...(parameter.description !== undefined ? {
|
|
664
|
+
description: parameter.description
|
|
665
|
+
} : {}),
|
|
666
|
+
...(isRequired !== undefined ? {
|
|
667
|
+
required: isRequired
|
|
668
|
+
} : {}),
|
|
669
|
+
schema
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
function mergeOperationParameters(inferred, explicit) {
|
|
673
|
+
if (!explicit || explicit.length === 0) {
|
|
674
|
+
return inferred;
|
|
675
|
+
}
|
|
676
|
+
const merged = new Map();
|
|
677
|
+
for (const parameter of inferred) {
|
|
678
|
+
merged.set(`${parameter.in}:${parameter.name}`, parameter);
|
|
679
|
+
}
|
|
680
|
+
for (const parameter of explicit) {
|
|
681
|
+
const explicitParameter = createExplicitParameter(parameter);
|
|
682
|
+
merged.set(`${explicitParameter.in}:${explicitParameter.name}`, explicitParameter);
|
|
683
|
+
}
|
|
684
|
+
return Array.from(merged.values());
|
|
685
|
+
}
|
|
686
|
+
function mergeOperationRequestBody(inferred, methodMeta) {
|
|
687
|
+
const explicit = createExplicitRequestBody(methodMeta);
|
|
688
|
+
if (!explicit) {
|
|
689
|
+
return inferred;
|
|
690
|
+
}
|
|
691
|
+
if (!inferred) {
|
|
692
|
+
return explicit;
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
...inferred,
|
|
696
|
+
...explicit
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
function createResponseObject(response, componentSchemas, context) {
|
|
700
|
+
const schema = response.schema ? response.schema : response.type ? ensureComponentSchema(response.type, componentSchemas, context) : undefined;
|
|
701
|
+
return {
|
|
702
|
+
description: response.description ?? 'OK',
|
|
703
|
+
...(schema ? {
|
|
704
|
+
content: {
|
|
705
|
+
'application/json': {
|
|
706
|
+
schema
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
} : {})
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
function createOperationResponses(descriptor, methodMeta, componentSchemas, defaultErrorResponsesPolicy, context) {
|
|
713
|
+
const responses = {};
|
|
714
|
+
if (methodMeta?.responses && methodMeta.responses.length > 0) {
|
|
715
|
+
for (const response of methodMeta.responses) {
|
|
716
|
+
responses[String(response.status)] = createResponseObject(response, componentSchemas, context);
|
|
717
|
+
}
|
|
718
|
+
} else {
|
|
719
|
+
responses[String(descriptor.route.successStatus ?? 200)] = {
|
|
720
|
+
description: 'OK'
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
if (defaultErrorResponsesPolicy === 'inject') {
|
|
724
|
+
addDefaultErrorResponses(responses, componentSchemas);
|
|
725
|
+
}
|
|
726
|
+
return responses;
|
|
727
|
+
}
|
|
728
|
+
function createOperationSecurity(methodMeta) {
|
|
729
|
+
if (methodMeta?.securityRequirements && methodMeta.securityRequirements.length > 0) {
|
|
730
|
+
return methodMeta.securityRequirements.map(requirement => ({
|
|
731
|
+
...requirement
|
|
732
|
+
}));
|
|
733
|
+
}
|
|
734
|
+
if (!methodMeta?.security || methodMeta.security.length === 0) {
|
|
735
|
+
return undefined;
|
|
736
|
+
}
|
|
737
|
+
return methodMeta.security.map(scheme => ({
|
|
738
|
+
[scheme]: []
|
|
739
|
+
}));
|
|
740
|
+
}
|
|
741
|
+
function hasBearerAuthRequirement(security) {
|
|
742
|
+
return Boolean(security?.some(requirement => Object.keys(requirement).includes('bearerAuth')));
|
|
743
|
+
}
|
|
744
|
+
function createOperationObject(descriptor, methodMeta, responses, componentSchemas, security, context) {
|
|
745
|
+
const parameters = mergeOperationParameters(createParameters(descriptor.route.request, context), methodMeta?.parameters);
|
|
746
|
+
const requestBody = mergeOperationRequestBody(createRequestBody(descriptor.route.request, componentSchemas, context), methodMeta);
|
|
747
|
+
return {
|
|
748
|
+
operationId: normalizeOperationId(descriptor),
|
|
749
|
+
responses,
|
|
750
|
+
tags: resolveControllerTags(descriptor),
|
|
751
|
+
...(methodMeta?.operation?.summary !== undefined && {
|
|
752
|
+
summary: methodMeta.operation.summary
|
|
753
|
+
}),
|
|
754
|
+
...(methodMeta?.operation?.description !== undefined && {
|
|
755
|
+
description: methodMeta.operation.description
|
|
756
|
+
}),
|
|
757
|
+
...(methodMeta?.operation?.deprecated !== undefined && {
|
|
758
|
+
deprecated: methodMeta.operation.deprecated
|
|
759
|
+
}),
|
|
760
|
+
...(parameters.length > 0 && {
|
|
761
|
+
parameters
|
|
762
|
+
}),
|
|
763
|
+
...(requestBody !== undefined && {
|
|
764
|
+
requestBody
|
|
765
|
+
}),
|
|
766
|
+
...(security !== undefined && {
|
|
767
|
+
security
|
|
768
|
+
})
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
function buildOperationEntry(descriptor, componentSchemas, defaultErrorResponsesPolicy, context) {
|
|
772
|
+
const openApiPath = expressPathToOpenApi(descriptor.route.path);
|
|
773
|
+
const method = descriptor.route.method.toLowerCase();
|
|
774
|
+
const methodMeta = getMethodApiMetadata(descriptor.controllerToken, descriptor.methodName);
|
|
775
|
+
if (methodMeta?.excludeEndpoint === true) {
|
|
776
|
+
return undefined;
|
|
777
|
+
}
|
|
778
|
+
const responses = createOperationResponses(descriptor, methodMeta, componentSchemas, defaultErrorResponsesPolicy, context);
|
|
779
|
+
const security = createOperationSecurity(methodMeta);
|
|
780
|
+
const operation = createOperationObject(descriptor, methodMeta, responses, componentSchemas, security, context);
|
|
781
|
+
return {
|
|
782
|
+
method,
|
|
783
|
+
openApiPath,
|
|
784
|
+
operation,
|
|
785
|
+
requiresBearerAuth: hasBearerAuthRequirement(security)
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
function createOpenApiComponents(componentSchemas, hasBearerAuth, configuredSecuritySchemes) {
|
|
789
|
+
const securitySchemes = {
|
|
790
|
+
...(configuredSecuritySchemes ?? {}),
|
|
791
|
+
...(hasBearerAuth && !(configuredSecuritySchemes && 'bearerAuth' in configuredSecuritySchemes) ? {
|
|
792
|
+
bearerAuth: {
|
|
793
|
+
bearerFormat: 'JWT',
|
|
794
|
+
scheme: 'bearer',
|
|
795
|
+
type: 'http'
|
|
796
|
+
}
|
|
797
|
+
} : {})
|
|
798
|
+
};
|
|
799
|
+
return {
|
|
800
|
+
...(Object.keys(componentSchemas).length > 0 && {
|
|
801
|
+
schemas: componentSchemas
|
|
802
|
+
}),
|
|
803
|
+
...(Object.keys(securitySchemes).length > 0 && {
|
|
804
|
+
securitySchemes
|
|
805
|
+
})
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
function registerExtraModels(extraModels, componentSchemas, context) {
|
|
809
|
+
for (const model of extraModels ?? []) {
|
|
810
|
+
ensureComponentSchema(model, componentSchemas, context);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Build an OpenAPI 3.1.0 document directly from handler descriptors.
|
|
816
|
+
*
|
|
817
|
+
* @param options Document-generation input including handlers, metadata, and optional schema transforms.
|
|
818
|
+
* @returns A generated OpenAPI document ready to serialize or serve.
|
|
819
|
+
*/
|
|
820
|
+
/**
|
|
821
|
+
* Build an OpenAPI 3.1 document from discovered Fluo HTTP handler descriptors.
|
|
822
|
+
*
|
|
823
|
+
* @param options Document assembly options, descriptors, and optional schema transforms.
|
|
824
|
+
* @returns A fully normalized OpenAPI document ready to serve or snapshot in tests.
|
|
825
|
+
*
|
|
826
|
+
* @example
|
|
827
|
+
* ```ts
|
|
828
|
+
* const document = buildOpenApiDocument({
|
|
829
|
+
* descriptors,
|
|
830
|
+
* title: 'Public API',
|
|
831
|
+
* version: '1.0.0',
|
|
832
|
+
* });
|
|
833
|
+
* ```
|
|
834
|
+
*/
|
|
835
|
+
export function buildOpenApiDocument(options) {
|
|
836
|
+
const paths = {};
|
|
837
|
+
const componentSchemas = {};
|
|
838
|
+
const defaultErrorResponsesPolicy = options.defaultErrorResponsesPolicy ?? 'inject';
|
|
839
|
+
const context = {
|
|
840
|
+
dtoEntries: new WeakMap(),
|
|
841
|
+
dtoSchemaNames: new WeakMap(),
|
|
842
|
+
usedSchemaNames: new Set(defaultErrorResponsesPolicy === 'inject' ? ['ErrorResponse'] : [])
|
|
843
|
+
};
|
|
844
|
+
let hasBearerAuth = false;
|
|
845
|
+
registerExtraModels(options.extraModels, componentSchemas, context);
|
|
846
|
+
for (const descriptor of options.descriptors) {
|
|
847
|
+
const entry = buildOperationEntry(descriptor, componentSchemas, defaultErrorResponsesPolicy, context);
|
|
848
|
+
if (!entry) {
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
const {
|
|
852
|
+
method,
|
|
853
|
+
openApiPath,
|
|
854
|
+
operation,
|
|
855
|
+
requiresBearerAuth
|
|
856
|
+
} = entry;
|
|
857
|
+
if (requiresBearerAuth) {
|
|
858
|
+
hasBearerAuth = true;
|
|
859
|
+
}
|
|
860
|
+
const pathItem = paths[openApiPath] ?? {};
|
|
861
|
+
pathItem[method] = operation;
|
|
862
|
+
paths[openApiPath] = pathItem;
|
|
863
|
+
}
|
|
864
|
+
const components = createOpenApiComponents(componentSchemas, hasBearerAuth, options.securitySchemes);
|
|
865
|
+
const document = {
|
|
866
|
+
...(Object.keys(components).length > 0 && {
|
|
867
|
+
components
|
|
868
|
+
}),
|
|
869
|
+
info: {
|
|
870
|
+
title: options.title,
|
|
871
|
+
version: options.version
|
|
872
|
+
},
|
|
873
|
+
openapi: '3.1.0',
|
|
874
|
+
paths
|
|
875
|
+
};
|
|
876
|
+
return options.documentTransform ? options.documentTransform(document) : document;
|
|
877
|
+
}
|