@silkweave/nestjs 1.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silkweave/nestjs",
3
- "version": "1.12.0",
3
+ "version": "2.0.0",
4
4
  "description": "Silkweave NestJS Adapter",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,25 +12,24 @@
12
12
  "peerDependencies": {
13
13
  "@nestjs/common": "^10.0.0 || ^11.0.0",
14
14
  "@nestjs/core": "^10.0.0 || ^11.0.0",
15
- "@nestjs/swagger": "^7.0.0 || ^8.0.0 || ^11.0.0"
15
+ "@nestjs/swagger": "^7.0.0 || ^8.0.0 || ^11.0.0",
16
+ "class-validator": "^0.14.0"
16
17
  },
17
18
  "peerDependenciesMeta": {
18
19
  "@nestjs/swagger": {
19
20
  "optional": true
21
+ },
22
+ "class-validator": {
23
+ "optional": true
20
24
  }
21
25
  },
22
26
  "dependencies": {
23
- "@trpc/server": "^11.7.1",
24
- "change-case": "^5.4.4",
25
27
  "cors": "^2.8.6",
26
28
  "express": "^5.2.1",
27
29
  "zod": "^3.25.0",
28
- "@silkweave/auth": "1.12.0",
29
- "@silkweave/core": "1.12.0",
30
- "@silkweave/mcp": "1.12.0",
31
- "@silkweave/trpc": "1.12.0",
32
- "@silkweave/typegen": "1.12.0",
33
- "@silkweave/logger": "1.12.0"
30
+ "@silkweave/auth": "2.0.0",
31
+ "@silkweave/core": "2.0.0",
32
+ "@silkweave/mcp": "2.0.0"
34
33
  },
