@silkweave/nestjs 1.10.0 → 1.12.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.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "Silkweave NestJS Adapter",
5
5
  "repository": {
6
6
  "type": "git",
@@ -11,7 +11,13 @@
11
11
  "main": "build/index.mjs",
12
12
  "peerDependencies": {
13
13
  "@nestjs/common": "^10.0.0 || ^11.0.0",
14
- "@nestjs/core": "^10.0.0 || ^11.0.0"
14
+ "@nestjs/core": "^10.0.0 || ^11.0.0",
15
+ "@nestjs/swagger": "^7.0.0 || ^8.0.0 || ^11.0.0"
16
+ },
17
+ "peerDependenciesMeta": {
18
+ "@nestjs/swagger": {
19
+ "optional": true
20
+ }
15
21
  },
16
22
  "dependencies": {
17
23
  "@trpc/server": "^11.7.1",
@@ -19,17 +25,18 @@
19
25
  "cors": "^2.8.6",
20
26
  "express": "^5.2.1",
21
27
  "zod": "^3.25.0",
22
- "@silkweave/auth": "1.10.0",
23
- "@silkweave/core": "1.10.0",
24
- "@silkweave/trpc": "1.10.0",
25
- "@silkweave/mcp": "1.10.0",
26
- "@silkweave/logger": "1.10.0",
27
- "@silkweave/typegen": "1.10.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"
28
34
  },
29
35
  "devDependencies": {
30
36
  "@eslint/js": "^10.0.1",
31
37
  "@nestjs/common": "^11.0.0",
32
38
  "@nestjs/core": "^11.0.0",
39
+ "@nestjs/swagger": "^11.0.0",
33
40
  "@stylistic/eslint-plugin": "^5.10.0",
34
41
  "@types/cors": "^2.8.19",
35
42
  "@types/express": "^5.0.6",
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable @typescript-eslint/no-unsafe-assignment */
2
2
  import { HttpException } from '@nestjs/common'
3
3
  import { AuthConfig, AuthInfo, validateToken } from '@silkweave/auth'
4
- import { type Action, type SilkweaveContext, SilkweaveError } from '@silkweave/core'
4
+ import { type Action, actionMethod, methodHasBody, resolveActionInput, type SilkweaveContext, SilkweaveError, validateActionRouting } from '@silkweave/core'
5
5
  import { buildLogLevels, type Logger, type LogLevel } from '@silkweave/logger'
6
6
  import type { IncomingMessage, ServerResponse } from 'http'
7
7
  import { z } from 'zod/v4'
@@ -28,7 +28,8 @@ export interface RestAdapterOptions {
28
28
  interface RequestLike {
29
29
  headers: Record<string, string | string[] | undefined>
30
30
  body?: unknown
31
- query?: unknown
31
+ query?: Record<string, unknown>
32
+ params?: Record<string, string | undefined>
32
33
  url?: string
33
34
  method?: string
34
35
  }
@@ -116,9 +117,11 @@ function createActionHandler(
116
117
  }
117
118
  authInfo = result.auth
118
119
  }
119
- const raw = action.kind === 'query'
120
- ? (reqLike.query ?? parseQuery(req.url))
121
- : (reqLike.body ?? await readJsonBody(req))
120
+ const query = reqLike.query ?? parseQuery(req.url)
121
+ const body = methodHasBody(actionMethod(action))
122
+ ? (reqLike.body ?? await readJsonBody(req))
123
+ : undefined
124
+ const raw = resolveActionInput(action, { params: reqLike.params, query, body })
122
125
  const input = action.input.parse(raw) as object
123
126
  const result = await action.run(input, context.fork({
124
127
  logger,
@@ -137,22 +140,31 @@ function createActionHandler(
137
140
  * REST adapter for `@silkweave/nestjs`. Registers each discovered `@Action`
138
141
  * as an individual route on Nest's HTTP adapter:
139
142
  *
140
- * - `kind: 'query'` `GET ${basePath}/${actionName-with-slashes}` (input from query string)
141
- * - `kind: 'mutation'` `POST ${basePath}/${actionName-with-slashes}` (input from JSON body)
143
+ * - HTTP verb comes from `method`, else `GET` for `kind: 'query'`, else `POST`.
144
+ * - Path comes from `path` (joined to `basePath`, may contain `:param`
145
+ * placeholders), else `${basePath}/${actionName-with-slashes}`.
146
+ * - Input is merged from the request body, the `queryParams` query-string
147
+ * fields, and any `:param` path placeholders (see `resolveActionInput`).
142
148
  *
143
- * Routes show up in Nest's `RoutesResolver` logger and are eligible for
144
- * `@nestjs/swagger` scanning. Works on `@nestjs/platform-express` out of the
145
- * box. For `@nestjs/platform-fastify`, register `@fastify/express` first.
149
+ * Routes show up in Nest's `RoutesResolver` logger. They are registered
150
+ * directly on the HTTP adapter (not as Nest controllers), so `@nestjs/swagger`'s
151
+ * controller scanner does not see them - use `addSilkweaveActions()` to add them
152
+ * to the OpenAPI document. Works on `@nestjs/platform-express` out of the box.
153
+ * For `@nestjs/platform-fastify`, register `@fastify/express` first.
146
154
  */
147
155
  export function rest(options: RestAdapterOptions = {}): NestSilkweaveAdapter {
156
+ const basePath = (options.basePath ?? '/api').replace(/\/$/, '')
148
157
  return {
149
158
  name: 'rest',
159
+ basePath,
150
160
  register({ httpAdapter, baseContext, actions }: NestAdapterRegisterContext): void {
151
- const basePath = (options.basePath ?? '/api').replace(/\/$/, '')
152
161
  const logger = createRestLogger()
153
162
  for (const action of actions) {
154
- const path = `${basePath}${actionNameToPath(action.name)}`
155
- const method = action.kind === 'query' ? 'get' : 'post'
163
+ validateActionRouting(action)
164
+ const path = action.path
165
+ ? `${basePath}/${action.path.replace(/^\//, '')}`
166
+ : `${basePath}${actionNameToPath(action.name)}`
167
+ const method = actionMethod(action).toLowerCase()
156
168
  const handler = createActionHandler(action, baseContext, options.auth, logger)
157
169
  ; (httpAdapter as unknown as Record<string, (path: string, h: typeof handler) => unknown>)[method](path, handler)
158
170
  }
package/src/index.ts CHANGED
@@ -8,5 +8,7 @@ export * from './lib/discovery.js'
8
8
  export * from './lib/filter.js'
9
9
  export * from './lib/guards.js'
10
10
  export * from './lib/metadata.js'
11
+ export * from './lib/openapi.js'
11
12
  export * from './lib/silkweave.module.js'
13
+ export * from './lib/swagger.js'
12
14
  export * from './lib/types.js'
@@ -30,9 +30,10 @@ export class ActionDiscovery {
30
30
  * a list of core `Action` objects ready to feed into `silkweave().actions()`.
31
31
  *
32
32
  * Action invocation is wrapped to (a) run `@UseGuards` guards declared on the
33
- * method or its class against the incoming HTTP request (read from
34
- * `ctx.get('request')`) and (b) bind `this` to the resolved Nest provider so
35
- * DI-injected dependencies remain available.
33
+ * method or its class against the incoming request (read from
34
+ * `ctx.get('request')`, populated by REST/tRPC and by MCP-over-HTTP from the
35
+ * SDK's `extra.requestInfo`) and (b) bind `this` to the resolved Nest provider
36
+ * so DI-injected dependencies remain available.
36
37
  */
37
38
  discover(): Action[] {
38
39
  const discovered: DiscoveredAction[] = []
@@ -66,9 +67,16 @@ export class ActionDiscovery {
66
67
 
67
68
  const applyGuards = async (context: SilkweaveContext): Promise<void> => {
68
69
  if (guards.length === 0) { return }
70
+ // REST and tRPC populate `request`/`response`; MCP-over-HTTP populates
71
+ // `request` (a `{ headers, url, params, query }` stand-in built from the
72
+ // SDK's `extra.requestInfo`). Transports with no HTTP request at all (e.g.
73
+ // MCP stdio) get a header-less stand-in so guards reading `req.headers`
74
+ // degrade gracefully (deny) instead of dereferencing `undefined`.
69
75
  const request = context.getOptional<unknown>('request')
70
- const response = context.getOptional<unknown>('response')
71
- await runGuards(guards, moduleRef, reflector, d.classRef, d.method, request, response)
76
+ const response = context.getOptional<unknown>('response') ?? null
77
+ const hasRequest = request != null
78
+ const guardRequest = hasRequest ? request : { headers: {}, params: {}, query: {} }
79
+ await runGuards(guards, moduleRef, reflector, d.classRef, d.method, guardRequest, response, hasRequest ? 'http' : 'rpc')
72
80
  }
73
81
 
74
82
  // Cast at the createAction boundary to bridge dual-zod-version installs
@@ -88,6 +96,9 @@ export class ActionDiscovery {
88
96
  input: d.meta.input,
89
97
  chunk: d.meta.chunk,
90
98
  kind: d.meta.kind ?? 'mutation',
99
+ method: d.meta.method,
100
+ path: d.meta.path,
101
+ queryParams: d.meta.queryParams,
91
102
  args: d.meta.args,
92
103
  isEnabled,
93
104
  toolResult: d.meta.toolResult,
@@ -104,6 +115,9 @@ export class ActionDiscovery {
104
115
  input: d.meta.input,
105
116
  output: d.meta.output,
106
117
  kind: d.meta.kind ?? 'mutation',
118
+ method: d.meta.method,
119
+ path: d.meta.path,
120
+ queryParams: d.meta.queryParams,
107
121
  args: d.meta.args,
108
122
  isEnabled,
109
123
  toolResult: d.meta.toolResult,
package/src/lib/guards.ts CHANGED
@@ -33,12 +33,17 @@ async function resolveGuard(ref: GuardRef, moduleRef: ModuleRef): Promise<CanAct
33
33
  }
34
34
 
35
35
  /**
36
- * Run the configured guards against an HTTP request. Throws `ForbiddenException`
36
+ * Run the configured guards against a request. Throws `ForbiddenException`
37
37
  * if any guard rejects, mirroring Nest's HTTP request-pipeline behavior.
38
38
  *
39
39
  * Pass `null` for `response` when running on top of a tRPC or MCP request that
40
40
  * doesn't surface a raw response object; guards that introspect the response
41
41
  * will receive `null`.
42
+ *
43
+ * `contextType` is reflected through `ExecutionContext.getType()` so guards can
44
+ * branch on the transport. It is `'http'` for REST/tRPC and for MCP-over-HTTP
45
+ * (where a header-bearing request stand-in is available); transports without any
46
+ * HTTP request (e.g. MCP stdio) pass `'rpc'`.
42
47
  */
43
48
  export async function runGuards(
44
49
  guards: GuardRef[],
@@ -47,10 +52,11 @@ export async function runGuards(
47
52
  classRef: Type<unknown>,
48
53
  handler: (...args: unknown[]) => unknown,
49
54
  request: unknown,
50
- response: unknown
55
+ response: unknown,
56
+ contextType: 'http' | 'rpc' = 'http'
51
57
  ): Promise<void> {
52
58
  if (guards.length === 0) { return }
53
- const context = new SilkweaveExecutionContext([request, response], classRef, handler, 'http')
59
+ const context = new SilkweaveExecutionContext([request, response], classRef, handler, contextType)
54
60
  for (const ref of guards) {
55
61
  const guard = await resolveGuard(ref, moduleRef)
56
62
  const result = guard.canActivate(context)
@@ -1,4 +1,4 @@
1
- import { type Action, type ActionKind, type SilkweaveContext } from '@silkweave/core'
1
+ import { type Action, type ActionKind, type HttpMethod, type SilkweaveContext } from '@silkweave/core'
2
2
  import type z from 'zod/v4'
3
3
 
4
4
  export const ACTION_METADATA = '__silkweave_action__'
@@ -24,6 +24,12 @@ export interface ActionMetadata<I extends object = object, O extends object = ob
24
24
  chunk?: z.ZodType<C>
25
25
  /** `'query'` (GET in REST, `.query()` in tRPC) or `'mutation'` (POST in REST, `.mutation()` in tRPC). Default: `'mutation'`. */
26
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)[]
27
33
  /** Allowlist of transports that should expose this action. Default: all registered transports. */
28
34
  transports?: Transport[]
29
35
  /** Dynamic enable check (in addition to `transports`). AND-combined with the transports filter. */
@@ -0,0 +1,116 @@
1
+ import { type Action, actionMethod, methodHasBody, pathParamNames } from '@silkweave/core'
2
+ import { camelCase } from 'change-case'
3
+ import { z } from 'zod/v4'
4
+
5
+ export interface ActionPathsOptions {
6
+ /** URL prefix the `rest()` adapter mounts on. Default `'/api'`. */
7
+ basePath?: string
8
+ /** OpenAPI tag the actions are grouped under. Default `'Actions'`. */
9
+ tag?: string
10
+ }
11
+
12
+ interface JsonSchemaObject {
13
+ properties?: Record<string, unknown>
14
+ required?: string[]
15
+ }
16
+
17
+ /** Convert a route template's `:param` placeholders to OpenAPI `{param}` form. */
18
+ function toOpenApiPath(template: string): string {
19
+ return template.replace(/:([A-Za-z0-9_]+)/g, '{$1}')
20
+ }
21
+
22
+ /** The OpenAPI path key for an action - mirrors the `rest()` adapter's routing. */
23
+ function actionRoute(action: Action, basePath: string): string {
24
+ const sub = action.path ? action.path.replace(/^\//, '') : action.name.replace(/\./g, '/')
25
+ return toOpenApiPath(`${basePath}/${sub}`.replace(/\/{2,}/g, '/'))
26
+ }
27
+
28
+ /** `z.toJSONSchema` tags a top-level `$schema`; drop it for a tidy OpenAPI doc. */
29
+ function toJsonSchema(schema: z.ZodType): Record<string, unknown> {
30
+ const { $schema: _schema, ...rest } = z.toJSONSchema(schema) as Record<string, unknown>
31
+ return rest
32
+ }
33
+
34
+ function responseSchema(action: Action): unknown {
35
+ if (action.output) { return toJsonSchema(action.output) }
36
+ if (action.chunk) { return { type: 'array', items: toJsonSchema(action.chunk) } }
37
+ return undefined
38
+ }
39
+
40
+ interface SourceSplit {
41
+ parameters: Array<Record<string, unknown>>
42
+ bodyProps: Record<string, unknown>
43
+ bodyRequired: string[]
44
+ }
45
+
46
+ /** Split an action's input fields into OpenAPI path/query parameters and a body, matching the `rest()` adapter. */
47
+ function splitInputSources(action: Action, hasBody: boolean): SourceSplit {
48
+ const json = z.toJSONSchema(action.input) as JsonSchemaObject
49
+ const required = new Set(json.required ?? [])
50
+ const pathParams = new Set(pathParamNames(action.path))
51
+ const queryKeys = new Set((action.queryParams ?? []).map(String))
52
+
53
+ const split: SourceSplit = { parameters: [], bodyProps: {}, bodyRequired: [] }
54
+ for (const [key, schema] of Object.entries(json.properties ?? {})) {
55
+ if (pathParams.has(key)) {
56
+ split.parameters.push({ name: key, in: 'path', required: true, schema })
57
+ } else if (!hasBody || queryKeys.has(key)) {
58
+ split.parameters.push({ name: key, in: 'query', required: required.has(key), schema })
59
+ } else {
60
+ split.bodyProps[key] = schema
61
+ if (required.has(key)) { split.bodyRequired.push(key) }
62
+ }
63
+ }
64
+ return split
65
+ }
66
+
67
+ /** Build the OpenAPI operation object for a single action. */
68
+ function buildOperation(action: Action, tag: string): Record<string, unknown> {
69
+ const hasBody = methodHasBody(actionMethod(action))
70
+ const { parameters, bodyProps, bodyRequired } = splitInputSources(action, hasBody)
71
+ const response = responseSchema(action)
72
+
73
+ const operation: Record<string, unknown> = {
74
+ tags: [tag],
75
+ summary: action.description,
76
+ operationId: camelCase(action.name),
77
+ responses: {
78
+ 200: {
79
+ description: 'Successful response',
80
+ ...(response ? { content: { 'application/json': { schema: response } } } : {})
81
+ }
82
+ }
83
+ }
84
+ if (parameters.length) { operation.parameters = parameters }
85
+ if (hasBody && Object.keys(bodyProps).length) {
86
+ operation.requestBody = {
87
+ required: bodyRequired.length > 0,
88
+ content: { 'application/json': { schema: { type: 'object', properties: bodyProps, required: bodyRequired } } }
89
+ }
90
+ }
91
+ return operation
92
+ }
93
+
94
+ /**
95
+ * Build an OpenAPI `paths` fragment for a set of Silkweave actions, mirroring
96
+ * exactly how the `rest()` adapter routes them: the same HTTP verb (`method` ??
97
+ * `kind`), the same `path`/`name`-derived route, and the same path/query/body
98
+ * field split (`pathParamNames` + `queryParams`). Schemas are inlined from the
99
+ * Zod input/output via `z.toJSONSchema`; no shared components are emitted.
100
+ */
101
+ export function buildActionPaths(
102
+ actions: Action[],
103
+ options: ActionPathsOptions = {}
104
+ ): Record<string, Record<string, unknown>> {
105
+ const basePath = (options.basePath ?? '/api').replace(/\/$/, '')
106
+ const tag = options.tag ?? 'Actions'
107
+ const paths: Record<string, Record<string, unknown>> = {}
108
+
109
+ for (const action of actions) {
110
+ const method = actionMethod(action).toLowerCase()
111
+ const route = actionRoute(action, basePath)
112
+ paths[route] = { ...(paths[route] ?? {}), [method]: buildOperation(action, tag) }
113
+ }
114
+
115
+ return paths
116
+ }
@@ -0,0 +1,74 @@
1
+ import { type INestApplication } from '@nestjs/common'
2
+ import type { OpenAPIObject } from '@nestjs/swagger'
3
+ import { type Action, createContext } from '@silkweave/core'
4
+ import { ActionDiscovery } from './discovery.js'
5
+ import { buildActionPaths } from './openapi.js'
6
+ import { SILKWEAVE_MODULE_OPTIONS, type SilkweaveModuleOptions } from './types.js'
7
+
8
+ export interface SilkweaveSwaggerOptions {
9
+ /**
10
+ * URL prefix the `rest()` adapter mounts on. Defaults to the configured
11
+ * `rest()` adapter's `basePath`, falling back to `'/api'`.
12
+ */
13
+ basePath?: string
14
+ /** OpenAPI tag the actions are grouped under. Default `'Actions'`. */
15
+ tag?: string
16
+ /**
17
+ * Include actions that are *not* enabled on the REST transport (gated out via
18
+ * `transports` / `isEnabled`). Default `false` - the document mirrors the
19
+ * routes the `rest()` adapter actually registers.
20
+ */
21
+ includeDisabled?: boolean
22
+ }
23
+
24
+ /**
25
+ * Merge every REST-exposed Silkweave `@Action` into a NestJS Swagger
26
+ * `OpenAPIObject`.
27
+ *
28
+ * `@nestjs/swagger` builds its document by scanning **controllers**, but
29
+ * Silkweave registers action routes directly on the HTTP adapter (so they sit
30
+ * ahead of controllers in the request pipeline) - which means the scanner never
31
+ * sees them. This helper closes that gap: it discovers the actions through the
32
+ * same `ActionDiscovery` provider the `rest()` adapter uses, builds OpenAPI
33
+ * paths with the same routing logic (`buildActionPaths`), and merges them into
34
+ * the document. The result stays in sync with the live routes without any
35
+ * dynamic controllers.
36
+ *
37
+ * Call it between `SwaggerModule.createDocument()` and `SwaggerModule.setup()`:
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * const document = SwaggerModule.createDocument(app, config)
42
+ * addSilkweaveActions(app, document)
43
+ * SwaggerModule.setup('api/docs', app, document)
44
+ * ```
45
+ */
46
+ export function addSilkweaveActions(
47
+ app: INestApplication,
48
+ document: OpenAPIObject,
49
+ options: SilkweaveSwaggerOptions = {}
50
+ ): OpenAPIObject {
51
+ const discovery = app.get(ActionDiscovery)
52
+ const moduleOptions = app.get<SilkweaveModuleOptions>(SILKWEAVE_MODULE_OPTIONS)
53
+ const restAdapter = moduleOptions.adapters.find((adapter) => adapter.name === 'rest')
54
+ const basePath = options.basePath ?? restAdapter?.basePath ?? '/api'
55
+
56
+ const allActions = discovery.discover()
57
+ const actions = options.includeDisabled
58
+ ? allActions
59
+ : allActions.filter((action) => isRestEnabled(action, moduleOptions))
60
+
61
+ const paths = buildActionPaths(actions, { basePath, tag: options.tag })
62
+
63
+ document.paths ??= {}
64
+ for (const [route, item] of Object.entries(paths)) {
65
+ document.paths[route] = { ...(document.paths[route] ?? {}), ...item }
66
+ }
67
+ return document
68
+ }
69
+
70
+ /** Whether an action would be registered by the `rest()` adapter (adapter: 'rest'). */
71
+ function isRestEnabled(action: Action, moduleOptions: SilkweaveModuleOptions): boolean {
72
+ if (!action.isEnabled) { return true }
73
+ return action.isEnabled(createContext({ ...(moduleOptions.context ?? {}), adapter: 'rest' }))
74
+ }
package/src/lib/types.ts CHANGED
@@ -29,6 +29,12 @@ export interface NestAdapterRegisterContext {
29
29
  export interface NestSilkweaveAdapter {
30
30
  /** Adapter discriminator - set on the silkweave context as `ctx.get('adapter')`. */
31
31
  readonly name: 'rest' | 'trpc' | 'mcp' | 'typegen'
32
+ /**
33
+ * URL prefix the adapter mounts on (e.g. `'/api'`). Surfaced for introspection
34
+ * tooling such as `addSilkweaveActions()` (OpenAPI/Swagger), which reads it to
35
+ * keep generated docs aligned with the live routes.
36
+ */
37
+ readonly basePath?: string
32
38
  /**
33
39
  * When `true`, the adapter receives every discovered action regardless of
34
40
  * each action's `transports` allowlist / `isEnabled` gate. Used by