@sezzlee/sdk-nestjs 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +360 -2
- package/dist/cache.d.ts +18 -0
- package/dist/cache.js +55 -0
- package/dist/catalog.d.ts +100 -0
- package/dist/catalog.js +456 -0
- package/dist/decorators.d.ts +172 -0
- package/dist/decorators.js +79 -0
- package/dist/discovery/body-encoding.d.ts +24 -0
- package/dist/discovery/body-encoding.js +211 -0
- package/dist/discovery/diagnostics.d.ts +4 -0
- package/dist/discovery/diagnostics.js +49 -0
- package/dist/discovery/endpoint-discovery.d.ts +52 -0
- package/dist/discovery/endpoint-discovery.js +722 -0
- package/dist/discovery/type-shape.d.ts +21 -0
- package/dist/discovery/type-shape.js +297 -0
- package/dist/dispatcher.d.ts +29 -0
- package/dist/dispatcher.js +182 -0
- package/dist/extension-points.d.ts +34 -0
- package/dist/extension-points.js +35 -0
- package/dist/families.d.ts +77 -0
- package/dist/families.js +157 -0
- package/dist/files.d.ts +43 -0
- package/dist/files.js +13 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +26 -0
- package/dist/invoke-result-mapper.d.ts +16 -0
- package/dist/invoke-result-mapper.js +44 -0
- package/dist/markers.d.ts +5 -0
- package/dist/markers.js +27 -0
- package/dist/meta-tools.d.ts +18 -0
- package/dist/meta-tools.js +306 -0
- package/dist/options-validation.d.ts +17 -0
- package/dist/options-validation.js +79 -0
- package/dist/options.d.ts +201 -0
- package/dist/options.js +151 -0
- package/dist/outer-connection.d.ts +18 -0
- package/dist/outer-connection.js +23 -0
- package/dist/sezzlee.module.d.ts +16 -0
- package/dist/sezzlee.module.js +180 -0
- package/dist/synthetic-context.d.ts +18 -0
- package/dist/synthetic-context.js +131 -0
- package/dist/transport/audience.d.ts +2 -0
- package/dist/transport/audience.js +21 -0
- package/dist/transport/protected-resource-metadata.d.ts +6 -0
- package/dist/transport/protected-resource-metadata.js +23 -0
- package/dist/transport/streamable-http.d.ts +25 -0
- package/dist/transport/streamable-http.js +52 -0
- package/dist/visibility/evaluator.d.ts +8 -0
- package/dist/visibility/evaluator.js +5 -0
- package/dist/visibility/probe.d.ts +25 -0
- package/dist/visibility/probe.js +104 -0
- package/dist/visibility/provider.d.ts +23 -0
- package/dist/visibility/provider.js +118 -0
- package/package.json +99 -3
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import { ParseArrayPipe, ParseBoolPipe, ParseEnumPipe, ParseFloatPipe, ParseIntPipe, ParseUUIDPipe, RequestMethod, } from "@nestjs/common";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { allowsAdditional, flattenableBody, simplifySchema, typeOf, } from "@sezzlee/core";
|
|
4
|
+
import { markersOf, } from "../decorators.js";
|
|
5
|
+
import { bodyEncodingOf } from "./body-encoding.js";
|
|
6
|
+
import { atLeast, severityOf } from "./diagnostics.js";
|
|
7
|
+
import { NestTypeShapeBinder, } from "./type-shape.js";
|
|
8
|
+
const load = createRequire(import.meta.url);
|
|
9
|
+
const { GUARDS_METADATA, HTTP_CODE_METADATA, METHOD_METADATA, MODULE_PATH, PATH_METADATA, ROUTE_ARGS_METADATA, VERSION_METADATA, } = load("@nestjs/common/constants");
|
|
10
|
+
const { RouteParamtypes } = load("@nestjs/common/enums/route-paramtypes.enum");
|
|
11
|
+
const { RoutePathFactory } = load("@nestjs/core/router/route-path-factory");
|
|
12
|
+
const BODY = RouteParamtypes["BODY"];
|
|
13
|
+
const QUERY = RouteParamtypes["QUERY"];
|
|
14
|
+
const PARAM = RouteParamtypes["PARAM"];
|
|
15
|
+
const HEADERS = RouteParamtypes["HEADERS"];
|
|
16
|
+
const FILE = RouteParamtypes["FILE"];
|
|
17
|
+
const FILES = RouteParamtypes["FILES"];
|
|
18
|
+
const RAW_BODY = RouteParamtypes["RAW_BODY"];
|
|
19
|
+
/** `RoutePathFactory` reads `getGlobalPrefixOptions()`; with no application there is no exclusion list. */
|
|
20
|
+
export function createRoutePaths(applicationConfig) {
|
|
21
|
+
return new RoutePathFactory(applicationConfig ?? { getGlobalPrefixOptions: () => ({}) });
|
|
22
|
+
}
|
|
23
|
+
const withoutApplication = createRoutePaths();
|
|
24
|
+
const methodNames = {
|
|
25
|
+
[RequestMethod.GET]: "GET",
|
|
26
|
+
[RequestMethod.POST]: "POST",
|
|
27
|
+
[RequestMethod.PUT]: "PUT",
|
|
28
|
+
[RequestMethod.DELETE]: "DELETE",
|
|
29
|
+
[RequestMethod.PATCH]: "PATCH",
|
|
30
|
+
[RequestMethod.HEAD]: "HEAD",
|
|
31
|
+
};
|
|
32
|
+
const pipeScalars = [
|
|
33
|
+
[ParseIntPipe, { type: "integer" }],
|
|
34
|
+
[ParseFloatPipe, { type: "number" }],
|
|
35
|
+
[ParseBoolPipe, { type: "boolean" }],
|
|
36
|
+
[ParseUUIDPipe, { type: "string", format: "uuid" }],
|
|
37
|
+
[ParseEnumPipe, { type: "string" }],
|
|
38
|
+
];
|
|
39
|
+
const queryScalars = new Set(["string", "number", "integer", "boolean"]);
|
|
40
|
+
export function discoverEndpoints(controllers, options = {}) {
|
|
41
|
+
const found = [];
|
|
42
|
+
for (const candidate of controllers) {
|
|
43
|
+
found.push(...fromController(candidate, options));
|
|
44
|
+
}
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
47
|
+
/** `RouterModule` writes the module path under a key scoped to the application instance, falling back to the bare key. */
|
|
48
|
+
export function modulePathOf(moduleMetatype, applicationId) {
|
|
49
|
+
if (typeof moduleMetatype !== "function") {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const scoped = Reflect.getMetadata(MODULE_PATH + applicationId, moduleMetatype);
|
|
53
|
+
return (scoped ??
|
|
54
|
+
Reflect.getMetadata(MODULE_PATH, moduleMetatype));
|
|
55
|
+
}
|
|
56
|
+
function fromController(candidate, options) {
|
|
57
|
+
const controller = candidate.metatype;
|
|
58
|
+
const prototype = controller.prototype;
|
|
59
|
+
const classPaths = pathsOf(controller);
|
|
60
|
+
const controllerVersion = Reflect.getMetadata(VERSION_METADATA, controller);
|
|
61
|
+
const containerMarkers = markersOf(controller);
|
|
62
|
+
const results = [];
|
|
63
|
+
for (const handlerName of handlerNames(prototype)) {
|
|
64
|
+
const handler = prototype[handlerName];
|
|
65
|
+
const verb = Reflect.getMetadata(METHOD_METADATA, handler);
|
|
66
|
+
if (verb === undefined) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const method = methodNames[verb];
|
|
70
|
+
const operationMarkers = markersOf(handler);
|
|
71
|
+
const hints = mergeHints(containerMarkers, operationMarkers);
|
|
72
|
+
if (method === undefined) {
|
|
73
|
+
options.report?.({
|
|
74
|
+
code: "unsupported_method",
|
|
75
|
+
message: `${controller.name}.${handlerName} is bound to an HTTP method with no neutral-model counterpart; endpoint skipped.`,
|
|
76
|
+
});
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for (const route of routesOf(candidate, classPaths, pathsOf(handler), controllerVersion, Reflect.getMetadata(VERSION_METADATA, handler), verb, options)) {
|
|
80
|
+
if (/[*]/.test(route)) {
|
|
81
|
+
options.report?.({
|
|
82
|
+
code: "unsupported_binding",
|
|
83
|
+
message: `${controller.name}.${handlerName} uses a wildcard route segment, which the neutral route model cannot express; endpoint skipped.`,
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const built = describe(controller, handlerName, handler, method, route, hints, options);
|
|
88
|
+
if (built === undefined) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
results.push({
|
|
92
|
+
descriptor: built,
|
|
93
|
+
controller,
|
|
94
|
+
handlerName,
|
|
95
|
+
containerMarkers: containerMarkers.map((marker) => marker.include),
|
|
96
|
+
operationMarkers: operationMarkers.map((marker) => marker.include),
|
|
97
|
+
hints,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
103
|
+
function routesOf(candidate, classPaths, methodPaths, controllerVersion, methodVersion, requestMethod, options) {
|
|
104
|
+
const factory = options.routePaths ?? withoutApplication;
|
|
105
|
+
const routes = new Set();
|
|
106
|
+
for (const ctrlPath of classPaths) {
|
|
107
|
+
for (const methodPath of methodPaths) {
|
|
108
|
+
const created = factory.create({
|
|
109
|
+
ctrlPath,
|
|
110
|
+
methodPath,
|
|
111
|
+
...(options.globalPrefix === undefined
|
|
112
|
+
? {}
|
|
113
|
+
: { globalPrefix: options.globalPrefix }),
|
|
114
|
+
...(candidate.modulePath === undefined
|
|
115
|
+
? {}
|
|
116
|
+
: { modulePath: candidate.modulePath }),
|
|
117
|
+
...(controllerVersion === undefined ? {} : { controllerVersion }),
|
|
118
|
+
...(methodVersion === undefined ? {} : { methodVersion }),
|
|
119
|
+
...(options.versioningOptions === undefined
|
|
120
|
+
? {}
|
|
121
|
+
: { versioningOptions: options.versioningOptions }),
|
|
122
|
+
}, requestMethod);
|
|
123
|
+
for (const route of created) {
|
|
124
|
+
routes.add(normalizeRoute(route));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return [...routes];
|
|
129
|
+
}
|
|
130
|
+
function describe(controller, handlerName, handler, method, route, hints, options) {
|
|
131
|
+
const args = argumentsOf(controller, handlerName);
|
|
132
|
+
const paramTypes = (Reflect.getMetadata("design:paramtypes", controller.prototype, handlerName) ?? []);
|
|
133
|
+
const reported = new Set();
|
|
134
|
+
const tracked = {
|
|
135
|
+
...options,
|
|
136
|
+
report: (diagnostic) => {
|
|
137
|
+
reported.add(`${diagnostic.code}\u0000${diagnostic.message}`);
|
|
138
|
+
options.report?.(diagnostic);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
const declared = [];
|
|
142
|
+
const expanded = [];
|
|
143
|
+
let bodySchema;
|
|
144
|
+
let bodyFields;
|
|
145
|
+
let unsupported = false;
|
|
146
|
+
let unresolvedQuery = false;
|
|
147
|
+
const files = [];
|
|
148
|
+
for (const [key, entry] of Object.entries(args)) {
|
|
149
|
+
const kind = Number(key.split(":")[0]);
|
|
150
|
+
const name = typeof entry.data === "string" ? entry.data : undefined;
|
|
151
|
+
const declaredType = paramTypes[entry.index];
|
|
152
|
+
switch (kind) {
|
|
153
|
+
case QUERY: {
|
|
154
|
+
if (name !== undefined) {
|
|
155
|
+
const grouped = options.queryGrouping === "group"
|
|
156
|
+
? groupedQueryFor(controller, handlerName, name, declaredType, tracked)
|
|
157
|
+
: undefined;
|
|
158
|
+
declared.push(grouped ?? {
|
|
159
|
+
name,
|
|
160
|
+
in: "query",
|
|
161
|
+
required: false,
|
|
162
|
+
schema: scalarFor(entry, declaredType),
|
|
163
|
+
});
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
const members = queryFor(controller, handlerName, declaredType, tracked);
|
|
167
|
+
if (members === undefined) {
|
|
168
|
+
unresolvedQuery = true;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
expanded.push(...members);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case HEADERS:
|
|
175
|
+
if (name === undefined) {
|
|
176
|
+
options.report?.({
|
|
177
|
+
code: "unbound_header_object",
|
|
178
|
+
message: `${controller.name}.${handlerName} binds the whole header object; its members cannot be read, so they are omitted from the tool. Bind the headers the tool needs by name.`,
|
|
179
|
+
});
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
declared.push({
|
|
183
|
+
name,
|
|
184
|
+
in: "header",
|
|
185
|
+
required: false,
|
|
186
|
+
schema: scalarFor(entry, declaredType),
|
|
187
|
+
});
|
|
188
|
+
break;
|
|
189
|
+
case BODY:
|
|
190
|
+
if (name === undefined) {
|
|
191
|
+
bodySchema = bodyFor(declaredType, tracked);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
bodyFields ??= {};
|
|
195
|
+
bodyFields[name] = bodyFor(declaredType, tracked);
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
case FILE:
|
|
199
|
+
files.push("single");
|
|
200
|
+
break;
|
|
201
|
+
case FILES:
|
|
202
|
+
files.push("multiple");
|
|
203
|
+
break;
|
|
204
|
+
case RAW_BODY:
|
|
205
|
+
unsupported = true;
|
|
206
|
+
break;
|
|
207
|
+
default:
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (unsupported) {
|
|
212
|
+
options.report?.({
|
|
213
|
+
code: "unsupported_binding",
|
|
214
|
+
message: `${controller.name}.${handlerName} binds the raw body, which sezzlee has no writer for; endpoint skipped.`,
|
|
215
|
+
});
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
if (bodySchema !== undefined && bodyFields !== undefined) {
|
|
219
|
+
options.report?.({
|
|
220
|
+
code: "multiple_body_bindings",
|
|
221
|
+
message: `${controller.name}.${handlerName} mixes a whole-body binding with named body members; endpoint skipped.`,
|
|
222
|
+
});
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
if (unresolvedQuery &&
|
|
226
|
+
atLeast((options.severity ?? severityOf)("unresolved_query_shape"), "endpointDropped")) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
if (bodyFields !== undefined) {
|
|
230
|
+
bodySchema = { type: "object", properties: bodyFields };
|
|
231
|
+
}
|
|
232
|
+
const encoding = bodyEncodingOf({
|
|
233
|
+
where: `${controller.name}.${handlerName}`,
|
|
234
|
+
controller,
|
|
235
|
+
handler,
|
|
236
|
+
hints,
|
|
237
|
+
files,
|
|
238
|
+
schema: bodySchema,
|
|
239
|
+
report: options.report,
|
|
240
|
+
});
|
|
241
|
+
if (encoding === undefined) {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
bodySchema = encoding.schema;
|
|
245
|
+
const pathParameters = placeholdersOf(route).map((placeholder) => ({
|
|
246
|
+
name: placeholder,
|
|
247
|
+
in: "path",
|
|
248
|
+
required: true,
|
|
249
|
+
schema: pathSchema(args, paramTypes, placeholder) ??
|
|
250
|
+
{ type: "string" },
|
|
251
|
+
}));
|
|
252
|
+
const claimed = new Set([
|
|
253
|
+
...pathParameters.map((parameter) => parameter.name),
|
|
254
|
+
...declared.map((parameter) => parameter.name),
|
|
255
|
+
]);
|
|
256
|
+
for (const parameter of expanded.filter((member) => claimed.has(member.name))) {
|
|
257
|
+
options.report?.({
|
|
258
|
+
code: "query_member_shadowed",
|
|
259
|
+
message: `${controller.name}.${handlerName} flattens a query member named '${parameter.name}', which a path parameter or a named binding already claims; the member is omitted from the tool. Rename it, or bind it by name.`,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const parameters = [
|
|
263
|
+
...pathParameters,
|
|
264
|
+
...declared.filter((parameter) => parameter.in !== "path"),
|
|
265
|
+
...expanded.filter((parameter) => !claimed.has(parameter.name)),
|
|
266
|
+
].map((parameter) => applyParameterHints(parameter, hints));
|
|
267
|
+
const responses = responsesOf(controller, handlerName, handler, method, hints, options, reported);
|
|
268
|
+
const descriptor = {
|
|
269
|
+
operationId: handlerName,
|
|
270
|
+
container: controller.name,
|
|
271
|
+
method,
|
|
272
|
+
route,
|
|
273
|
+
auth: authOf(controller, handler, options.globalGuards ?? []),
|
|
274
|
+
tags: [controller.name.replace(/Controller$/, "")],
|
|
275
|
+
...(parameters.length === 0 ? {} : { parameters }),
|
|
276
|
+
...(bodySchema === undefined
|
|
277
|
+
? {}
|
|
278
|
+
: {
|
|
279
|
+
requestBody: {
|
|
280
|
+
schema: bodySchema,
|
|
281
|
+
...(hints.bodyRequired === undefined
|
|
282
|
+
? {}
|
|
283
|
+
: { required: hints.bodyRequired }),
|
|
284
|
+
...(encoding.contentType === undefined
|
|
285
|
+
? {}
|
|
286
|
+
: { contentType: encoding.contentType }),
|
|
287
|
+
},
|
|
288
|
+
}),
|
|
289
|
+
...(responses === undefined ? {} : { responses }),
|
|
290
|
+
};
|
|
291
|
+
const description = descriptionOf(handler, hints);
|
|
292
|
+
return description === undefined
|
|
293
|
+
? descriptor
|
|
294
|
+
: { ...descriptor, description };
|
|
295
|
+
}
|
|
296
|
+
function authOf(controller, handler, globalGuards) {
|
|
297
|
+
const guards = [
|
|
298
|
+
...globalGuards,
|
|
299
|
+
...guardsOn(controller),
|
|
300
|
+
...guardsOn(handler),
|
|
301
|
+
];
|
|
302
|
+
if (guards.length === 0) {
|
|
303
|
+
return { anonymous: "unknown", policies: [], imperative: false };
|
|
304
|
+
}
|
|
305
|
+
let anonymous;
|
|
306
|
+
const policies = [];
|
|
307
|
+
for (const guard of guards) {
|
|
308
|
+
const declaration = declarationOf(guard);
|
|
309
|
+
if (declaration === undefined) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (declaration.anonymous !== undefined) {
|
|
313
|
+
anonymous =
|
|
314
|
+
declaration.anonymous === "no" || anonymous === "no"
|
|
315
|
+
? "no"
|
|
316
|
+
: declaration.anonymous;
|
|
317
|
+
}
|
|
318
|
+
for (const policy of declaration.policies ?? []) {
|
|
319
|
+
if (!policies.includes(policy)) {
|
|
320
|
+
policies.push(policy);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
anonymous: anonymous ?? "unknown",
|
|
326
|
+
policies,
|
|
327
|
+
imperative: guards.some((guard) => declarationOf(guard) === undefined),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
function declarationOf(guard) {
|
|
331
|
+
const instance = typeof guard === "function"
|
|
332
|
+
? guard.prototype
|
|
333
|
+
: guard;
|
|
334
|
+
const describe = instance
|
|
335
|
+
?.describeVisibility;
|
|
336
|
+
if (typeof describe !== "function") {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
return describe.call(instance);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function guardsOn(target) {
|
|
347
|
+
const declared = Reflect.getMetadata(GUARDS_METADATA, target);
|
|
348
|
+
return declared ?? [];
|
|
349
|
+
}
|
|
350
|
+
function descriptionOf(handler, hints) {
|
|
351
|
+
if (hints.description !== undefined && hints.description.length > 0) {
|
|
352
|
+
return hints.description;
|
|
353
|
+
}
|
|
354
|
+
const operation = Reflect.getMetadata("swagger/apiOperation", handler);
|
|
355
|
+
const declared = operation?.description ?? operation?.summary;
|
|
356
|
+
return declared !== undefined && declared.length > 0 ? declared : undefined;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Constructors `design:returntype` writes when it has erased the type it was asked about.
|
|
360
|
+
*
|
|
361
|
+
* TypeScript emits the declared return type with its generics gone, so `Promise<OrderDto>` reaches
|
|
362
|
+
* runtime as `Promise` and `OrderDto[]` as `Array`; `any`, `unknown` and an inferred return all
|
|
363
|
+
* arrive as `Object`. Binding one of these produces a schema for the wrong type — `Promise` has no
|
|
364
|
+
* validator metadata at all — so the inference tier must decline rather than publish it.
|
|
365
|
+
*/
|
|
366
|
+
const erasedReturnTypes = new Set([Promise, Array, Object, Function]);
|
|
367
|
+
function readableReturnType(candidate) {
|
|
368
|
+
return typeof candidate === "function" && !erasedReturnTypes.has(candidate);
|
|
369
|
+
}
|
|
370
|
+
function defaultStatusOf(handler, method) {
|
|
371
|
+
const declared = Reflect.getMetadata(HTTP_CODE_METADATA, handler);
|
|
372
|
+
return declared === undefined
|
|
373
|
+
? method === "POST"
|
|
374
|
+
? "201"
|
|
375
|
+
: "200"
|
|
376
|
+
: String(declared);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Writes a response body's schema, keeping the read-only members the request side drops.
|
|
380
|
+
*
|
|
381
|
+
* A get-only member is a response field precisely because it is read-only, so dropping it would
|
|
382
|
+
* hide the field from the agent (schema-conversion-rules.md Table 4). One DTO bound as both request
|
|
383
|
+
* body and response would otherwise report every shape diagnostic twice, so `reported` — seeded by
|
|
384
|
+
* the request pass — bounds the second pass to what this endpoint has not already said.
|
|
385
|
+
*/
|
|
386
|
+
function responseSchemaOf(declaredType, isArray, options, reported) {
|
|
387
|
+
const { schema, diagnostics } = shapeOf(declaredType, options, {
|
|
388
|
+
dropReadOnlyProperties: false,
|
|
389
|
+
});
|
|
390
|
+
for (const diagnostic of diagnostics) {
|
|
391
|
+
const key = `${diagnostic.code}\u0000${diagnostic.message}`;
|
|
392
|
+
if (reported.has(key)) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
reported.add(key);
|
|
396
|
+
options.report?.(diagnostic);
|
|
397
|
+
}
|
|
398
|
+
return isArray ? { type: "array", items: schema } : schema;
|
|
399
|
+
}
|
|
400
|
+
function bodyOfDeclaration(declaration, options, reported) {
|
|
401
|
+
if (typeof declaration === "function") {
|
|
402
|
+
return { schema: responseSchemaOf(declaration, false, options, reported) };
|
|
403
|
+
}
|
|
404
|
+
if (Array.isArray(declaration)) {
|
|
405
|
+
return {
|
|
406
|
+
schema: responseSchemaOf(declaration[0], true, options, reported),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
return "schema" in declaration ? { schema: declaration.schema } : {};
|
|
410
|
+
}
|
|
411
|
+
function declaredResponses(hints, options, reported) {
|
|
412
|
+
const entries = Object.entries(hints.responses ?? {});
|
|
413
|
+
if (entries.length === 0) {
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
return Object.fromEntries(entries.map(([status, declaration]) => [
|
|
417
|
+
status,
|
|
418
|
+
bodyOfDeclaration(declaration, options, reported),
|
|
419
|
+
]));
|
|
420
|
+
}
|
|
421
|
+
function swaggerResponses(handler, options, reported) {
|
|
422
|
+
const metadata = Reflect.getMetadata("swagger/apiResponse", handler);
|
|
423
|
+
const entries = Object.entries(metadata ?? {}).filter(([status]) => /^[1-5][0-9]{2}$/.test(status));
|
|
424
|
+
if (entries.length === 0) {
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
return Object.fromEntries(entries.map(([status, entry]) => [
|
|
428
|
+
status,
|
|
429
|
+
readableReturnType(entry?.type)
|
|
430
|
+
? {
|
|
431
|
+
schema: responseSchemaOf(entry?.type, entry?.isArray === true, options, reported),
|
|
432
|
+
}
|
|
433
|
+
: {},
|
|
434
|
+
]));
|
|
435
|
+
}
|
|
436
|
+
function inferredResponses(controller, handlerName, handler, method, options, reported) {
|
|
437
|
+
const returnType = Reflect.getMetadata("design:returntype", controller.prototype, handlerName);
|
|
438
|
+
if (!readableReturnType(returnType)) {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
[defaultStatusOf(handler, method)]: {
|
|
443
|
+
schema: responseSchemaOf(returnType, false, options, reported),
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
function responsesOf(controller, handlerName, handler, method, hints, options, reported) {
|
|
448
|
+
return (declaredResponses(hints, options, reported) ??
|
|
449
|
+
swaggerResponses(handler, options, reported) ??
|
|
450
|
+
inferredResponses(controller, handlerName, handler, method, options, reported));
|
|
451
|
+
}
|
|
452
|
+
function shapeOf(declaredType, options, simplification = {}) {
|
|
453
|
+
const collected = [];
|
|
454
|
+
const binder = new NestTypeShapeBinder({
|
|
455
|
+
...options.schema,
|
|
456
|
+
report: (diagnostic) => collected.push(diagnostic),
|
|
457
|
+
});
|
|
458
|
+
const { schema, diagnostics } = simplifySchema(binder.bind(declaredType), simplification);
|
|
459
|
+
collected.push(...diagnostics);
|
|
460
|
+
return { schema, diagnostics: collected };
|
|
461
|
+
}
|
|
462
|
+
function bodyFor(declaredType, options) {
|
|
463
|
+
const { schema, diagnostics } = shapeOf(declaredType, options);
|
|
464
|
+
for (const diagnostic of diagnostics) {
|
|
465
|
+
options.report?.(diagnostic);
|
|
466
|
+
}
|
|
467
|
+
return schema;
|
|
468
|
+
}
|
|
469
|
+
function isQueryable(schema) {
|
|
470
|
+
const type = typeOf(schema);
|
|
471
|
+
if (type === "array") {
|
|
472
|
+
const items = schema.items;
|
|
473
|
+
return items !== undefined && queryScalars.has(typeOf(items) ?? "");
|
|
474
|
+
}
|
|
475
|
+
return queryScalars.has(type ?? "");
|
|
476
|
+
}
|
|
477
|
+
function queryFor(controller, handlerName, declaredType, options) {
|
|
478
|
+
const { schema, diagnostics } = shapeOf(declaredType, options);
|
|
479
|
+
const flattenable = flattenableBody(schema);
|
|
480
|
+
const properties = flattenable?.properties ?? {};
|
|
481
|
+
const unresolved = () => {
|
|
482
|
+
options.report?.({
|
|
483
|
+
code: "unresolved_query_shape",
|
|
484
|
+
message: `${controller.name}.${handlerName} binds the whole query object but no member of '${nameOf(declaredType)}' can be read, so the tool would carry no filters. Decorate its properties with class-validator, or declare options.schema.typeShape.`,
|
|
485
|
+
});
|
|
486
|
+
return undefined;
|
|
487
|
+
};
|
|
488
|
+
if (allowsAdditional(schema) || Object.keys(properties).length === 0) {
|
|
489
|
+
return unresolved();
|
|
490
|
+
}
|
|
491
|
+
const required = new Set(flattenable?.required ?? []);
|
|
492
|
+
const parameters = [];
|
|
493
|
+
const skipped = [];
|
|
494
|
+
for (const [name, property] of Object.entries(properties)) {
|
|
495
|
+
if (!isQueryable(property)) {
|
|
496
|
+
skipped.push(name);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
parameters.push({
|
|
500
|
+
name,
|
|
501
|
+
in: "query",
|
|
502
|
+
required: required.has(name),
|
|
503
|
+
schema: property,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
if (parameters.length === 0) {
|
|
507
|
+
return unresolved();
|
|
508
|
+
}
|
|
509
|
+
for (const diagnostic of diagnostics) {
|
|
510
|
+
if (diagnostic.code !== "unreadable_shape") {
|
|
511
|
+
options.report?.(diagnostic);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (skipped.length > 0) {
|
|
515
|
+
options.report?.({
|
|
516
|
+
code: "unbound_query_object",
|
|
517
|
+
message: `${controller.name}.${handlerName} binds the whole query object; ${skipped.join(", ")} cannot be expressed as query parameters and are omitted from the tool.`,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
return parameters;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Folds a named `@Query('x')` DTO into one object-valued parameter.
|
|
524
|
+
*
|
|
525
|
+
* Declining is not a failure: an unreadable or inexpressible shape falls back
|
|
526
|
+
* to the binding this SDK already produced, so turning grouping on cannot drop
|
|
527
|
+
* an endpoint that stands today. Express parses the bracket form only under
|
|
528
|
+
* `query parser: 'extended'`, which {@link SezzleeCatalog} checks once the
|
|
529
|
+
* catalog is built.
|
|
530
|
+
*/
|
|
531
|
+
function groupedQueryFor(controller, handlerName, groupAs, declaredType, options) {
|
|
532
|
+
const { schema } = shapeOf(declaredType, options);
|
|
533
|
+
const flattenable = flattenableBody(schema);
|
|
534
|
+
const required = new Set(flattenable?.required ?? []);
|
|
535
|
+
const members = {};
|
|
536
|
+
const skipped = [];
|
|
537
|
+
for (const [name, property] of Object.entries(flattenable?.properties ?? {})) {
|
|
538
|
+
if (isQueryable(property)) {
|
|
539
|
+
members[name] = property;
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
skipped.push(name);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const decline = (why) => {
|
|
546
|
+
options.report?.({
|
|
547
|
+
code: "unbound_query_object",
|
|
548
|
+
message: `${controller.name}.${handlerName} binds the query object '${groupAs}', but ${why}, so it is not grouped. Decorate its properties with class-validator, or declare options.schema.typeShape.`,
|
|
549
|
+
});
|
|
550
|
+
return undefined;
|
|
551
|
+
};
|
|
552
|
+
if (allowsAdditional(schema)) {
|
|
553
|
+
return decline(`'${nameOf(declaredType)}' is open-ended`);
|
|
554
|
+
}
|
|
555
|
+
const names = Object.keys(members);
|
|
556
|
+
if (names.length === 0) {
|
|
557
|
+
return decline(`no member of '${nameOf(declaredType)}' can be expressed as a query value`);
|
|
558
|
+
}
|
|
559
|
+
if (skipped.length > 0) {
|
|
560
|
+
options.report?.({
|
|
561
|
+
code: "unbound_query_object",
|
|
562
|
+
message: `${controller.name}.${handlerName} binds the query object '${groupAs}'; ${skipped.join(", ")} cannot be expressed as query values and are omitted from the '${groupAs}' argument.`,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
const requiredMembers = names.filter((name) => required.has(name));
|
|
566
|
+
return {
|
|
567
|
+
name: groupAs,
|
|
568
|
+
in: "query",
|
|
569
|
+
required: false,
|
|
570
|
+
style: "deepObject",
|
|
571
|
+
objectNotation: "bracket",
|
|
572
|
+
schema: {
|
|
573
|
+
type: "object",
|
|
574
|
+
properties: members,
|
|
575
|
+
...(requiredMembers.length === 0 ? {} : { required: requiredMembers }),
|
|
576
|
+
additionalProperties: false,
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
function applyParameterHints(parameter, hints) {
|
|
581
|
+
const hint = hints.parameters?.[parameter.name];
|
|
582
|
+
if (hint === undefined) {
|
|
583
|
+
return parameter;
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
...parameter,
|
|
587
|
+
...(hint.required === undefined || parameter.in === "path"
|
|
588
|
+
? {}
|
|
589
|
+
: { required: hint.required }),
|
|
590
|
+
...(hint.style === undefined ? {} : { style: hint.style }),
|
|
591
|
+
...(hint.explode === undefined ? {} : { explode: hint.explode }),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function nameOf(declaredType) {
|
|
595
|
+
return typeof declaredType === "function"
|
|
596
|
+
? declaredType.name
|
|
597
|
+
: "the query type";
|
|
598
|
+
}
|
|
599
|
+
function scalarFor(entry, declaredType) {
|
|
600
|
+
const piped = pipeSchema(entry.pipes);
|
|
601
|
+
if (piped !== undefined) {
|
|
602
|
+
return piped;
|
|
603
|
+
}
|
|
604
|
+
if (declaredType === Number) {
|
|
605
|
+
return { type: "number" };
|
|
606
|
+
}
|
|
607
|
+
if (declaredType === Boolean) {
|
|
608
|
+
return { type: "boolean" };
|
|
609
|
+
}
|
|
610
|
+
return { type: "string" };
|
|
611
|
+
}
|
|
612
|
+
function pipeSchema(pipes) {
|
|
613
|
+
for (const pipe of pipes ?? []) {
|
|
614
|
+
const constructor = typeof pipe === "function"
|
|
615
|
+
? pipe
|
|
616
|
+
: pipe?.constructor;
|
|
617
|
+
if (constructor === ParseArrayPipe) {
|
|
618
|
+
return { type: "array", items: { type: "string" } };
|
|
619
|
+
}
|
|
620
|
+
for (const [candidate, schema] of pipeScalars) {
|
|
621
|
+
if (constructor === candidate) {
|
|
622
|
+
return { ...schema };
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
function pathSchema(args, paramTypes, placeholder) {
|
|
629
|
+
for (const [key, entry] of Object.entries(args)) {
|
|
630
|
+
const kind = Number(key.split(":")[0]);
|
|
631
|
+
if (kind !== PARAM || entry.data !== placeholder) {
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
return scalarFor(entry, paramTypes[entry.index]);
|
|
635
|
+
}
|
|
636
|
+
return undefined;
|
|
637
|
+
}
|
|
638
|
+
function argumentsOf(controller, handlerName) {
|
|
639
|
+
return (Reflect.getMetadata(ROUTE_ARGS_METADATA, controller, handlerName) ??
|
|
640
|
+
{});
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Flat last-wins merge, except for curation.
|
|
644
|
+
*
|
|
645
|
+
* `arguments` merges per argument and per field so a container-level rule that hides a tenant
|
|
646
|
+
* identifier survives a method-level rule that only renames something else. A flat replacement
|
|
647
|
+
* would silently unhide it.
|
|
648
|
+
*/
|
|
649
|
+
function mergeHints(container, operation) {
|
|
650
|
+
const merged = {};
|
|
651
|
+
const curation = {};
|
|
652
|
+
const variants = [];
|
|
653
|
+
for (const marker of [...container, ...operation]) {
|
|
654
|
+
for (const [key, value] of Object.entries(marker.options)) {
|
|
655
|
+
if (value === undefined) {
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (key === "arguments") {
|
|
659
|
+
Object.assign(curation, value);
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
if (key === "variants") {
|
|
663
|
+
variants.push(...value);
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
merged[key] = value;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (Object.keys(curation).length > 0) {
|
|
670
|
+
merged["arguments"] = curation;
|
|
671
|
+
}
|
|
672
|
+
if (variants.length > 0) {
|
|
673
|
+
merged["variants"] = variants;
|
|
674
|
+
}
|
|
675
|
+
return merged;
|
|
676
|
+
}
|
|
677
|
+
function handlerNames(prototype) {
|
|
678
|
+
const names = new Set();
|
|
679
|
+
let current = prototype;
|
|
680
|
+
while (current !== null && current !== Object.prototype) {
|
|
681
|
+
for (const name of Object.getOwnPropertyNames(current)) {
|
|
682
|
+
if (name !== "constructor" &&
|
|
683
|
+
typeof current[name] === "function") {
|
|
684
|
+
names.add(name);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
current = Object.getPrototypeOf(current);
|
|
688
|
+
}
|
|
689
|
+
return [...names];
|
|
690
|
+
}
|
|
691
|
+
function pathsOf(target) {
|
|
692
|
+
const declared = Reflect.getMetadata(PATH_METADATA, target);
|
|
693
|
+
if (declared === undefined) {
|
|
694
|
+
return [""];
|
|
695
|
+
}
|
|
696
|
+
const paths = Array.isArray(declared) ? declared : [declared];
|
|
697
|
+
return paths.length === 0 ? [""] : paths;
|
|
698
|
+
}
|
|
699
|
+
export function normalizeRoute(route) {
|
|
700
|
+
const segments = [];
|
|
701
|
+
for (const segment of route.split("/")) {
|
|
702
|
+
const trimmed = segment.trim();
|
|
703
|
+
if (trimmed.length > 0 && trimmed !== "/") {
|
|
704
|
+
segments.push(normalizeSegment(trimmed));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return `/${segments.join("/")}`;
|
|
708
|
+
}
|
|
709
|
+
function normalizeSegment(segment) {
|
|
710
|
+
if (!segment.startsWith(":")) {
|
|
711
|
+
return segment;
|
|
712
|
+
}
|
|
713
|
+
const name = segment.slice(1).replace(/[?]$/, "");
|
|
714
|
+
return `{${name}}`;
|
|
715
|
+
}
|
|
716
|
+
function placeholdersOf(route) {
|
|
717
|
+
const found = [];
|
|
718
|
+
for (const match of route.matchAll(/\{([^}]+)\}/g)) {
|
|
719
|
+
found.push(match[1]);
|
|
720
|
+
}
|
|
721
|
+
return found;
|
|
722
|
+
}
|