@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.
@@ -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
+ }