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