@contractkit/plugin-typescript 0.33.3 → 0.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$ci.log +4 -4
- package/.turbo/turbo-test$colon$ci.log +26 -25
- package/CHANGELOG.md +285 -0
- package/dist/codegen-contract.d.ts +22 -6
- package/dist/codegen-contract.d.ts.map +1 -1
- package/dist/codegen-mcp.d.ts.map +1 -1
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/codegen-plain-types.d.ts.map +1 -1
- package/dist/codegen-revive.d.ts +38 -4
- package/dist/codegen-revive.d.ts.map +1 -1
- package/dist/codegen-sdk.d.ts +2 -0
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +421 -202
- package/dist/index.js.map +1 -1
- package/dist/path-utils.d.ts +9 -0
- package/dist/path-utils.d.ts.map +1 -1
- package/dist/ts-render.d.ts +14 -0
- package/dist/ts-render.d.ts.map +1 -1
- package/llms.txt +14 -0
- package/package.json +3 -2
- package/src/codegen-contract.ts +84 -68
- package/src/codegen-mcp.ts +22 -11
- package/src/codegen-operation.ts +32 -19
- package/src/codegen-plain-types.ts +15 -8
- package/src/codegen-revive.ts +140 -20
- package/src/codegen-sdk.ts +322 -63
- package/src/index.ts +46 -4
- package/src/path-utils.ts +10 -0
- package/src/ts-render.ts +26 -0
- package/tests/codegen-contract.test.ts +87 -32
- package/tests/codegen-mcp.test.ts +23 -1
- package/tests/codegen-operation.test.ts +68 -8
- package/tests/codegen-plain-types.test.ts +13 -5
- package/tests/codegen-sdk.test.ts +265 -10
- package/tests/codegen-server.test.ts +43 -1
- package/tests/helpers.ts +7 -2
- package/tests/pipeline.test.ts +51 -4
- package/tests/ts-render.test.ts +29 -0
package/dist/path-utils.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { ContractRootNode, OpRootNode } from '@contractkit/core';
|
|
2
2
|
export declare const TEMPLATE_VAR_RE: RegExp;
|
|
3
|
+
/** Global-flagged twin of {@link TEMPLATE_VAR_RE}, for finding every variable in a string. */
|
|
4
|
+
export declare const TEMPLATE_VAR_RE_G: RegExp;
|
|
5
|
+
/**
|
|
6
|
+
* Substitute `{key}` placeholders from `vars`.
|
|
7
|
+
*
|
|
8
|
+
* An unknown key is left in place rather than throwing, because the caller is mid-way through
|
|
9
|
+
* building a path and has better context for the complaint — see the check at the emit funnel in
|
|
10
|
+
* `index.ts`, which reports it against the file it would have been written to.
|
|
11
|
+
*/
|
|
3
12
|
export declare function resolveTemplate(template: string, vars: Record<string, string>): string;
|
|
4
13
|
export declare function includesFilename(p: string): boolean;
|
|
5
14
|
export declare function commonDir(files: string[], rootDir: string): string;
|
package/dist/path-utils.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../src/path-utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGtE,eAAO,MAAM,eAAe,QAAY,CAAC;AAmBzC,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAEtF;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAclE;AAID,wBAAgB,gBAAgB,CAC5B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAiBR;AAED,wBAAgB,sBAAsB,CAClC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAER;AAID,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAkBf;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBnH;AAED,wBAAgB,qBAAqB,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAef;AAED,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,CAiBnG;AAED,wBAAgB,6BAA6B,CACzC,MAAM,EAAE,UAAU,EAAE,EACpB,YAAY,EAAE,gBAAgB,EAAE,EAChC,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,EAC5B,gBAAgB,GAAE,GAAG,CAAC,MAAM,CAAa,GAC1C,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CA0CpB"}
|
|
1
|
+
{"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../src/path-utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGtE,eAAO,MAAM,eAAe,QAAY,CAAC;AAmBzC,8FAA8F;AAC9F,eAAO,MAAM,iBAAiB,QAAe,CAAC;AAE9C;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAEtF;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAclE;AAID,wBAAgB,gBAAgB,CAC5B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAiBR;AAED,wBAAgB,sBAAsB,CAClC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAER;AAID,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAkBf;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBnH;AAED,wBAAgB,qBAAqB,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAef;AAED,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,CAiBnG;AAED,wBAAgB,6BAA6B,CACzC,MAAM,EAAE,UAAU,EAAE,EACpB,YAAY,EAAE,gBAAgB,EAAE,EAChC,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,EAC5B,gBAAgB,GAAE,GAAG,CAAC,MAAM,CAAa,GAC1C,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CA0CpB"}
|
package/dist/ts-render.d.ts
CHANGED
|
@@ -9,6 +9,20 @@ export declare function quoteKey(name: string): string;
|
|
|
9
9
|
export declare function escapeJsDocLines(text: string): string[];
|
|
10
10
|
/** Escape a string for inclusion inside a single-quoted TypeScript string literal. */
|
|
11
11
|
export declare function escapeSingleQuoted(s: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Render the markdown link back to the `.ck` declaration a generated construct came from, as
|
|
14
|
+
* `[label](./path/to/file.ck#L12)`. Returns the link only; callers supply the surrounding prose
|
|
15
|
+
* and comment prefix, since some sites emit it inside a JSDoc block and others inside a `//` line.
|
|
16
|
+
*
|
|
17
|
+
* The path is relative to the emitted file's own directory, so it resolves when the reader clicks
|
|
18
|
+
* it from wherever the file was written. `outPath` is optional because codegen can run without a
|
|
19
|
+
* destination (the prettier plugin, and several tests), in which case the source path is used
|
|
20
|
+
* as-is.
|
|
21
|
+
*
|
|
22
|
+
* Not `file://./path`: `file://` opens an authority component, so the `.` parses as the host and
|
|
23
|
+
* the link resolves to nothing. A plain relative path is the correct form.
|
|
24
|
+
*/
|
|
25
|
+
export declare function sourceLink(label: string, outPath: string | undefined, sourceFile: string, line?: number): string;
|
|
12
26
|
/** Convert an HTTP header name (e.g. `preference-applied`, `X-Request-ID`, `ETag`) to camelCase for use as a JS property. */
|
|
13
27
|
export declare function headerNameToProperty(name: string): string;
|
|
14
28
|
/**
|
package/dist/ts-render.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,qFAAqF;AACrF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAIhH;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CAoC9F;AA4DD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BlI;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BpI"}
|
package/llms.txt
CHANGED
|
@@ -82,6 +82,20 @@ Options worth knowing:
|
|
|
82
82
|
schema for argument validation and `z.toJSONSchema`. It falls back to `server.output.types` (when
|
|
83
83
|
`server.zod`) or the `zod` sub-config's output.
|
|
84
84
|
|
|
85
|
+
## Wiring the MCP tools
|
|
86
|
+
|
|
87
|
+
`mcp.tools.ts` exports `registerMcpTools(container)`, which **builds and returns** the
|
|
88
|
+
`McpToolHandlerMap`. It registers nothing: `register` belongs to InjectKit's `Registry`
|
|
89
|
+
(composition phase), while a `Container` (resolution phase) only resolves. Bind the aggregator from
|
|
90
|
+
a factory, which is also what supplies the `Container` it needs:
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
registry.register(McpToolHandlerMap).useFactory(registerMcpTools).asSingleton();
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The emitted tool classes are not registered for you either, so register each one on the same
|
|
97
|
+
`Registry` before the aggregator resolves it.
|
|
98
|
+
|
|
85
99
|
## Programmatic use
|
|
86
100
|
|
|
87
101
|
```typescript
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contractkit/plugin-typescript",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.1",
|
|
4
4
|
"description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -27,9 +27,10 @@
|
|
|
27
27
|
".": "./dist/index.js"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@contractkit/core": "0.
|
|
30
|
+
"@contractkit/core": "0.29.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
+
"zod": "^4.3.6",
|
|
33
34
|
"@repo/config-eslint": "0.3.1",
|
|
34
35
|
"@repo/config-typescript": "0.1.0"
|
|
35
36
|
},
|
package/src/codegen-contract.ts
CHANGED
|
@@ -21,10 +21,10 @@ import {
|
|
|
21
21
|
computeModelsWithOutput as ckComputeModelsWithOutput,
|
|
22
22
|
collectExternalOutputRefs as ckCollectExternalOutputRefs,
|
|
23
23
|
} from '@contractkit/core';
|
|
24
|
-
import { escapeJsDocLines } from './ts-render.js';
|
|
24
|
+
import { escapeJsDocLines, sourceLink } from './ts-render.js';
|
|
25
25
|
import type { TsRenderTarget } from './ts-render.js';
|
|
26
26
|
import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
|
|
27
|
-
import { renderReviveFunctions, reviveFnName,
|
|
27
|
+
import { renderReviveFunctions, reviveFnName, coerceDeclsFor } from './codegen-revive.js';
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Maps a ContractKit object mode to its Zod constructor name.
|
|
@@ -57,9 +57,9 @@ export interface ContractCodegenContext {
|
|
|
57
57
|
/** If set, import JsonValue from this path instead of re-declaring it (avoids barrel re-export conflicts) */
|
|
58
58
|
jsonValueImportPath?: string;
|
|
59
59
|
/**
|
|
60
|
-
* Runtime the emitted
|
|
61
|
-
*
|
|
62
|
-
* `
|
|
60
|
+
* Runtime the emitted types describe. Only affects scalars whose TypeScript type differs per
|
|
61
|
+
* runtime: `binary` is a `Buffer` on a Node server and a `Blob` in a fetch client, and
|
|
62
|
+
* `_ZodBinary` is generated to match. Default `'client'`.
|
|
63
63
|
*/
|
|
64
64
|
target?: TsRenderTarget;
|
|
65
65
|
/** Model names that carry a `decimal`, directly or transitively. */
|
|
@@ -128,8 +128,7 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
|
|
|
128
128
|
for (const l of escapeJsDocLines(model.description)) lines.push(` * ${l}`);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
lines.push(` * generated from [${model.name}](file://./${relPath}#L${model.loc.line})`);
|
|
131
|
+
lines.push(` * generated from ${sourceLink(model.name, outPath, model.loc.file, model.loc.line)}`);
|
|
133
132
|
lines.push('*/');
|
|
134
133
|
return lines;
|
|
135
134
|
}
|
|
@@ -137,8 +136,10 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
|
|
|
137
136
|
/**
|
|
138
137
|
* Generate a TypeScript module containing Zod schemas for every model in `root`.
|
|
139
138
|
*
|
|
140
|
-
* Emits
|
|
141
|
-
*
|
|
139
|
+
* Emits two schemas per model when visibility modifiers are present: `Model` (read — no
|
|
140
|
+
* writeonly fields) and `ModelInput` (write — no readonly fields). Writeonly inheritance rides
|
|
141
|
+
* on the Input chain, since a child's `Input` extends its parent's `Input`, which already carries
|
|
142
|
+
* the parent's writeonly fields.
|
|
142
143
|
*
|
|
143
144
|
* @param root - The parsed contract root node.
|
|
144
145
|
* @param context - Optional cross-file context for import resolution and Input/Output variant tracking.
|
|
@@ -187,7 +188,15 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
|
|
|
187
188
|
}
|
|
188
189
|
lines.push('');
|
|
189
190
|
if (needsBinary) {
|
|
190
|
-
|
|
191
|
+
// The one scalar with no single correct runtime type, so it follows `target` here exactly
|
|
192
|
+
// as `renderTsScalar` does. An SDK type file reaching this through the shared
|
|
193
|
+
// `generateContract` used to emit `Buffer.isBuffer` into a browser client, whose scaffold
|
|
194
|
+
// declares no `@types/node` — the type did not resolve and the check could not run.
|
|
195
|
+
lines.push(
|
|
196
|
+
context?.target === 'server'
|
|
197
|
+
? `const _ZodBinary = z.custom<Buffer>((val) => Buffer.isBuffer(val), { error: 'Must be binary data' });`
|
|
198
|
+
: `const _ZodBinary = z.custom<Blob>((val) => val instanceof Blob, { error: 'Must be binary data' });`,
|
|
199
|
+
);
|
|
191
200
|
}
|
|
192
201
|
if (needsDatetime) {
|
|
193
202
|
lines.push(
|
|
@@ -210,9 +219,9 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
|
|
|
210
219
|
}
|
|
211
220
|
if (needsBinary || needsDatetime || needsInterval || needsDecimal || needsJson) lines.push('');
|
|
212
221
|
|
|
213
|
-
const modelsWithWriteonly = new Set(root.models.filter(m => m.fields.some(f => f.visibility === 'writeonly')).map(m => m.name));
|
|
214
222
|
const modelMap = new Map(root.models.map(m => [m.name, m]));
|
|
215
223
|
|
|
224
|
+
|
|
216
225
|
const reviveOpts =
|
|
217
226
|
context?.emitRevivers && context.modelsWithDecimal
|
|
218
227
|
? { modelsWithDecimal: context.modelsWithDecimal, modelsWithOutput: allModelsWithOutput, modelMap }
|
|
@@ -220,7 +229,7 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
|
|
|
220
229
|
|
|
221
230
|
const bodyLines: string[] = [];
|
|
222
231
|
for (const model of topoSortModels(root.models)) {
|
|
223
|
-
bodyLines.push(...generateModel(model, context?.currentOutPath, allModelsWithInput,
|
|
232
|
+
bodyLines.push(...generateModel(model, context?.currentOutPath, allModelsWithInput, modelMap, allModelsWithOutput));
|
|
224
233
|
if (reviveOpts) {
|
|
225
234
|
const revivers = renderReviveFunctions(model, reviveOpts);
|
|
226
235
|
if (revivers.length > 0) {
|
|
@@ -232,9 +241,10 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
|
|
|
232
241
|
}
|
|
233
242
|
|
|
234
243
|
// Decided from the emitted revivers rather than from a predicate over the AST, so the
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
244
|
+
// declarations and their uses cannot drift apart and leave an unused local behind.
|
|
245
|
+
const coerceDecls = coerceDeclsFor(bodyLines);
|
|
246
|
+
if (coerceDecls.length > 0) {
|
|
247
|
+
lines.push(...coerceDecls);
|
|
238
248
|
lines.push('');
|
|
239
249
|
}
|
|
240
250
|
lines.push(...bodyLines);
|
|
@@ -282,7 +292,6 @@ function generateModel(
|
|
|
282
292
|
model: ModelNode,
|
|
283
293
|
outPath?: string,
|
|
284
294
|
modelsWithInput?: Set<string>,
|
|
285
|
-
modelsWithWriteonly?: Set<string>,
|
|
286
295
|
modelMap?: Map<string, ModelNode>,
|
|
287
296
|
modelsWithOutput?: Set<string>,
|
|
288
297
|
): string[] {
|
|
@@ -298,7 +307,7 @@ function generateModel(
|
|
|
298
307
|
const needsInputSplit = effective.fields.some(f => f.visibility !== 'normal') || (modelsWithInput?.has(effective.name) ?? false);
|
|
299
308
|
|
|
300
309
|
const lines = needsInputSplit
|
|
301
|
-
? generateThreeSchemaModel(effective, outPath, modelsWithInput,
|
|
310
|
+
? generateThreeSchemaModel(effective, outPath, modelsWithInput, modelMap)
|
|
302
311
|
: generateSimpleModel(effective, outPath);
|
|
303
312
|
|
|
304
313
|
// Emit Output type alias when this model (transitively) has format(output=...)
|
|
@@ -418,7 +427,6 @@ function generateThreeSchemaModel(
|
|
|
418
427
|
model: ModelNode,
|
|
419
428
|
outPath?: string,
|
|
420
429
|
modelsWithInput?: Set<string>,
|
|
421
|
-
modelsWithWriteonly?: Set<string>,
|
|
422
430
|
modelMap?: Map<string, ModelNode>,
|
|
423
431
|
): string[] {
|
|
424
432
|
const lines: string[] = [];
|
|
@@ -429,25 +437,9 @@ function generateThreeSchemaModel(
|
|
|
429
437
|
const wrapper = modeToWrapper(model.mode ?? 'strict');
|
|
430
438
|
|
|
431
439
|
const allFields = model.fields;
|
|
432
|
-
const hasWriteonly = allFields.some(f => f.visibility === 'writeonly');
|
|
433
440
|
|
|
434
441
|
const bases = model.bases ?? [];
|
|
435
442
|
|
|
436
|
-
// Base schema — all fields (used internally when a submodel extends this one).
|
|
437
|
-
// Only needed when this model has writeonly fields; otherwise Base === Read.
|
|
438
|
-
if (hasWriteonly) {
|
|
439
|
-
const baseBody = renderFields(allFields, model.mode);
|
|
440
|
-
if (bases.length > 0) {
|
|
441
|
-
const { head, tail } = buildExtendChain(bases, b => (modelsWithWriteonly?.has(b) ? `${b}Base` : b));
|
|
442
|
-
lines.push(`const ${name}Base = ${head}${tail}.extend({`);
|
|
443
|
-
} else {
|
|
444
|
-
lines.push(`const ${name}Base = ${wrapper}({`);
|
|
445
|
-
}
|
|
446
|
-
lines.push(...baseBody.map(l => ` ${l}`));
|
|
447
|
-
lines.push(`});`);
|
|
448
|
-
lines.push('');
|
|
449
|
-
}
|
|
450
|
-
|
|
451
443
|
// Read schema — omit writeonly fields; extends parent read schema
|
|
452
444
|
const readFields = allFields.filter(f => f.visibility !== 'writeonly');
|
|
453
445
|
const readBody = renderFields(readFields, model.mode);
|
|
@@ -555,12 +547,20 @@ function renderFieldsAsSnakeCase(fields: FieldNode[], defaultMode?: ObjectMode):
|
|
|
555
547
|
});
|
|
556
548
|
}
|
|
557
549
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
550
|
+
/**
|
|
551
|
+
* Append the modifier chain a declared field carries: nullability, then a default or optionality,
|
|
552
|
+
* then the description.
|
|
553
|
+
*
|
|
554
|
+
* Takes a structural subset of `FieldNode` rather than the node itself, so an `OpParamNode` — which
|
|
555
|
+
* is a `FieldNode` minus `visibility`, `deprecated` and `override` — can be rendered through the
|
|
556
|
+
* same path. That is what lets the router, the SDK and OpenAPI agree on what an inline `query:` or
|
|
557
|
+
* `headers:` field means.
|
|
558
|
+
*
|
|
559
|
+
* A default and `optional` are mutually exclusive on purpose: `.default()` already makes the input
|
|
560
|
+
* side optional, and adding `.optional()` on top would widen the *output* type to include
|
|
561
|
+
* `undefined`, which is exactly what a default exists to prevent.
|
|
562
|
+
*/
|
|
563
|
+
export function applyFieldModifiers(expr: string, field: Pick<FieldNode, 'nullable' | 'default' | 'optional' | 'description'>): string {
|
|
564
564
|
if (field.nullable) expr += '.nullable()';
|
|
565
565
|
if (field.default !== undefined) {
|
|
566
566
|
const dv = typeof field.default === 'string' ? `"${escapeString(field.default)}"` : String(field.default);
|
|
@@ -569,6 +569,16 @@ function renderField(field: FieldNode, defaultMode?: ObjectMode): string[] {
|
|
|
569
569
|
expr += '.optional()';
|
|
570
570
|
}
|
|
571
571
|
if (field.description) expr += `.describe("${escapeString(field.description)}")`;
|
|
572
|
+
return expr;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function renderField(field: FieldNode, defaultMode?: ObjectMode): string[] {
|
|
576
|
+
const lines: string[] = [];
|
|
577
|
+
if (field.deprecated) lines.push('/** @deprecated */');
|
|
578
|
+
|
|
579
|
+
let expr = renderType(field.type, undefined, defaultMode);
|
|
580
|
+
|
|
581
|
+
expr = applyFieldModifiers(expr, field);
|
|
572
582
|
|
|
573
583
|
lines.push(`${quoteKey(field.name)}: ${expr},`);
|
|
574
584
|
return lines;
|
|
@@ -641,6 +651,21 @@ function regexHasAnchor(source: string): boolean {
|
|
|
641
651
|
return backslashes % 2 === 0;
|
|
642
652
|
}
|
|
643
653
|
|
|
654
|
+
/**
|
|
655
|
+
* Coercion for the numeric scalars: convert a non-empty string, pass everything else through for
|
|
656
|
+
* `z.number()` to judge.
|
|
657
|
+
*
|
|
658
|
+
* `z.coerce.number()` is `Number(v)`, which accepts far more than a number. `[]` and `''` become
|
|
659
|
+
* `0`, `null` becomes `0`, `true` becomes `1` — so `{"quantity": []}` validated as `0` and the
|
|
660
|
+
* handler ran on a value the client never sent. Only the string case is a real coercion; it exists
|
|
661
|
+
* because query strings and headers arrive as text, and it stays because a JSON body carrying
|
|
662
|
+
* `"42"` is common enough that rejecting it would break working callers.
|
|
663
|
+
*
|
|
664
|
+
* The `boolean` scalar below already has this shape and needed no change: its preprocess maps only
|
|
665
|
+
* the two literal strings and hands everything else to `z.boolean()`, which rejects it.
|
|
666
|
+
*/
|
|
667
|
+
const NUMERIC_PREPROCESS = `(v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v)`;
|
|
668
|
+
|
|
644
669
|
function renderScalar(s: ScalarTypeNode): string {
|
|
645
670
|
switch (s.name) {
|
|
646
671
|
case 'string': {
|
|
@@ -652,17 +677,14 @@ function renderScalar(s: ScalarTypeNode): string {
|
|
|
652
677
|
if (s.regex) e += `.regex(${renderRegexLiteral(s.regex)})`;
|
|
653
678
|
return e;
|
|
654
679
|
}
|
|
655
|
-
case 'number':
|
|
656
|
-
let e = 'z.coerce.number()';
|
|
657
|
-
if (s.min !== undefined) e += `.min(${s.min})`;
|
|
658
|
-
if (s.max !== undefined) e += `.max(${s.max})`;
|
|
659
|
-
return e;
|
|
660
|
-
}
|
|
680
|
+
case 'number':
|
|
661
681
|
case 'int': {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
682
|
+
// Constraints go on the inner schema, not the outer expression: `z.preprocess` returns
|
|
683
|
+
// a ZodPipe, which has no `.min()`. Same shape as the `bigint` case below.
|
|
684
|
+
let inner = s.name === 'int' ? 'z.number().int()' : 'z.number()';
|
|
685
|
+
if (s.min !== undefined) inner += `.min(${s.min})`;
|
|
686
|
+
if (s.max !== undefined) inner += `.max(${s.max})`;
|
|
687
|
+
return `z.preprocess(${NUMERIC_PREPROCESS}, ${inner})`;
|
|
666
688
|
}
|
|
667
689
|
case 'bigint': {
|
|
668
690
|
let inner = 'z.bigint()';
|
|
@@ -848,8 +870,16 @@ function renderInlineObject(o: InlineObjectTypeNode, parseCaseTransform?: 'snake
|
|
|
848
870
|
// ─── Input type rendering ─────────────────────────────────────────────────
|
|
849
871
|
|
|
850
872
|
/**
|
|
851
|
-
*
|
|
852
|
-
*
|
|
873
|
+
* The request-side rendering of a scalar. A pure passthrough today, and the seam where a genuine
|
|
874
|
+
* input/wire split would live.
|
|
875
|
+
*
|
|
876
|
+
* The docstring here used to claim it "coerces from string input", describing a distinction the
|
|
877
|
+
* body does not make: `XInput` is a single exported `const`, and `generateParamValidation`'s ref
|
|
878
|
+
* branch uses that same schema for `query: X` that the body path uses for
|
|
879
|
+
* `request: { application/json: X }`. Making it strict would break query-by-model; leaving it
|
|
880
|
+
* coercing leaves a JSON body accepting string-shaped numbers. A real split needs a second emitted
|
|
881
|
+
* variant (`XInputWire`) plus the import plumbing to reach it, which is separate work — the
|
|
882
|
+
* narrowed coercion in `renderScalar` closes the soundness hole in the meantime.
|
|
853
883
|
*/
|
|
854
884
|
function renderInputScalar(s: ScalarTypeNode): string {
|
|
855
885
|
return renderScalar(s);
|
|
@@ -924,14 +954,7 @@ function renderInputField(field: FieldNode, modelsWithInput: Set<string>, defaul
|
|
|
924
954
|
|
|
925
955
|
let expr = renderInputType(field.type, modelsWithInput, defaultMode);
|
|
926
956
|
|
|
927
|
-
|
|
928
|
-
if (field.default !== undefined) {
|
|
929
|
-
const dv = typeof field.default === 'string' ? `"${escapeString(field.default)}"` : String(field.default);
|
|
930
|
-
expr += `.default(${dv})`;
|
|
931
|
-
} else if (field.optional) {
|
|
932
|
-
expr += '.optional()';
|
|
933
|
-
}
|
|
934
|
-
if (field.description) expr += `.describe("${escapeString(field.description)}")`;
|
|
957
|
+
expr = applyFieldModifiers(expr, field);
|
|
935
958
|
|
|
936
959
|
lines.push(`${quoteKey(field.name)}: ${expr},`);
|
|
937
960
|
return lines;
|
|
@@ -996,14 +1019,7 @@ function renderQueryField(field: FieldNode, modelsWithInput?: Set<string>, defau
|
|
|
996
1019
|
? renderInputType(field.type, modelsWithInput, defaultMode)
|
|
997
1020
|
: renderType(field.type, undefined, defaultMode);
|
|
998
1021
|
|
|
999
|
-
|
|
1000
|
-
if (field.default !== undefined) {
|
|
1001
|
-
const dv = typeof field.default === 'string' ? `"${escapeString(field.default)}"` : String(field.default);
|
|
1002
|
-
expr += `.default(${dv})`;
|
|
1003
|
-
} else if (field.optional) {
|
|
1004
|
-
expr += '.optional()';
|
|
1005
|
-
}
|
|
1006
|
-
if (field.description) expr += `.describe("${escapeString(field.description)}")`;
|
|
1022
|
+
expr = applyFieldModifiers(expr, field);
|
|
1007
1023
|
|
|
1008
1024
|
return `${quoteKey(field.name)}: ${expr},`;
|
|
1009
1025
|
}
|
package/src/codegen-mcp.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { OpRootNode, OpRouteNode, OpOperationNode, McpConfigNode, ParamSource, ContractTypeNode } from '@contractkit/core';
|
|
2
|
-
import { resolveModifiers, emittedResponses } from '@contractkit/core';
|
|
2
|
+
import { resolveModifiers, emittedResponses, toIdentifier } from '@contractkit/core';
|
|
3
3
|
import { renderType, renderInputType, pascalToDotCase } from './codegen-contract.js';
|
|
4
4
|
import { inferService, deriveModulePath, buildArgs, deriveBaseName } from './codegen-operation.js';
|
|
5
|
-
import { quoteKey, escapeSingleQuoted } from './ts-render.js';
|
|
5
|
+
import { quoteKey, escapeSingleQuoted, sourceLink } from './ts-render.js';
|
|
6
6
|
import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
|
|
7
7
|
import { basename, dirname, relative } from 'node:path';
|
|
8
8
|
|
|
@@ -110,7 +110,9 @@ function buildArgsProps(route: OpRouteNode, op: OpOperationNode, modelsWithInput
|
|
|
110
110
|
if (route.params) {
|
|
111
111
|
if (route.params.kind === 'params') {
|
|
112
112
|
for (const node of route.params.nodes) {
|
|
113
|
-
|
|
113
|
+
// The handler destructures these, so the key has to be a valid identifier. The MCP
|
|
114
|
+
// input schema is ours to name — nothing on an HTTP wire depends on it.
|
|
115
|
+
props.push({ key: toIdentifier(node.name), expr: renderInputType(node.type, modelsWithInput), optional: false });
|
|
114
116
|
}
|
|
115
117
|
} else if (route.params.kind === 'ref') {
|
|
116
118
|
props.push({ key: 'params', expr: refSchema(route.params.name, modelsWithInput), optional: false });
|
|
@@ -337,9 +339,8 @@ function renderToolClass(plan: ToolPlan, file: string, options: McpCodegenOption
|
|
|
337
339
|
const lines: string[] = [];
|
|
338
340
|
|
|
339
341
|
// JSDoc source link
|
|
340
|
-
const relFile = options.outPath ? relative(dirname(options.outPath), file) : file;
|
|
341
342
|
lines.push('/**');
|
|
342
|
-
lines.push(` * from
|
|
343
|
+
lines.push(` * from ${sourceLink(basename(file), options.outPath, file, op.loc.line)}`);
|
|
343
344
|
lines.push(' */');
|
|
344
345
|
|
|
345
346
|
lines.push('@Injectable()');
|
|
@@ -371,7 +372,10 @@ function renderToolClass(plan: ToolPlan, file: string, options: McpCodegenOption
|
|
|
371
372
|
const isVoid = !primaryResponseBody(op);
|
|
372
373
|
const structured = !!outExpr;
|
|
373
374
|
|
|
374
|
-
|
|
375
|
+
// No args to destructure means the parameter goes unread, which trips no-unused-vars in
|
|
376
|
+
// consumers that lint generated output; the leading underscore opts it out.
|
|
377
|
+
const argsParam = destructure.length > 0 ? 'args' : '_args';
|
|
378
|
+
lines.push(` async handle(${argsParam}: Record<string, unknown>, _context: McpToolContext): Promise<CallToolResult> {`);
|
|
375
379
|
if (destructure.length > 0) {
|
|
376
380
|
lines.push(` const { ${destructure.join(', ')} } = await parseAndValidate(args, ${argsConstName});`);
|
|
377
381
|
}
|
|
@@ -455,8 +459,7 @@ export function generateMcpFile(root: OpRootNode, options: McpCodegenOptions = {
|
|
|
455
459
|
// Schema imports.
|
|
456
460
|
imports.push(...schemaImportLines(collectSchemaIds(plans, options.modelsWithInput), options));
|
|
457
461
|
|
|
458
|
-
const
|
|
459
|
-
const header = `// Auto-generated MCP tools\n// generated from [${basename(root.file)}](file://./${relFile})`;
|
|
462
|
+
const header = `// Auto-generated MCP tools\n// generated from ${sourceLink(basename(root.file), options.outPath, root.file)}`;
|
|
460
463
|
|
|
461
464
|
return `${header}\n${imports.join('\n')}\n\n${bodyWithHelpers}\n`;
|
|
462
465
|
}
|
|
@@ -477,11 +480,19 @@ export function generateMcpAggregator(entries: McpAggregatorEntry[]): string {
|
|
|
477
480
|
lines.push(`import { McpToolHandlerMap } from '@maroonedsoftware/mcp';`);
|
|
478
481
|
for (const e of sorted) lines.push(`import { ${e.registerFn} } from '${e.importPath}';`);
|
|
479
482
|
lines.push('');
|
|
480
|
-
lines.push('/**
|
|
483
|
+
lines.push('/**');
|
|
484
|
+
lines.push(' * Build the MCP tool catalog.');
|
|
485
|
+
lines.push(' *');
|
|
486
|
+
lines.push(' * Bind it to the `McpToolHandlerMap` token from a factory, which is what supplies the');
|
|
487
|
+
lines.push(' * `Container` needed to resolve each handler:');
|
|
488
|
+
lines.push(' *');
|
|
489
|
+
lines.push(' * ```ts');
|
|
490
|
+
lines.push(' * registry.register(McpToolHandlerMap).useFactory(registerMcpTools).asSingleton();');
|
|
491
|
+
lines.push(' * ```');
|
|
492
|
+
lines.push(' */');
|
|
481
493
|
lines.push('export function registerMcpTools(container: Container): McpToolHandlerMap {');
|
|
482
494
|
lines.push(' const map = new McpToolHandlerMap();');
|
|
483
495
|
for (const e of sorted) lines.push(` ${e.registerFn}(map, container);`);
|
|
484
|
-
lines.push(' container.register(McpToolHandlerMap, { useValue: map });');
|
|
485
496
|
lines.push(' return map;');
|
|
486
497
|
lines.push('}');
|
|
487
498
|
return lines.join('\n') + '\n';
|
|
@@ -493,7 +504,7 @@ export function generateMcpRouter(options: { path?: string } = {}): string {
|
|
|
493
504
|
return `import { ServerKitRouter, bodyParserMiddleware, requireSignature } from '@maroonedsoftware/koa';
|
|
494
505
|
import { McpDispatcher, createMcpRequestContext, MCP_AUTH_POLICY } from '@maroonedsoftware/mcp';
|
|
495
506
|
|
|
496
|
-
/** Mount the MCP endpoint onto a ServerKit router.
|
|
507
|
+
/** Mount the MCP endpoint onto a ServerKit router. Bind \`registerMcpTools\` to the \`McpToolHandlerMap\` token. */
|
|
497
508
|
export function mountMcp(router: ReturnType<typeof ServerKitRouter>): void {
|
|
498
509
|
router.post('${path}', bodyParserMiddleware(['json']), requireSignature('mcp', { policy: MCP_AUTH_POLICY }), async (ctx) => {
|
|
499
510
|
const dispatcher = ctx.container.get(McpDispatcher);
|
package/src/codegen-operation.ts
CHANGED
|
@@ -9,15 +9,16 @@ import type {
|
|
|
9
9
|
ParamSource,
|
|
10
10
|
ObjectMode,
|
|
11
11
|
} from '@contractkit/core';
|
|
12
|
-
import { resolveModifiers, resolveSecurity, SECURITY_NONE, classifyContentType, emittedResponses } from '@contractkit/core';
|
|
12
|
+
import { resolveModifiers, resolveSecurity, SECURITY_NONE, classifyContentType, emittedResponses, PATH_PARAM_RE_G, toIdentifier } from '@contractkit/core';
|
|
13
13
|
import {
|
|
14
14
|
renderType,
|
|
15
15
|
renderInputType,
|
|
16
16
|
renderQueryType,
|
|
17
|
+
applyFieldModifiers,
|
|
17
18
|
pascalToDotCase,
|
|
18
19
|
modeToWrapper,
|
|
19
20
|
} from './codegen-contract.js';
|
|
20
|
-
import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted } from './ts-render.js';
|
|
21
|
+
import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted, sourceLink } from './ts-render.js';
|
|
21
22
|
import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
|
|
22
23
|
import { basename, dirname, relative } from 'path';
|
|
23
24
|
|
|
@@ -174,8 +175,7 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
|
|
|
174
175
|
|
|
175
176
|
lines.push('');
|
|
176
177
|
lines.push('/**');
|
|
177
|
-
|
|
178
|
-
lines.push(` * generated from [${basename(root.file)}](file://./${relFile})`);
|
|
178
|
+
lines.push(` * generated from ${sourceLink(basename(root.file), options.outPath, root.file)}`);
|
|
179
179
|
lines.push('*/');
|
|
180
180
|
lines.push(`export const ${routerName} = ServerKitRouter();`);
|
|
181
181
|
lines.push('');
|
|
@@ -294,8 +294,7 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
294
294
|
for (const l of escapeJsDocLines(desc)) lines.push(` * ${l}`);
|
|
295
295
|
}
|
|
296
296
|
// Source location comment
|
|
297
|
-
|
|
298
|
-
lines.push(` * from [${basename(file)}](file://./${relFile}#L${op.loc.line})`);
|
|
297
|
+
lines.push(` * from ${sourceLink(basename(file), outPath, file, op.loc.line)}`);
|
|
299
298
|
|
|
300
299
|
// Security annotation (operation-level wins; falls back to route → file level)
|
|
301
300
|
const effectiveSecurity = resolveSecurity(route, op, root);
|
|
@@ -311,7 +310,10 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
311
310
|
lines.push('*/');
|
|
312
311
|
|
|
313
312
|
const method = op.method;
|
|
314
|
-
|
|
313
|
+
// `:name` from `{name}`, mapped to a valid identifier. Unlike a query parameter or a header,
|
|
314
|
+
// a path placeholder's name never reaches the wire — Koa matches by position — so renaming it
|
|
315
|
+
// is free, and it is what lets `ctx.params` be destructured at all.
|
|
316
|
+
const path = route.path.replace(PATH_PARAM_RE_G, (_m, name: string) => `:${toIdentifier(name)}`);
|
|
315
317
|
const bodies = op.request?.bodies ?? [];
|
|
316
318
|
const hasBody = bodies.length > 0;
|
|
317
319
|
const isSingleMultipart = bodies.length === 1 && bodies[0]!.contentType === 'multipart/form-data';
|
|
@@ -449,9 +451,12 @@ function generateSingleStatusResult(
|
|
|
449
451
|
}
|
|
450
452
|
|
|
451
453
|
lines.push('');
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
|
|
454
|
+
// 204 when the operation emits nothing. Falling back to the first *declared* status wrote an
|
|
455
|
+
// error code on the success path, since a documentation-only block starts at a 4xx. 204 is
|
|
456
|
+
// also what aligns the three generators: `observableResponses` excludes a bare `400:` too, so
|
|
457
|
+
// the SDK already types such a method `Promise<void>` and `thrownResponses` puts the 400 in
|
|
458
|
+
// `@throws`. A bodyless 204 success is exactly what `Promise<void>` means.
|
|
459
|
+
lines.push(` ctx.status = ${resp?.statusCode ?? 204};`);
|
|
455
460
|
lines.push(...headerSetLines(respHeaders, ' '));
|
|
456
461
|
|
|
457
462
|
if (bodies.length === 1) {
|
|
@@ -627,7 +632,7 @@ export function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
|
|
|
627
632
|
// Path params: spread individually (inline) or pass 'params' object (type-ref/ContractTypeNode)
|
|
628
633
|
if (route.params) {
|
|
629
634
|
if (route.params.kind === 'params') {
|
|
630
|
-
args.push(...route.params.nodes.map(p => p.name));
|
|
635
|
+
args.push(...route.params.nodes.map(p => toIdentifier(p.name)));
|
|
631
636
|
} else {
|
|
632
637
|
args.push('params');
|
|
633
638
|
}
|
|
@@ -803,18 +808,26 @@ function generateParamValidation(
|
|
|
803
808
|
if (source.nodes.length > 0) {
|
|
804
809
|
// Destructure only for params (spread individually in service call);
|
|
805
810
|
// query/headers are passed as whole objects.
|
|
806
|
-
|
|
811
|
+
// Path params are destructured and spread into the service call; query and headers pass
|
|
812
|
+
// as whole objects.
|
|
813
|
+
const isPathParams = ctxExpr === 'ctx.params';
|
|
814
|
+
const bind = (name: string) => (isPathParams ? toIdentifier(name) : name);
|
|
815
|
+
const lhs = varName === 'params' ? `{ ${source.nodes.map(p => bind(p.name)).join(', ')} }` : varName;
|
|
807
816
|
lines.push(` const ${lhs} = await parseAndValidate(`);
|
|
808
817
|
lines.push(` ${ctxExpr},`);
|
|
809
818
|
lines.push(` ${modeToWrapper(mode)}({`);
|
|
810
819
|
for (const param of source.nodes) {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
820
|
+
// For path params the key must match the name in the route pattern above, which
|
|
821
|
+
// is what `ctx.params` is keyed by. For query and headers it is the wire name,
|
|
822
|
+
// quoted when that is not an identifier — those the client actually sends.
|
|
823
|
+
const bound = bind(param.name);
|
|
824
|
+
const key = isValidIdentifier(bound) ? bound : `'${bound}'`;
|
|
825
|
+
// Delegating to renderQueryType rather than hand-rolling the array preprocess here:
|
|
826
|
+
// it is the same rule, and a second copy is a second thing to keep in sync. The
|
|
827
|
+
// modifier chain then comes from the shared helper, so an inline param means the
|
|
828
|
+
// same thing to the router as a model field does.
|
|
829
|
+
const base = isQuery ? renderQueryType(param.type, modelsWithInput) : renderInputType(param.type, modelsWithInput);
|
|
830
|
+
lines.push(` ${key}: ${applyFieldModifiers(base, param)},`);
|
|
818
831
|
}
|
|
819
832
|
lines.push(` })${suffix},`);
|
|
820
833
|
lines.push(` );`);
|