@silkweave/nestjs 2.0.0 → 2.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.
@@ -1,270 +0,0 @@
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
- }
@@ -1,39 +0,0 @@
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,75 +0,0 @@
1
- import { Inject, Module, type DynamicModule, type MiddlewareConsumer, type NestModule } from '@nestjs/common'
2
- import { DiscoveryModule, HttpAdapterHost } from '@nestjs/core'
3
- import { createContext } from '@silkweave/core'
4
- import { ControllerDiscovery } from './controllerDiscovery.js'
5
- import { SILKWEAVE_MODULE_OPTIONS, type SilkweaveModuleOptions } from './types.js'
6
-
7
- /**
8
- * Root module for `@silkweave/nestjs`.
9
- *
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.
20
- *
21
- * @example
22
- * ```ts
23
- * @Module({
24
- * imports: [
25
- * SilkweaveModule.forRoot({
26
- * silkweave: { name: 'app', description: 'My App', version: '1.0.0' },
27
- * adapters: [mcp({ basePath: '/mcp' })]
28
- * })
29
- * ],
30
- * controllers: [ChannelsController]
31
- * })
32
- * export class AppModule {}
33
- * ```
34
- */
35
- @Module({})
36
- export class SilkweaveModule implements NestModule {
37
- constructor(
38
- @Inject(SILKWEAVE_MODULE_OPTIONS) private readonly options: SilkweaveModuleOptions,
39
- private readonly discovery: ControllerDiscovery,
40
- private readonly httpAdapterHost: HttpAdapterHost
41
- ) { }
42
-
43
- static forRoot(options: SilkweaveModuleOptions): DynamicModule {
44
- return {
45
- module: SilkweaveModule,
46
- global: true,
47
- imports: [DiscoveryModule],
48
- providers: [
49
- { provide: SILKWEAVE_MODULE_OPTIONS, useValue: options },
50
- ControllerDiscovery
51
- ],
52
- exports: []
53
- }
54
- }
55
-
56
- configure(_consumer: MiddlewareConsumer): void {
57
- const httpAdapter = this.httpAdapterHost.httpAdapter
58
- if (!httpAdapter) {
59
- throw new Error('@silkweave/nestjs: HttpAdapterHost.httpAdapter is not available.')
60
- }
61
- const allActions = this.discovery.discover(this.options.openapi)
62
- for (const adapter of this.options.adapters) {
63
- const baseContext = createContext({ ...(this.options.context ?? {}), adapter: adapter.name })
64
- const actions = adapter.allActions
65
- ? allActions
66
- : allActions.filter((a) => !a.isEnabled || a.isEnabled(baseContext))
67
- adapter.register({
68
- httpAdapter,
69
- silkweaveOptions: this.options.silkweave,
70
- baseContext,
71
- actions
72
- })
73
- }
74
- }
75
- }
package/src/lib/types.ts DELETED
@@ -1,61 +0,0 @@
1
- import type { HttpAdapterHost } from '@nestjs/core'
2
- import type { Action, SilkweaveContext, SilkweaveOptions } from '@silkweave/core'
3
- import type { OpenApiDocument } from './reflect/openapi.js'
4
-
5
- /**
6
- * Context passed to a Nest Silkweave adapter when `SilkweaveModule` wires it
7
- * up. Adapters register their routes directly on `httpAdapter` (no
8
- * placeholder middleware, no `silkweave()` builder), so they only fire
9
- * `register()` once and own the rest of their lifecycle implicitly through
10
- * Nest.
11
- */
12
- export interface NestAdapterRegisterContext {
13
- /** Nest's underlying HTTP adapter (Express or Fastify). */
14
- httpAdapter: NonNullable<HttpAdapterHost['httpAdapter']>
15
- /** Identity the adapter surfaces to clients (e.g. MCP server name). */
16
- silkweaveOptions: SilkweaveOptions
17
- /** Per-adapter context - already forked with `{ adapter: adapter.name, ...userContext }`. */
18
- baseContext: SilkweaveContext
19
- /** Actions filtered to those enabled on this adapter. */
20
- actions: Action[]
21
- }
22
-
23
- /**
24
- * A Silkweave Nest adapter. Each transport (REST, tRPC, MCP) implements this
25
- * shape. `register()` is called from `SilkweaveModule.configure()` - which
26
- * runs *before* Nest's controller routes are mapped - so adapter routes
27
- * always sit ahead of any catch-all controllers in the framework's request
28
- * pipeline.
29
- */
30
- export interface NestSilkweaveAdapter {
31
- /** Adapter discriminator - set on the silkweave context as `ctx.get('adapter')`. */
32
- readonly name: 'mcp'
33
- /** URL prefix the adapter mounts on (e.g. `'/mcp'`). Surfaced for introspection. */
34
- readonly basePath?: string
35
- /**
36
- * When `true`, the adapter receives every discovered action regardless of
37
- * each action's `isEnabled` gate. Reserved for non-runtime adapters that need
38
- * the entire action surface.
39
- */
40
- readonly allActions?: boolean
41
- /** Register this adapter's routes on Nest's HTTP server. */
42
- register(ctx: NestAdapterRegisterContext): void
43
- }
44
-
45
- export interface SilkweaveModuleOptions {
46
- /** Identity for the silkweave instance - surfaced to MCP clients, OpenAPI, etc. */
47
- silkweave: SilkweaveOptions
48
- /** Adapters to mount. Currently `mcp()`. */
49
- adapters: NestSilkweaveAdapter[]
50
- /** Initial context keys merged into every adapter's `baseContext`. */
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
59
- }
60
-
61
- export const SILKWEAVE_MODULE_OPTIONS = '__silkweave_module_options__'
package/tsconfig.json DELETED
@@ -1,25 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "lib": ["ES2022"],
7
- "outDir": "build",
8
- "rootDir": "src",
9
- "strict": true,
10
- "esModuleInterop": true,
11
- "skipLibCheck": true,
12
- "forceConsistentCasingInFileNames": true,
13
- "allowSyntheticDefaultImports": true,
14
- "resolveJsonModule": true,
15
- "composite": true,
16
- "declaration": true,
17
- "declarationMap": true,
18
- "sourceMap": true,
19
- "experimentalDecorators": true,
20
- "emitDecoratorMetadata": true,
21
- "useDefineForClassFields": false
22
- },
23
- "include": ["src"],
24
- "exclude": ["node_modules", "build"]
25
- }