@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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +360 -2
  3. package/dist/cache.d.ts +18 -0
  4. package/dist/cache.js +55 -0
  5. package/dist/catalog.d.ts +100 -0
  6. package/dist/catalog.js +456 -0
  7. package/dist/decorators.d.ts +172 -0
  8. package/dist/decorators.js +79 -0
  9. package/dist/discovery/body-encoding.d.ts +24 -0
  10. package/dist/discovery/body-encoding.js +211 -0
  11. package/dist/discovery/diagnostics.d.ts +4 -0
  12. package/dist/discovery/diagnostics.js +49 -0
  13. package/dist/discovery/endpoint-discovery.d.ts +52 -0
  14. package/dist/discovery/endpoint-discovery.js +722 -0
  15. package/dist/discovery/type-shape.d.ts +21 -0
  16. package/dist/discovery/type-shape.js +297 -0
  17. package/dist/dispatcher.d.ts +29 -0
  18. package/dist/dispatcher.js +182 -0
  19. package/dist/extension-points.d.ts +34 -0
  20. package/dist/extension-points.js +35 -0
  21. package/dist/families.d.ts +77 -0
  22. package/dist/families.js +157 -0
  23. package/dist/files.d.ts +43 -0
  24. package/dist/files.js +13 -0
  25. package/dist/index.d.ts +48 -0
  26. package/dist/index.js +26 -0
  27. package/dist/invoke-result-mapper.d.ts +16 -0
  28. package/dist/invoke-result-mapper.js +44 -0
  29. package/dist/markers.d.ts +5 -0
  30. package/dist/markers.js +27 -0
  31. package/dist/meta-tools.d.ts +18 -0
  32. package/dist/meta-tools.js +306 -0
  33. package/dist/options-validation.d.ts +17 -0
  34. package/dist/options-validation.js +79 -0
  35. package/dist/options.d.ts +201 -0
  36. package/dist/options.js +151 -0
  37. package/dist/outer-connection.d.ts +18 -0
  38. package/dist/outer-connection.js +23 -0
  39. package/dist/sezzlee.module.d.ts +16 -0
  40. package/dist/sezzlee.module.js +180 -0
  41. package/dist/synthetic-context.d.ts +18 -0
  42. package/dist/synthetic-context.js +131 -0
  43. package/dist/transport/audience.d.ts +2 -0
  44. package/dist/transport/audience.js +21 -0
  45. package/dist/transport/protected-resource-metadata.d.ts +6 -0
  46. package/dist/transport/protected-resource-metadata.js +23 -0
  47. package/dist/transport/streamable-http.d.ts +25 -0
  48. package/dist/transport/streamable-http.js +52 -0
  49. package/dist/visibility/evaluator.d.ts +8 -0
  50. package/dist/visibility/evaluator.js +5 -0
  51. package/dist/visibility/probe.d.ts +25 -0
  52. package/dist/visibility/probe.js +104 -0
  53. package/dist/visibility/provider.d.ts +23 -0
  54. package/dist/visibility/provider.js +118 -0
  55. package/package.json +99 -3
