@spinajs/http-swagger 2.0.470 → 2.0.472

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.
Files changed (71) hide show
  1. package/lib/cjs/cli/GenerateSwaggerCache.d.ts +10 -0
  2. package/lib/cjs/cli/GenerateSwaggerCache.d.ts.map +1 -0
  3. package/lib/cjs/cli/GenerateSwaggerCache.js +43 -0
  4. package/lib/cjs/cli/GenerateSwaggerCache.js.map +1 -0
  5. package/lib/cjs/config/http-swagger.d.ts +42 -0
  6. package/lib/cjs/config/http-swagger.d.ts.map +1 -0
  7. package/lib/cjs/config/http-swagger.js +84 -0
  8. package/lib/cjs/config/http-swagger.js.map +1 -0
  9. package/lib/cjs/controllers/SwaggerController.d.ts +26 -0
  10. package/lib/cjs/controllers/SwaggerController.d.ts.map +1 -0
  11. package/lib/cjs/controllers/SwaggerController.js +74 -0
  12. package/lib/cjs/controllers/SwaggerController.js.map +1 -0
  13. package/lib/cjs/index.d.ts +7 -0
  14. package/lib/cjs/index.d.ts.map +1 -0
  15. package/lib/cjs/index.js +23 -0
  16. package/lib/cjs/index.js.map +1 -0
  17. package/lib/cjs/interfaces.d.ts +211 -0
  18. package/lib/cjs/interfaces.d.ts.map +1 -0
  19. package/lib/cjs/interfaces.js +3 -0
  20. package/lib/cjs/interfaces.js.map +1 -0
  21. package/lib/cjs/openapi-builder.d.ts +153 -0
  22. package/lib/cjs/openapi-builder.d.ts.map +1 -0
  23. package/lib/cjs/openapi-builder.js +844 -0
  24. package/lib/cjs/openapi-builder.js.map +1 -0
  25. package/lib/cjs/package.json +1 -0
  26. package/lib/cjs/swagger-cache.d.ts +18 -0
  27. package/lib/cjs/swagger-cache.d.ts.map +1 -0
  28. package/lib/cjs/swagger-cache.js +110 -0
  29. package/lib/cjs/swagger-cache.js.map +1 -0
  30. package/lib/cjs/swagger-service.d.ts +24 -0
  31. package/lib/cjs/swagger-service.d.ts.map +1 -0
  32. package/lib/cjs/swagger-service.js +88 -0
  33. package/lib/cjs/swagger-service.js.map +1 -0
  34. package/lib/mjs/cli/GenerateSwaggerCache.d.ts +10 -0
  35. package/lib/mjs/cli/GenerateSwaggerCache.d.ts.map +1 -0
  36. package/lib/mjs/cli/GenerateSwaggerCache.js +40 -0
  37. package/lib/mjs/cli/GenerateSwaggerCache.js.map +1 -0
  38. package/lib/mjs/config/http-swagger.d.ts +42 -0
  39. package/lib/mjs/config/http-swagger.d.ts.map +1 -0
  40. package/lib/mjs/config/http-swagger.js +82 -0
  41. package/lib/mjs/config/http-swagger.js.map +1 -0
  42. package/lib/mjs/controllers/SwaggerController.d.ts +26 -0
  43. package/lib/mjs/controllers/SwaggerController.d.ts.map +1 -0
  44. package/lib/mjs/controllers/SwaggerController.js +71 -0
  45. package/lib/mjs/controllers/SwaggerController.js.map +1 -0
  46. package/lib/mjs/index.d.ts +7 -0
  47. package/lib/mjs/index.d.ts.map +1 -0
  48. package/lib/mjs/index.js +7 -0
  49. package/lib/mjs/index.js.map +1 -0
  50. package/lib/mjs/interfaces.d.ts +211 -0
  51. package/lib/mjs/interfaces.d.ts.map +1 -0
  52. package/lib/mjs/interfaces.js +2 -0
  53. package/lib/mjs/interfaces.js.map +1 -0
  54. package/lib/mjs/openapi-builder.d.ts +153 -0
  55. package/lib/mjs/openapi-builder.d.ts.map +1 -0
  56. package/lib/mjs/openapi-builder.js +840 -0
  57. package/lib/mjs/openapi-builder.js.map +1 -0
  58. package/lib/mjs/package.json +1 -0
  59. package/lib/mjs/swagger-cache.d.ts +18 -0
  60. package/lib/mjs/swagger-cache.d.ts.map +1 -0
  61. package/lib/mjs/swagger-cache.js +107 -0
  62. package/lib/mjs/swagger-cache.js.map +1 -0
  63. package/lib/mjs/swagger-service.d.ts +24 -0
  64. package/lib/mjs/swagger-service.d.ts.map +1 -0
  65. package/lib/mjs/swagger-service.js +85 -0
  66. package/lib/mjs/swagger-service.js.map +1 -0
  67. package/lib/tsconfig.cjs.tsbuildinfo +1 -0
  68. package/lib/tsconfig.mjs.tsbuildinfo +1 -0
  69. package/lib/views/swagger-ui-init.js +11 -0
  70. package/lib/views/swagger.pug +16 -0
  71. package/package.json +15 -11
