@contractkit/plugin-typescript 0.33.2 → 0.34.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/.turbo/turbo-build$colon$ci.log +4 -4
- package/.turbo/turbo-test$colon$ci.log +26 -25
- package/CHANGELOG.md +273 -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 +413 -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/package.json +3 -2
- package/src/codegen-contract.ts +84 -68
- package/src/codegen-mcp.ts +12 -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 +31 -0
- 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/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
ContractRootNode,
|
|
9
9
|
OpRootNode,
|
|
10
10
|
ModelNode,
|
|
11
|
+
ScalarTypeNode,
|
|
11
12
|
IncrementalManifest,
|
|
12
13
|
IncrementalUnit,
|
|
13
14
|
IncrementalOutputFile,
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
collectTransitiveModelRefs,
|
|
22
23
|
collectTypeRefs,
|
|
23
24
|
computeModelsWithCaseTransform,
|
|
24
|
-
|
|
25
|
+
computeModelsWithScalar,
|
|
25
26
|
} from '@contractkit/core';
|
|
26
27
|
import {
|
|
27
28
|
generateSdk,
|
|
@@ -43,9 +44,14 @@ import {
|
|
|
43
44
|
type SdkScaffoldDeps,
|
|
44
45
|
} from './codegen-sdk.js';
|
|
45
46
|
import { generatePlainTypes } from './codegen-plain-types.js';
|
|
47
|
+
import { DEFAULT_REVIVABLE_SCALARS } from './codegen-revive.js';
|
|
48
|
+
|
|
49
|
+
/** Taint set for the SDK's bigint response reviver. */
|
|
50
|
+
const BIGINT_SCALARS: ReadonlySet<ScalarTypeNode['name']> = new Set(['bigint']);
|
|
46
51
|
import { generateMcpFile, generateMcpAggregator, generateMcpRouter, hasMcpOperations, deriveMcpRegisterFnName } from './codegen-mcp.js';
|
|
47
52
|
import {
|
|
48
53
|
TEMPLATE_VAR_RE,
|
|
54
|
+
TEMPLATE_VAR_RE_G,
|
|
49
55
|
resolveTemplate,
|
|
50
56
|
commonDir,
|
|
51
57
|
computeOpOutPath,
|
|
@@ -167,7 +173,12 @@ export interface TypescriptPluginConfig {
|
|
|
167
173
|
// ─── Caching constants ─────────────────────────────────────────────────────
|
|
168
174
|
|
|
169
175
|
/** Bumped when the codegen output shape changes in a way that should bust every per-file fingerprint. */
|
|
170
|
-
export const TYPESCRIPT_CODEGEN_VERSION = '
|
|
176
|
+
export const TYPESCRIPT_CODEGEN_VERSION = '2';
|
|
177
|
+
|
|
178
|
+
// The taint set is `DEFAULT_REVIVABLE_SCALARS` rather than decimal alone, which is what makes a
|
|
179
|
+
// temporal field a real Luxon object in an SDK client rather than a string wearing a `DateTime`
|
|
180
|
+
// type. It also feeds every `hashFingerprint` that already slices this set, so a model gaining a
|
|
181
|
+
// `datetime` in another `.ck` file invalidates this file's cached output with no extra plumbing.
|
|
171
182
|
|
|
172
183
|
/** Filename for the persisted TypeScript manifest under the CLI cache directory. */
|
|
173
184
|
const CACHE_MANIFEST_FILENAME = 'typescript-manifest.json';
|
|
@@ -241,9 +252,23 @@ async function runTypescriptCodegen(
|
|
|
241
252
|
|
|
242
253
|
deleteStalePaths(result.deletedPaths);
|
|
243
254
|
|
|
255
|
+
const unresolved = new Set<string>();
|
|
244
256
|
for (const { relativePath, content, ifAbsent } of result.filesToWrite) {
|
|
257
|
+
for (const [, key] of relativePath.matchAll(TEMPLATE_VAR_RE_G)) unresolved.add(`${key}::${relativePath}`);
|
|
245
258
|
ctx.emitFile(relativePath, content, ifAbsent ? { ifAbsent: true } : undefined);
|
|
246
259
|
}
|
|
260
|
+
// `resolveTemplate` leaves an unknown `{key}` in place, which then joins straight into the
|
|
261
|
+
// output path — producing a literal `{area}` directory rather than an error. `assertWithinBase`
|
|
262
|
+
// does not catch it, since the path is inside the base, just wrong. Checked here rather than
|
|
263
|
+
// threaded down through five path helpers: every output path passes through this one funnel,
|
|
264
|
+
// whichever helper built it.
|
|
265
|
+
for (const entry of [...unresolved].sort()) {
|
|
266
|
+
const [key, outPath] = entry.split('::');
|
|
267
|
+
ctx.warn?.(
|
|
268
|
+
`Output path template variable {${key}} has no value, so '${outPath}' contains it literally. ` +
|
|
269
|
+
`Declare it in the source file's 'options { keys { ${key}: ... } }' block, or remove it from the path template.`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
247
272
|
|
|
248
273
|
writeManifest(manifestPath, result.manifest);
|
|
249
274
|
}
|
|
@@ -467,7 +492,11 @@ function collectSdkOutput(
|
|
|
467
492
|
const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
|
|
468
493
|
// Computed across every contract root, not per file: one decimal below a model taints it, and
|
|
469
494
|
// the reference that reaches it may live in another .ck file entirely.
|
|
470
|
-
const modelsWithDecimal =
|
|
495
|
+
const modelsWithDecimal = computeModelsWithScalar(inputs.contractRoots.flatMap(r => r.models), DEFAULT_REVIVABLE_SCALARS);
|
|
496
|
+
// Which response bodies need the `123n` reviver. Transitive, because a bigint reached through
|
|
497
|
+
// a referenced model counts, and cross-file, because that model may live in another .ck file —
|
|
498
|
+
// which is also why it is sliced into every fingerprint below, exactly as modelsWithDecimal is.
|
|
499
|
+
const modelsWithBigInt = computeModelsWithScalar(inputs.contractRoots.flatMap(r => r.models), BIGINT_SCALARS);
|
|
471
500
|
const modelMap = buildModelMap(inputs.contractRoots);
|
|
472
501
|
const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
|
|
473
502
|
const ckCommonRoot = commonDir(allFiles, rootDir);
|
|
@@ -509,6 +538,7 @@ function collectSdkOutput(
|
|
|
509
538
|
// Not covered by `root`: adding a decimal to a model in a *different* .ck file changes
|
|
510
539
|
// this file's revivers with no change to `root` or the config.
|
|
511
540
|
modelsWithDecimal: sliceModelSet(refs, ownNames, modelsWithDecimal),
|
|
541
|
+
modelsWithBigInt: sliceModelSet(refs, ownNames, modelsWithBigInt),
|
|
512
542
|
sdkOptionsPath,
|
|
513
543
|
sub: subConfigKey,
|
|
514
544
|
});
|
|
@@ -527,6 +557,9 @@ function collectSdkOutput(
|
|
|
527
557
|
modelsWithOutput,
|
|
528
558
|
modelsWithDecimal,
|
|
529
559
|
emitRevivers: true,
|
|
560
|
+
// An SDK client runs in a browser as readily as in Node, and its scaffold
|
|
561
|
+
// declares no `@types/node`.
|
|
562
|
+
target: 'client',
|
|
530
563
|
});
|
|
531
564
|
} else {
|
|
532
565
|
let rel = relative(dirname(typeOutPath), sdkOptionsPath).replace(/\.ts$/, '.js');
|
|
@@ -587,6 +620,7 @@ function collectSdkOutput(
|
|
|
587
620
|
modelsWithInput: sliceModelSet(refs, new Set(), modelsWithInput),
|
|
588
621
|
modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
|
|
589
622
|
modelsWithDecimal: sliceModelSet(refs, new Set(), modelsWithDecimal),
|
|
623
|
+
modelsWithBigInt: sliceModelSet(refs, new Set(), modelsWithBigInt),
|
|
590
624
|
sdkOptionsPath,
|
|
591
625
|
className,
|
|
592
626
|
includeInternal: config.includeInternal ?? false,
|
|
@@ -606,6 +640,7 @@ function collectSdkOutput(
|
|
|
606
640
|
modelsWithInput,
|
|
607
641
|
modelsWithOutput,
|
|
608
642
|
modelsWithDecimal,
|
|
643
|
+
modelsWithBigInt,
|
|
609
644
|
modelMap,
|
|
610
645
|
includeInternal: config.includeInternal,
|
|
611
646
|
clientClassName: className,
|
|
@@ -630,6 +665,7 @@ function collectSdkOutput(
|
|
|
630
665
|
modelsWithInput: sliceModelSet(refs, new Set(), modelsWithInput),
|
|
631
666
|
modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
|
|
632
667
|
modelsWithDecimal: sliceModelSet(refs, new Set(), modelsWithDecimal),
|
|
668
|
+
modelsWithBigInt: sliceModelSet(refs, new Set(), modelsWithBigInt),
|
|
633
669
|
sdkOptionsPath,
|
|
634
670
|
includeInternal: config.includeInternal ?? false,
|
|
635
671
|
sub: subConfigKey,
|
|
@@ -648,6 +684,7 @@ function collectSdkOutput(
|
|
|
648
684
|
modelsWithInput,
|
|
649
685
|
modelsWithOutput,
|
|
650
686
|
modelsWithDecimal,
|
|
687
|
+
modelsWithBigInt,
|
|
651
688
|
modelMap,
|
|
652
689
|
includeInternal: config.includeInternal,
|
|
653
690
|
}),
|
|
@@ -731,6 +768,7 @@ function collectSdkOutput(
|
|
|
731
768
|
modelsWithInput: sliceModelSet(allInlineRefs, new Set(), modelsWithInput),
|
|
732
769
|
modelsWithOutput: sliceModelSet(allInlineRefs, new Set(), modelsWithOutput),
|
|
733
770
|
modelsWithDecimal: sliceModelSet(allInlineRefs, new Set(), modelsWithDecimal),
|
|
771
|
+
modelsWithBigInt: sliceModelSet(allInlineRefs, new Set(), modelsWithBigInt),
|
|
734
772
|
sdkOptionsPath,
|
|
735
773
|
includeInternal: config.includeInternal ?? false,
|
|
736
774
|
sub: subConfigKey,
|
|
@@ -746,6 +784,7 @@ function collectSdkOutput(
|
|
|
746
784
|
modelsWithInput,
|
|
747
785
|
modelsWithOutput,
|
|
748
786
|
modelsWithDecimal,
|
|
787
|
+
modelsWithBigInt,
|
|
749
788
|
modelMap,
|
|
750
789
|
includeInternal: config.includeInternal,
|
|
751
790
|
},
|
|
@@ -878,7 +917,10 @@ function collectZodOutput(
|
|
|
878
917
|
render: () => [
|
|
879
918
|
{
|
|
880
919
|
relativePath: outPath,
|
|
881
|
-
|
|
920
|
+
// Server-shaped, which is what this sub-generator has always emitted. The
|
|
921
|
+
// standalone `zod:` output has no target option of its own; only the SDK's
|
|
922
|
+
// schemas are client-shaped, and they pass their own target.
|
|
923
|
+
content: generateContract(ast, { modelOutPaths, currentOutPath: outPath, modelsWithInput, modelsWithOutput, target: 'server' }),
|
|
882
924
|
},
|
|
883
925
|
],
|
|
884
926
|
});
|
package/src/path-utils.ts
CHANGED
|
@@ -21,6 +21,16 @@ function assertWithinBase(baseOutDir: string, outPath: string): string {
|
|
|
21
21
|
return outPath;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Global-flagged twin of {@link TEMPLATE_VAR_RE}, for finding every variable in a string. */
|
|
25
|
+
export const TEMPLATE_VAR_RE_G = /\{(\w+)\}/g;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Substitute `{key}` placeholders from `vars`.
|
|
29
|
+
*
|
|
30
|
+
* An unknown key is left in place rather than throwing, because the caller is mid-way through
|
|
31
|
+
* building a path and has better context for the complaint — see the check at the emit funnel in
|
|
32
|
+
* `index.ts`, which reports it against the file it would have been written to.
|
|
33
|
+
*/
|
|
24
34
|
export function resolveTemplate(template: string, vars: Record<string, string>): string {
|
|
25
35
|
return template.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? `{${key}}`);
|
|
26
36
|
}
|
package/src/ts-render.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dirname, relative } from 'node:path';
|
|
1
2
|
import type { ContractTypeNode, FieldNode, ScalarTypeNode } from '@contractkit/core';
|
|
2
3
|
|
|
3
4
|
/** Declaration emitted into generated files that reference the `json` scalar. */
|
|
@@ -20,6 +21,25 @@ export function escapeSingleQuoted(s: string): string {
|
|
|
20
21
|
return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Render the markdown link back to the `.ck` declaration a generated construct came from, as
|
|
26
|
+
* `[label](./path/to/file.ck#L12)`. Returns the link only; callers supply the surrounding prose
|
|
27
|
+
* and comment prefix, since some sites emit it inside a JSDoc block and others inside a `//` line.
|
|
28
|
+
*
|
|
29
|
+
* The path is relative to the emitted file's own directory, so it resolves when the reader clicks
|
|
30
|
+
* it from wherever the file was written. `outPath` is optional because codegen can run without a
|
|
31
|
+
* destination (the prettier plugin, and several tests), in which case the source path is used
|
|
32
|
+
* as-is.
|
|
33
|
+
*
|
|
34
|
+
* Not `file://./path`: `file://` opens an authority component, so the `.` parses as the host and
|
|
35
|
+
* the link resolves to nothing. A plain relative path is the correct form.
|
|
36
|
+
*/
|
|
37
|
+
export function sourceLink(label: string, outPath: string | undefined, sourceFile: string, line?: number): string {
|
|
38
|
+
const rel = outPath ? relative(dirname(outPath), sourceFile) : sourceFile;
|
|
39
|
+
const href = rel.startsWith('.') ? rel : `./${rel}`;
|
|
40
|
+
return `[${label}](${href}${line === undefined ? '' : `#L${line}`})`;
|
|
41
|
+
}
|
|
42
|
+
|
|
23
43
|
/** Convert an HTTP header name (e.g. `preference-applied`, `X-Request-ID`, `ETag`) to camelCase for use as a JS property. */
|
|
24
44
|
export function headerNameToProperty(name: string): string {
|
|
25
45
|
const parts = name.split(/[-_]/).filter(Boolean);
|
|
@@ -107,8 +127,14 @@ function renderTsScalar(name: ScalarTypeNode['name'], target: TsRenderTarget): s
|
|
|
107
127
|
case 'date':
|
|
108
128
|
case 'time':
|
|
109
129
|
case 'datetime':
|
|
130
|
+
// Luxon objects on both sides. The router parses them via `_ZodDatetime`, and the SDK
|
|
131
|
+
// rehydrates them in its generated revivers, so `string` was a claim neither honoured.
|
|
132
|
+
return 'DateTime';
|
|
110
133
|
case 'duration':
|
|
134
|
+
return 'Duration';
|
|
111
135
|
case 'interval':
|
|
136
|
+
// The exception: `_ZodInterval` transforms back to an ISO string on output, so a
|
|
137
|
+
// string is genuinely what a consumer receives.
|
|
112
138
|
return 'string';
|
|
113
139
|
case 'null':
|
|
114
140
|
return 'null';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { generateContract, renderType } from '../src/codegen-contract.js';
|
|
1
|
+
import { generateContract, renderType, applyFieldModifiers } from '../src/codegen-contract.js';
|
|
2
2
|
import type { ContractCodegenContext } from '../src/codegen-contract.js';
|
|
3
3
|
import {
|
|
4
4
|
scalarType,
|
|
5
|
+
opParam,
|
|
5
6
|
arrayType,
|
|
6
7
|
tupleType,
|
|
7
8
|
recordType,
|
|
@@ -18,6 +19,10 @@ import {
|
|
|
18
19
|
} from './helpers.js';
|
|
19
20
|
import type { ScalarTypeNode } from '@contractkit/core';
|
|
20
21
|
|
|
22
|
+
/** The narrowed numeric coercion `renderScalar` emits — see NUMERIC_PREPROCESS in codegen-contract. */
|
|
23
|
+
const NUM = `z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number())`;
|
|
24
|
+
const NUM_INT = `z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number().int())`;
|
|
25
|
+
|
|
21
26
|
describe('renderType', () => {
|
|
22
27
|
// ─── Scalar types ───────────────────────────────────────────────
|
|
23
28
|
|
|
@@ -70,24 +75,24 @@ describe('renderType', () => {
|
|
|
70
75
|
expect(renderType(scalarType('string', { regex: 'price:\\$' }))).toBe('z.string().regex(/^price:\\$$/)');
|
|
71
76
|
});
|
|
72
77
|
|
|
73
|
-
it('
|
|
74
|
-
expect(renderType(scalarType('number'))).toBe(
|
|
78
|
+
it('coerces number from a string, rejecting what Number() would swallow', () => {
|
|
79
|
+
expect(renderType(scalarType('number'))).toBe(NUM);
|
|
75
80
|
});
|
|
76
81
|
|
|
77
|
-
it('renders
|
|
78
|
-
expect(renderType(scalarType('number', { min: 0 }))).toBe('z.
|
|
82
|
+
it('renders number with min', () => {
|
|
83
|
+
expect(renderType(scalarType('number', { min: 0 }))).toBe(`z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number().min(0))`);
|
|
79
84
|
});
|
|
80
85
|
|
|
81
|
-
it('renders
|
|
82
|
-
expect(renderType(scalarType('number', { min: 0, max: 100 }))).toBe('z.
|
|
86
|
+
it('renders number with min and max', () => {
|
|
87
|
+
expect(renderType(scalarType('number', { min: 0, max: 100 }))).toBe(`z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number().min(0).max(100))`);
|
|
83
88
|
});
|
|
84
89
|
|
|
85
|
-
it('
|
|
86
|
-
expect(renderType(scalarType('int'))).toBe(
|
|
90
|
+
it('coerces int from a string, rejecting what Number() would swallow', () => {
|
|
91
|
+
expect(renderType(scalarType('int'))).toBe(NUM_INT);
|
|
87
92
|
});
|
|
88
93
|
|
|
89
|
-
it('renders
|
|
90
|
-
expect(renderType(scalarType('int', { min: 1, max: 10 }))).toBe('z.
|
|
94
|
+
it('renders int with constraints, chained inside the preprocess', () => {
|
|
95
|
+
expect(renderType(scalarType('int', { min: 1, max: 10 }))).toBe(`z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number().int().min(1).max(10))`);
|
|
91
96
|
});
|
|
92
97
|
|
|
93
98
|
it('renders z.bigint() with preprocess coercion from string or bigint', () => {
|
|
@@ -238,11 +243,11 @@ describe('renderType', () => {
|
|
|
238
243
|
});
|
|
239
244
|
|
|
240
245
|
it('renders tuple type', () => {
|
|
241
|
-
expect(renderType(tupleType(scalarType('number'), scalarType('string')))).toBe(
|
|
246
|
+
expect(renderType(tupleType(scalarType('number'), scalarType('string')))).toBe(`z.tuple([${NUM}, z.string()])`);
|
|
242
247
|
});
|
|
243
248
|
|
|
244
249
|
it('renders record type', () => {
|
|
245
|
-
expect(renderType(recordType(scalarType('string'), scalarType('number')))).toBe(
|
|
250
|
+
expect(renderType(recordType(scalarType('string'), scalarType('number')))).toBe(`z.record(z.string(), ${NUM})`);
|
|
246
251
|
});
|
|
247
252
|
|
|
248
253
|
it('renders enum type', () => {
|
|
@@ -266,7 +271,7 @@ describe('renderType', () => {
|
|
|
266
271
|
});
|
|
267
272
|
|
|
268
273
|
it('renders union type', () => {
|
|
269
|
-
expect(renderType(unionType(scalarType('string'), scalarType('number')))).toBe(
|
|
274
|
+
expect(renderType(unionType(scalarType('string'), scalarType('number')))).toBe(`z.union([z.string(), ${NUM}])`);
|
|
270
275
|
});
|
|
271
276
|
|
|
272
277
|
it('renders discriminated union as z.discriminatedUnion', () => {
|
|
@@ -286,7 +291,7 @@ describe('renderType', () => {
|
|
|
286
291
|
const result = renderType(inlineObjectType([field('key', scalarType('string')), field('value', scalarType('number'))]));
|
|
287
292
|
expect(result).toContain('z.strictObject({');
|
|
288
293
|
expect(result).toContain('key: z.string(),');
|
|
289
|
-
expect(result).toContain(
|
|
294
|
+
expect(result).toContain(`value: ${NUM},`);
|
|
290
295
|
});
|
|
291
296
|
});
|
|
292
297
|
});
|
|
@@ -300,7 +305,7 @@ describe('generateContract', () => {
|
|
|
300
305
|
const output = generateContract(root);
|
|
301
306
|
expect(output).toContain('export const User = z.strictObject({');
|
|
302
307
|
expect(output).toContain('name: z.string(),');
|
|
303
|
-
expect(output).toContain(
|
|
308
|
+
expect(output).toContain(`age: ${NUM},`);
|
|
304
309
|
expect(output).toContain('export type User = z.infer<typeof User>;');
|
|
305
310
|
});
|
|
306
311
|
|
|
@@ -458,13 +463,20 @@ describe('generateContract', () => {
|
|
|
458
463
|
expect(output).toContain("import { DateTime } from 'luxon';");
|
|
459
464
|
});
|
|
460
465
|
|
|
461
|
-
it('emits _ZodBinary
|
|
466
|
+
it('emits a Blob _ZodBinary by default, since the default target is the client', () => {
|
|
462
467
|
const root = contractRoot([model('M', [field('f', scalarType('binary'))])]);
|
|
463
468
|
const output = generateContract(root);
|
|
464
|
-
expect(output).toContain('const _ZodBinary = z.custom<
|
|
469
|
+
expect(output).toContain('const _ZodBinary = z.custom<Blob>');
|
|
465
470
|
expect(output).toContain('_ZodBinary,');
|
|
466
471
|
});
|
|
467
472
|
|
|
473
|
+
it('emits a Buffer _ZodBinary for the server target', () => {
|
|
474
|
+
const root = contractRoot([model('M', [field('f', scalarType('binary'))])]);
|
|
475
|
+
const output = generateContract(root, { modelOutPaths: new Map(), currentOutPath: '/out/m.ts', target: 'server' });
|
|
476
|
+
// `Buffer` in an SDK type file is unresolvable: the scaffold declares no @types/node.
|
|
477
|
+
expect(output).toContain('const _ZodBinary = z.custom<Buffer>');
|
|
478
|
+
});
|
|
479
|
+
|
|
468
480
|
it('omits _ZodBinary helper when no binary fields', () => {
|
|
469
481
|
const root = contractRoot([model('M', [field('f', scalarType('string'))])]);
|
|
470
482
|
const output = generateContract(root);
|
|
@@ -567,7 +579,7 @@ describe('generateContract', () => {
|
|
|
567
579
|
// ─── Three-schema pattern (visibility) ─────────────────────────
|
|
568
580
|
|
|
569
581
|
describe('three-schema pattern', () => {
|
|
570
|
-
it('generates
|
|
582
|
+
it('generates Read and Write schemas when visibility fields exist', () => {
|
|
571
583
|
const root = contractRoot([
|
|
572
584
|
model('User', [
|
|
573
585
|
field('id', scalarType('uuid'), { visibility: 'readonly' }),
|
|
@@ -576,11 +588,12 @@ describe('generateContract', () => {
|
|
|
576
588
|
]),
|
|
577
589
|
]);
|
|
578
590
|
const output = generateContract(root);
|
|
579
|
-
expect(output).toContain('const UserBase = z.strictObject({');
|
|
580
591
|
expect(output).toContain('export const User = z.strictObject({');
|
|
581
592
|
expect(output).toContain('export const UserInput = z.strictObject({');
|
|
582
593
|
expect(output).toContain('export type User = z.infer<typeof User>;');
|
|
583
594
|
expect(output).toContain('export type UserInput = z.infer<typeof UserInput>;');
|
|
595
|
+
// No writeonly model extends User, so nothing would read a UserBase.
|
|
596
|
+
expect(output).not.toContain('const UserBase');
|
|
584
597
|
});
|
|
585
598
|
|
|
586
599
|
it('read schema omits writeonly fields', () => {
|
|
@@ -732,20 +745,31 @@ describe('generateContract', () => {
|
|
|
732
745
|
expect(output).toContain('export const AdminInput = User.extend({');
|
|
733
746
|
});
|
|
734
747
|
|
|
735
|
-
it('
|
|
748
|
+
it('inherits writeonly fields through the Input chain, with no Base schema', () => {
|
|
736
749
|
const root = contractRoot([
|
|
737
750
|
model('User', [field('password', scalarType('string'), { visibility: 'writeonly' }), field('name', scalarType('string'))]),
|
|
738
|
-
model('Admin', [field('
|
|
751
|
+
model('Admin', [field('token', scalarType('string'), { visibility: 'writeonly' }), field('role', scalarType('string'))], {
|
|
752
|
+
bases: ['User'],
|
|
753
|
+
}),
|
|
739
754
|
]);
|
|
740
755
|
const output = generateContract(root);
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
expect(output).toContain('export const UserInput =');
|
|
745
|
-
// Admin has no writeonly — no AdminBase; but its Input still extends UserInput
|
|
746
|
-
expect(output).not.toContain('AdminBase');
|
|
747
|
-
expect(output).toContain('export const Admin = User.extend({');
|
|
756
|
+
expect(output).not.toContain('Base');
|
|
757
|
+
// AdminInput extends UserInput, which carries User's writeonly `password` — which is
|
|
758
|
+
// what the Base schemas were meant to deliver and never did.
|
|
748
759
|
expect(output).toContain('export const AdminInput = UserInput.extend({');
|
|
760
|
+
expect(output).toContain('export const Admin = User.extend({');
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it('leaves a user-declared model named XBase alone', () => {
|
|
764
|
+
const root = contractRoot([
|
|
765
|
+
model('User', [field('password', scalarType('string'), { visibility: 'writeonly' }), field('name', scalarType('string'))]),
|
|
766
|
+
model('UserBase', [field('label', scalarType('string'))]),
|
|
767
|
+
]);
|
|
768
|
+
const output = generateContract(root);
|
|
769
|
+
// A text-derived rule would have had to distinguish these two; nothing generated is
|
|
770
|
+
// named UserBase any more, so the user's own model is the only one.
|
|
771
|
+
expect(output.match(/const UserBase\b/g)).toHaveLength(1);
|
|
772
|
+
expect(output).toContain('export const UserBase = z.strictObject({');
|
|
749
773
|
});
|
|
750
774
|
|
|
751
775
|
it('child inheriting from external parent with Input variant uses ParentInput.extend()', () => {
|
|
@@ -922,7 +946,7 @@ describe('generateContract', () => {
|
|
|
922
946
|
it('includes source location comment above schema', () => {
|
|
923
947
|
const root = contractRoot([model('User', [field('name', scalarType('string'))], { loc: { file: 'user.ck', line: 5 } })]);
|
|
924
948
|
const output = generateContract(root);
|
|
925
|
-
expect(output).toContain('
|
|
949
|
+
expect(output).toContain('[User](./user.ck#L5)');
|
|
926
950
|
});
|
|
927
951
|
|
|
928
952
|
it('includes source location for three-schema models', () => {
|
|
@@ -932,7 +956,7 @@ describe('generateContract', () => {
|
|
|
932
956
|
}),
|
|
933
957
|
]);
|
|
934
958
|
const output = generateContract(root);
|
|
935
|
-
expect(output).toContain('
|
|
959
|
+
expect(output).toContain('[User](./user.ck#L1)');
|
|
936
960
|
});
|
|
937
961
|
});
|
|
938
962
|
|
|
@@ -1236,10 +1260,41 @@ describe('generateContract', () => {
|
|
|
1236
1260
|
]);
|
|
1237
1261
|
const output = generateContract(root);
|
|
1238
1262
|
// Inline object emits its own transform inside the Webhook input shape.
|
|
1239
|
-
expect(output).toContain(
|
|
1263
|
+
expect(output).toContain(`Amount: ${NUM}.nullish()`);
|
|
1240
1264
|
expect(output).toContain('id: data.Id,');
|
|
1241
1265
|
expect(output).toContain('...(data.Amount != null ? { amount: data.Amount } : {}),');
|
|
1242
1266
|
expect(output).not.toContain('?? undefined');
|
|
1243
1267
|
});
|
|
1244
1268
|
});
|
|
1245
1269
|
});
|
|
1270
|
+
|
|
1271
|
+
// ─── applyFieldModifiers ──────────────────────────────────────────────────
|
|
1272
|
+
|
|
1273
|
+
describe('applyFieldModifiers', () => {
|
|
1274
|
+
it('appends nothing for a plain required field', () => {
|
|
1275
|
+
expect(applyFieldModifiers('z.string()', {})).toBe('z.string()');
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
it('chains nullable, then optionality, then the description', () => {
|
|
1279
|
+
expect(applyFieldModifiers('z.string()', { nullable: true, optional: true, description: 'a note' })).toBe(
|
|
1280
|
+
'z.string().nullable().optional().describe("a note")',
|
|
1281
|
+
);
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
it('prefers a default over .optional()', () => {
|
|
1285
|
+
// `.default()` already makes the input side optional; adding `.optional()` on top would
|
|
1286
|
+
// widen the output type to include undefined, which is what a default exists to prevent.
|
|
1287
|
+
expect(applyFieldModifiers('z.number()', { optional: true, default: 20 })).toBe('z.number().default(20)');
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
it('quotes and escapes a string default', () => {
|
|
1291
|
+
expect(applyFieldModifiers('z.string()', { default: 'a "quoted" value' })).toBe('z.string().default("a \\"quoted\\" value")');
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
it('accepts an OpParamNode, which is a FieldNode without the visibility modifiers', () => {
|
|
1295
|
+
// This is the point of the structural parameter type: it lets a `query:` or `headers:`
|
|
1296
|
+
// field render through exactly the same path a model field does.
|
|
1297
|
+
const param = opParam('limit', scalarType('int'), { optional: true, default: 20 });
|
|
1298
|
+
expect(applyFieldModifiers(NUM_INT, param)).toBe(`${NUM_INT}.default(20)`);
|
|
1299
|
+
});
|
|
1300
|
+
});
|
|
@@ -233,6 +233,37 @@ describe('generateMcpRouter', () => {
|
|
|
233
233
|
expect(out).toContain("dispatcher.sessionMode === 'stateful'");
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
+
it('parses the body before verifying the signature', () => {
|
|
237
|
+
const out = generateMcpRouter({ path: '/mcp' });
|
|
238
|
+
// `requireSignature` HMACs `ctx.rawBody`, which only `bodyParserMiddleware` populates,
|
|
239
|
+
// so the parser has to run first. Asserting the whole route line pins the order too.
|
|
240
|
+
expect(out).toContain("import { ServerKitRouter, bodyParserMiddleware, requireSignature } from '@maroonedsoftware/koa';");
|
|
241
|
+
expect(out).toContain(
|
|
242
|
+
"router.post('/mcp', bodyParserMiddleware(['json']), requireSignature('mcp', { policy: MCP_AUTH_POLICY }), async (ctx) => {",
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('answers notifications with 202 rather than falling through to a 404', () => {
|
|
247
|
+
const out = generateMcpRouter();
|
|
248
|
+
// `dispatch` resolves undefined for a notification; an unset ctx.body 404s in errorMiddleware.
|
|
249
|
+
expect(out).toContain('if (response) ctx.body = response;');
|
|
250
|
+
expect(out).toContain('else ctx.status = 202;');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('stringifies rawBody before JSON.parse', () => {
|
|
254
|
+
const out = generateMcpRouter();
|
|
255
|
+
// ctx.rawBody is BinaryLike; JSON.parse takes string.
|
|
256
|
+
expect(out).toContain('JSON.parse(String(ctx.rawBody))');
|
|
257
|
+
expect(out).not.toContain('JSON.parse(ctx.rawBody)');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('hands the stateful transport the parsed body', () => {
|
|
261
|
+
const out = generateMcpRouter();
|
|
262
|
+
// The parser drains the stream and writes ctx.parsedBody; ctx.request.body is never set.
|
|
263
|
+
expect(out).toContain('body: ctx.parsedBody');
|
|
264
|
+
expect(out).not.toContain('ctx.request.body');
|
|
265
|
+
});
|
|
266
|
+
|
|
236
267
|
it('defaults the mount path to /mcp', () => {
|
|
237
268
|
expect(generateMcpRouter()).toContain("router.post('/mcp'");
|
|
238
269
|
});
|
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
opRoot,
|
|
20
20
|
} from './helpers.js';
|
|
21
21
|
|
|
22
|
+
/** The narrowed numeric coercion `renderScalar` emits — see NUMERIC_PREPROCESS in codegen-contract. */
|
|
23
|
+
const NUM = `z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number())`;
|
|
24
|
+
const NUM_INT = `z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number().int())`;
|
|
25
|
+
|
|
22
26
|
describe('generateOperation', () => {
|
|
23
27
|
// ─── Router name derivation ─────────────────────────────────────
|
|
24
28
|
|
|
@@ -401,8 +405,8 @@ describe('generateOperation', () => {
|
|
|
401
405
|
]);
|
|
402
406
|
const output = generateOp(root);
|
|
403
407
|
expect(output).toContain('ctx.query');
|
|
404
|
-
expect(output).toContain(
|
|
405
|
-
expect(output).toContain(
|
|
408
|
+
expect(output).toContain(`page: ${NUM_INT}`);
|
|
409
|
+
expect(output).toContain(`limit: ${NUM_INT}`);
|
|
406
410
|
});
|
|
407
411
|
|
|
408
412
|
it('generates parseAndValidate import when operation has query', () => {
|
|
@@ -441,7 +445,7 @@ describe('generateOperation', () => {
|
|
|
441
445
|
expect(output).toContain('z.preprocess');
|
|
442
446
|
expect(output).toContain("typeof v === 'string' ? v.split(',') : v");
|
|
443
447
|
// Non-array params should not be wrapped
|
|
444
|
-
expect(output).toContain(
|
|
448
|
+
expect(output).toContain(`limit: ${NUM_INT}`);
|
|
445
449
|
});
|
|
446
450
|
|
|
447
451
|
it('imports Input variant for refs inside an intersection query', () => {
|
|
@@ -542,7 +546,7 @@ describe('generateOperation', () => {
|
|
|
542
546
|
// Boolean should use preprocess for string coercion
|
|
543
547
|
expect(output).toContain("active: z.preprocess((v) => v === 'true' ? true : v === 'false' ? false : v, z.boolean())");
|
|
544
548
|
// Int should still use z.coerce
|
|
545
|
-
expect(output).toContain(
|
|
549
|
+
expect(output).toContain(`page: ${NUM_INT}`);
|
|
546
550
|
});
|
|
547
551
|
});
|
|
548
552
|
|
|
@@ -756,10 +760,21 @@ describe('generateOperation', () => {
|
|
|
756
760
|
expect(output).not.toContain('ctx.body =');
|
|
757
761
|
});
|
|
758
762
|
|
|
759
|
-
it('defaults to status
|
|
763
|
+
it('defaults to status 204 when no response is specified', () => {
|
|
760
764
|
const root = opRoot([opRoute('/users', [opOperation('get')])]);
|
|
761
765
|
const output = generateOp(root);
|
|
762
|
-
|
|
766
|
+
// Nothing is emitted, so there is no body to send; 204 says that precisely, and it is
|
|
767
|
+
// what the SDK's `Promise<void>` for the same operation already means.
|
|
768
|
+
expect(output).toContain('ctx.status = 204');
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('returns 204 rather than an error status when only a 4xx is declared', () => {
|
|
772
|
+
const root = opRoot([opRoute('/users', [opOperation('delete', { responses: [opResponse(400)] })])]);
|
|
773
|
+
const output = generateOp(root);
|
|
774
|
+
// A bare `400:` is documentation — something else produces it. Falling back to the
|
|
775
|
+
// first declared status wrote 400 on the success path.
|
|
776
|
+
expect(output).toContain('ctx.status = 204');
|
|
777
|
+
expect(output).not.toContain('ctx.status = 400');
|
|
763
778
|
});
|
|
764
779
|
|
|
765
780
|
// ─── Which statuses the service produces ─────────────────────────
|
|
@@ -1345,7 +1360,7 @@ describe('generateOperation', () => {
|
|
|
1345
1360
|
it('includes source location in JSDoc above handler', () => {
|
|
1346
1361
|
const root = opRoot([opRoute('/users', [opOperation('get', { loc: { file: 'users.op', line: 3 } })])], 'users.op');
|
|
1347
1362
|
const output = generateOp(root);
|
|
1348
|
-
expect(output).toContain('
|
|
1363
|
+
expect(output).toContain('[users.op](./users.op#L3)');
|
|
1349
1364
|
});
|
|
1350
1365
|
});
|
|
1351
1366
|
|
|
@@ -1369,7 +1384,7 @@ describe('generateOperation', () => {
|
|
|
1369
1384
|
const root = opRoot([opRoute('/users', [opOperation('get')])]);
|
|
1370
1385
|
const output = generateOp(root);
|
|
1371
1386
|
expect(output).toContain('/**');
|
|
1372
|
-
expect(output).
|
|
1387
|
+
expect(output).toMatch(/ \* from \[[^\]]+\]\(\.\/[^)]+#L\d+\)/);
|
|
1373
1388
|
});
|
|
1374
1389
|
});
|
|
1375
1390
|
|
|
@@ -1622,3 +1637,48 @@ describe('generateOp — route modifiers JSDoc', () => {
|
|
|
1622
1637
|
});
|
|
1623
1638
|
});
|
|
1624
1639
|
});
|
|
1640
|
+
|
|
1641
|
+
// ─── Hyphenated path parameters ───────────────────────────────────────────
|
|
1642
|
+
|
|
1643
|
+
describe('generateOperation — path parameter names that are not identifiers', () => {
|
|
1644
|
+
const root = () =>
|
|
1645
|
+
opRoot([
|
|
1646
|
+
opRoute(
|
|
1647
|
+
'/invoices/{invoice-id}',
|
|
1648
|
+
[opOperation('get', { service: 'InvoiceService.getById', responses: [opResponse(200, 'Invoice', 'application/json')] })],
|
|
1649
|
+
[opParam('invoice-id', scalarType('uuid'))],
|
|
1650
|
+
),
|
|
1651
|
+
]);
|
|
1652
|
+
|
|
1653
|
+
it('registers a Koa pattern with a bindable name', () => {
|
|
1654
|
+
const out = generateOp(root());
|
|
1655
|
+
// Previously `{invoice-id}` survived verbatim, so the route was a literal path that no
|
|
1656
|
+
// real request could ever match. The name is internal — Koa matches by position — so
|
|
1657
|
+
// renaming it costs nothing on the wire.
|
|
1658
|
+
expect(out).toContain("get('/invoices/:invoiceId'");
|
|
1659
|
+
expect(out).not.toContain('{invoice-id}');
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
it('keys the params schema by the same name, since that is what ctx.params carries', () => {
|
|
1663
|
+
const out = generateOp(root());
|
|
1664
|
+
expect(out).toContain('const { invoiceId } = await parseAndValidate(');
|
|
1665
|
+
expect(out).toContain('invoiceId: z.uuid()');
|
|
1666
|
+
expect(out).toContain('service.getById(invoiceId)');
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
it('leaves query and header names alone, which the client actually sends', () => {
|
|
1670
|
+
const out = generateOp(
|
|
1671
|
+
opRoot([
|
|
1672
|
+
opRoute('/things', [
|
|
1673
|
+
opOperation('get', {
|
|
1674
|
+
query: [opParam('sort-by', scalarType('string'))],
|
|
1675
|
+
headers: [opParam('x-api-key', scalarType('string'))],
|
|
1676
|
+
responses: [opResponse(200, 'Thing', 'application/json')],
|
|
1677
|
+
}),
|
|
1678
|
+
]),
|
|
1679
|
+
]),
|
|
1680
|
+
);
|
|
1681
|
+
expect(out).toContain("'sort-by': z.string()");
|
|
1682
|
+
expect(out).toContain("'x-api-key': z.string()");
|
|
1683
|
+
});
|
|
1684
|
+
});
|