@@ -0,0 +1,79 @@
1
+ export const hidden = {
2
+ value: (value) => ({
3
+ hide: { kind: "constant", value },
4
+ }),
5
+ from: (source) => ({
6
+ hide: { kind: "deferred", source },
7
+ }),
8
+ omit: () => ({ hide: { kind: "omit" } }),
9
+ };
10
+ /**
11
+ * Key-checks a curation against a DTO's own keys.
12
+ *
13
+ * Optional: `arguments` accepts a plain record. Reaching for this moves the
14
+ * `curation_unresolved` diagnostic from startup to compile time, which is where a typo belongs.
15
+ */
16
+ export function curate(rules) {
17
+ return rules;
18
+ }
19
+ export const MCP_SELECTION = "sezzlee:selection";
20
+ export function McpVariant(options) {
21
+ return ((_target, _property, descriptor) => {
22
+ if (descriptor?.value !== undefined) {
23
+ appendVariant(descriptor.value, options);
24
+ }
25
+ });
26
+ }
27
+ export function McpToolFamily(options) {
28
+ return ((_target, _property, descriptor) => {
29
+ if (descriptor?.value !== undefined) {
30
+ updateCarrier(descriptor.value, (carried) => ({
31
+ ...carried,
32
+ family: options,
33
+ }));
34
+ }
35
+ });
36
+ }
37
+ function append(target, marker) {
38
+ const existing = Reflect.getOwnMetadata(MCP_SELECTION, target) ?? [];
39
+ Reflect.defineMetadata(MCP_SELECTION, [...existing, marker], target);
40
+ }
41
+ function appendVariant(target, variant) {
42
+ /**
43
+ * Prepended, not appended: method decorators evaluate bottom-up, so appending would publish the
44
+ * variants in the reverse of the order they are written in, and the order is what the catalog
45
+ * zips names against.
46
+ */
47
+ updateCarrier(target, (carried) => ({
48
+ ...carried,
49
+ variants: [variant, ...(carried.variants ?? [])],
50
+ }));
51
+ }
52
+ function updateCarrier(target, update) {
53
+ const existing = Reflect.getOwnMetadata(MCP_SELECTION, target) ?? [];
54
+ const carrier = existing.find((marker) => marker.include);
55
+ const marker = {
56
+ include: true,
57
+ options: update(carrier?.options ?? {}),
58
+ };
59
+ Reflect.defineMetadata(MCP_SELECTION, carrier === undefined
60
+ ? [...existing, marker]
61
+ : existing.map((entry) => (entry === carrier ? marker : entry)), target);
62
+ }
63
+ function decorate(marker) {
64
+ return ((target, _property, descriptor) => {
65
+ append(descriptor?.value !== undefined ? descriptor.value : target, marker);
66
+ });
67
+ }
68
+ export function McpTool(options = {}) {
69
+ return decorate({ include: true, options });
70
+ }
71
+ export function McpIgnore() {
72
+ return decorate({ include: false, options: {} });
73
+ }
74
+ export function markersOf(target) {
75
+ if (target === undefined) {
76
+ return [];
77
+ }
78
+ return (Reflect.getOwnMetadata(MCP_SELECTION, target) ?? []);
79
+ }
@@ -0,0 +1,24 @@
1
+ import { type JsonSchemaObject } from "@sezzlee/core";
2
+ import type { McpToolOptions } from "../decorators.js";
3
+ import type { DiscoveryDiagnostic } from "./endpoint-discovery.js";
4
+ export type FileBinding = "single" | "multiple";
5
+ export interface BodyEncodingInput {
6
+ readonly where: string;
7
+ readonly controller: NewableFunction;
8
+ readonly handler: (...args: never[]) => unknown;
9
+ readonly hints: McpToolOptions;
10
+ readonly files: readonly FileBinding[];
11
+ readonly schema: JsonSchemaObject | undefined;
12
+ readonly report: ((diagnostic: DiscoveryDiagnostic) => void) | undefined;
13
+ }
14
+ /** `objectNotation` is never written: Nest's `qs` reads brackets, and absent means bracket. */
15
+ export interface BodyEncoding {
16
+ readonly schema: JsonSchemaObject | undefined;
17
+ readonly contentType?: string;
18
+ }
19
+ /**
20
+ * Decides the body's media type and completes its schema with the multipart file fields.
21
+ *
22
+ * @returns `undefined` when the endpoint has to be dropped; the reason has been reported.
23
+ */
24
+ export declare function bodyEncodingOf(input: BodyEncodingInput): BodyEncoding | undefined;
@@ -0,0 +1,211 @@
1
+ import { isJsonMediaType, jsonMediaType, multipartMediaType, textMediaType, urlEncodedMediaType, } from "@sezzlee/core";
2
+ function mediaTypeOf(value) {
3
+ return (value.split(";")[0] ?? "").trim().toLowerCase();
4
+ }
5
+ function declaredConsumes(controller, handler) {
6
+ const read = (target) => Reflect.getMetadata("swagger/apiConsumes", target);
7
+ const declared = read(handler) ?? read(controller);
8
+ return Array.isArray(declared)
9
+ ? declared
10
+ .filter((entry) => typeof entry === "string")
11
+ .map(mediaTypeOf)
12
+ : [];
13
+ }
14
+ function covers(accepted, mediaType) {
15
+ if (accepted === mediaType || accepted === "*/*") {
16
+ return true;
17
+ }
18
+ return accepted.endsWith("/*") && mediaType.startsWith(accepted.slice(0, -1));
19
+ }
20
+ /**
21
+ * Chooses from an `@ApiConsumes` list for an endpoint that binds no file.
22
+ *
23
+ * Guard: the choice is what the backend parses, not what the documentation says. Express's
24
+ * default JSON parser reads `application/json` only and leaves a `+json` body unparsed
25
+ * (form-body-probe N6), and Nest has no 415 filter, so the JSON family collapses to
26
+ * `application/json`. Multipart is never chosen here: multer runs only behind a file interceptor,
27
+ * so without a file binding a multipart body would reach the handler empty while a JSON one binds.
28
+ */
29
+ function chooseFrom(accepted) {
30
+ const concrete = accepted.filter((type) => !type.includes("*"));
31
+ if (accepted.some((type) => covers(type, jsonMediaType) || isJsonMediaType(type))) {
32
+ return jsonMediaType;
33
+ }
34
+ if (concrete.includes(urlEncodedMediaType)) {
35
+ return urlEncodedMediaType;
36
+ }
37
+ if (concrete.includes(textMediaType)) {
38
+ return textMediaType;
39
+ }
40
+ const other = concrete.find((type) => type !== multipartMediaType);
41
+ return other ?? jsonMediaType;
42
+ }
43
+ function isWritable(mediaType) {
44
+ return (isJsonMediaType(mediaType) ||
45
+ mediaType === urlEncodedMediaType ||
46
+ mediaType === multipartMediaType ||
47
+ mediaType === textMediaType);
48
+ }
49
+ function fileSchemaOf(declaration) {
50
+ const part = {
51
+ type: "string",
52
+ contentMediaType: declaration.mediaType ?? "application/octet-stream",
53
+ };
54
+ const schema = declaration.multiple === true ? { type: "array", items: part } : part;
55
+ return declaration.description === undefined
56
+ ? schema
57
+ : { ...schema, description: declaration.description };
58
+ }
59
+ function isBinary(schema) {
60
+ return (schema?.type === "string" &&
61
+ (schema.format === "binary" || schema.contentMediaType !== undefined));
62
+ }
63
+ /**
64
+ * Reads the file fields `@ApiBody` documents: a body schema property that is `format: "binary"`,
65
+ * or an array of them. The same metadata `@nestjs/swagger` itself writes, read by key so the SDK
66
+ * takes no dependency on it.
67
+ */
68
+ function swaggerFileFields(handler) {
69
+ const parameters = Reflect.getMetadata("swagger/apiParameters", handler);
70
+ if (!Array.isArray(parameters)) {
71
+ return undefined;
72
+ }
73
+ const found = {};
74
+ for (const parameter of parameters) {
75
+ if (parameter.in !== "body") {
76
+ continue;
77
+ }
78
+ const schema = parameter.schema;
79
+ const required = new Set(schema?.required ?? []);
80
+ for (const [name, property] of Object.entries(schema?.properties ?? {})) {
81
+ const multiple = property.type === "array" && isBinary(property.items);
82
+ if (!multiple && !isBinary(property)) {
83
+ continue;
84
+ }
85
+ const part = multiple ? property.items : property;
86
+ found[name] = {
87
+ ...(multiple ? { multiple: true } : {}),
88
+ ...(required.has(name) ? { required: true } : {}),
89
+ ...(property.description === undefined
90
+ ? {}
91
+ : { description: property.description }),
92
+ ...(part?.contentMediaType === undefined
93
+ ? {}
94
+ : { mediaType: part.contentMediaType }),
95
+ };
96
+ }
97
+ }
98
+ return Object.keys(found).length === 0 ? undefined : found;
99
+ }
100
+ function withFiles(schema, files) {
101
+ const base = schema ?? { type: "object", properties: {} };
102
+ const required = [
103
+ ...(base.required ?? []),
104
+ ...Object.entries(files)
105
+ .filter(([, declaration]) => declaration.required === true)
106
+ .map(([name]) => name),
107
+ ];
108
+ const properties = { ...(base.properties ?? {}) };
109
+ for (const [name, declaration] of Object.entries(files)) {
110
+ properties[name] = fileSchemaOf(declaration);
111
+ }
112
+ return {
113
+ ...base,
114
+ type: "object",
115
+ properties,
116
+ ...(required.length === 0 ? {} : { required }),
117
+ };
118
+ }
119
+ /**
120
+ * Inlines a form body's `$ref` members one level deep.
121
+ *
122
+ * The binder hoists every nested DTO into `$defs`, but a form field addresses its members by name
123
+ * on the wire, so the template needs them in place; a reference left behind would drop the
124
+ * endpoint as `unsupported_body_shape` for a shape that is perfectly expressible.
125
+ */
126
+ function inlineReferences(schema) {
127
+ const defs = schema?.$defs;
128
+ if (schema === undefined ||
129
+ defs === undefined ||
130
+ schema.properties === undefined) {
131
+ return schema;
132
+ }
133
+ const properties = {};
134
+ for (const [name, property] of Object.entries(schema.properties)) {
135
+ const reference = /^#\/\$defs\/(.+)$/.exec(property.$ref ?? "")?.[1];
136
+ const target = reference === undefined ? undefined : defs[reference];
137
+ properties[name] =
138
+ target === undefined ? property : structuredClone(target);
139
+ }
140
+ const inlined = { ...schema, properties };
141
+ if (!JSON.stringify(properties).includes('"$ref"')) {
142
+ delete inlined.$defs;
143
+ }
144
+ return inlined;
145
+ }
146
+ /**
147
+ * Decides the body's media type and completes its schema with the multipart file fields.
148
+ *
149
+ * @returns `undefined` when the endpoint has to be dropped; the reason has been reported.
150
+ */
151
+ export function bodyEncodingOf(input) {
152
+ const { where, hints, report } = input;
153
+ const hasFiles = input.files.length > 0;
154
+ const accepted = declaredConsumes(input.controller, input.handler);
155
+ let contentType;
156
+ if (hints.consumes !== undefined) {
157
+ contentType = mediaTypeOf(hints.consumes);
158
+ if (accepted.length > 0 &&
159
+ !accepted.some((type) => covers(type, contentType))) {
160
+ report?.({
161
+ code: "content_type_not_accepted",
162
+ message: `${where} declares consumes '${contentType}', which its @ApiConsumes list (${accepted.join(", ")}) does not accept; endpoint skipped.`,
163
+ });
164
+ return undefined;
165
+ }
166
+ }
167
+ else if (hasFiles) {
168
+ contentType = multipartMediaType;
169
+ }
170
+ else {
171
+ contentType = chooseFrom(accepted);
172
+ }
173
+ if (!isWritable(contentType)) {
174
+ report?.({
175
+ code: "unsupported_binding",
176
+ message: `${where} takes a ${contentType} body, which sezzlee has no writer for; endpoint skipped.`,
177
+ });
178
+ return undefined;
179
+ }
180
+ if (hasFiles && contentType !== multipartMediaType) {
181
+ report?.({
182
+ code: "content_type_not_accepted",
183
+ message: `${where} binds a file, which only multipart/form-data can carry, but declares '${contentType}'; endpoint skipped.`,
184
+ });
185
+ return undefined;
186
+ }
187
+ if (contentType === multipartMediaType && !hasFiles) {
188
+ report?.({
189
+ code: "body_parser_missing",
190
+ message: `${where} takes a multipart/form-data body, which Nest parses only behind a file interceptor; bind the file with @UploadedFile() or declare another media type. Endpoint skipped.`,
191
+ });
192
+ return undefined;
193
+ }
194
+ let schema = input.schema;
195
+ if (hasFiles) {
196
+ const files = hints.files ?? swaggerFileFields(input.handler);
197
+ if (files === undefined) {
198
+ report?.({
199
+ code: "unresolved_file_field",
200
+ message: `${where} binds an uploaded file, but its multipart field name cannot be read from a file interceptor. Declare it with @McpTool({ files: { <field>: {} } }) or document it with @ApiBody; endpoint skipped.`,
201
+ });
202
+ return undefined;
203
+ }
204
+ schema = withFiles(schema, files);
205
+ }
206
+ if (contentType === urlEncodedMediaType ||
207
+ contentType === multipartMediaType) {
208
+ return { schema: inlineReferences(schema), contentType };
209
+ }
210
+ return contentType === jsonMediaType ? { schema } : { schema, contentType };
211
+ }
@@ -0,0 +1,4 @@
1
+ import { type CatalogSeverity, type DiagnosticsOptions } from "@sezzlee/core";
2
+ export { atLeast } from "@sezzlee/core";
3
+ export type { CatalogDiagnostic, CatalogSeverity, DiagnosticsOptions, } from "@sezzlee/core";
4
+ export declare function severityOf(code: string, options?: DiagnosticsOptions): CatalogSeverity;
@@ -0,0 +1,49 @@
1
+ import { familySeverities, severityIn, } from "@sezzlee/core";
2
+ import { sdkFamilySeverities } from "../families.js";
3
+ export { atLeast } from "@sezzlee/core";
4
+ const defaults = {
5
+ ...familySeverities,
6
+ ...sdkFamilySeverities,
7
+ name_collision: "fatal",
8
+ ambiguous_selection: "fatal",
9
+ invalid_name: "fatal",
10
+ prm_path_prefixed: "fatal",
11
+ query_parser_not_extended: "fatal",
12
+ argument_collision: "endpointDropped",
13
+ multiple_body_bindings: "endpointDropped",
14
+ unsupported_binding: "endpointDropped",
15
+ unsupported_body_shape: "endpointDropped",
16
+ content_type_not_accepted: "endpointDropped",
17
+ unresolved_file_field: "endpointDropped",
18
+ body_parser_missing: "endpointDropped",
19
+ unsupported_method: "endpointDropped",
20
+ schema_def_conflict: "endpointDropped",
21
+ template_rejected: "endpointDropped",
22
+ duplicate_argument: "endpointDropped",
23
+ unsupported_object_style: "endpointDropped",
24
+ unsupported_object_nesting: "endpointDropped",
25
+ unresolved_query_shape: "endpointDropped",
26
+ curation_unresolved: "endpointDropped",
27
+ invalid_fill_constant: "endpointDropped",
28
+ hidden_required_omitted: "endpointDropped",
29
+ unknown_fill_source: "endpointDropped",
30
+ variant_declaration_conflict: "endpointDropped",
31
+ sealed_curation_overridden: "fatal",
32
+ ambiguous_curation: "fatal",
33
+ curated_open_body: "warning",
34
+ curation_leaks_name: "warning",
35
+ curation_leaks_name_in_argument: "warning",
36
+ curation_unused_on_kept_route: "warning",
37
+ variant_indistinguishable: "warning",
38
+ route_folded: "warning",
39
+ optional_body_argument: "warning",
40
+ synthetic_body_argument: "warning",
41
+ unflattenable_body_root: "warning",
42
+ body_field_collision: "warning",
43
+ unbound_query_object: "warning",
44
+ unbound_header_object: "warning",
45
+ query_member_shadowed: "warning",
46
+ };
47
+ export function severityOf(code, options = {}) {
48
+ return severityIn(defaults, code, options);
49
+ }
@@ -0,0 +1,52 @@
1
+ import type { EndpointDescriptor } from "@sezzlee/core";
2
+ import { type McpToolOptions } from "../decorators.js";
3
+ import { type CatalogSeverity } from "./diagnostics.js";
4
+ import { type TypeShapeBinderOptions } from "./type-shape.js";
5
+ export interface RoutePathMetadata {
6
+ readonly ctrlPath?: string;
7
+ readonly methodPath?: string;
8
+ readonly globalPrefix?: string;
9
+ readonly modulePath?: string;
10
+ readonly controllerVersion?: unknown;
11
+ readonly methodVersion?: unknown;
12
+ readonly versioningOptions?: unknown;
13
+ }
14
+ export interface RoutePaths {
15
+ create(metadata: RoutePathMetadata, requestMethod?: number): string[];
16
+ }
17
+ /** `RoutePathFactory` reads `getGlobalPrefixOptions()`; with no application there is no exclusion list. */
18
+ export declare function createRoutePaths(applicationConfig?: unknown): RoutePaths;
19
+ export interface DiscoveryDiagnostic {
20
+ readonly code: string;
21
+ readonly message: string;
22
+ }
23
+ export interface ControllerCandidate {
24
+ readonly metatype: NewableFunction;
25
+ readonly modulePath?: string;
26
+ }
27
+ export interface VisibilityDeclaration {
28
+ readonly anonymous?: "yes" | "no" | "unknown";
29
+ readonly policies?: readonly string[];
30
+ }
31
+ export interface DiscoveryOptions {
32
+ readonly globalPrefix?: string;
33
+ readonly versioningOptions?: unknown;
34
+ readonly routePaths?: RoutePaths;
35
+ readonly schema?: TypeShapeBinderOptions;
36
+ readonly globalGuards?: readonly unknown[];
37
+ readonly severity?: (code: string) => CatalogSeverity;
38
+ readonly report?: (diagnostic: DiscoveryDiagnostic) => void;
39
+ readonly queryGrouping?: "flatten" | "group";
40
+ }
41
+ export interface DiscoveredEndpoint {
42
+ readonly descriptor: EndpointDescriptor;
43
+ readonly controller: NewableFunction;
44
+ readonly handlerName: string;
45
+ readonly containerMarkers: readonly boolean[];
46
+ readonly operationMarkers: readonly boolean[];
47
+ readonly hints: McpToolOptions;
48
+ }
49
+ export declare function discoverEndpoints(controllers: readonly ControllerCandidate[], options?: DiscoveryOptions): DiscoveredEndpoint[];
50
+ /** `RouterModule` writes the module path under a key scoped to the application instance, falling back to the bare key. */
51
+ export declare function modulePathOf(moduleMetatype: unknown, applicationId: string): string | undefined;
52
+ export declare function normalizeRoute(route: string): string;