@@ -0,0 +1,840 @@
1
+ import { TypedArray } from '@spinajs/di';
2
+ import { ParameterType, RouteType } from '@spinajs/http';
3
+ import { SCHEMA_SYMBOL } from '@spinajs/validation';
4
+ /**
5
+ * Set of ParameterType values that map to internal framework params (not API params).
6
+ * These are skipped when generating OpenAPI parameters.
7
+ */
8
+ const INTERNAL_PARAMS = new Set([
9
+ ParameterType.Req,
10
+ ParameterType.Res,
11
+ ParameterType.FromDi,
12
+ ParameterType.FromSession,
13
+ ParameterType.RequestType,
14
+ 'ArgAsRequest',
15
+ 'ArgAsResponse',
16
+ 'FromDi',
17
+ 'FromSession',
18
+ 'RequestTypeRouteArgs',
19
+ ]);
20
+ /**
21
+ * ParameterType values that represent request body data
22
+ */
23
+ const BODY_PARAMS = new Set([
24
+ ParameterType.FromBody,
25
+ ParameterType.BodyField,
26
+ ParameterType.FromForm,
27
+ ParameterType.FormField,
28
+ ParameterType.FromModel,
29
+ ParameterType.FromFile,
30
+ ParameterType.FromCSV,
31
+ ParameterType.FromJSONFile,
32
+ 'FromBody',
33
+ 'BodyFieldRouteArgs',
34
+ 'FromForm',
35
+ 'FromFormField',
36
+ 'FromModel',
37
+ 'FromFile',
38
+ 'FromCSV',
39
+ 'FromJSONFile',
40
+ // orm-http: @AsModel — alias for FromBody (creates instance from request body)
41
+ 'AsDbModel',
42
+ ]);
43
+ /**
44
+ * Mapping from ParameterType to OpenAPI 'in' location
45
+ */
46
+ const PARAM_LOCATION_MAP = {
47
+ FromQuery: 'query',
48
+ QueryFieldRouteArgs: 'query',
49
+ FromParams: 'path',
50
+ ParamFieldRouteArgs: 'path',
51
+ FromHeader: 'header',
52
+ HeadersFieldRouteArgs: 'header',
53
+ FromCookie: 'cookie',
54
+ };
55
+ /**
56
+ * Reusable response component names per HTTP status code. Drives the
57
+ * `#/components/responses/<Name>` $ref emitted for documented error responses.
58
+ */
59
+ const STANDARD_RESPONSE_NAMES = {
60
+ '400': 'BadRequest',
61
+ '401': 'Unauthorized',
62
+ '403': 'Forbidden',
63
+ '404': 'NotFound',
64
+ '405': 'MethodNotAllowed',
65
+ '406': 'NotAcceptable',
66
+ '409': 'Conflict',
67
+ '413': 'PayloadTooLarge',
68
+ '422': 'ValidationError',
69
+ '500': 'ServerError',
70
+ };
71
+ /**
72
+ * Default human-readable descriptions for the reusable responses, used when the
73
+ * JSDoc didn't supply one.
74
+ */
75
+ const STANDARD_RESPONSE_DESCRIPTIONS = {
76
+ '400': 'Bad request — the server could not understand the request',
77
+ '401': 'Unauthorized — valid authentication required',
78
+ '403': 'Forbidden — caller lacks the required permission',
79
+ '404': 'Not found — the requested resource does not exist',
80
+ '405': 'Method not allowed for this resource',
81
+ '406': 'Not acceptable — cannot produce a response matching the Accept header',
82
+ '409': 'Conflict — request conflicts with current resource state',
83
+ '413': 'Payload too large',
84
+ '422': 'Validation error — request body failed schema validation',
85
+ '500': 'Internal server error',
86
+ };
87
+ export class OpenApiBuilder {
88
+ constructor(config) {
89
+ this.tags = new Map();
90
+ this.registeredResponses = new Set();
91
+ this.errorSchemaRegistered = false;
92
+ this.registeredPolicies = new Set();
93
+ this.policySectionEntries = [];
94
+ this.infoDescriptionBase = '';
95
+ this.config = config;
96
+ this.infoDescriptionBase = config.description ?? '';
97
+ this.document = {
98
+ openapi: '3.0.3',
99
+ info: {
100
+ title: config.title || 'API Documentation',
101
+ version: config.version || '1.0.0',
102
+ description: config.description,
103
+ },
104
+ servers: config.servers?.map((s) => ({ url: s.url, description: s.description })),
105
+ paths: {},
106
+ components: {},
107
+ tags: [],
108
+ };
109
+ if (config.securitySchemes) {
110
+ this.document.components = {
111
+ securitySchemes: config.securitySchemes,
112
+ };
113
+ }
114
+ if (config.security) {
115
+ this.document.security = config.security;
116
+ }
117
+ }
118
+ /**
119
+ * Add a controller's routes to the OpenAPI document.
120
+ */
121
+ addController(controller, docCache) {
122
+ const descriptor = controller.instance?.Descriptor;
123
+ if (!descriptor)
124
+ return;
125
+ const basePath = descriptor.BasePath || '';
126
+ const controllerName = controller.name.replace(/Controller$/, '');
127
+ // Register tag from class documentation or controller name
128
+ const tagName = docCache.classTags?.[0] || controllerName;
129
+ if (!this.tags.has(tagName)) {
130
+ this.tags.set(tagName, {
131
+ name: tagName,
132
+ description: docCache.classDescription,
133
+ });
134
+ }
135
+ // RBAC metadata (from rbac-http @Resource / @Permission) — accessed via the
136
+ // global Symbol.for('ACL_CONTROLLER_DESCRIPTOR_SYMBOL') so http-swagger
137
+ // doesn't depend on rbac-http.
138
+ const rbac = this.readRbacDescriptor(controller.instance);
139
+ for (const [methodName, route] of descriptor.Routes) {
140
+ const methodNameStr = methodName;
141
+ const methodDoc = docCache.methods[methodNameStr];
142
+ const fullPath = this.buildPath(basePath, route.Path, route.Method);
143
+ const httpMethod = this.mapRouteType(route.Type);
144
+ if (!httpMethod)
145
+ continue;
146
+ const operation = this.buildOperation(controller.name, methodNameStr, route, methodDoc, tagName);
147
+ this.applyRbac(operation, rbac, methodNameStr);
148
+ this.applyPolicies(operation, docCache, methodNameStr);
149
+ if (!this.document.paths[fullPath]) {
150
+ this.document.paths[fullPath] = {};
151
+ }
152
+ this.document.paths[fullPath][httpMethod] = operation;
153
+ }
154
+ }
155
+ /**
156
+ * Surface policy information on an operation:
157
+ * - merge controller-level and route-level policies (controller policies run first)
158
+ * - vendor extension `x-policies` for tooling
159
+ * - human-readable line appended to `description`; each policy links to the
160
+ * reusable "Policies" section appended to `info.description`
161
+ *
162
+ * The JSDoc descriptions for each unique policy are registered once on the
163
+ * document via `registerPolicyReference()`.
164
+ */
165
+ applyPolicies(operation, docCache, methodName) {
166
+ const controllerPolicies = docCache.controllerPolicies ?? [];
167
+ const routePolicies = docCache.routePolicies?.[methodName] ?? [];
168
+ const all = [...controllerPolicies, ...routePolicies];
169
+ if (all.length === 0)
170
+ return;
171
+ operation['x-policies'] = all;
172
+ const lines = [];
173
+ for (const name of all) {
174
+ const doc = docCache.policies?.[name];
175
+ this.registerPolicyReference(name, doc);
176
+ const anchor = this.policyAnchor(name);
177
+ const scope = controllerPolicies.includes(name) && !routePolicies.includes(name) ? ' (controller)' : '';
178
+ lines.push(`- [\`${name}\`](#${anchor})${scope}${doc?.description ? ` — ${this.firstSentence(doc.description)}` : ''}`);
179
+ }
180
+ const block = `**Policies applied:**\n${lines.join('\n')}`;
181
+ operation.description = operation.description ? `${operation.description}\n\n${block}` : block;
182
+ }
183
+ /**
184
+ * Lazily register a policy in the document-level "Policies" section.
185
+ * OpenAPI 3.0 has no first-class "policies" components bucket, so we render
186
+ * them into `info.description` as a markdown anchor that Swagger UI can
187
+ * link to from operation descriptions.
188
+ */
189
+ registerPolicyReference(name, doc) {
190
+ if (this.registeredPolicies.has(name))
191
+ return;
192
+ this.registeredPolicies.add(name);
193
+ const anchor = this.policyAnchor(name);
194
+ const description = doc?.description?.trim() || '_No description available — add JSDoc to the policy class._';
195
+ this.policySectionEntries.push(`### <a id="${anchor}"></a>\`${name}\`\n\n${description}`);
196
+ // Rebuild info.description so it always reflects the latest set
197
+ const heading = '## Policies\n\nThe following policies are applied to one or more operations. ' +
198
+ 'Each policy runs before the route handler and may reject the request.\n\n';
199
+ const base = this.infoDescriptionBase;
200
+ this.document.info.description = `${base}${base ? '\n\n' : ''}${heading}${this.policySectionEntries.join('\n\n')}`;
201
+ }
202
+ policyAnchor(name) {
203
+ return `policy-${name.replace(/[^A-Za-z0-9]+/g, '-').toLowerCase()}`;
204
+ }
205
+ firstSentence(text) {
206
+ const cleaned = text.replace(/\s+/g, ' ').trim();
207
+ const m = cleaned.match(/^[^.!?]+[.!?]/);
208
+ return (m ? m[0] : cleaned).slice(0, 200);
209
+ }
210
+ /**
211
+ * Read rbac-http's controller descriptor via the global metadata symbol.
212
+ * Returns undefined when the controller isn't RBAC-decorated.
213
+ */
214
+ readRbacDescriptor(instance) {
215
+ if (!instance)
216
+ return undefined;
217
+ const ACL_SYMBOL = Symbol.for('ACL_CONTROLLER_DESCRIPTOR_SYMBOL');
218
+ const meta = Reflect.getMetadata(ACL_SYMBOL, instance);
219
+ if (!meta)
220
+ return undefined;
221
+ const routes = new Map();
222
+ if (meta.Routes && typeof meta.Routes.forEach === 'function') {
223
+ meta.Routes.forEach((v, k) => {
224
+ if (Array.isArray(v?.Permission))
225
+ routes.set(k, v.Permission);
226
+ });
227
+ }
228
+ return {
229
+ resource: meta.Resource ?? '',
230
+ defaultPermission: Array.isArray(meta.Permission) ? meta.Permission : [],
231
+ routes,
232
+ };
233
+ }
234
+ /**
235
+ * Attach RBAC info to an operation:
236
+ * - vendor extensions `x-rbac-resource` and `x-rbac-permissions` for
237
+ * tooling / code-gen consumers
238
+ * - a human-readable line appended to the description for Swagger UI
239
+ */
240
+ applyRbac(operation, rbac, methodName) {
241
+ if (!rbac)
242
+ return;
243
+ const perRoute = rbac.routes.get(methodName);
244
+ const permissions = perRoute && perRoute.length > 0 ? perRoute : rbac.defaultPermission;
245
+ if (!rbac.resource && permissions.length === 0)
246
+ return;
247
+ if (rbac.resource)
248
+ operation['x-rbac-resource'] = rbac.resource;
249
+ if (permissions.length > 0)
250
+ operation['x-rbac-permissions'] = permissions;
251
+ const permList = permissions.length > 0 ? permissions.map((p) => `\`${p}\``).join(' or ') : '_any role with resource access_';
252
+ const resourceText = rbac.resource ? `\`${rbac.resource}\`` : '_unspecified_';
253
+ const line = `**RBAC:** requires ${permList} on resource ${resourceText}`;
254
+ operation.description = operation.description ? `${operation.description}\n\n${line}` : line;
255
+ }
256
+ /**
257
+ * Build the final OpenAPI document.
258
+ */
259
+ build() {
260
+ this.document.tags = Array.from(this.tags.values());
261
+ return this.document;
262
+ }
263
+ /**
264
+ * Build the full URL path for a route.
265
+ */
266
+ buildPath(basePath, routePath, methodName) {
267
+ let path = '';
268
+ const prefix = this.config.basePath ? `/${this.config.basePath}` : '';
269
+ if (routePath) {
270
+ if (routePath === '/') {
271
+ path = `/${basePath}/`;
272
+ }
273
+ else if (basePath === '/') {
274
+ path = `/${routePath}`;
275
+ }
276
+ else {
277
+ path = `/${basePath}/${routePath}`;
278
+ }
279
+ }
280
+ else {
281
+ path = `/${basePath}/${methodName}`;
282
+ }
283
+ // Convert Express-style params (:id) to OpenAPI style ({id})
284
+ path = path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}');
285
+ return `${prefix}${path}`;
286
+ }
287
+ /**
288
+ * Map SpineJS RouteType to OpenAPI HTTP method string.
289
+ */
290
+ mapRouteType(type) {
291
+ switch (type) {
292
+ case RouteType.GET:
293
+ case RouteType.FILE:
294
+ return 'get';
295
+ case RouteType.POST:
296
+ return 'post';
297
+ case RouteType.PUT:
298
+ return 'put';
299
+ case RouteType.DELETE:
300
+ return 'delete';
301
+ case RouteType.PATCH:
302
+ return 'patch';
303
+ case RouteType.HEAD:
304
+ return 'head';
305
+ default:
306
+ return null;
307
+ }
308
+ }
309
+ /**
310
+ * Build an OpenAPI operation from a route and its documentation.
311
+ */
312
+ buildOperation(controllerName, methodName, route, methodDoc, tagName) {
313
+ const operation = {
314
+ operationId: `${controllerName}_${methodName}`,
315
+ tags: methodDoc?.tags || [tagName],
316
+ summary: methodDoc?.summary,
317
+ description: methodDoc?.description,
318
+ deprecated: methodDoc?.deprecated,
319
+ parameters: [],
320
+ responses: this.buildResponses(methodDoc),
321
+ // Per-operation security: undefined = inherit global, [] = public, [...] = explicit schemes
322
+ security: methodDoc?.security,
323
+ };
324
+ const bodyParams = [];
325
+ // Extract path param names from the Express route path as fallback when param.Name is not set
326
+ const pathParamNames = (route.Path || '').match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g)?.map((p) => p.slice(1)) ?? [];
327
+ let pathParamIndex = 0;
328
+ // Process route parameters
329
+ for (const [, param] of route.Parameters) {
330
+ if (INTERNAL_PARAMS.has(param.Type)) {
331
+ continue;
332
+ }
333
+ // Resolve effective location. Most types come from PARAM_LOCATION_MAP,
334
+ // but orm-http's @FromModel (Type='FromDbModel') is dynamic — it reads
335
+ // the PK from req.params / query / body / header per Options.paramType,
336
+ // defaulting to path. @Filter (Type='FilterModelRouteArg') always reads from query.
337
+ let location = PARAM_LOCATION_MAP[param.Type];
338
+ if (!location) {
339
+ if (param.Type === 'FromDbModel') {
340
+ location = this.fromDbModelLocation(param);
341
+ }
342
+ else if (param.Type === 'FilterModelRouteArg') {
343
+ location = 'query';
344
+ }
345
+ }
346
+ const isPathParam = location === 'path';
347
+ const resolvedName = param.Name || (isPathParam ? pathParamNames[pathParamIndex++] : undefined);
348
+ if (isPathParam && resolvedName && !param.Name) {
349
+ param.Name = resolvedName;
350
+ }
351
+ const paramDoc = methodDoc?.params?.[resolvedName ?? param.Name];
352
+ if (BODY_PARAMS.has(param.Type)) {
353
+ bodyParams.push({ param, doc: paramDoc });
354
+ continue;
355
+ }
356
+ if (location) {
357
+ const apiParam = this.buildParameter(param, location, paramDoc, resolvedName);
358
+ operation.parameters.push(apiParam);
359
+ }
360
+ }
361
+ // Build request body from body params
362
+ if (bodyParams.length > 0) {
363
+ operation.requestBody = this.buildRequestBody(bodyParams, route);
364
+ }
365
+ // Add examples to request body if available
366
+ if (methodDoc?.examples && operation.requestBody) {
367
+ const content = operation.requestBody.content['application/json'];
368
+ if (content) {
369
+ content.examples = {};
370
+ methodDoc.examples.forEach((ex, i) => {
371
+ const key = ex.name || `example_${i + 1}`;
372
+ content.examples[key] = {
373
+ summary: ex.name,
374
+ description: ex.description,
375
+ value: this.tryParseJson(ex.value),
376
+ };
377
+ });
378
+ }
379
+ }
380
+ // Remove empty parameters array
381
+ if (operation.parameters.length === 0) {
382
+ delete operation.parameters;
383
+ }
384
+ return operation;
385
+ }
386
+ /**
387
+ * Describe the JSON filter envelope used by orm-http's @Filter decorator.
388
+ *
389
+ * @Filter accepts either:
390
+ * - a Model constructor — @Filterable on its columns stores the filterable
391
+ * column map on the model descriptor (Reflect metadata under
392
+ * Symbol.for('MODEL_DESCRIPTOR')) at class-load time. We read it directly
393
+ * so the schema is available even before orm-http's runtime mixin attaches.
394
+ * - an IColumnFilter[] — we build the same envelope shape inline from the
395
+ * column descriptors.
396
+ *
397
+ * Both cases produce the same { op, filters: [...] } envelope as the runtime
398
+ * builder in packages/orm-http/src/{model.ts,route-arg.ts}.
399
+ */
400
+ buildFilterSchema(param) {
401
+ const options = param.Options;
402
+ // Model-constructor case
403
+ if (options && typeof options === 'function') {
404
+ const columns = this.extractFilterableColumns(options);
405
+ if (columns.length > 0) {
406
+ return this.filterEnvelopeSchema(columns);
407
+ }
408
+ // Fall back to runtime mixin if metadata wasn't populated for some reason
409
+ const ctor = options;
410
+ if (typeof ctor.filterSchema === 'function') {
411
+ try {
412
+ const schema = ctor.filterSchema();
413
+ if (schema && typeof schema === 'object') {
414
+ return this.convertJsonSchema(schema);
415
+ }
416
+ }
417
+ catch {
418
+ // ignore
419
+ }
420
+ }
421
+ }
422
+ // Explicit IColumnFilter[] case
423
+ if (Array.isArray(options)) {
424
+ const columns = options
425
+ .filter((x) => !!x && typeof x === 'object' && 'column' in x && 'operators' in x);
426
+ if (columns.length > 0) {
427
+ return this.filterEnvelopeSchema(columns);
428
+ }
429
+ }
430
+ return {
431
+ type: 'object',
432
+ description: 'Filter envelope ({ op, filters: [...] })',
433
+ };
434
+ }
435
+ /**
436
+ * Read the FilterableColumns map from a model constructor via the orm model
437
+ * descriptor metadata. Keeps http-swagger free of a hard @spinajs/orm dep —
438
+ * the symbol is global (`Symbol.for('MODEL_DESCRIPTOR')`).
439
+ */
440
+ extractFilterableColumns(modelCtor) {
441
+ const MODEL_DESCRIPTOR_SYMBOL = Symbol.for('MODEL_DESCRIPTOR');
442
+ const metadata = Reflect.getMetadata(MODEL_DESCRIPTOR_SYMBOL, modelCtor);
443
+ if (!metadata)
444
+ return [];
445
+ const descriptor = metadata[modelCtor.name];
446
+ const map = descriptor?.FilterableColumns;
447
+ if (!map || typeof map.entries !== 'function')
448
+ return [];
449
+ const result = [];
450
+ for (const [column, val] of map.entries()) {
451
+ result.push({ column, operators: val?.operators ?? [] });
452
+ }
453
+ return result;
454
+ }
455
+ /**
456
+ * Build the { op, filters: [...] } envelope schema given a list of filterable columns.
457
+ *
458
+ * OAS 3.0.3 (the doc version we emit) does NOT support multi-type arrays for
459
+ * `type` — that's an OAS 3.1 feature. Swagger UI renders such schemas as
460
+ * "Unknown Type: ...". We emit `oneOf` for the Value union and single-typed
461
+ * sub-schemas for everything else.
462
+ */
463
+ filterEnvelopeSchema(columns) {
464
+ const filterItems = columns.map((x) => ({
465
+ type: 'object',
466
+ required: ['Column', 'Value', 'Operator'],
467
+ properties: {
468
+ Column: { type: 'string', enum: [x.column], example: x.column },
469
+ Value: {
470
+ oneOf: [
471
+ { type: 'string' },
472
+ { type: 'integer' },
473
+ { type: 'boolean' },
474
+ { type: 'array', items: { type: 'string' } },
475
+ ],
476
+ description: 'Scalar or array value matching the column type',
477
+ },
478
+ Operator: {
479
+ type: 'string',
480
+ enum: x.operators,
481
+ example: x.operators[0],
482
+ },
483
+ },
484
+ }));
485
+ return {
486
+ type: 'object',
487
+ required: ['op', 'filters'],
488
+ properties: {
489
+ op: { type: 'string', enum: ['and', 'or'], example: 'and' },
490
+ filters: {
491
+ type: 'array',
492
+ items: filterItems.length === 1
493
+ ? filterItems[0]
494
+ : { oneOf: filterItems },
495
+ },
496
+ },
497
+ };
498
+ }
499
+ /**
500
+ * Resolve the effective OpenAPI location for orm-http's @FromModel param.
501
+ * Mirrors FromDbModel.extract(): paramType decides where the PK is read from
502
+ * (defaults to path/req.params).
503
+ */
504
+ fromDbModelLocation(param) {
505
+ const paramType = param.Options?.paramType;
506
+ switch (paramType) {
507
+ case ParameterType.FromQuery:
508
+ case 'FromQuery':
509
+ return 'query';
510
+ case ParameterType.FromHeader:
511
+ case 'FromHeader':
512
+ return 'header';
513
+ case ParameterType.FromBody:
514
+ case 'FromBody':
515
+ // FromBody body location is not a "parameter" in OpenAPI — fall back to path
516
+ // (the common case) so the value still appears in the doc.
517
+ return 'path';
518
+ case ParameterType.FromParams:
519
+ case 'FromParams':
520
+ default:
521
+ return 'path';
522
+ }
523
+ }
524
+ /**
525
+ * Build an OpenAPI parameter from route parameter info.
526
+ */
527
+ buildParameter(param, location, doc, resolvedName) {
528
+ const schema = this.schemaFromParam(param, doc?.type);
529
+ const isArray = schema?.type === 'array';
530
+ return {
531
+ name: resolvedName || param.Name || `param_${param.Index}`,
532
+ in: location,
533
+ description: doc?.description,
534
+ required: location === 'path',
535
+ schema,
536
+ ...(isArray && location === 'query' ? { style: 'form', explode: true } : {}),
537
+ };
538
+ }
539
+ /**
540
+ * Resolve the best schema for a route parameter.
541
+ * Priority: JSDoc type → decorator schema (param.Schema) → auto-detected primitive (param.RouteParamSchema) → @Schema metadata on DTO class → runtime type inference
542
+ */
543
+ schemaFromParam(param, docType) {
544
+ if (docType) {
545
+ return this.inferSchemaFromString(docType);
546
+ }
547
+ // orm-http @Filter — describe the JSON filter envelope so API consumers know what to send.
548
+ if (param.Type === 'FilterModelRouteArg') {
549
+ return this.buildFilterSchema(param);
550
+ }
551
+ if (param.Schema && typeof param.Schema === 'object') {
552
+ return this.convertJsonSchema(param.Schema);
553
+ }
554
+ if (param.RouteParamSchema && typeof param.RouteParamSchema === 'object') {
555
+ return this.convertJsonSchema(param.RouteParamSchema);
556
+ }
557
+ const runtimeType = param.RuntimeType;
558
+ if (runtimeType) {
559
+ if (runtimeType instanceof TypedArray) {
560
+ const itemType = runtimeType.Type;
561
+ const itemSchema = Reflect.getMetadata(SCHEMA_SYMBOL, itemType) ?? (itemType?.prototype ? Reflect.getMetadata(SCHEMA_SYMBOL, itemType.prototype) : undefined);
562
+ if (itemSchema) {
563
+ return { type: 'array', items: this.convertJsonSchema(itemSchema) };
564
+ }
565
+ }
566
+ else {
567
+ const rt = runtimeType;
568
+ const classSchema = Reflect.getMetadata(SCHEMA_SYMBOL, rt) ?? (rt?.prototype ? Reflect.getMetadata(SCHEMA_SYMBOL, rt.prototype) : undefined);
569
+ if (classSchema) {
570
+ return this.convertJsonSchema(classSchema);
571
+ }
572
+ }
573
+ }
574
+ return this.inferSchema(runtimeType, undefined);
575
+ }
576
+ /**
577
+ * Convert a JSON Schema object to an OpenAPI schema, mapping known keywords.
578
+ */
579
+ convertJsonSchema(jsonSchema) {
580
+ if (!jsonSchema || typeof jsonSchema !== 'object') {
581
+ return { type: 'string' };
582
+ }
583
+ const result = {};
584
+ // OAS 3.0 forbids multi-type arrays (3.1 only). Translate to oneOf so Swagger UI
585
+ // doesn't render "Unknown Type: ...".
586
+ if (Array.isArray(jsonSchema.type)) {
587
+ result.oneOf = jsonSchema.type.map((t) => t === 'array' ? { type: 'array', items: { type: 'string' } } : { type: t });
588
+ }
589
+ else if (jsonSchema.type) {
590
+ result.type = jsonSchema.type;
591
+ }
592
+ if (jsonSchema.format)
593
+ result.format = jsonSchema.format;
594
+ if (jsonSchema.description)
595
+ result.description = jsonSchema.description;
596
+ if (jsonSchema.enum)
597
+ result.enum = jsonSchema.enum;
598
+ if (jsonSchema.required)
599
+ result.required = jsonSchema.required;
600
+ if (jsonSchema.minimum !== undefined)
601
+ result.minimum = jsonSchema.minimum;
602
+ if (jsonSchema.maximum !== undefined)
603
+ result.maximum = jsonSchema.maximum;
604
+ if (jsonSchema.minLength !== undefined)
605
+ result.minLength = jsonSchema.minLength;
606
+ if (jsonSchema.maxLength !== undefined)
607
+ result.maxLength = jsonSchema.maxLength;
608
+ if (jsonSchema.pattern)
609
+ result.pattern = jsonSchema.pattern;
610
+ if (jsonSchema.nullable)
611
+ result.nullable = jsonSchema.nullable;
612
+ if (jsonSchema.items) {
613
+ result.items = this.convertJsonSchema(jsonSchema.items);
614
+ }
615
+ if (jsonSchema.properties) {
616
+ result.properties = {};
617
+ for (const [k, v] of Object.entries(jsonSchema.properties)) {
618
+ result.properties[k] = this.convertJsonSchema(v);
619
+ }
620
+ }
621
+ if (Array.isArray(jsonSchema.oneOf)) {
622
+ result.oneOf = jsonSchema.oneOf.map((s) => this.convertJsonSchema(s));
623
+ }
624
+ if (Array.isArray(jsonSchema.anyOf)) {
625
+ result.anyOf = jsonSchema.anyOf.map((s) => this.convertJsonSchema(s));
626
+ }
627
+ if (Array.isArray(jsonSchema.allOf)) {
628
+ result.allOf = jsonSchema.allOf.map((s) => this.convertJsonSchema(s));
629
+ }
630
+ if (jsonSchema.const !== undefined) {
631
+ // OAS 3.0 has no `const` — emit single-value enum.
632
+ result.enum = [jsonSchema.const];
633
+ if (!result.type)
634
+ result.type = typeof jsonSchema.const;
635
+ }
636
+ if (jsonSchema.example !== undefined)
637
+ result.example = jsonSchema.example;
638
+ // If only enum is present with no type, infer type from first enum value
639
+ if (!result.type && result.enum && result.enum.length > 0) {
640
+ result.type = typeof result.enum[0];
641
+ }
642
+ return result;
643
+ }
644
+ /**
645
+ * Build an OpenAPI request body from body-type parameters.
646
+ */
647
+ buildRequestBody(bodyParams, _route) {
648
+ // Check if any param is a file upload
649
+ const hasFile = bodyParams.some((bp) => bp.param.Type === ParameterType.FromFile || bp.param.Type === 'FromFile');
650
+ const contentType = hasFile ? 'multipart/form-data' : 'application/json';
651
+ // If there's a single body param with a model type, use it directly
652
+ if (bodyParams.length === 1 && !hasFile) {
653
+ const bp = bodyParams[0];
654
+ return {
655
+ description: bp.doc?.description,
656
+ required: true,
657
+ content: {
658
+ [contentType]: {
659
+ schema: this.schemaFromParam(bp.param, bp.doc?.type),
660
+ },
661
+ },
662
+ };
663
+ }
664
+ // Multiple body params → build an object schema
665
+ const properties = {};
666
+ for (const bp of bodyParams) {
667
+ const name = bp.param.Name || `param_${bp.param.Index}`;
668
+ properties[name] = {
669
+ ...this.schemaFromParam(bp.param, bp.doc?.type),
670
+ description: bp.doc?.description,
671
+ };
672
+ }
673
+ return {
674
+ required: true,
675
+ content: {
676
+ [contentType]: {
677
+ schema: {
678
+ type: 'object',
679
+ properties,
680
+ },
681
+ },
682
+ },
683
+ };
684
+ }
685
+ /**
686
+ * Build response definitions from JSDoc @returns and @response tags.
687
+ * Only responses explicitly documented in JSDoc are included.
688
+ */
689
+ buildResponses(methodDoc) {
690
+ const responses = {};
691
+ if (methodDoc?.returns) {
692
+ const schema = methodDoc.returns.type
693
+ ? this.inferSchemaFromString(methodDoc.returns.type)
694
+ : (methodDoc.returns.schema ?? { type: 'object' });
695
+ responses['200'] = {
696
+ description: methodDoc.returns.description || 'Successful response',
697
+ content: { 'application/json': { schema } },
698
+ };
699
+ }
700
+ else {
701
+ responses['200'] = { description: 'Successful response' };
702
+ }
703
+ if (methodDoc?.responses) {
704
+ for (const [statusCode, resp] of Object.entries(methodDoc.responses)) {
705
+ // If the JSDoc supplies an explicit schema type, render inline.
706
+ // Otherwise, for known standard codes, $ref a reusable component so
707
+ // Swagger UI shows a clickable link and the same error envelope schema
708
+ // across the whole document.
709
+ if (resp.type) {
710
+ responses[statusCode] = {
711
+ description: resp.description,
712
+ content: { 'application/json': { schema: this.inferSchemaFromString(resp.type) } },
713
+ };
714
+ continue;
715
+ }
716
+ const refName = this.registerStandardResponse(statusCode, resp.description);
717
+ if (refName) {
718
+ responses[statusCode] = { $ref: `#/components/responses/${refName}` };
719
+ }
720
+ else {
721
+ responses[statusCode] = { description: resp.description };
722
+ }
723
+ }
724
+ }
725
+ return responses;
726
+ }
727
+ /**
728
+ * Lazily register a reusable response component for a standard HTTP status
729
+ * code (e.g. 401 → `#/components/responses/Unauthorized`) and the shared
730
+ * Error schema. Returns the component name to $ref, or undefined if the code
731
+ * isn't in our standard set.
732
+ *
733
+ * JSDoc description overrides the default; first JSDoc description wins per
734
+ * status code so the component stays stable across operations.
735
+ */
736
+ registerStandardResponse(statusCode, description) {
737
+ const name = STANDARD_RESPONSE_NAMES[statusCode];
738
+ if (!name)
739
+ return undefined;
740
+ this.ensureErrorSchema();
741
+ if (!this.registeredResponses.has(name)) {
742
+ const components = (this.document.components ??= {});
743
+ const responses = (components.responses ??= {});
744
+ responses[name] = {
745
+ description: description || STANDARD_RESPONSE_DESCRIPTIONS[statusCode] || name,
746
+ content: {
747
+ 'application/json': { schema: { $ref: '#/components/schemas/Error' } },
748
+ },
749
+ };
750
+ this.registeredResponses.add(name);
751
+ }
752
+ return name;
753
+ }
754
+ /**
755
+ * Register the shared Error schema once. Matches the runtime error envelope
756
+ * built in packages/http/src/error.ts (spread of the Error instance + message,
757
+ * with optional stack in dev).
758
+ */
759
+ ensureErrorSchema() {
760
+ if (this.errorSchemaRegistered)
761
+ return;
762
+ const components = (this.document.components ??= {});
763
+ const schemas = (components.schemas ??= {});
764
+ if (!schemas.Error) {
765
+ schemas.Error = {
766
+ type: 'object',
767
+ required: ['message'],
768
+ properties: {
769
+ message: { type: 'string', description: 'Human-readable error message' },
770
+ code: { type: 'integer', description: 'HTTP status code (when present)' },
771
+ stack: { type: 'object', description: 'Stack trace — dev environments only', nullable: true },
772
+ },
773
+ };
774
+ }
775
+ this.errorSchemaRegistered = true;
776
+ }
777
+ /**
778
+ * Infer an OpenAPI schema from a TypeScript runtime type.
779
+ */
780
+ inferSchema(runtimeType, docType) {
781
+ if (docType) {
782
+ return this.inferSchemaFromString(docType);
783
+ }
784
+ if (!runtimeType) {
785
+ return { type: 'string' };
786
+ }
787
+ // Handle primitive constructors
788
+ if (runtimeType === String)
789
+ return { type: 'string' };
790
+ if (runtimeType === Number)
791
+ return { type: 'number' };
792
+ if (runtimeType === Boolean)
793
+ return { type: 'boolean' };
794
+ if (runtimeType === Array)
795
+ return { type: 'array', items: { type: 'string' } };
796
+ if (runtimeType === Object)
797
+ return { type: 'object' };
798
+ // Handle class types (DTO, Model classes) - reference by name
799
+ if (typeof runtimeType === 'function' && runtimeType.name) {
800
+ return { type: 'object', description: `${runtimeType.name}` };
801
+ }
802
+ return { type: 'string' };
803
+ }
804
+ /**
805
+ * Infer schema from a JSDoc type string like {string}, {number}, {MyDto}
806
+ */
807
+ inferSchemaFromString(typeStr) {
808
+ const cleaned = typeStr.replace(/[{}]/g, '').trim();
809
+ switch (cleaned.toLowerCase()) {
810
+ case 'string':
811
+ return { type: 'string' };
812
+ case 'number':
813
+ case 'integer':
814
+ return { type: 'number' };
815
+ case 'boolean':
816
+ return { type: 'boolean' };
817
+ case 'object':
818
+ return { type: 'object' };
819
+ case 'array':
820
+ return { type: 'array', items: { type: 'string' } };
821
+ default:
822
+ // Could be a DTO/Model class name
823
+ return { type: 'object', description: cleaned };
824
+ }
825
+ }
826
+ /**
827
+ * Try to parse a string as JSON, return as-is if not valid JSON.
828
+ */
829
+ tryParseJson(value) {
830
+ if (!value)
831
+ return undefined;
832
+ try {
833
+ return JSON.parse(value);
834
+ }
835
+ catch {
836
+ return value;
837
+ }
838
+ }
839
+ }
840
+ //# sourceMappingURL=openapi-builder.js.map