@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.
@@ -1,173 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-unsafe-assignment */
2
- import { HttpException } from '@nestjs/common'
3
- import { AuthConfig, AuthInfo, validateToken } from '@silkweave/auth'
4
- import { type Action, actionMethod, methodHasBody, resolveActionInput, type SilkweaveContext, SilkweaveError, validateActionRouting } from '@silkweave/core'
5
- import { buildLogLevels, type Logger, type LogLevel } from '@silkweave/logger'
6
- import type { IncomingMessage, ServerResponse } from 'http'
7
- import { z } from 'zod/v4'
8
- import type { NestAdapterRegisterContext, NestSilkweaveAdapter } from '../lib/types.js'
9
-
10
- const CONSOLE_LEVEL_MAP: Record<LogLevel, 'log' | 'info' | 'warn' | 'error'> = {
11
- emergency: 'error',
12
- alert: 'error',
13
- critical: 'error',
14
- error: 'error',
15
- warning: 'warn',
16
- notice: 'info',
17
- info: 'info',
18
- debug: 'log'
19
- }
20
-
21
- export interface RestAdapterOptions {
22
- /** URL prefix joined to each action's path. e.g. `'/api'` → `POST /api/users/list`. Default: `'/api'`. */
23
- basePath?: string
24
- /** Optional bearer-token auth applied to every REST action. */
25
- auth?: AuthConfig
26
- }
27
-
28
- interface RequestLike {
29
- headers: Record<string, string | string[] | undefined>
30
- body?: unknown
31
- query?: Record<string, unknown>
32
- params?: Record<string, string | undefined>
33
- url?: string
34
- method?: string
35
- }
36
-
37
- function createRestLogger(): Logger {
38
- return {
39
- ...buildLogLevels((level, data) => { console[CONSOLE_LEVEL_MAP[level]](data) }),
40
- progress: () => { /* progress notifications not supported on REST */ }
41
- }
42
- }
43
-
44
- function actionNameToPath(name: string): string {
45
- return `/${name.replace(/\./g, '/')}`
46
- }
47
-
48
- async function readJsonBody(req: IncomingMessage): Promise<unknown> {
49
- return new Promise((resolve, reject) => {
50
- const chunks: Buffer[] = []
51
- req.on('data', (c: Buffer) => chunks.push(c))
52
- req.on('end', () => {
53
- if (chunks.length === 0) { return resolve({}) }
54
- try {
55
- resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')))
56
- } catch (err) {
57
- reject(err instanceof Error ? err : new Error('Unable to parse JSON body'))
58
- }
59
- })
60
- req.on('error', reject)
61
- })
62
- }
63
-
64
- function parseQuery(url: string | undefined): Record<string, string> {
65
- if (!url) { return {} }
66
- const qIndex = url.indexOf('?')
67
- if (qIndex === -1) { return {} }
68
- const params: Record<string, string> = {}
69
- const search = new URLSearchParams(url.slice(qIndex + 1))
70
- for (const [k, v] of search.entries()) { params[k] = v }
71
- return params
72
- }
73
-
74
- function sendJson(res: ServerResponse, body: unknown, status: number): void {
75
- if (res.headersSent) { return }
76
- res.statusCode = status
77
- res.setHeader('Content-Type', 'application/json')
78
- res.end(JSON.stringify(body))
79
- }
80
-
81
- function handleRestError(err: unknown, res: ServerResponse): void {
82
- if (err instanceof z.ZodError) {
83
- sendJson(res, { error: 'validation_error', issues: err.issues }, 400)
84
- return
85
- }
86
- if (err instanceof SilkweaveError) {
87
- sendJson(res, { error: err.code, message: err.message }, err.statusCode)
88
- return
89
- }
90
- if (err instanceof HttpException) {
91
- const body = err.getResponse()
92
- const payload = typeof body === 'string' ? { error: err.name, message: body } : body
93
- sendJson(res, payload, err.getStatus())
94
- return
95
- }
96
- console.error(err)
97
- sendJson(res, { error: 'internal', message: 'Internal error' }, 500)
98
- }
99
-
100
- function createActionHandler(
101
- action: Action,
102
- context: SilkweaveContext,
103
- auth: AuthConfig | undefined,
104
- logger: Logger
105
- ): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
106
- return async (req, res) => {
107
- try {
108
- const reqLike = req as unknown as RequestLike
109
- let authInfo: AuthInfo | undefined
110
- if (auth) {
111
- const authHeader = typeof reqLike.headers.authorization === 'string' ? reqLike.headers.authorization : undefined
112
- const result = await validateToken(authHeader, auth, context.fork({ request: req }))
113
- if (result.error) {
114
- for (const [k, v] of Object.entries(result.error.headers)) { res.setHeader(k, v) }
115
- sendJson(res, result.error.body, result.error.statusCode)
116
- return
117
- }
118
- authInfo = result.auth
119
- }
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 })
125
- const input = action.input.parse(raw) as object
126
- const result = await action.run(input, context.fork({
127
- logger,
128
- request: req,
129
- response: res,
130
- ...(authInfo ? { auth: authInfo } : {})
131
- }))
132
- sendJson(res, result, 200)
133
- } catch (err) {
134
- handleRestError(err, res)
135
- }
136
- }
137
- }
138
-
139
- /**
140
- * REST adapter for `@silkweave/nestjs`. Registers each discovered `@Action`
141
- * as an individual route on Nest's HTTP adapter:
142
- *
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`).
148
- *
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.
154
- */
155
- export function rest(options: RestAdapterOptions = {}): NestSilkweaveAdapter {
156
- const basePath = (options.basePath ?? '/api').replace(/\/$/, '')
157
- return {
158
- name: 'rest',
159
- basePath,
160
- register({ httpAdapter, baseContext, actions }: NestAdapterRegisterContext): void {
161
- const logger = createRestLogger()
162
- for (const action of actions) {
163
- validateActionRouting(action)
164
- const path = action.path
165
- ? `${basePath}/${action.path.replace(/^\//, '')}`
166
- : `${basePath}${actionNameToPath(action.name)}`
167
- const method = actionMethod(action).toLowerCase()
168
- const handler = createActionHandler(action, baseContext, options.auth, logger)
169
- ; (httpAdapter as unknown as Record<string, (path: string, h: typeof handler) => unknown>)[method](path, handler)
170
- }
171
- }
172
- }
173
- }
@@ -1,58 +0,0 @@
1
- import { AuthConfig } from '@silkweave/auth'
2
- import { buildRouter, createActionLogger, resolveAuth, type TrpcHandlerContext } from '@silkweave/trpc'
3
- import { createExpressMiddleware } from '@trpc/server/adapters/express'
4
- import type { IncomingMessage, ServerResponse } from 'http'
5
- import type { NestAdapterRegisterContext, NestSilkweaveAdapter } from '../lib/types.js'
6
-
7
- export interface TrpcAdapterOptions {
8
- /** URL prefix at which the tRPC handler is mounted. Default `'/trpc'`. */
9
- basePath?: string
10
- /** Optional bearer-token auth applied to every tRPC procedure. */
11
- auth?: AuthConfig
12
- }
13
-
14
- /**
15
- * tRPC adapter for `@silkweave/nestjs`. Builds a tRPC router from discovered
16
- * `@Action` methods and mounts the resulting express middleware at
17
- * `basePath` on Nest's HTTP adapter.
18
- *
19
- * Action names with dots (e.g. `users.list` from `@Actions('users')`) collapse
20
- * to camelCase procedure keys (`usersList`).
21
- *
22
- * Works on `@nestjs/platform-express`. On `@nestjs/platform-fastify`, register
23
- * `@fastify/express` first so Nest can serve Express-style middleware.
24
- */
25
- export function trpc(options: TrpcAdapterOptions = {}): NestSilkweaveAdapter {
26
- return {
27
- name: 'trpc',
28
- register({ httpAdapter, baseContext, actions }: NestAdapterRegisterContext): void {
29
- const basePath = (options.basePath ?? '/trpc').replace(/\/$/, '') || '/'
30
- const router = buildRouter(actions)
31
- const logger = createActionLogger()
32
- const createContext = async (
33
- opts: { req: IncomingMessage; res: ServerResponse }
34
- ): Promise<TrpcHandlerContext> => {
35
- const resolved = await resolveAuth(options.auth, opts.req.headers.authorization, baseContext.fork({ request: opts.req }))
36
- if (resolved.kind === 'error') {
37
- for (const [key, value] of Object.entries<string>(resolved.error.headers)) { opts.res.setHeader(key, value) }
38
- opts.res.statusCode = resolved.error.statusCode
39
- opts.res.setHeader('Content-Type', 'application/json')
40
- opts.res.end(JSON.stringify(resolved.error.body))
41
- throw new Error('Unauthorized')
42
- }
43
- return {
44
- silkweaveContext: baseContext.fork({
45
- logger,
46
- request: opts.req,
47
- response: opts.res,
48
- ...(resolved.authInfo ? { auth: resolved.authInfo } : {})
49
- })
50
- }
51
- }
52
- const middleware = createExpressMiddleware({ router, createContext })
53
- ;(httpAdapter as unknown as { use: (path: string, h: unknown) => unknown }).use(basePath, middleware)
54
- }
55
- }
56
- }
57
-
58
- export { type InferTrpcRouter } from '@silkweave/trpc'
@@ -1,55 +0,0 @@
1
- import { renderTypegen, type TypegenFormat } from '@silkweave/typegen'
2
- import { mkdir, writeFile } from 'node:fs/promises'
3
- import { dirname, resolve } from 'node:path'
4
- import type { NestAdapterRegisterContext, NestSilkweaveAdapter } from '../lib/types.js'
5
-
6
- export interface TypegenAdapterOptions {
7
- /** Output file path for the generated `.d.ts` file. Resolved against `process.cwd()`. Parent directories are created automatically. */
8
- path: string
9
- /**
10
- * What to emit (default `'all'`):
11
- * - `'interfaces'` - `{Name}Input` / `{Name}Output` interfaces per action
12
- * - `'trpc-router'` - `AppRouter` type alias for `createTRPCClient<AppRouter>()`
13
- * - `'all'` - both
14
- */
15
- format?: TypegenFormat
16
- /**
17
- * Whether to write the file at all. Default: `process.env.NODE_ENV !== 'production'`.
18
- * Server processes generally shouldn't write to disk in production deploys -
19
- * leave it on the default unless you know you want otherwise.
20
- */
21
- enabled?: boolean
22
- }
23
-
24
- /**
25
- * Typegen adapter for `@silkweave/nestjs`. Discovers every `@Action`-decorated
26
- * method (regardless of `transports` filtering) and writes a single `.d.ts`
27
- * file with REST input/output interfaces and/or a tRPC `AppRouter` type alias.
28
- *
29
- * Designed for the monorepo pattern where the server (this app) is the source
30
- * of truth for types and consumer apps import from `path` - e.g.
31
- * `path: '../app/src/types/silkweave.ts'`.
32
- */
33
- export function typegen(options: TypegenAdapterOptions): NestSilkweaveAdapter {
34
- const enabled = options.enabled ?? (process.env['NODE_ENV'] !== 'production')
35
- return {
36
- name: 'typegen',
37
- allActions: true,
38
- register({ actions }: NestAdapterRegisterContext): void {
39
- if (!enabled) { return }
40
- const output = renderTypegen(actions, options.format ?? 'all')
41
- const target = resolve(options.path)
42
- // Fire-and-forget: we don't want to make `configure()` await disk IO and
43
- // delay app startup. Errors are surfaced via console.
44
- void (async () => {
45
- try {
46
- await mkdir(dirname(target), { recursive: true })
47
- await writeFile(target, output, 'utf-8')
48
- console.info(`@silkweave/nestjs typegen: wrote ${actions.length} action types to ${target}`)
49
- } catch (err) {
50
- console.error('@silkweave/nestjs typegen:', err)
51
- }
52
- })()
53
- }
54
- }
55
- }
@@ -1,38 +0,0 @@
1
- import { SetMetadata } from '@nestjs/common'
2
- import { ACTION_METADATA, type ActionMetadata } from '../lib/metadata.js'
3
-
4
- /**
5
- * Method decorator that registers a Silkweave action.
6
- *
7
- * The decorated method becomes an Action and is exposed via every adapter
8
- * configured on `SilkweaveModule` (REST/tRPC/MCP), unless `transports` is
9
- * provided to restrict it.
10
- *
11
- * The method receives `(input, context)` where `input` is the parsed Zod input
12
- * and `context` is the `SilkweaveContext` (with `logger`, `request`, optional
13
- * `auth`). The class instance is a normal Nest provider, so other services can
14
- * be injected via the constructor.
15
- *
16
- * @example
17
- * ```ts
18
- * @Injectable()
19
- * @Actions('users')
20
- * export class UserActions {
21
- * constructor(private db: DbService) {}
22
- *
23
- * @Action({
24
- * description: 'List users',
25
- * input: z.object({ limit: z.number().optional() }),
26
- * kind: 'query'
27
- * })
28
- * list(input: { limit?: number }, ctx: SilkweaveContext) {
29
- * return this.db.listUsers(input.limit)
30
- * }
31
- * }
32
- * ```
33
- */
34
- export function Action<I extends object = object, O extends object = object, C = unknown>(
35
- options: ActionMetadata<I, O, C>
36
- ): MethodDecorator {
37
- return SetMetadata(ACTION_METADATA, options)
38
- }
@@ -1,25 +0,0 @@
1
- import { SetMetadata } from '@nestjs/common'
2
- import { ACTIONS_METADATA, type ActionsClassMetadata, type Transport } from '../lib/metadata.js'
3
-
4
- /**
5
- * Class decorator that groups a provider's `@Action` methods under a common
6
- * prefix. The prefix is joined to each method's action name with a dot
7
- * (e.g. `@Actions('users')` + method `list` → action name `users.list`).
8
- *
9
- * The class itself remains a normal Nest provider - add `@Injectable()`
10
- * separately so it can be resolved by the DI container.
11
- *
12
- * Accepts either a prefix string (shorthand) or a full options object:
13
- * ```ts
14
- * @Actions('users')
15
- * @Actions({ prefix: 'users', transports: ['rest', 'trpc'] })
16
- * ```
17
- */
18
- export function Actions(prefixOrOptions: string | ActionsClassMetadata = {}): ClassDecorator {
19
- const options: ActionsClassMetadata = typeof prefixOrOptions === 'string'
20
- ? { prefix: prefixOrOptions }
21
- : prefixOrOptions
22
- return SetMetadata(ACTIONS_METADATA, options)
23
- }
24
-
25
- export type { ActionsClassMetadata, Transport }
@@ -1,131 +0,0 @@
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 ActionKind, type SilkweaveContext, type StreamingActionInput } from '@silkweave/core'
5
- import { kebabCase } from 'change-case'
6
- import { buildIsEnabled } from './filter.js'
7
- import { collectGuards, runGuards } from './guards.js'
8
- import { ACTION_METADATA, ACTIONS_METADATA, type ActionMetadata, type ActionsClassMetadata } from './metadata.js'
9
-
10
- interface DiscoveredAction {
11
- instance: object
12
- classRef: Type<unknown>
13
- method: (...args: unknown[]) => unknown
14
- methodName: string
15
- meta: ActionMetadata
16
- classMeta: ActionsClassMetadata
17
- }
18
-
19
- @Injectable()
20
- export class ActionDiscovery {
21
- constructor(
22
- private readonly discovery: DiscoveryService,
23
- private readonly scanner: MetadataScanner,
24
- private readonly reflector: Reflector,
25
- private readonly moduleRef: ModuleRef
26
- ) { }
27
-
28
- /**
29
- * Walk every Nest provider, find methods annotated with `@Action`, and build
30
- * a list of core `Action` objects ready to feed into `silkweave().actions()`.
31
- *
32
- * Action invocation is wrapped to (a) run `@UseGuards` guards declared on the
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.
37
- */
38
- discover(): Action[] {
39
- const discovered: DiscoveredAction[] = []
40
- const wrappers = this.discovery.getProviders()
41
- for (const wrapper of wrappers) {
42
- const { instance } = wrapper
43
- if (!instance || typeof instance !== 'object') { continue }
44
- const proto = Object.getPrototypeOf(instance) as object | null
45
- if (!proto) { continue }
46
- const classRef = instance.constructor as Type<unknown>
47
- const classMeta = (this.reflector.get<ActionsClassMetadata>(ACTIONS_METADATA, classRef) ?? {})
48
- for (const methodName of this.scanner.getAllMethodNames(proto)) {
49
- const method = (proto as Record<string, unknown>)[methodName] as ((...args: unknown[]) => unknown) | undefined
50
- if (typeof method !== 'function') { continue }
51
- const meta = this.reflector.get<ActionMetadata>(ACTION_METADATA, method)
52
- if (!meta) { continue }
53
- discovered.push({ instance, classRef, method, methodName, meta, classMeta })
54
- }
55
- }
56
- return discovered.map((d) => this.toAction(d))
57
- }
58
-
59
- private toAction(d: DiscoveredAction): Action {
60
- const baseName = d.meta.name ?? kebabCase(d.methodName)
61
- const name = d.classMeta.prefix ? `${d.classMeta.prefix}.${baseName}` : baseName
62
- const transports = d.meta.transports ?? d.classMeta.transports
63
- const isEnabled = buildIsEnabled(transports, d.meta.isEnabled)
64
- const guards = collectGuards(this.reflector, d.classRef, d.method)
65
- const moduleRef = this.moduleRef
66
- const reflector = this.reflector
67
-
68
- const applyGuards = async (context: SilkweaveContext): Promise<void> => {
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`.
75
- const request = context.getOptional<unknown>('request')
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')
80
- }
81
-
82
- // Cast at the createAction boundary to bridge dual-zod-version installs
83
- // (zod@3.25 + zod@4.x can both be present transitively). Runtime is fine -
84
- // they share the same /v4 surface - but the structural types are distinct.
85
- const streaming = d.method.constructor?.name === 'AsyncGeneratorFunction'
86
-
87
- if (streaming) {
88
- if (!d.meta.chunk) {
89
- throw new Error(`@Action "${name}" is an async generator but has no \`chunk\` schema`)
90
- }
91
- const method = d.method
92
- const instance = d.instance
93
- return createAction({
94
- name,
95
- description: d.meta.description,
96
- input: d.meta.input,
97
- chunk: d.meta.chunk,
98
- kind: d.meta.kind ?? 'mutation',
99
- method: d.meta.method,
100
- path: d.meta.path,
101
- queryParams: d.meta.queryParams,
102
- args: d.meta.args,
103
- isEnabled,
104
- toolResult: d.meta.toolResult,
105
- run: async function* (input: object, context: SilkweaveContext) {
106
- await applyGuards(context)
107
- yield* (method.call(instance, input, context) as AsyncGenerator<object, void, void>)
108
- }
109
- } as StreamingActionInput<object, unknown, string, ActionKind>) as Action
110
- }
111
-
112
- return createAction({
113
- name,
114
- description: d.meta.description,
115
- input: d.meta.input,
116
- output: d.meta.output,
117
- kind: d.meta.kind ?? 'mutation',
118
- method: d.meta.method,
119
- path: d.meta.path,
120
- queryParams: d.meta.queryParams,
121
- args: d.meta.args,
122
- isEnabled,
123
- toolResult: d.meta.toolResult,
124
- run: async (input: object, context: SilkweaveContext): Promise<object> => {
125
- await applyGuards(context)
126
- const result = await (d.method.call(d.instance, input, context) as Promise<object>)
127
- return result
128
- }
129
- })
130
- }
131
- }
package/src/lib/filter.ts DELETED
@@ -1,26 +0,0 @@
1
- import type { SilkweaveContext } from '@silkweave/core'
2
- import type { Transport } from './metadata.js'
3
-
4
- /**
5
- * Compile a `transports` allowlist + optional user `isEnabled` into a single
6
- * `(ctx) => boolean` callback compatible with `Action.isEnabled`.
7
- *
8
- * - If `transports` is omitted, the action runs on every adapter.
9
- * - If `transports` is set, the action is gated on `ctx.get<string>('adapter')`
10
- * matching one of the listed transports. Each adapter in `@silkweave/nestjs`
11
- * forks its context with `{ adapter: 'rest' | 'trpc' | 'mcp' }`.
12
- * - If both `transports` and `userIsEnabled` are set, they are AND-combined.
13
- */
14
- export function buildIsEnabled(
15
- transports: Transport[] | undefined,
16
- userIsEnabled: ((ctx: SilkweaveContext) => boolean) | undefined
17
- ): ((ctx: SilkweaveContext) => boolean) | undefined {
18
- if (!transports && !userIsEnabled) { return undefined }
19
- return (ctx) => {
20
- if (transports) {
21
- const adapter = ctx.getOptional<string>('adapter')
22
- if (adapter && !transports.includes(adapter as Transport)) { return false }
23
- }
24
- return userIsEnabled ? userIsEnabled(ctx) : true
25
- }
26
- }
@@ -1,116 +0,0 @@
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
- }