@silkweave/nestjs 1.11.0 → 2.0.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/.turbo/turbo-build.log +16 -17
- package/.turbo/turbo-prepack.log +7 -8
- package/README.md +98 -135
- package/build/index.d.mts +222 -197
- package/build/index.d.mts.map +1 -1
- package/build/index.mjs +698 -352
- package/build/index.mjs.map +1 -1
- package/package.json +16 -10
- package/src/decorator/mcp.ts +39 -0
- package/src/index.ts +5 -7
- package/src/lib/controllerDiscovery.ts +201 -0
- package/src/lib/metadata.ts +32 -46
- package/src/lib/rebind.ts +117 -0
- package/src/lib/reflect/classValidator.ts +56 -0
- package/src/lib/reflect/openapi.ts +87 -0
- package/src/lib/reflect/params.ts +63 -0
- package/src/lib/reflect/route.ts +58 -0
- package/src/lib/reflect/schema.ts +270 -0
- package/src/lib/reflect/swagger.ts +39 -0
- package/src/lib/silkweave.module.ts +17 -17
- package/src/lib/types.ts +14 -5
- package/tsconfig.tsbuildinfo +1 -1
- package/src/adapter/rest.ts +0 -161
- package/src/adapter/trpc.ts +0 -58
- package/src/adapter/typegen.ts +0 -55
- package/src/decorator/action.ts +0 -38
- package/src/decorator/actions.ts +0 -25
- package/src/lib/discovery.ts +0 -125
- package/src/lib/filter.ts +0 -26
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `RouteParamtypes` numeric values from `@nestjs/common` (re-declared to avoid a
|
|
6
|
+
* value import of an internal enum). These are how `@Param`/`@Query`/`@Body`/...
|
|
7
|
+
* tag each handler argument in `ROUTE_ARGS_METADATA`.
|
|
8
|
+
*/
|
|
9
|
+
export const PARAMTYPE = {
|
|
10
|
+
REQUEST: 0,
|
|
11
|
+
RESPONSE: 1,
|
|
12
|
+
NEXT: 2,
|
|
13
|
+
BODY: 3,
|
|
14
|
+
QUERY: 4,
|
|
15
|
+
PARAM: 5,
|
|
16
|
+
HEADERS: 6,
|
|
17
|
+
SESSION: 7,
|
|
18
|
+
FILE: 8,
|
|
19
|
+
FILES: 9,
|
|
20
|
+
HOST: 10,
|
|
21
|
+
IP: 11,
|
|
22
|
+
RAW_BODY: 12
|
|
23
|
+
} as const
|
|
24
|
+
|
|
25
|
+
export interface ParamSlot {
|
|
26
|
+
/** `PARAMTYPE` value - which decorator tagged this argument. */
|
|
27
|
+
paramtype: number
|
|
28
|
+
/** Handler argument position. */
|
|
29
|
+
index: number
|
|
30
|
+
/** Decorator sub-key: `@Param('id')` → `'id'`; a bare `@Body()` → `undefined`. */
|
|
31
|
+
data?: string
|
|
32
|
+
/** Parameter-bound pipes (`@Param('id', ParseIntPipe)`). */
|
|
33
|
+
pipes: unknown[]
|
|
34
|
+
/** TypeScript-emitted constructor at this position (e.g. `String`, `CreateDto`). */
|
|
35
|
+
designType?: unknown
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read and normalise the route-argument metadata for a controller method.
|
|
40
|
+
* Returns one {@link ParamSlot} per decorated handler argument, sorted by
|
|
41
|
+
* argument index.
|
|
42
|
+
*/
|
|
43
|
+
export function readParamSlots(classRef: any, methodName: string, proto: any): ParamSlot[] {
|
|
44
|
+
const raw = Reflect.getMetadata(ROUTE_ARGS_METADATA, classRef, methodName) as
|
|
45
|
+
Record<string, { index: number; data?: unknown; pipes?: unknown[] }> | undefined
|
|
46
|
+
if (!raw) { return [] }
|
|
47
|
+
const designTypes = (Reflect.getMetadata('design:paramtypes', proto, methodName) as unknown[] | undefined) ?? []
|
|
48
|
+
|
|
49
|
+
const slots: ParamSlot[] = []
|
|
50
|
+
for (const key of Object.keys(raw)) {
|
|
51
|
+
const entry = raw[key]
|
|
52
|
+
const paramtype = Number(key.split(':')[0])
|
|
53
|
+
if (Number.isNaN(paramtype)) { continue }
|
|
54
|
+
slots.push({
|
|
55
|
+
paramtype,
|
|
56
|
+
index: entry.index,
|
|
57
|
+
data: typeof entry.data === 'string' ? entry.data : undefined,
|
|
58
|
+
pipes: entry.pipes ?? [],
|
|
59
|
+
designType: designTypes[entry.index]
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
return slots.sort((a, b) => a.index - b.index)
|
|
63
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { PATH_METADATA, METHOD_METADATA } from '@nestjs/common/constants.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `RequestMethod` numeric values (from `@nestjs/common`) mapped to verbs. We
|
|
6
|
+
* map our own table rather than importing the enum to avoid pulling a value
|
|
7
|
+
* import for a handful of constants.
|
|
8
|
+
*/
|
|
9
|
+
const REQUEST_METHOD: Record<number, string> = {
|
|
10
|
+
0: 'GET',
|
|
11
|
+
1: 'POST',
|
|
12
|
+
2: 'PUT',
|
|
13
|
+
3: 'DELETE',
|
|
14
|
+
4: 'PATCH',
|
|
15
|
+
6: 'OPTIONS',
|
|
16
|
+
7: 'HEAD'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RouteInfo {
|
|
20
|
+
/** HTTP verb (`GET`/`POST`/...). `PATCH` and others outside the REST set fall back to `POST`-ish handling by callers. */
|
|
21
|
+
method: string
|
|
22
|
+
/** Full route template in Nest form, e.g. `sessions/:sessionId/channels/:channelId` (no leading slash). */
|
|
23
|
+
path: string
|
|
24
|
+
/** Full route template in OpenAPI form, e.g. `/sessions/{sessionId}/channels/{channelId}`. */
|
|
25
|
+
openapiPath: string
|
|
26
|
+
/** Names of the `:param` placeholders across controller + method path. */
|
|
27
|
+
pathParams: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeSegment(value: unknown): string {
|
|
31
|
+
if (typeof value !== 'string') { return '' }
|
|
32
|
+
return value.replace(/^\/+/, '').replace(/\/+$/, '')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function joinPath(...segments: string[]): string {
|
|
36
|
+
return segments.map(normalizeSegment).filter(Boolean).join('/')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the composed route (controller prefix + method path), HTTP verb, and
|
|
41
|
+
* path-param names for a decorated controller method, reading Nest's own
|
|
42
|
+
* `PATH_METADATA`/`METHOD_METADATA` reflection.
|
|
43
|
+
*/
|
|
44
|
+
export function reflectRoute(classRef: any, method: (...args: any[]) => any): RouteInfo {
|
|
45
|
+
const classPath = Reflect.getMetadata(PATH_METADATA, classRef) as string | string[] | undefined
|
|
46
|
+
const methodPath = Reflect.getMetadata(PATH_METADATA, method) as string | string[] | undefined
|
|
47
|
+
const verbCode = Reflect.getMetadata(METHOD_METADATA, method) as number | undefined
|
|
48
|
+
|
|
49
|
+
const classSeg = Array.isArray(classPath) ? (classPath[0] ?? '') : (classPath ?? '')
|
|
50
|
+
const methodSeg = Array.isArray(methodPath) ? (methodPath[0] ?? '') : (methodPath ?? '')
|
|
51
|
+
const path = joinPath(classSeg, methodSeg)
|
|
52
|
+
const httpMethod = REQUEST_METHOD[verbCode ?? 0] ?? 'GET'
|
|
53
|
+
|
|
54
|
+
const pathParams = [...path.matchAll(/:([A-Za-z0-9_]+)/g)].map((m) => m[1])
|
|
55
|
+
const openapiPath = `/${path.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}`
|
|
56
|
+
|
|
57
|
+
return { method: httpMethod, path, openapiPath, pathParams }
|
|
58
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { z } from 'zod/v4'
|
|
3
|
+
import { classValidatorMetas } from './classValidator.js'
|
|
4
|
+
|
|
5
|
+
/** `@nestjs/swagger` reflect-metadata keys. Read directly so swagger stays an optional peer. */
|
|
6
|
+
const API_MODEL_PROPERTIES = 'swagger/apiModelProperties'
|
|
7
|
+
const API_MODEL_PROPERTIES_ARRAY = 'swagger/apiModelPropertiesArray'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A transport-neutral description of a single input field. Every metadata
|
|
11
|
+
* source (swagger decorators, class-validator, an OpenAPI document, TypeScript
|
|
12
|
+
* `design:type`) is mapped to this shape, the shapes are merged by precedence,
|
|
13
|
+
* and the result is converted to Zod once. Keeping a single intermediate keeps
|
|
14
|
+
* the per-source mappers small and the Zod construction in one place.
|
|
15
|
+
*/
|
|
16
|
+
export interface FieldDesc {
|
|
17
|
+
type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'unknown'
|
|
18
|
+
required?: boolean
|
|
19
|
+
description?: string
|
|
20
|
+
enum?: (string | number)[]
|
|
21
|
+
items?: FieldDesc
|
|
22
|
+
min?: number
|
|
23
|
+
max?: number
|
|
24
|
+
minLength?: number
|
|
25
|
+
maxLength?: number
|
|
26
|
+
format?: string
|
|
27
|
+
default?: unknown
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Strip `undefined` values so a later source never clobbers an earlier one with a hole. */
|
|
31
|
+
function defined<T extends object>(obj: T): Partial<T> {
|
|
32
|
+
const out: Record<string, unknown> = {}
|
|
33
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
34
|
+
if (v !== undefined) { out[k] = v }
|
|
35
|
+
}
|
|
36
|
+
return out as Partial<T>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Merge `over` onto `base` - defined keys of `over` win. */
|
|
40
|
+
export function mergeField(base: FieldDesc, over: FieldDesc): FieldDesc {
|
|
41
|
+
return { ...base, ...defined(over) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function enumToZod(values: (string | number)[]): z.ZodType {
|
|
45
|
+
const strings = values.filter((v): v is string => typeof v === 'string')
|
|
46
|
+
if (strings.length === values.length && strings.length > 0) {
|
|
47
|
+
return z.enum(strings as [string, ...string[]])
|
|
48
|
+
}
|
|
49
|
+
const literals = values.map((v) => z.literal(v))
|
|
50
|
+
if (literals.length === 0) { return z.unknown() }
|
|
51
|
+
if (literals.length === 1) { return literals[0] }
|
|
52
|
+
return z.union(literals as unknown as [z.ZodType, z.ZodType, ...z.ZodType[]])
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function baseToZod(d: FieldDesc): z.ZodType {
|
|
56
|
+
switch (d.type) {
|
|
57
|
+
case 'string': {
|
|
58
|
+
let s = z.string()
|
|
59
|
+
if (d.minLength != null) { s = s.min(d.minLength) }
|
|
60
|
+
if (d.maxLength != null) { s = s.max(d.maxLength) }
|
|
61
|
+
return s
|
|
62
|
+
}
|
|
63
|
+
case 'integer':
|
|
64
|
+
case 'number': {
|
|
65
|
+
let n = z.number()
|
|
66
|
+
if (d.type === 'integer') { n = n.int() }
|
|
67
|
+
if (d.min != null) { n = n.min(d.min) }
|
|
68
|
+
if (d.max != null) { n = n.max(d.max) }
|
|
69
|
+
return n
|
|
70
|
+
}
|
|
71
|
+
case 'boolean':
|
|
72
|
+
return z.boolean()
|
|
73
|
+
case 'array':
|
|
74
|
+
return z.array(d.items ? fieldToZod({ ...d.items, required: true }) : z.unknown())
|
|
75
|
+
case 'object':
|
|
76
|
+
return z.record(z.string(), z.unknown())
|
|
77
|
+
default:
|
|
78
|
+
return z.unknown()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Convert a merged {@link FieldDesc} to a Zod schema. */
|
|
83
|
+
export function fieldToZod(d: FieldDesc): z.ZodType {
|
|
84
|
+
let schema = (d.enum?.length) ? enumToZod(d.enum) : baseToZod(d)
|
|
85
|
+
if (d.description) { schema = schema.describe(d.description) }
|
|
86
|
+
if (d.default !== undefined) {
|
|
87
|
+
schema = (schema as any).default(d.default)
|
|
88
|
+
} else if (d.required === false) {
|
|
89
|
+
schema = schema.optional()
|
|
90
|
+
}
|
|
91
|
+
return schema
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- per-source mappers ---------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/** Normalise a TS-enum object or an array literal to a flat value list. */
|
|
97
|
+
export function normalizeEnum(value: unknown): (string | number)[] | undefined {
|
|
98
|
+
if (Array.isArray(value)) {
|
|
99
|
+
return value.filter((v): v is string | number => typeof v === 'string' || typeof v === 'number')
|
|
100
|
+
}
|
|
101
|
+
if (value && typeof value === 'object') {
|
|
102
|
+
return Object.values(value).filter((v): v is string | number => typeof v === 'string' || typeof v === 'number')
|
|
103
|
+
}
|
|
104
|
+
return undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Map a swagger/`design:type` type token (constructor, `[Type]`, or string) to a base type. */
|
|
108
|
+
export function typeTokenToBase(type: unknown): FieldDesc['type'] | undefined {
|
|
109
|
+
if (type == null) { return undefined }
|
|
110
|
+
if (type === String) { return 'string' }
|
|
111
|
+
if (type === Number) { return 'number' }
|
|
112
|
+
if (type === Boolean) { return 'boolean' }
|
|
113
|
+
if (type === Array) { return 'array' }
|
|
114
|
+
if (type === Date) { return 'string' }
|
|
115
|
+
if (Array.isArray(type)) { return 'array' }
|
|
116
|
+
if (typeof type === 'string') {
|
|
117
|
+
const t = type.toLowerCase()
|
|
118
|
+
if (t === 'string' || t === 'number' || t === 'integer' || t === 'boolean' || t === 'array' || t === 'object') {
|
|
119
|
+
return t
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** From the constructor TypeScript emits via `emitDecoratorMetadata`. */
|
|
126
|
+
export function designTypeToField(ctor: unknown): FieldDesc {
|
|
127
|
+
const type = typeTokenToBase(ctor)
|
|
128
|
+
const f: FieldDesc = {}
|
|
129
|
+
if (type) { f.type = type }
|
|
130
|
+
return f
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** From an `@ApiParam`/`@ApiQuery` entry stored under `swagger/apiParameters`. */
|
|
134
|
+
export function swaggerParamToField(p: Record<string, any>): FieldDesc {
|
|
135
|
+
const f: FieldDesc = {}
|
|
136
|
+
if (p['description']) { f.description = p['description'] }
|
|
137
|
+
if (typeof p['required'] === 'boolean') { f.required = p['required'] }
|
|
138
|
+
const type = typeTokenToBase(p['type'])
|
|
139
|
+
if (type) { f.type = type }
|
|
140
|
+
if (p['isArray']) { f.type = 'array' }
|
|
141
|
+
const en = normalizeEnum(p['enum'])
|
|
142
|
+
if (en) { f.enum = en }
|
|
143
|
+
if (p['schema'] && typeof p['schema'] === 'object') {
|
|
144
|
+
return mergeField(f, openapiSchemaToField(p['schema']))
|
|
145
|
+
}
|
|
146
|
+
return f
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** From an `@ApiProperty` options object stored under `swagger/apiModelProperties`. */
|
|
150
|
+
export function apiPropertyToField(o: Record<string, any>): FieldDesc {
|
|
151
|
+
const f: FieldDesc = {}
|
|
152
|
+
if (o['description']) { f.description = o['description'] }
|
|
153
|
+
if (typeof o['required'] === 'boolean') { f.required = o['required'] }
|
|
154
|
+
const type = typeTokenToBase(o['type'])
|
|
155
|
+
if (type) { f.type = type }
|
|
156
|
+
if (o['isArray']) {
|
|
157
|
+
f.items = { type: type && type !== 'array' ? type : 'unknown' }
|
|
158
|
+
f.type = 'array'
|
|
159
|
+
}
|
|
160
|
+
const en = normalizeEnum(o['enum'])
|
|
161
|
+
if (en) { f.enum = en }
|
|
162
|
+
if (o['minimum'] != null) { f.min = o['minimum'] }
|
|
163
|
+
if (o['maximum'] != null) { f.max = o['maximum'] }
|
|
164
|
+
if (o['minLength'] != null) { f.minLength = o['minLength'] }
|
|
165
|
+
if (o['maxLength'] != null) { f.maxLength = o['maxLength'] }
|
|
166
|
+
if (o['format']) { f.format = o['format'] }
|
|
167
|
+
if (o['default'] !== undefined) { f.default = o['default'] }
|
|
168
|
+
return f
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** `class-validator` decorator type → field base type. */
|
|
172
|
+
const CV_TYPE: Record<string, FieldDesc['type']> = {
|
|
173
|
+
isString: 'string',
|
|
174
|
+
isInt: 'integer',
|
|
175
|
+
isBoolean: 'boolean',
|
|
176
|
+
isArray: 'array'
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `class-validator` decorator type → numeric-constraint field key. */
|
|
180
|
+
const CV_NUMERIC: Record<string, 'min' | 'max' | 'minLength' | 'maxLength'> = {
|
|
181
|
+
min: 'min',
|
|
182
|
+
max: 'max',
|
|
183
|
+
minLength: 'minLength',
|
|
184
|
+
maxLength: 'maxLength'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Fold one `class-validator` metadata entry into the field descriptor. Built-in
|
|
189
|
+
* validators record their identity in `name` (`isString`, `minLength`, ...) with
|
|
190
|
+
* `type: 'customValidation'`; `@IsOptional` uses `name: 'isOptional'`. We key off
|
|
191
|
+
* `name`, falling back to `type`.
|
|
192
|
+
*/
|
|
193
|
+
function applyValidationMeta(f: FieldDesc, meta: { type?: string; name?: string; constraints?: unknown[] }): void {
|
|
194
|
+
const key = meta.name ?? meta.type
|
|
195
|
+
if (!key) { return }
|
|
196
|
+
if (key in CV_TYPE) { f.type = CV_TYPE[key]; return }
|
|
197
|
+
const c0 = meta.constraints?.[0]
|
|
198
|
+
if (key in CV_NUMERIC) {
|
|
199
|
+
if (typeof c0 === 'number') { f[CV_NUMERIC[key]] = c0 }
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (key === 'isNumber') { if (!f.type) { f.type = 'number' } return }
|
|
203
|
+
if (key === 'isEmail') { if (!f.type) { f.type = 'string' } f.format = 'email'; return }
|
|
204
|
+
if (key === 'isDate' || key === 'isDateString') { f.type = 'string'; f.format = 'date-time'; return }
|
|
205
|
+
if (key === 'isEnum') { const e = normalizeEnum(c0); if (e) { f.enum = e } return }
|
|
206
|
+
if (key === 'isOptional') { f.required = false }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** From an array of `class-validator` validation-metadata entries for one property. */
|
|
210
|
+
export function classValidatorToField(metas: Array<{ type?: string; name?: string; constraints?: unknown[] }>): FieldDesc {
|
|
211
|
+
const f: FieldDesc = {}
|
|
212
|
+
for (const meta of metas) { applyValidationMeta(f, meta) }
|
|
213
|
+
return f
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** From an OpenAPI Schema Object (used by both the doc and `@ApiParam({ schema })`). */
|
|
217
|
+
export function openapiSchemaToField(schema: Record<string, any>): FieldDesc {
|
|
218
|
+
const f: FieldDesc = {}
|
|
219
|
+
const type = typeof schema['type'] === 'string' ? schema['type'].toLowerCase() : undefined
|
|
220
|
+
if (type === 'string' || type === 'number' || type === 'integer' || type === 'boolean' || type === 'array' || type === 'object') {
|
|
221
|
+
f.type = type
|
|
222
|
+
}
|
|
223
|
+
if (schema['description']) { f.description = schema['description'] }
|
|
224
|
+
const en = normalizeEnum(schema['enum'])
|
|
225
|
+
if (en) { f.enum = en }
|
|
226
|
+
if (schema['minimum'] != null) { f.min = schema['minimum'] }
|
|
227
|
+
if (schema['maximum'] != null) { f.max = schema['maximum'] }
|
|
228
|
+
if (schema['minLength'] != null) { f.minLength = schema['minLength'] }
|
|
229
|
+
if (schema['maxLength'] != null) { f.maxLength = schema['maxLength'] }
|
|
230
|
+
if (schema['format']) { f.format = schema['format'] }
|
|
231
|
+
if (schema['default'] !== undefined) { f.default = schema['default'] }
|
|
232
|
+
if (schema['items'] && typeof schema['items'] === 'object') {
|
|
233
|
+
f.items = openapiSchemaToField(schema['items'])
|
|
234
|
+
}
|
|
235
|
+
return f
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Reflect a whole-DTO class (`@Body() dto: CreateDto`) into per-property
|
|
240
|
+
* {@link FieldDesc}s. Property names are the union of `@ApiProperty` and
|
|
241
|
+
* `class-validator` decorated fields; each field merges `design:type` (base),
|
|
242
|
+
* then `class-validator` constraints, then `@ApiProperty` (highest). Properties
|
|
243
|
+
* default to required unless a source marks them optional.
|
|
244
|
+
*/
|
|
245
|
+
export function reflectDtoFields(dtoType: any): Record<string, FieldDesc> {
|
|
246
|
+
const proto = dtoType?.prototype
|
|
247
|
+
if (!proto) { return {} }
|
|
248
|
+
const cvMetas = classValidatorMetas(dtoType)
|
|
249
|
+
|
|
250
|
+
const names = new Set<string>()
|
|
251
|
+
const swaggerArray = (Reflect.getMetadata(API_MODEL_PROPERTIES_ARRAY, proto) as string[] | undefined) ?? []
|
|
252
|
+
for (const entry of swaggerArray) { names.add(entry.replace(/^:/, '')) }
|
|
253
|
+
for (const name of Object.keys(cvMetas)) { names.add(name) }
|
|
254
|
+
|
|
255
|
+
const out: Record<string, FieldDesc> = {}
|
|
256
|
+
for (const name of names) {
|
|
257
|
+
let f = designTypeToField(Reflect.getMetadata('design:type', proto, name))
|
|
258
|
+
if (cvMetas[name]) { f = mergeField(f, classValidatorToField(cvMetas[name])) }
|
|
259
|
+
const apiProp = Reflect.getMetadata(API_MODEL_PROPERTIES, proto, name) as Record<string, any> | undefined
|
|
260
|
+
if (apiProp) {
|
|
261
|
+
const fromApi = apiPropertyToField(apiProp)
|
|
262
|
+
// `@ApiProperty` is required unless `required: false` is explicit.
|
|
263
|
+
if (fromApi.required === undefined && apiProp['required'] === undefined) { fromApi.required = true }
|
|
264
|
+
f = mergeField(f, fromApi)
|
|
265
|
+
}
|
|
266
|
+
if (f.required === undefined) { f.required = true }
|
|
267
|
+
out[name] = f
|
|
268
|
+
}
|
|
269
|
+
return out
|
|
270
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import type { FieldDesc } from './schema.js'
|
|
3
|
+
import { swaggerParamToField } from './schema.js'
|
|
4
|
+
|
|
5
|
+
/** `@nestjs/swagger` reflect-metadata keys (read directly - swagger is an optional peer). */
|
|
6
|
+
const API_OPERATION = 'swagger/apiOperation'
|
|
7
|
+
const API_PARAMETERS = 'swagger/apiParameters'
|
|
8
|
+
|
|
9
|
+
export interface OperationMeta {
|
|
10
|
+
/** Tool-description candidate from `@ApiOperation({ summary | description })`. */
|
|
11
|
+
description?: string
|
|
12
|
+
/** Per-name `FieldDesc` reflected from `@ApiParam`/`@ApiQuery` entries. */
|
|
13
|
+
params: Record<string, FieldDesc>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read operation-level `@nestjs/swagger` metadata off a controller method:
|
|
18
|
+
* `@ApiOperation` (summary/description) and the `@ApiParam`/`@ApiQuery` array
|
|
19
|
+
* (each describing one path/query field). Returns empty data when swagger
|
|
20
|
+
* decorators are absent.
|
|
21
|
+
*/
|
|
22
|
+
export function reflectOperation(method: (...args: any[]) => any): OperationMeta {
|
|
23
|
+
const operation = Reflect.getMetadata(API_OPERATION, method) as Record<string, any> | undefined
|
|
24
|
+
const description = operation
|
|
25
|
+
? (typeof operation['summary'] === 'string' && operation['summary']
|
|
26
|
+
? operation['summary']
|
|
27
|
+
: (typeof operation['description'] === 'string' ? operation['description'] : undefined))
|
|
28
|
+
: undefined
|
|
29
|
+
|
|
30
|
+
const parameters = (Reflect.getMetadata(API_PARAMETERS, method) as Array<Record<string, any>> | undefined) ?? []
|
|
31
|
+
const params: Record<string, FieldDesc> = {}
|
|
32
|
+
for (const p of parameters) {
|
|
33
|
+
const name = typeof p['name'] === 'string' ? p['name'] : undefined
|
|
34
|
+
if (!name) { continue }
|
|
35
|
+
params[name] = swaggerParamToField(p)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { description, params }
|
|
39
|
+
}
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import { Inject, Module, type DynamicModule, type MiddlewareConsumer, type NestModule } from '@nestjs/common'
|
|
2
2
|
import { DiscoveryModule, HttpAdapterHost } from '@nestjs/core'
|
|
3
3
|
import { createContext } from '@silkweave/core'
|
|
4
|
-
import {
|
|
4
|
+
import { ControllerDiscovery } from './controllerDiscovery.js'
|
|
5
5
|
import { SILKWEAVE_MODULE_OPTIONS, type SilkweaveModuleOptions } from './types.js'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Root module for `@silkweave/nestjs`.
|
|
9
9
|
*
|
|
10
|
-
* Discovers every `@
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
10
|
+
* Discovers every `@Mcp`-decorated **controller method** via `DiscoveryService`,
|
|
11
|
+
* reflects each into a Silkweave action (input schema from the route + parameter
|
|
12
|
+
* decorators + optional OpenAPI document; invocation by re-binding the validated
|
|
13
|
+
* input back into the method), and registers the configured adapter(s) - `mcp()` -
|
|
14
|
+
* directly on Nest's HTTP adapter inside `configure()`.
|
|
15
|
+
*
|
|
16
|
+
* Because `configure()` runs during `registerModules` - before Nest's
|
|
17
|
+
* `registerRouter()` step - Silkweave's routes always sit ahead of every
|
|
18
|
+
* controller in the Express stack. The controllers keep serving HTTP exactly as
|
|
19
|
+
* before; `@Mcp` is purely additive.
|
|
17
20
|
*
|
|
18
21
|
* @example
|
|
19
22
|
* ```ts
|
|
@@ -21,13 +24,10 @@ import { SILKWEAVE_MODULE_OPTIONS, type SilkweaveModuleOptions } from './types.j
|
|
|
21
24
|
* imports: [
|
|
22
25
|
* SilkweaveModule.forRoot({
|
|
23
26
|
* silkweave: { name: 'app', description: 'My App', version: '1.0.0' },
|
|
24
|
-
* adapters: [
|
|
25
|
-
* rest({ basePath: '/api' }),
|
|
26
|
-
* trpc({ basePath: '/trpc' }),
|
|
27
|
-
* mcp({ basePath: '/mcp' })
|
|
28
|
-
* ]
|
|
27
|
+
* adapters: [mcp({ basePath: '/mcp' })]
|
|
29
28
|
* })
|
|
30
|
-
* ]
|
|
29
|
+
* ],
|
|
30
|
+
* controllers: [ChannelsController]
|
|
31
31
|
* })
|
|
32
32
|
* export class AppModule {}
|
|
33
33
|
* ```
|
|
@@ -36,7 +36,7 @@ import { SILKWEAVE_MODULE_OPTIONS, type SilkweaveModuleOptions } from './types.j
|
|
|
36
36
|
export class SilkweaveModule implements NestModule {
|
|
37
37
|
constructor(
|
|
38
38
|
@Inject(SILKWEAVE_MODULE_OPTIONS) private readonly options: SilkweaveModuleOptions,
|
|
39
|
-
private readonly discovery:
|
|
39
|
+
private readonly discovery: ControllerDiscovery,
|
|
40
40
|
private readonly httpAdapterHost: HttpAdapterHost
|
|
41
41
|
) { }
|
|
42
42
|
|
|
@@ -47,7 +47,7 @@ export class SilkweaveModule implements NestModule {
|
|
|
47
47
|
imports: [DiscoveryModule],
|
|
48
48
|
providers: [
|
|
49
49
|
{ provide: SILKWEAVE_MODULE_OPTIONS, useValue: options },
|
|
50
|
-
|
|
50
|
+
ControllerDiscovery
|
|
51
51
|
],
|
|
52
52
|
exports: []
|
|
53
53
|
}
|
|
@@ -58,7 +58,7 @@ export class SilkweaveModule implements NestModule {
|
|
|
58
58
|
if (!httpAdapter) {
|
|
59
59
|
throw new Error('@silkweave/nestjs: HttpAdapterHost.httpAdapter is not available.')
|
|
60
60
|
}
|
|
61
|
-
const allActions = this.discovery.discover()
|
|
61
|
+
const allActions = this.discovery.discover(this.options.openapi)
|
|
62
62
|
for (const adapter of this.options.adapters) {
|
|
63
63
|
const baseContext = createContext({ ...(this.options.context ?? {}), adapter: adapter.name })
|
|
64
64
|
const actions = adapter.allActions
|
package/src/lib/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HttpAdapterHost } from '@nestjs/core'
|
|
2
2
|
import type { Action, SilkweaveContext, SilkweaveOptions } from '@silkweave/core'
|
|
3
|
+
import type { OpenApiDocument } from './reflect/openapi.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Context passed to a Nest Silkweave adapter when `SilkweaveModule` wires it
|
|
@@ -28,12 +29,13 @@ export interface NestAdapterRegisterContext {
|
|
|
28
29
|
*/
|
|
29
30
|
export interface NestSilkweaveAdapter {
|
|
30
31
|
/** Adapter discriminator - set on the silkweave context as `ctx.get('adapter')`. */
|
|
31
|
-
readonly name: '
|
|
32
|
+
readonly name: 'mcp'
|
|
33
|
+
/** URL prefix the adapter mounts on (e.g. `'/mcp'`). Surfaced for introspection. */
|
|
34
|
+
readonly basePath?: string
|
|
32
35
|
/**
|
|
33
36
|
* When `true`, the adapter receives every discovered action regardless of
|
|
34
|
-
* each action's `
|
|
35
|
-
*
|
|
36
|
-
* action surface.
|
|
37
|
+
* each action's `isEnabled` gate. Reserved for non-runtime adapters that need
|
|
38
|
+
* the entire action surface.
|
|
37
39
|
*/
|
|
38
40
|
readonly allActions?: boolean
|
|
39
41
|
/** Register this adapter's routes on Nest's HTTP server. */
|
|
@@ -43,10 +45,17 @@ export interface NestSilkweaveAdapter {
|
|
|
43
45
|
export interface SilkweaveModuleOptions {
|
|
44
46
|
/** Identity for the silkweave instance - surfaced to MCP clients, OpenAPI, etc. */
|
|
45
47
|
silkweave: SilkweaveOptions
|
|
46
|
-
/** Adapters to mount.
|
|
48
|
+
/** Adapters to mount. Currently `mcp()`. */
|
|
47
49
|
adapters: NestSilkweaveAdapter[]
|
|
48
50
|
/** Initial context keys merged into every adapter's `baseContext`. */
|
|
49
51
|
context?: Record<string, unknown>
|
|
52
|
+
/**
|
|
53
|
+
* Optional OpenAPI document (e.g. from `SwaggerModule.createDocument(app, cfg)`)
|
|
54
|
+
* used as an authoritative source when reflecting `@Mcp` tool input schemas.
|
|
55
|
+
* Matched to each method by HTTP verb + path; falls back to decorator
|
|
56
|
+
* reflection when an operation or field isn't present.
|
|
57
|
+
*/
|
|
58
|
+
openapi?: OpenApiDocument
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
export const SILKWEAVE_MODULE_OPTIONS = '__silkweave_module_options__'
|