@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.
- package/lib/cjs/cli/GenerateSwaggerCache.d.ts +10 -0
- package/lib/cjs/cli/GenerateSwaggerCache.d.ts.map +1 -0
- package/lib/cjs/cli/GenerateSwaggerCache.js +43 -0
- package/lib/cjs/cli/GenerateSwaggerCache.js.map +1 -0
- package/lib/cjs/config/http-swagger.d.ts +42 -0
- package/lib/cjs/config/http-swagger.d.ts.map +1 -0
- package/lib/cjs/config/http-swagger.js +84 -0
- package/lib/cjs/config/http-swagger.js.map +1 -0
- package/lib/cjs/controllers/SwaggerController.d.ts +26 -0
- package/lib/cjs/controllers/SwaggerController.d.ts.map +1 -0
- package/lib/cjs/controllers/SwaggerController.js +74 -0
- package/lib/cjs/controllers/SwaggerController.js.map +1 -0
- package/lib/cjs/index.d.ts +7 -0
- package/lib/cjs/index.d.ts.map +1 -0
- package/lib/cjs/index.js +23 -0
- package/lib/cjs/index.js.map +1 -0
- package/lib/cjs/interfaces.d.ts +211 -0
- package/lib/cjs/interfaces.d.ts.map +1 -0
- package/lib/cjs/interfaces.js +3 -0
- package/lib/cjs/interfaces.js.map +1 -0
- package/lib/cjs/openapi-builder.d.ts +153 -0
- package/lib/cjs/openapi-builder.d.ts.map +1 -0
- package/lib/cjs/openapi-builder.js +844 -0
- package/lib/cjs/openapi-builder.js.map +1 -0
- package/lib/cjs/package.json +1 -0
- package/lib/cjs/swagger-cache.d.ts +18 -0
- package/lib/cjs/swagger-cache.d.ts.map +1 -0
- package/lib/cjs/swagger-cache.js +110 -0
- package/lib/cjs/swagger-cache.js.map +1 -0
- package/lib/cjs/swagger-service.d.ts +24 -0
- package/lib/cjs/swagger-service.d.ts.map +1 -0
- package/lib/cjs/swagger-service.js +88 -0
- package/lib/cjs/swagger-service.js.map +1 -0
- package/lib/mjs/cli/GenerateSwaggerCache.d.ts +10 -0
- package/lib/mjs/cli/GenerateSwaggerCache.d.ts.map +1 -0
- package/lib/mjs/cli/GenerateSwaggerCache.js +40 -0
- package/lib/mjs/cli/GenerateSwaggerCache.js.map +1 -0
- package/lib/mjs/config/http-swagger.d.ts +42 -0
- package/lib/mjs/config/http-swagger.d.ts.map +1 -0
- package/lib/mjs/config/http-swagger.js +82 -0
- package/lib/mjs/config/http-swagger.js.map +1 -0
- package/lib/mjs/controllers/SwaggerController.d.ts +26 -0
- package/lib/mjs/controllers/SwaggerController.d.ts.map +1 -0
- package/lib/mjs/controllers/SwaggerController.js +71 -0
- package/lib/mjs/controllers/SwaggerController.js.map +1 -0
- package/lib/mjs/index.d.ts +7 -0
- package/lib/mjs/index.d.ts.map +1 -0
- package/lib/mjs/index.js +7 -0
- package/lib/mjs/index.js.map +1 -0
- package/lib/mjs/interfaces.d.ts +211 -0
- package/lib/mjs/interfaces.d.ts.map +1 -0
- package/lib/mjs/interfaces.js +2 -0
- package/lib/mjs/interfaces.js.map +1 -0
- package/lib/mjs/openapi-builder.d.ts +153 -0
- package/lib/mjs/openapi-builder.d.ts.map +1 -0
- package/lib/mjs/openapi-builder.js +840 -0
- package/lib/mjs/openapi-builder.js.map +1 -0
- package/lib/mjs/package.json +1 -0
- package/lib/mjs/swagger-cache.d.ts +18 -0
- package/lib/mjs/swagger-cache.d.ts.map +1 -0
- package/lib/mjs/swagger-cache.js +107 -0
- package/lib/mjs/swagger-cache.js.map +1 -0
- package/lib/mjs/swagger-service.d.ts +24 -0
- package/lib/mjs/swagger-service.d.ts.map +1 -0
- package/lib/mjs/swagger-service.js +85 -0
- package/lib/mjs/swagger-service.js.map +1 -0
- package/lib/tsconfig.cjs.tsbuildinfo +1 -0
- package/lib/tsconfig.mjs.tsbuildinfo +1 -0
- package/lib/views/swagger-ui-init.js +11 -0
- package/lib/views/swagger.pug +16 -0
- 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
|