35
34
  "devDependencies": {
36
35
  "@eslint/js": "^10.0.1",
@@ -0,0 +1,39 @@
1
+ import { SetMetadata } from '@nestjs/common'
2
+ import { MCP_METADATA, type McpMetadata } from '../lib/metadata.js'
3
+
4
+ /**
5
+ * Method decorator that exposes an existing NestJS controller route as an MCP
6
+ * tool. It is **additive** - the route keeps serving HTTP exactly as before;
7
+ * `@Mcp()` just opts the method into MCP discovery.
8
+ *
9
+ * The tool's name, description, and input schema are reflected from the
10
+ * method's own metadata:
11
+ * - **fields** from the parameter decorators (`@Param`/`@Query`/`@Body`) - a
12
+ * `@Param('id')` becomes an `id` field; a whole-DTO `@Body() dto: CreateDto`
13
+ * is flattened to its properties,
14
+ * - **types/constraints/descriptions** from `@nestjs/swagger`
15
+ * (`@ApiParam`/`@ApiQuery`/`@ApiProperty`/`@ApiOperation`) and, when present,
16
+ * `class-validator` decorators on the DTOs,
17
+ * - optionally refined by an OpenAPI document passed to `SilkweaveModule`.
18
+ *
19
+ * On a tool call the input is split back into the method's positional arguments
20
+ * and the method is invoked directly (with `@UseGuards` guards applied first).
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * @Controller('sessions/:sessionId/channels')
25
+ * export class ChannelsController {
26
+ * @Get(':channelId')
27
+ * @ApiOperation({ summary: 'Get a specific channel by ID' })
28
+ * @ApiParam({ name: 'sessionId', description: 'Session ID' })
29
+ * @ApiParam({ name: 'channelId', description: 'Channel ID' })
30
+ * @Mcp()
31
+ * findOne(@Param('sessionId') sessionId: string, @Param('channelId') channelId: string) {
32
+ * return this.service.get(sessionId, channelId)
33
+ * }
34
+ * }
35
+ * ```
36
+ */
37
+ export function Mcp(options: McpMetadata = {}): MethodDecorator {
38
+ return SetMetadata(MCP_METADATA, options)
39
+ }
package/src/index.ts CHANGED
@@ -1,14 +1,10 @@
1
1
  export * from './adapter/mcp.js'
2
- export * from './adapter/rest.js'
3
- export * from './adapter/trpc.js'
4
- export * from './adapter/typegen.js'
5
- export * from './decorator/action.js'
6
- export * from './decorator/actions.js'
7
- export * from './lib/discovery.js'
8
- export * from './lib/filter.js'
2
+ export * from './decorator/mcp.js'
3
+ export * from './lib/controllerDiscovery.js'
9
4
  export * from './lib/guards.js'
10
5
  export * from './lib/metadata.js'
11
- export * from './lib/openapi.js'
6
+ export * from './lib/rebind.js'
12
7
  export * from './lib/silkweave.module.js'
13
- export * from './lib/swagger.js'
14
8
  export * from './lib/types.js'
9
+ export * from './lib/reflect/openapi.js'
10
+ export * from './lib/reflect/schema.js'
@@ -0,0 +1,201 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
2
+ import { Injectable, type Type } from '@nestjs/common'
3
+ import { DiscoveryService, MetadataScanner, ModuleRef, Reflector } from '@nestjs/core'
4
+ import { createAction, type Action, type SilkweaveContext } from '@silkweave/core'
5
+ import { z } from 'zod/v4'
6
+ import { collectGuards, runGuards } from './guards.js'
7
+ import { MCP_METADATA, type McpMetadata } from './metadata.js'
8
+ import { invokeRebound, specialBinding, type Binding } from './rebind.js'
9
+ import { buildOpenApiLookup, openApiFields, type OpenApiDocument, type OpenApiLookup } from './reflect/openapi.js'
10
+ import { PARAMTYPE, type ParamSlot, readParamSlots } from './reflect/params.js'
11
+ import { reflectRoute } from './reflect/route.js'
12
+ import { type FieldDesc, fieldToZod, mergeField, reflectDtoFields } from './reflect/schema.js'
13
+ import { reflectOperation } from './reflect/swagger.js'
14
+
15
+ interface DiscoveredMcp {
16
+ instance: object
17
+ classRef: Type<unknown>
18
+ method: (...args: unknown[]) => unknown
19
+ methodName: string
20
+ meta: McpMetadata
21
+ }
22
+
23
+ interface BuiltInput {
24
+ shape: Record<string, z.ZodType>
25
+ bindings: Binding[]
26
+ }
27
+
28
+ @Injectable()
29
+ export class ControllerDiscovery {
30
+ constructor(
31
+ private readonly discovery: DiscoveryService,
32
+ private readonly scanner: MetadataScanner,
33
+ private readonly reflector: Reflector,
34
+ private readonly moduleRef: ModuleRef
35
+ ) { }
36
+
37
+ /**
38
+ * Walk every Nest provider/controller, find methods annotated with `@Mcp`,
39
+ * and build a core `Action` per method whose input schema is reflected from
40
+ * the route + parameter decorators (+ optional OpenAPI document) and whose
41
+ * `run` re-binds the validated input back into the method's positional
42
+ * arguments (with `@UseGuards` guards applied first).
43
+ */
44
+ discover(openapi?: OpenApiDocument): Action[] {
45
+ const lookup = openapi ? buildOpenApiLookup(openapi) : undefined
46
+ const discovered: DiscoveredMcp[] = []
47
+ for (const wrapper of this.discovery.getProviders().concat(this.discovery.getControllers())) {
48
+ const { instance } = wrapper
49
+ if (!instance || typeof instance !== 'object') { continue }
50
+ const proto = Object.getPrototypeOf(instance) as object | null
51
+ if (!proto) { continue }
52
+ const classRef = instance.constructor as Type<unknown>
53
+ for (const methodName of this.scanner.getAllMethodNames(proto)) {
54
+ const method = (proto as Record<string, unknown>)[methodName] as ((...args: unknown[]) => unknown) | undefined
55
+ if (typeof method !== 'function') { continue }
56
+ const meta = this.reflector.get<McpMetadata>(MCP_METADATA, method)
57
+ if (!meta) { continue }
58
+ discovered.push({ instance, classRef, method, methodName, meta })
59
+ }
60
+ }
61
+ return discovered.map((d) => this.toAction(d, lookup))
62
+ }
63
+
64
+ private toAction(d: DiscoveredMcp, lookup: OpenApiLookup | undefined): Action {
65
+ const proto = Object.getPrototypeOf(d.instance) as object
66
+ const route = reflectRoute(d.classRef, d.method)
67
+ const slots = readParamSlots(d.classRef, d.methodName, proto)
68
+ const operation = reflectOperation(d.method)
69
+ const docFields = lookup ? openApiFields(lookup, route.method, route.openapiPath) : {}
70
+
71
+ const { shape, bindings } = this.buildInput(d, route.pathParams, slots, operation.params, docFields)
72
+
73
+ const base = d.classRef.name.replace(/Controller$/, '')
74
+ const name = d.meta.name ?? `${base}.${d.methodName}`
75
+ const description = d.meta.description ?? operation.description ?? `${d.methodName} (${route.method} /${route.path})`
76
+ const applyParamPipes = d.meta.pipes !== 'skip'
77
+
78
+ const guards = collectGuards(this.reflector, d.classRef, d.method)
79
+ const { moduleRef, reflector } = this
80
+ const classRef = d.classRef
81
+ const method = d.method
82
+ const instance = d.instance
83
+
84
+ const applyGuards = async (context: SilkweaveContext): Promise<void> => {
85
+ if (guards.length === 0) { return }
86
+ const request = context.getOptional<unknown>('request')
87
+ const response = context.getOptional<unknown>('response') ?? null
88
+ const hasRequest = request != null
89
+ const guardRequest = hasRequest ? request : { headers: {}, params: {}, query: {} }
90
+ await runGuards(guards, moduleRef, reflector, classRef, method, guardRequest, response, hasRequest ? 'http' : 'rpc')
91
+ }
92
+
93
+ return createAction({
94
+ name,
95
+ description,
96
+ input: z.object(shape),
97
+ // Only the MCP adapter exposes `@Mcp` methods; a future `@Trpc` decorator
98
+ // would tag its own actions for the tRPC adapter.
99
+ isEnabled: (ctx) => ctx.getOptional<string>('adapter') === 'mcp',
100
+ run: async (input: object, context: SilkweaveContext): Promise<object> => {
101
+ await applyGuards(context)
102
+ const request = context.getOptional<{ headers?: Record<string, unknown> }>('request')
103
+ const result = await invokeRebound(method, instance, input as Record<string, unknown>, bindings, request, applyParamPipes)
104
+ return (result ?? {})
105
+ }
106
+ }) as Action
107
+ }
108
+
109
+ /** Build the merged Zod input shape and the per-argument re-bind plan. */
110
+ private buildInput(
111
+ d: DiscoveredMcp,
112
+ pathParams: string[],
113
+ slots: ReturnType<typeof readParamSlots>,
114
+ operationParams: Record<string, FieldDesc>,
115
+ docFields: Record<string, FieldDesc>
116
+ ): BuiltInput {
117
+ const proto = Object.getPrototypeOf(d.instance) as object
118
+ const designTypes = (Reflect.getMetadata('design:paramtypes', proto, d.methodName) as unknown[] | undefined) ?? []
119
+ const fields: Record<string, FieldDesc> = {}
120
+ const maxIndex = slots.reduce((m, s) => Math.max(m, s.index), -1)
121
+ const bindings: Binding[] = Array.from({ length: maxIndex + 1 }, () => ({ kind: 'missing' as const }))
122
+
123
+ const addField = (name: string, desc: FieldDesc): void => {
124
+ fields[name] = name in fields ? mergeField(fields[name], desc) : desc
125
+ }
126
+
127
+ for (const slot of slots) {
128
+ const { binding, fields: contributed } = contributeSlot(slot, pathParams, designTypes)
129
+ bindings[slot.index] = binding
130
+ for (const [name, desc] of Object.entries(contributed)) { addField(name, desc) }
131
+ }
132
+
133
+ // Layer operation-level (`@ApiParam`/`@ApiQuery`) then OpenAPI-document
134
+ // metadata over the structural fields (later sources win per field).
135
+ for (const [name, desc] of Object.entries(operationParams)) {
136
+ if (name in fields) { fields[name] = mergeField(fields[name], desc) }
137
+ }
138
+ for (const [name, desc] of Object.entries(docFields)) {
139
+ if (name in fields) { fields[name] = mergeField(fields[name], desc) }
140
+ }
141
+
142
+ const shape: Record<string, z.ZodType> = {}
143
+ for (const [name, desc] of Object.entries(fields)) { shape[name] = fieldToZod(desc) }
144
+ // `@Mcp({ input })` raw-shape override wins per field.
145
+ Object.assign(shape, d.meta.input ?? {})
146
+
147
+ return { shape, bindings }
148
+ }
149
+ }
150
+
151
+ function designTypeAt(designTypes: unknown[], index: number): FieldDesc {
152
+ const ctor = designTypes[index]
153
+ if (ctor === String) { return { type: 'string' } }
154
+ if (ctor === Number) { return { type: 'number' } }
155
+ if (ctor === Boolean) { return { type: 'boolean' } }
156
+ return {}
157
+ }
158
+
159
+ interface SlotContribution {
160
+ binding: Binding
161
+ /** Input fields this slot contributes, keyed by field name. */
162
+ fields: Record<string, FieldDesc>
163
+ }
164
+
165
+ /** A `@Param('id')` scalar or a bare `@Param()` covering all path params. */
166
+ function paramContribution(slot: ParamSlot, pathParams: string[]): SlotContribution {
167
+ if (slot.data) {
168
+ return {
169
+ binding: { kind: 'value', field: slot.data, source: 'path', metatype: slot.designType, pipes: slot.pipes },
170
+ fields: { [slot.data]: { type: 'string', required: true } }
171
+ }
172
+ }
173
+ const fields: Record<string, FieldDesc> = {}
174
+ for (const p of pathParams) { fields[p] = { type: 'string', required: true } }
175
+ return { binding: { kind: 'params', fields: pathParams }, fields }
176
+ }
177
+
178
+ /** A `@Query('x')`/`@Body('x')` scalar or a whole-DTO `@Query()`/`@Body()`. */
179
+ function bodyOrQueryContribution(slot: ParamSlot, source: 'query' | 'body', requiredScalar: boolean, designTypes: unknown[]): SlotContribution {
180
+ if (slot.data) {
181
+ return {
182
+ binding: { kind: 'value', field: slot.data, source, metatype: slot.designType, pipes: slot.pipes },
183
+ fields: { [slot.data]: mergeField(designTypeAt(designTypes, slot.index), { required: requiredScalar }) }
184
+ }
185
+ }
186
+ const dtoFields = reflectDtoFields(slot.designType)
187
+ return {
188
+ binding: { kind: 'object', source, fields: Object.keys(dtoFields), metatype: slot.designType, pipes: slot.pipes },
189
+ fields: dtoFields
190
+ }
191
+ }
192
+
193
+ /** Map one parameter slot to its input-field contribution and re-bind instruction. */
194
+ function contributeSlot(slot: ParamSlot, pathParams: string[], designTypes: unknown[]): SlotContribution {
195
+ switch (slot.paramtype) {
196
+ case PARAMTYPE.PARAM: return paramContribution(slot, pathParams)
197
+ case PARAMTYPE.QUERY: return bodyOrQueryContribution(slot, 'query', false, designTypes)
198
+ case PARAMTYPE.BODY: return bodyOrQueryContribution(slot, 'body', true, designTypes)
199
+ default: return { binding: specialBinding(slot.paramtype, slot.data) ?? { kind: 'missing' }, fields: {} }
200
+ }
201
+ }
@@ -1,58 +1,38 @@
1
- import { type Action, type ActionKind, type HttpMethod, type SilkweaveContext } from '@silkweave/core'
2
- import type z from 'zod/v4'
1
+ import type { z } from 'zod/v4'
3
2
 
4
- export const ACTION_METADATA = '__silkweave_action__'
5
- export const ACTIONS_METADATA = '__silkweave_actions__'
3
+ /** Reflect-metadata key carrying `@Mcp` options on a controller method. */
4
+ export const MCP_METADATA = '__silkweave_mcp__'
6
5
 
7
- export type Transport = 'rest' | 'trpc' | 'mcp'
8
-
9
- export interface ActionMetadata<I extends object = object, O extends object = object, C = unknown> {
10
- /** Action name. Defaults to the kebab-cased method name. */
6
+ /**
7
+ * Options for the `@Mcp()` method decorator. Every field is optional - an empty
8
+ * `@Mcp()` exposes the decorated controller route as an MCP tool with its name,
9
+ * description, and input schema fully reflected from the method's route
10
+ * (`@Get`/`@Post`/...), parameter decorators (`@Param`/`@Query`/`@Body`), and
11
+ * any `@nestjs/swagger` (`@ApiOperation`/`@ApiParam`/`@ApiProperty`) or
12
+ * `class-validator` metadata it carries.
13
+ */
14
+ export interface McpMetadata {
15
+ /**
16
+ * MCP tool name override. When unset it is derived from the controller class
17
+ * and method name (e.g. `ChannelsController.findOne` → `ChannelsFindOne`).
18
+ */
11
19
  name?: string
12
- /** Human-readable description. Becomes the MCP tool description and REST OpenAPI summary. */
13
- description: string
14
- /** Zod object schema for the action's input. */
15
- input: z.ZodType<I> & { shape: Record<string, z.ZodTypeAny> }
16
- /** Optional Zod object schema for the action's output (used by tRPC type inference). */
17
- output?: z.ZodType<O> & { shape: Record<string, z.ZodTypeAny> }
18
20
  /**
19
- * Zod schema for individual chunks yielded by a streaming (async-generator)
20
- * action method. Required when the decorated method is an `async function*`.
21
- * Mirrors `Action.chunk` in @silkweave/core; typegen/trpc use it to expose
22
- * the action as a tRPC subscription.
21
+ * Tool description override. When unset it falls back to the method's
22
+ * `@ApiOperation({ summary | description })`, then a generated default.
23
23
  */
24
- chunk?: z.ZodType<C>
25
- /** `'query'` (GET in REST, `.query()` in tRPC) or `'mutation'` (POST in REST, `.mutation()` in tRPC). Default: `'mutation'`. */
26
- kind?: ActionKind
27
- /** HTTP verb for the REST route. Defaults to `POST` (or `GET` when `kind` is `'query'`). Overrides the `kind`-derived default. */
28
- method?: HttpMethod
29
- /** REST route path, optionally with `:param` placeholders (e.g. `'spaces/:spaceId/users'`). Each placeholder must be a key of `input`. Defaults to the action name with dots as slashes. */
30
- path?: string
31
- /** Input fields read from the URL query string instead of the request body (e.g. `['offset', 'limit']`). Each must be a key of `input`. */
32
- queryParams?: (keyof I)[]
33
- /** Allowlist of transports that should expose this action. Default: all registered transports. */
34
- transports?: Transport[]
35
- /** Dynamic enable check (in addition to `transports`). AND-combined with the transports filter. */
36
- isEnabled?: (context: SilkweaveContext) => boolean
37
- /** Custom MCP `CallToolResult` formatter. See `@silkweave/mcp`'s `smartToolResult`. */
38
- toolResult?: Action<I, O>['toolResult']
39
- /** CLI positional-argument keys (unused for HTTP-only adapters, kept for parity with `createAction`). */
40
- args?: (keyof I)[]
41
- /** Custom MCP tool name override. If unset, derived from action name as PascalCase. */
42
- mcpToolName?: string
43
- }
44
-
45
- export interface ActionsClassMetadata {
46
- /** Prefix joined to the method-level action name with a dot. e.g. `Actions('users')` + method `list` → `users.list`. */
47
- prefix?: string
48
- /** Class-level transports allowlist. Method-level `transports` overrides; otherwise inherits this. */
49
- transports?: Transport[]
50
- }
51
-
52
- export interface ResultToolResult<O extends object = object> {
53
- toolResult?: Action<object, O>['toolResult']
24
+ description?: string
25
+ /**
26
+ * Zod raw-shape override merged over the reflected input fields (override
27
+ * wins per field). The escape hatch for shapes reflection can't express
28
+ * losslessly - discriminated unions, custom validators, `@Transform`, etc.
29
+ */
30
+ input?: Record<string, z.ZodType>
31
+ /**
32
+ * Whether to apply the controller method's parameter-bound pipes
33
+ * (`@Param('id', ParseIntPipe)`) when re-binding the call. Default `'apply'`.
34
+ * Global/`ValidationPipe`, interceptors, and exception filters never run -
35
+ * the method is invoked directly, not through Nest's HTTP request pipeline.
36
+ */
37
+ pipes?: 'apply' | 'skip'
54
38
  }
55
-
56
- export type AnyActionMetadata = ActionMetadata<object, object> & ResultToolResult<object>
57
-
58
- export const ACTION_RESPONSE_KEY = '__silkweave_response__'
@@ -0,0 +1,117 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
2
+ import { PARAMTYPE } from './reflect/params.js'
3
+
4
+ /**
5
+ * Instruction for reconstructing one positional handler argument from the flat
6
+ * MCP tool input. Built at discovery time, consumed by {@link invokeRebound}.
7
+ */
8
+ export type Binding =
9
+ | { kind: 'value'; field: string; source: 'path' | 'query' | 'body'; metatype?: unknown; pipes?: unknown[] }
10
+ | { kind: 'object'; source: 'query' | 'body'; fields: string[]; metatype?: unknown; pipes?: unknown[] }
11
+ | { kind: 'params'; fields: string[] }
12
+ | { kind: 'request' }
13
+ | { kind: 'response' }
14
+ | { kind: 'headers'; data?: string }
15
+ | { kind: 'ip' }
16
+ | { kind: 'host'; data?: string }
17
+ | { kind: 'missing' }
18
+
19
+ interface RequestLike {
20
+ headers?: Record<string, unknown>
21
+ ip?: unknown
22
+ hosts?: Record<string, unknown>
23
+ }
24
+
25
+ function newInstance(P: any): any | null {
26
+ try { return new P() } catch { return null }
27
+ }
28
+
29
+ async function applyPipes(value: unknown, pipes: unknown[] | undefined, metatype: unknown, type: 'param' | 'query' | 'body', data: string | undefined): Promise<unknown> {
30
+ if (!pipes || pipes.length === 0) { return value }
31
+ let current = value
32
+ for (const p of pipes) {
33
+ const pipe = typeof p === 'function' ? newInstance(p) : p
34
+ if (pipe && typeof (pipe).transform === 'function') {
35
+ current = await (pipe).transform(current, { type, metatype, data })
36
+ }
37
+ }
38
+ return current
39
+ }
40
+
41
+ /** Pick a subset of keys (skipping `undefined`) into a fresh object. */
42
+ function pickFields(input: Record<string, unknown>, fields: string[]): Record<string, unknown> {
43
+ const obj: Record<string, unknown> = {}
44
+ for (const f of fields) {
45
+ if (input[f] !== undefined) { obj[f] = input[f] }
46
+ }
47
+ return obj
48
+ }
49
+
50
+ /** Resolve a single positional argument from one binding. */
51
+ async function resolveArg(
52
+ b: Binding,
53
+ input: Record<string, unknown>,
54
+ request: RequestLike | undefined,
55
+ applyParamPipes: boolean
56
+ ): Promise<unknown> {
57
+ switch (b.kind) {
58
+ case 'value': {
59
+ const raw = input[b.field]
60
+ const type = b.source === 'path' ? 'param' : b.source
61
+ return applyParamPipes ? applyPipes(raw, b.pipes, b.metatype, type, b.field) : raw
62
+ }
63
+ case 'object': {
64
+ const obj = pickFields(input, b.fields)
65
+ return applyParamPipes ? applyPipes(obj, b.pipes, b.metatype, b.source, undefined) : obj
66
+ }
67
+ case 'params':
68
+ return pickFields(input, b.fields)
69
+ case 'headers':
70
+ return b.data ? request?.headers?.[b.data.toLowerCase()] : (request?.headers ?? {})
71
+ case 'request':
72
+ return request ?? { headers: {} }
73
+ case 'ip':
74
+ return request?.ip
75
+ case 'host':
76
+ return b.data ? request?.hosts?.[b.data] : request?.hosts
77
+ default:
78
+ return undefined
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Reconstruct the controller method's positional arguments from the validated
84
+ * tool `input` (and the request stand-in for `@Req`/`@Headers`/...), then call
85
+ * it. `applyParamPipes` controls whether parameter-bound pipes run.
86
+ */
87
+ export async function invokeRebound(
88
+ method: (...args: any[]) => any,
89
+ instance: object,
90
+ input: Record<string, unknown>,
91
+ bindings: Binding[],
92
+ request: RequestLike | undefined,
93
+ applyParamPipes: boolean
94
+ ): Promise<unknown> {
95
+ const args: unknown[] = []
96
+ for (let i = 0; i < bindings.length; i += 1) {
97
+ args[i] = await resolveArg(bindings[i], input, request, applyParamPipes)
98
+ }
99
+ return await method.apply(instance, args)
100
+ }
101
+
102
+ /** Map a non-input parameter slot (`@Req`/`@Headers`/`@Ip`/...) to its runtime binding. */
103
+ export function specialBinding(paramtype: number, data: string | undefined): Binding | null {
104
+ switch (paramtype) {
105
+ case PARAMTYPE.REQUEST: return { kind: 'request' }
106
+ case PARAMTYPE.RESPONSE:
107
+ case PARAMTYPE.NEXT: return { kind: 'response' }
108
+ case PARAMTYPE.HEADERS: return { kind: 'headers', data }
109
+ case PARAMTYPE.IP: return { kind: 'ip' }
110
+ case PARAMTYPE.HOST: return { kind: 'host', data }
111
+ case PARAMTYPE.SESSION:
112
+ case PARAMTYPE.FILE:
113
+ case PARAMTYPE.FILES:
114
+ case PARAMTYPE.RAW_BODY: return { kind: 'missing' }
115
+ default: return null
116
+ }
117
+ }
@@ -0,0 +1,56 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
2
+ import { createRequire } from 'node:module'
3
+ import { join } from 'node:path'
4
+
5
+ let cached: any | null | undefined
6
+
7
+ /**
8
+ * Lazily resolve the optional `class-validator` peer; `null` when not installed.
9
+ * Resolution is attempted both from this package and from the app's working
10
+ * directory - the latter is what finds it in a pnpm install where the optional
11
+ * peer lives in the consumer app rather than alongside `@silkweave/nestjs`. A
12
+ * pnpm-deduped install shares one physical copy, so the metadata singleton the
13
+ * app's decorators wrote to is the same one we read.
14
+ */
15
+ function loadClassValidator(): any | null {
16
+ if (cached !== undefined) { return cached }
17
+ for (const base of [import.meta.url, join(process.cwd(), 'noop.js')]) {
18
+ try {
19
+ cached = createRequire(base)('class-validator')
20
+ return cached
21
+ } catch { /* try the next resolution base */ }
22
+ }
23
+ cached = null
24
+ return cached
25
+ }
26
+
27
+ export interface ValidationMeta {
28
+ /** `ValidationTypes` discriminator (e.g. `customValidation`, `conditionalValidation`). */
29
+ type?: string
30
+ /** Validator name - the actionable identity for built-ins (`isString`, `minLength`, ...). */
31
+ name?: string
32
+ constraints?: unknown[]
33
+ }
34
+
35
+ /**
36
+ * Read `class-validator` metadata for a DTO class, grouped by property name.
37
+ * Returns an empty map when `class-validator` is not installed or the class
38
+ * carries no validation decorators - so callers degrade gracefully to swagger /
39
+ * `design:type` reflection.
40
+ */
41
+ export function classValidatorMetas(dtoType: any): Record<string, ValidationMeta[]> {
42
+ const cv = loadClassValidator()
43
+ if (!cv?.getMetadataStorage) { return {} }
44
+ let metas: Array<{ propertyName?: string; type?: string; name?: string; constraints?: unknown[] }>
45
+ try {
46
+ metas = cv.getMetadataStorage().getTargetValidationMetadatas(dtoType, null, false, false)
47
+ } catch {
48
+ return {}
49
+ }
50
+ const out: Record<string, ValidationMeta[]> = {}
51
+ for (const m of metas) {
52
+ if (!m.propertyName) { continue }
53
+ (out[m.propertyName] ??= []).push({ type: m.type, name: m.name, constraints: m.constraints })
54
+ }
55
+ return out
56
+ }
@@ -0,0 +1,87 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
2
+ import { type FieldDesc, mergeField, openapiSchemaToField } from './schema.js'
3
+
4
+ /**
5
+ * A minimal view of an OpenAPI document - the subset we read. Matches the shape
6
+ * `SwaggerModule.createDocument()` returns, but typed loosely so callers can
7
+ * pass any compatible object without a hard `@nestjs/swagger` dependency.
8
+ */
9
+ export interface OpenApiDocument {
10
+ paths?: Record<string, Record<string, any>>
11
+ components?: { schemas?: Record<string, any> }
12
+ }
13
+
14
+ export interface OpenApiLookup {
15
+ doc: OpenApiDocument
16
+ /** `${METHOD} ${path}` → operation object. */
17
+ operations: Map<string, any>
18
+ }
19
+
20
+ /** Index a document's operations by `${METHOD} ${path}` for fast matching. */
21
+ export function buildOpenApiLookup(doc: OpenApiDocument): OpenApiLookup {
22
+ const operations = new Map<string, any>()
23
+ for (const [path, item] of Object.entries(doc.paths ?? {})) {
24
+ for (const [verb, op] of Object.entries(item ?? {})) {
25
+ operations.set(`${verb.toUpperCase()} ${path}`, op)
26
+ }
27
+ }
28
+ return { doc, operations }
29
+ }
30
+
31
+ /** Locate the operation for a route, tolerating a global path prefix on the document side. */
32
+ function findOperation(lookup: OpenApiLookup, method: string, openapiPath: string): any | undefined {
33
+ const exact = lookup.operations.get(`${method} ${openapiPath}`)
34
+ if (exact) { return exact }
35
+ // Fall back to a suffix match so a `setGlobalPrefix('api')` document still resolves.
36
+ for (const [key, op] of lookup.operations) {
37
+ const [verb, path] = key.split(' ')
38
+ if (verb === method && (path.endsWith(openapiPath) || openapiPath.endsWith(path))) { return op }
39
+ }
40
+ return undefined
41
+ }
42
+
43
+ function resolveRef(doc: OpenApiDocument, schema: any): any {
44
+ let current = schema
45
+ let guard = 0
46
+ while (current && typeof current === 'object' && typeof current['$ref'] === 'string' && guard < 10) {
47
+ const match = /^#\/components\/schemas\/(.+)$/.exec(current['$ref'])
48
+ if (!match) { break }
49
+ current = doc.components?.schemas?.[match[1]]
50
+ guard += 1
51
+ }
52
+ return current
53
+ }
54
+
55
+ /**
56
+ * Resolve the per-field descriptors for a route from an ingested OpenAPI
57
+ * document. Merges `parameters` (path/query/header) and the JSON request-body
58
+ * schema's properties into a single field map. Returns `{}` when the operation
59
+ * isn't found - callers fall back to decorator reflection.
60
+ */
61
+ export function openApiFields(lookup: OpenApiLookup, method: string, openapiPath: string): Record<string, FieldDesc> {
62
+ const op = findOperation(lookup, method, openapiPath)
63
+ if (!op) { return {} }
64
+ const out: Record<string, FieldDesc> = {}
65
+
66
+ for (const param of (op['parameters'] as Array<Record<string, any>> | undefined) ?? []) {
67
+ const name = typeof param['name'] === 'string' ? param['name'] : undefined
68
+ if (!name) { continue }
69
+ const schema = param['schema'] ? resolveRef(lookup.doc, param['schema']) : undefined
70
+ let field = schema ? openapiSchemaToField(schema) : {}
71
+ if (param['description']) { field = mergeField(field, { description: param['description'] }) }
72
+ if (typeof param['required'] === 'boolean') { field = mergeField(field, { required: param['required'] }) }
73
+ out[name] = field
74
+ }
75
+
76
+ const bodySchema = resolveRef(lookup.doc, op['requestBody']?.['content']?.['application/json']?.['schema'])
77
+ if (bodySchema && typeof bodySchema === 'object' && bodySchema['properties']) {
78
+ const required = new Set<string>(Array.isArray(bodySchema['required']) ? bodySchema['required'] : [])
79
+ for (const [name, propSchema] of Object.entries(bodySchema['properties'] as Record<string, any>)) {
80
+ const field = openapiSchemaToField(resolveRef(lookup.doc, propSchema))
81
+ field.required = required.has(name)
82
+ out[name] = field
83
+ }
84
+ }
85
+
86
+ return out
87
+ }