@contractkit/plugin-typescript 0.31.2 → 0.33.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 +5 -5
- package/.turbo/turbo-test$colon$ci.log +23 -21
- package/CHANGELOG.md +166 -0
- package/README.md +8 -0
- package/dist/codegen-contract.d.ts +7 -0
- package/dist/codegen-contract.d.ts.map +1 -1
- package/dist/codegen-mcp.d.ts.map +1 -1
- package/dist/codegen-operation.d.ts +12 -0
- 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 +42 -0
- package/dist/codegen-revive.d.ts.map +1 -0
- package/dist/codegen-sdk.d.ts +18 -6
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/decimal-runtime.d.ts +47 -0
- package/dist/decimal-runtime.d.ts.map +1 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +610 -49
- package/dist/index.js.map +1 -1
- package/dist/ts-render.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen-contract.ts +58 -3
- package/src/codegen-mcp.ts +5 -0
- package/src/codegen-operation.ts +132 -10
- package/src/codegen-plain-types.ts +42 -2
- package/src/codegen-revive.ts +304 -0
- package/src/codegen-sdk.ts +248 -37
- package/src/decimal-runtime.ts +50 -0
- package/src/index.ts +60 -3
- package/src/ts-render.ts +6 -0
- package/tests/codegen-contract.test.ts +124 -1
- package/tests/codegen-operation.test.ts +266 -2
- package/tests/codegen-sdk.test.ts +77 -6
- package/tests/codegen-server.test.ts +48 -0
- package/tests/helpers.ts +5 -0
- package/tests/pipeline.test.ts +35 -2
package/src/index.ts
CHANGED
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
hashFingerprint,
|
|
21
21
|
collectTransitiveModelRefs,
|
|
22
22
|
collectTypeRefs,
|
|
23
|
+
computeModelsWithCaseTransform,
|
|
24
|
+
computeModelsWithDecimal,
|
|
23
25
|
} from '@contractkit/core';
|
|
24
26
|
import {
|
|
25
27
|
generateSdk,
|
|
@@ -73,6 +75,15 @@ export interface ServerConfig {
|
|
|
73
75
|
servicePathTemplate?: string;
|
|
74
76
|
/** Whether to emit handlers for `internal` operations. Default true. */
|
|
75
77
|
includeInternal?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* When true, each handler re-parses the service result through its declared response schema
|
|
80
|
+
* before writing `ctx.body`, and writes the parsed value. Requires `zod: true` — without it
|
|
81
|
+
* `output.types` emits plain interfaces, which are types with no runtime schema value.
|
|
82
|
+
*
|
|
83
|
+
* A body that transitively references a model with `format(input=...)`/`format(output=...)`, and
|
|
84
|
+
* a status whose several mimes carry different body types, are left unvalidated. Default false.
|
|
85
|
+
*/
|
|
86
|
+
validateResponses?: boolean;
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
/** TypeScript SDK client output: the client class, per-area operation clients, and their types. */
|
|
@@ -92,7 +103,8 @@ export interface SdkConfig {
|
|
|
92
103
|
* write-once: the files are created only when absent and are never overwritten or
|
|
93
104
|
* cleaned up on later builds, so any edits you make to them are preserved.
|
|
94
105
|
* Dependency ranges are derived from the contracts (always `zod` when `zod: true`;
|
|
95
|
-
* `luxon` when any covered model uses a date/time/datetime/interval scalar
|
|
106
|
+
* `luxon` when any covered model uses a date/time/datetime/duration/interval scalar;
|
|
107
|
+
* `decimal.js` when any uses a decimal).
|
|
96
108
|
*/
|
|
97
109
|
scaffold?: boolean;
|
|
98
110
|
}
|
|
@@ -182,6 +194,15 @@ export function createTypescriptPlugin(config: TypescriptPluginConfig, rootDir:
|
|
|
182
194
|
};
|
|
183
195
|
}
|
|
184
196
|
|
|
197
|
+
/** Reject config combinations that would generate code that cannot compile or cannot run. */
|
|
198
|
+
function assertValidConfig(config: TypescriptPluginConfig): void {
|
|
199
|
+
if (config.server?.validateResponses && !config.server.zod) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
'plugin-typescript: server.validateResponses requires server.zod: true — without it output.types emits plain TypeScript interfaces, which are types with no runtime schema value for the router to validate against.',
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
185
206
|
/**
|
|
186
207
|
* Shared orchestration. Each sub-generator (server / sdk / zod / types) contributes a
|
|
187
208
|
* set of cacheable units (per-file fingerprints) plus a set of always-regenerated global
|
|
@@ -196,6 +217,7 @@ async function runTypescriptCodegen(
|
|
|
196
217
|
config: TypescriptPluginConfig,
|
|
197
218
|
rootDir: string,
|
|
198
219
|
): Promise<void> {
|
|
220
|
+
assertValidConfig(config);
|
|
199
221
|
const manifestPath = resolve(ctx.cacheDir, CACHE_MANIFEST_FILENAME);
|
|
200
222
|
const prevManifest: IncrementalManifest = ctx.cacheEnabled ? readManifest(manifestPath) : emptyIncrementalManifest(TYPESCRIPT_CODEGEN_VERSION);
|
|
201
223
|
|
|
@@ -322,6 +344,10 @@ function collectServerOutput(
|
|
|
322
344
|
const serverBase = resolve(rootDir, config.baseDir ?? '.');
|
|
323
345
|
const modelsWithInput = inputs.modelsWithInput as Set<string>;
|
|
324
346
|
const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
|
|
347
|
+
// Not `modelsWithOutput`: that set seeds only from `format(output=...)`, because only that case
|
|
348
|
+
// needs an `Output` type alias. A `format(input=...)`-only model is just as untouchable for
|
|
349
|
+
// response validation — its schema's input casing is not what the service hands back.
|
|
350
|
+
const modelsWithTransform = computeModelsWithCaseTransform(inputs.contractRoots.flatMap(r => r.models));
|
|
325
351
|
const modelMap = buildModelMap(inputs.contractRoots);
|
|
326
352
|
const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
|
|
327
353
|
const commonRoot = commonDir(allFiles, rootDir);
|
|
@@ -391,6 +417,10 @@ function collectServerOutput(
|
|
|
391
417
|
modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
|
|
392
418
|
servicePathTemplate: config.servicePathTemplate ?? null,
|
|
393
419
|
includeInternal: config.includeInternal ?? true,
|
|
420
|
+
// Not covered by `sub`: adding `format(input=snake)` to a *different* .ck file changes
|
|
421
|
+
// this router's output with no change to `root` or the config.
|
|
422
|
+
modelsWithTransform: sliceModelSet(refs, new Set(), modelsWithTransform),
|
|
423
|
+
validateResponses: config.validateResponses ?? false,
|
|
394
424
|
sub: subConfigKey,
|
|
395
425
|
});
|
|
396
426
|
units.push({
|
|
@@ -405,7 +435,9 @@ function collectServerOutput(
|
|
|
405
435
|
modelOutPaths: serverModelOutPaths,
|
|
406
436
|
modelsWithInput,
|
|
407
437
|
modelsWithOutput,
|
|
438
|
+
modelsWithTransform,
|
|
408
439
|
includeInternal: config.includeInternal,
|
|
440
|
+
validateResponses: config.validateResponses,
|
|
409
441
|
}),
|
|
410
442
|
},
|
|
411
443
|
],
|
|
@@ -433,6 +465,9 @@ function collectSdkOutput(
|
|
|
433
465
|
|
|
434
466
|
const modelsWithInput = inputs.modelsWithInput as Set<string>;
|
|
435
467
|
const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
|
|
468
|
+
// Computed across every contract root, not per file: one decimal below a model taints it, and
|
|
469
|
+
// the reference that reaches it may live in another .ck file entirely.
|
|
470
|
+
const modelsWithDecimal = computeModelsWithDecimal(inputs.contractRoots.flatMap(r => r.models));
|
|
436
471
|
const modelMap = buildModelMap(inputs.contractRoots);
|
|
437
472
|
const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
|
|
438
473
|
const ckCommonRoot = commonDir(allFiles, rootDir);
|
|
@@ -471,6 +506,9 @@ function collectSdkOutput(
|
|
|
471
506
|
outPathSlice: sliceOutPathMap(refs, sdkModelOutPaths, modelsWithInput, modelsWithOutput),
|
|
472
507
|
modelsWithInput: sliceModelSet(refs, ownNames, modelsWithInput),
|
|
473
508
|
modelsWithOutput: sliceModelSet(refs, ownNames, modelsWithOutput),
|
|
509
|
+
// Not covered by `root`: adding a decimal to a model in a *different* .ck file changes
|
|
510
|
+
// this file's revivers with no change to `root` or the config.
|
|
511
|
+
modelsWithDecimal: sliceModelSet(refs, ownNames, modelsWithDecimal),
|
|
474
512
|
sdkOptionsPath,
|
|
475
513
|
sub: subConfigKey,
|
|
476
514
|
});
|
|
@@ -479,12 +517,16 @@ function collectSdkOutput(
|
|
|
479
517
|
fingerprint,
|
|
480
518
|
render: () => {
|
|
481
519
|
let content: string;
|
|
520
|
+
// `emitRevivers` is set for SDK type files only: a server handler receives decimals
|
|
521
|
+
// already parsed by `_ZodDecimal`, so it has nothing to rehydrate.
|
|
482
522
|
if (config.zod) {
|
|
483
523
|
content = generateContract(ast, {
|
|
484
524
|
modelOutPaths: sdkModelOutPaths,
|
|
485
525
|
currentOutPath: typeOutPath,
|
|
486
526
|
modelsWithInput,
|
|
487
527
|
modelsWithOutput,
|
|
528
|
+
modelsWithDecimal,
|
|
529
|
+
emitRevivers: true,
|
|
488
530
|
});
|
|
489
531
|
} else {
|
|
490
532
|
let rel = relative(dirname(typeOutPath), sdkOptionsPath).replace(/\.ts$/, '.js');
|
|
@@ -494,6 +536,8 @@ function collectSdkOutput(
|
|
|
494
536
|
currentOutPath: typeOutPath,
|
|
495
537
|
modelsWithInput,
|
|
496
538
|
modelsWithOutput,
|
|
539
|
+
modelsWithDecimal,
|
|
540
|
+
emitRevivers: true,
|
|
497
541
|
jsonValueImportPath: rel,
|
|
498
542
|
});
|
|
499
543
|
}
|
|
@@ -542,6 +586,7 @@ function collectSdkOutput(
|
|
|
542
586
|
outPathSlice: sliceOutPathMap(refs, sdkModelOutPaths, modelsWithInput, modelsWithOutput),
|
|
543
587
|
modelsWithInput: sliceModelSet(refs, new Set(), modelsWithInput),
|
|
544
588
|
modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
|
|
589
|
+
modelsWithDecimal: sliceModelSet(refs, new Set(), modelsWithDecimal),
|
|
545
590
|
sdkOptionsPath,
|
|
546
591
|
className,
|
|
547
592
|
includeInternal: config.includeInternal ?? false,
|
|
@@ -560,6 +605,8 @@ function collectSdkOutput(
|
|
|
560
605
|
sdkOptionsPath,
|
|
561
606
|
modelsWithInput,
|
|
562
607
|
modelsWithOutput,
|
|
608
|
+
modelsWithDecimal,
|
|
609
|
+
modelMap,
|
|
563
610
|
includeInternal: config.includeInternal,
|
|
564
611
|
clientClassName: className,
|
|
565
612
|
}),
|
|
@@ -582,6 +629,7 @@ function collectSdkOutput(
|
|
|
582
629
|
outPathSlice: sliceOutPathMap(refs, sdkModelOutPaths, modelsWithInput, modelsWithOutput),
|
|
583
630
|
modelsWithInput: sliceModelSet(refs, new Set(), modelsWithInput),
|
|
584
631
|
modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
|
|
632
|
+
modelsWithDecimal: sliceModelSet(refs, new Set(), modelsWithDecimal),
|
|
585
633
|
sdkOptionsPath,
|
|
586
634
|
includeInternal: config.includeInternal ?? false,
|
|
587
635
|
sub: subConfigKey,
|
|
@@ -599,6 +647,8 @@ function collectSdkOutput(
|
|
|
599
647
|
sdkOptionsPath,
|
|
600
648
|
modelsWithInput,
|
|
601
649
|
modelsWithOutput,
|
|
650
|
+
modelsWithDecimal,
|
|
651
|
+
modelMap,
|
|
602
652
|
includeInternal: config.includeInternal,
|
|
603
653
|
}),
|
|
604
654
|
},
|
|
@@ -680,6 +730,7 @@ function collectSdkOutput(
|
|
|
680
730
|
outPathSlice: sliceOutPathMap(allInlineRefs, sdkModelOutPaths, modelsWithInput, modelsWithOutput),
|
|
681
731
|
modelsWithInput: sliceModelSet(allInlineRefs, new Set(), modelsWithInput),
|
|
682
732
|
modelsWithOutput: sliceModelSet(allInlineRefs, new Set(), modelsWithOutput),
|
|
733
|
+
modelsWithDecimal: sliceModelSet(allInlineRefs, new Set(), modelsWithDecimal),
|
|
683
734
|
sdkOptionsPath,
|
|
684
735
|
includeInternal: config.includeInternal ?? false,
|
|
685
736
|
sub: subConfigKey,
|
|
@@ -694,6 +745,8 @@ function collectSdkOutput(
|
|
|
694
745
|
sdkOptionsPath,
|
|
695
746
|
modelsWithInput,
|
|
696
747
|
modelsWithOutput,
|
|
748
|
+
modelsWithDecimal,
|
|
749
|
+
modelMap,
|
|
697
750
|
includeInternal: config.includeInternal,
|
|
698
751
|
},
|
|
699
752
|
}));
|
|
@@ -757,9 +810,13 @@ function collectSdkOutput(
|
|
|
757
810
|
const coveredRoots = sdkContractEntries.map(e => e.ast);
|
|
758
811
|
const deps: SdkScaffoldDeps = {
|
|
759
812
|
zod: !!config.zod,
|
|
760
|
-
|
|
761
|
-
|
|
813
|
+
// `duration` belongs here too: `generateContract` imports `Duration` from luxon for it,
|
|
814
|
+
// so a contract whose only temporal scalar is a duration used to scaffold a package.json
|
|
815
|
+
// with no luxon dependency and fail to compile.
|
|
816
|
+
luxon: coveredRoots.some(r =>
|
|
817
|
+
(['datetime', 'date', 'time', 'duration', 'interval'] as const).some(name => rootNeedsScalar(r, name)),
|
|
762
818
|
),
|
|
819
|
+
decimal: coveredRoots.some(r => rootNeedsScalar(r, 'decimal')),
|
|
763
820
|
};
|
|
764
821
|
globalFiles.push({
|
|
765
822
|
relativePath: join(sdkBase, 'package.json'),
|
package/src/ts-render.ts
CHANGED
|
@@ -96,6 +96,12 @@ function renderTsScalar(name: ScalarTypeNode['name'], target: TsRenderTarget): s
|
|
|
96
96
|
return 'number';
|
|
97
97
|
case 'bigint':
|
|
98
98
|
return 'bigint';
|
|
99
|
+
case 'decimal':
|
|
100
|
+
// The one scalar whose wire view and server view agree — see the note on this
|
|
101
|
+
// function and on `serverTsScalar`. A `decimal` travels as a quoted string, and both
|
|
102
|
+
// the router (via `_ZodDecimal`) and the SDK (via the generated `reviveX` functions)
|
|
103
|
+
// hand the developer a real `Decimal`, so there is no `target` split to make.
|
|
104
|
+
return 'Decimal';
|
|
99
105
|
case 'boolean':
|
|
100
106
|
return 'boolean';
|
|
101
107
|
case 'date':
|
|
@@ -27,7 +27,7 @@ describe('renderType', () => {
|
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
it('throws on an unmapped scalar name', () => {
|
|
30
|
-
expect(() => renderType({ kind: 'scalar', name: '
|
|
30
|
+
expect(() => renderType({ kind: 'scalar', name: 'quaternion' } as any)).toThrow(/unmapped scalar 'quaternion'/);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
it('renders z.string() with min/max', () => {
|
|
@@ -101,6 +101,36 @@ describe('renderType', () => {
|
|
|
101
101
|
expect(result).toBe(`z.preprocess((val) => typeof val === 'string' ? BigInt(val.replace(/n$/, '')) : val, z.bigint().min(0n).max(100n))`);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
it('renders bare _ZodDecimal for an unconstrained decimal', () => {
|
|
105
|
+
expect(renderType(scalarType('decimal'))).toBe('_ZodDecimal');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('renders a scale refinement on decimal', () => {
|
|
109
|
+
expect(renderType(scalarType('decimal', { scale: 2 }))).toBe(
|
|
110
|
+
`_ZodDecimal.refine((v) => v.decimalPlaces() <= 2, { message: 'Must be at most 2 decimal places' })`,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('compares decimal bounds as exact strings, never as floats', () => {
|
|
115
|
+
// `0.01` through `Number()` is the precision loss this scalar exists to prevent, so the
|
|
116
|
+
// bound is emitted as a quoted string for decimal.js to parse.
|
|
117
|
+
const result = renderType(scalarType('decimal', { min: '0.01', max: '999999.99' }));
|
|
118
|
+
expect(result).toBe(
|
|
119
|
+
`_ZodDecimal.refine((v) => v.gte('0.01') && v.lte('999999.99'), { message: 'Must be at least 0.01, at most 999999.99' })`,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('renders scale and bounds together', () => {
|
|
124
|
+
const result = renderType(scalarType('decimal', { min: '0', scale: 2 }));
|
|
125
|
+
expect(result).toBe(
|
|
126
|
+
`_ZodDecimal.refine((v) => v.decimalPlaces() <= 2 && v.gte('0'), { message: 'Must be at most 2 decimal places, at least 0' })`,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('singularises the scale message at 1', () => {
|
|
131
|
+
expect(renderType(scalarType('decimal', { scale: 1 }))).toContain(`message: 'Must be at most 1 decimal place'`);
|
|
132
|
+
});
|
|
133
|
+
|
|
104
134
|
it('renders z.boolean() with string coercion preprocess', () => {
|
|
105
135
|
expect(renderType(scalarType('boolean'))).toBe(`z.preprocess((v) => v === 'true' ? true : v === 'false' ? false : v, z.boolean())`);
|
|
106
136
|
});
|
|
@@ -329,6 +359,99 @@ describe('generateContract', () => {
|
|
|
329
359
|
);
|
|
330
360
|
});
|
|
331
361
|
|
|
362
|
+
it('emits the decimal.js import and _ZodDecimal helper when a decimal field is present', () => {
|
|
363
|
+
const root = contractRoot([model('M', [field('amount', scalarType('decimal'))])]);
|
|
364
|
+
const output = generateContract(root);
|
|
365
|
+
// Named, not default: under NodeNext the default export resolves to decimal.js's
|
|
366
|
+
// namespace declaration and the generated SDK fails to compile.
|
|
367
|
+
expect(output).toContain(`import { Decimal } from 'decimal.js';`);
|
|
368
|
+
expect(output).toContain(`Decimal.set({ toExpNeg: -9e15, toExpPos: 9e15 });`);
|
|
369
|
+
expect(output).toContain(`const _ZodDecimal = z.preprocess(`);
|
|
370
|
+
// No output `.transform()` — `isRevalidatable` treats every scalar as idempotent under
|
|
371
|
+
// re-parse, which `server.validateResponses` depends on.
|
|
372
|
+
expect(output).not.toContain('_ZodDecimal = z.preprocess(...).transform');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('omits the decimal runtime entirely when no decimal field is present', () => {
|
|
376
|
+
const root = contractRoot([model('M', [field('n', scalarType('number'))])]);
|
|
377
|
+
const output = generateContract(root);
|
|
378
|
+
expect(output).not.toContain('decimal.js');
|
|
379
|
+
expect(output).not.toContain('_ZodDecimal');
|
|
380
|
+
expect(output).not.toContain('Decimal.set');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('emits revivers only when asked, and only for decimal-carrying models', () => {
|
|
384
|
+
const root = contractRoot([
|
|
385
|
+
model('Money', [field('amount', scalarType('decimal'))]),
|
|
386
|
+
model('Plain', [field('name', scalarType('string'))]),
|
|
387
|
+
]);
|
|
388
|
+
const ctx = { modelOutPaths: new Map(), currentOutPath: '/out/t.ts' };
|
|
389
|
+
|
|
390
|
+
// Server-side schemas get decimals already parsed by `_ZodDecimal`, so no revivers.
|
|
391
|
+
const withoutRevivers = generateContract(root, ctx);
|
|
392
|
+
expect(withoutRevivers).not.toContain('reviveMoney');
|
|
393
|
+
expect(withoutRevivers).not.toContain('__dec');
|
|
394
|
+
|
|
395
|
+
const withRevivers = generateContract(root, { ...ctx, modelsWithDecimal: new Set(['Money']), emitRevivers: true });
|
|
396
|
+
expect(withRevivers).toContain('export function reviveMoney(raw: Money): Money {');
|
|
397
|
+
expect(withRevivers).toContain(`__o0["amount"] = __dec(__o0["amount"], 'Money.amount');`);
|
|
398
|
+
expect(withRevivers).not.toContain('revivePlain');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('guards an optional or nullable decimal instead of coercing null', () => {
|
|
402
|
+
const root = contractRoot([
|
|
403
|
+
model('M', [
|
|
404
|
+
field('a', scalarType('decimal'), { optional: true }),
|
|
405
|
+
field('b', unionType(scalarType('decimal'), scalarType('null'))),
|
|
406
|
+
]),
|
|
407
|
+
]);
|
|
408
|
+
const out = generateContract(root, {
|
|
409
|
+
modelOutPaths: new Map(),
|
|
410
|
+
currentOutPath: '/out/t.ts',
|
|
411
|
+
modelsWithDecimal: new Set(['M']),
|
|
412
|
+
emitRevivers: true,
|
|
413
|
+
});
|
|
414
|
+
expect(out).toContain(`if (__o0["a"] != null) {`);
|
|
415
|
+
expect(out).toContain(`if (__o0["b"] != null) {`);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('keys the Output reviver by the output casing while children stay camel', () => {
|
|
419
|
+
const root = contractRoot([
|
|
420
|
+
model('Money', [field('amount', scalarType('decimal'))]),
|
|
421
|
+
model('Snake', [field('grossPay', scalarType('decimal')), field('childRef', refType('Money'))], { outputCase: 'snake' }),
|
|
422
|
+
]);
|
|
423
|
+
const out = generateContract(root, {
|
|
424
|
+
modelOutPaths: new Map(),
|
|
425
|
+
currentOutPath: '/out/t.ts',
|
|
426
|
+
modelsWithDecimal: new Set(['Money', 'Snake']),
|
|
427
|
+
emitRevivers: true,
|
|
428
|
+
});
|
|
429
|
+
expect(out).toContain('export function reviveSnakeOutput(raw: SnakeOutput): SnakeOutput {');
|
|
430
|
+
expect(out).toContain(`__o0["gross_pay"] = __dec(__o0["gross_pay"], 'Snake.gross_pay');`);
|
|
431
|
+
// `computeModelsWithOutput` propagates referrer→referenced, so Money is NOT transformed
|
|
432
|
+
// and keeps its camelCase keys — it must use the base reviver, not an Output one.
|
|
433
|
+
expect(out).toContain(`reviveMoney(__o0["child_ref"] as never);`);
|
|
434
|
+
expect(out).not.toContain('reviveMoneyOutput');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('recurses through a self-referential model without looping', () => {
|
|
438
|
+
const root = contractRoot([
|
|
439
|
+
model('Category', [field('subtotal', scalarType('decimal')), field('children', arrayType(refType('Category')))]),
|
|
440
|
+
]);
|
|
441
|
+
const out = generateContract(root, {
|
|
442
|
+
modelOutPaths: new Map(),
|
|
443
|
+
currentOutPath: '/out/t.ts',
|
|
444
|
+
modelsWithDecimal: new Set(['Category']),
|
|
445
|
+
emitRevivers: true,
|
|
446
|
+
});
|
|
447
|
+
expect(out).toContain('reviveCategory(__a1[__i2] as never);');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('detects a decimal nested in an array', () => {
|
|
451
|
+
const root = contractRoot([model('M', [field('amounts', arrayType(scalarType('decimal')))])]);
|
|
452
|
+
expect(generateContract(root)).toContain(`import { Decimal } from 'decimal.js';`);
|
|
453
|
+
});
|
|
454
|
+
|
|
332
455
|
it('detects DateTime in nested array types', () => {
|
|
333
456
|
const root = contractRoot([model('M', [field('d', arrayType(scalarType('datetime')))])]);
|
|
334
457
|
const output = generateContract(root);
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { generateOp } from '../src/codegen-operation.js';
|
|
3
3
|
import { SECURITY_NONE } from '@contractkit/core';
|
|
4
|
+
import type { ContractTypeNode } from '@contractkit/core';
|
|
4
5
|
import {
|
|
5
6
|
scalarType,
|
|
6
7
|
arrayType,
|
|
7
8
|
refType,
|
|
8
9
|
inlineObjectType,
|
|
10
|
+
intersectionType,
|
|
9
11
|
field,
|
|
10
12
|
opParam,
|
|
11
13
|
opRequest,
|
|
@@ -61,6 +63,69 @@ describe('generateOperation', () => {
|
|
|
61
63
|
expect(output).toContain('User');
|
|
62
64
|
});
|
|
63
65
|
|
|
66
|
+
// Imports are collected from the AST, which over-approximates: a model with an Input/Output
|
|
67
|
+
// variant contributes its base name even when only the variant is annotated, and the
|
|
68
|
+
// collectors walk every operation including ones `includeInternal: false` drops. Each name
|
|
69
|
+
// is filtered back through the same `uses` gate the other symbols go through, because an
|
|
70
|
+
// import nothing references trips `noUnusedLocals` in the consuming project.
|
|
71
|
+
describe('every imported name is referenced by the generated body', () => {
|
|
72
|
+
/** Names in `import { … } from '…'` lines, minus everything the body actually mentions. */
|
|
73
|
+
const unusedImports = (output: string): string[] => {
|
|
74
|
+
const lines = output.split('\n');
|
|
75
|
+
const imported = lines
|
|
76
|
+
.filter(l => l.startsWith('import '))
|
|
77
|
+
.flatMap(l => l.match(/import\s+(?:type\s+)?\{([^}]*)\}/)?.[1]?.split(',') ?? [])
|
|
78
|
+
.map(n => n.replace(/^\s*type\s+/, '').trim())
|
|
79
|
+
.filter(Boolean);
|
|
80
|
+
const body = lines.filter(l => !l.startsWith('import ')).join('\n');
|
|
81
|
+
return imported.filter(name => !new RegExp(`\\b${name}\\b`).test(body));
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
it('drops the base model when only its Output variant is annotated', () => {
|
|
85
|
+
const root = opRoot([opRoute('/t', [opOperation('post', { responses: [opResponse(200, 'AuthToken', 'application/json')] })])]);
|
|
86
|
+
const output = generateOp(root, { modelsWithOutput: new Set(['AuthToken']) });
|
|
87
|
+
expect(output).toContain('AuthTokenOutput');
|
|
88
|
+
expect(output).not.toContain('import { AuthToken,');
|
|
89
|
+
expect(unusedImports(output)).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('drops the base model when only its Input variant is validated', () => {
|
|
93
|
+
const root = opRoot([opRoute('/u', [opOperation('post', { request: opRequest('CreateUser') })])]);
|
|
94
|
+
const output = generateOp(root, { modelsWithInput: new Set(['CreateUser']) });
|
|
95
|
+
expect(output).toContain('CreateUserInput');
|
|
96
|
+
expect(output).not.toContain('import { CreateUser,');
|
|
97
|
+
expect(unusedImports(output)).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// `collectTypes`/`collectServices` walk every operation, so a router whose only op is
|
|
101
|
+
// dropped used to import a service and a model it had no handler to use them in.
|
|
102
|
+
it('imports nothing for a router whose only operation is an excluded internal one', () => {
|
|
103
|
+
const root = opRoot([
|
|
104
|
+
opRoute('/i', [opOperation('get', { modifiers: ['internal'], responses: [opResponse(200, 'Secret', 'application/json')] })]),
|
|
105
|
+
]);
|
|
106
|
+
const output = generateOp(root, { includeInternal: false });
|
|
107
|
+
expect(output).not.toContain('Secret');
|
|
108
|
+
expect(output).not.toContain('Service');
|
|
109
|
+
expect(unusedImports(output)).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('keeps the base model when the body does reference it', () => {
|
|
113
|
+
const root = opRoot([
|
|
114
|
+
opRoute('/users', [opOperation('post', { request: opRequest('User'), responses: [opResponse(201, 'User', 'application/json')] })]),
|
|
115
|
+
]);
|
|
116
|
+
const output = generateOp(root);
|
|
117
|
+
expect(output).toContain('User');
|
|
118
|
+
expect(unusedImports(output)).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('keeps the base model when validateResponses uses it as the runtime schema', () => {
|
|
122
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
123
|
+
const output = generateOp(root, { validateResponses: true });
|
|
124
|
+
expect(output).toContain('parseAndValidate(result, User, 500)');
|
|
125
|
+
expect(unusedImports(output)).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
64
129
|
it('generates parseAndValidate import when route has params', () => {
|
|
65
130
|
const root = opRoot([opRoute('/users/{id}', [opOperation('get')], [opParam('id', scalarType('uuid'))])]);
|
|
66
131
|
const output = generateOp(root);
|
|
@@ -949,8 +1014,9 @@ describe('generateOperation', () => {
|
|
|
949
1014
|
});
|
|
950
1015
|
|
|
951
1016
|
it('emits no _ZodBinary helper for a binary response body', () => {
|
|
952
|
-
//
|
|
953
|
-
// so declaring the helper would leave it unused and trip
|
|
1017
|
+
// Without `validateResponses` a response body is an annotation, not a schema — the
|
|
1018
|
+
// handler never validates it, so declaring the helper would leave it unused and trip
|
|
1019
|
+
// `noUnusedLocals`. With the flag on it is referenced, and the helper comes back.
|
|
954
1020
|
const root = opRoot([
|
|
955
1021
|
opRoute('/art', [
|
|
956
1022
|
opOperation('get', {
|
|
@@ -1014,6 +1080,204 @@ describe('generateOperation', () => {
|
|
|
1014
1080
|
|
|
1015
1081
|
// ─── Service inference ────────────────────────────────────────
|
|
1016
1082
|
|
|
1083
|
+
// ─── Response body validation ──────────────────────────────────────
|
|
1084
|
+
|
|
1085
|
+
describe('response body validation (validateResponses)', () => {
|
|
1086
|
+
const V = { validateResponses: true };
|
|
1087
|
+
const respRoot = (bodyType: string | ContractTypeNode, contentType = 'application/json') =>
|
|
1088
|
+
opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, bodyType, contentType)] })])]);
|
|
1089
|
+
|
|
1090
|
+
it('is off by default', () => {
|
|
1091
|
+
expect(generateOp(respRoot('User'))).toContain('ctx.body = result;');
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it('validates a ref response body against its schema', () => {
|
|
1095
|
+
expect(generateOp(respRoot('User'), V)).toContain('ctx.body = await parseAndValidate(result, User, 500);');
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
it('re-validates a decimal body, which `isRevalidatable` assumes is idempotent', () => {
|
|
1099
|
+
// `isRevalidatable` returns true for every scalar on the grounds that scalars are
|
|
1100
|
+
// idempotent under re-parse. `_ZodDecimal` satisfies that only because it has no output
|
|
1101
|
+
// `.transform()` — preprocess passes an already-`Decimal` value straight through. If it
|
|
1102
|
+
// were modelled on `_ZodInterval`, which does transform, this would 500 at runtime.
|
|
1103
|
+
const root = respRoot(inlineObjectType([field('amount', scalarType('decimal', { scale: 2 }))]));
|
|
1104
|
+
const output = generateOp(root, V);
|
|
1105
|
+
expect(output).toContain('await parseAndValidate(result,');
|
|
1106
|
+
expect(output).toContain('_ZodDecimal');
|
|
1107
|
+
expect(output).not.toMatch(/_ZodDecimal[^\n]*\.transform\(/);
|
|
1108
|
+
// The helper references `Decimal`, so the import has to come with it.
|
|
1109
|
+
expect(output).toContain(`import { Decimal } from 'decimal.js';`);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
// A service returning a shape its own contract rejects is a server fault, not a client one.
|
|
1113
|
+
// 500 also diverts the field-level detail to `internalDetails`, keeping it off the wire.
|
|
1114
|
+
it('throws 500, not the parseAndValidate default of 400', () => {
|
|
1115
|
+
const output = generateOp(respRoot('User'), V);
|
|
1116
|
+
expect(output).toContain('parseAndValidate(result, User, 500)');
|
|
1117
|
+
expect(output).not.toContain('parseAndValidate(result, User)');
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
it('wraps an array response body in z.array', () => {
|
|
1121
|
+
const root = respRoot(arrayType(refType('User')));
|
|
1122
|
+
expect(generateOp(root, V)).toContain('ctx.body = await parseAndValidate(result, z.array(User), 500);');
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it('validates a scalar response body against the rendered scalar schema', () => {
|
|
1126
|
+
const root = respRoot(scalarType('string'), 'text/plain');
|
|
1127
|
+
expect(generateOp(root, V)).toContain('ctx.body = await parseAndValidate(result, z.string(), 500);');
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
it('reuses the extracted schema const for a complex inline body', () => {
|
|
1131
|
+
const root = respRoot(inlineObjectType([field('total', scalarType('int'))]));
|
|
1132
|
+
const output = generateOp(root, V);
|
|
1133
|
+
expect(output).toContain('const resultType = z.strictObject({');
|
|
1134
|
+
expect(output).toContain('ctx.body = await parseAndValidate(result, resultType, 500);');
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
it('validates result.body when the status declares headers', () => {
|
|
1138
|
+
const root = opRoot([
|
|
1139
|
+
opRoute('/users', [
|
|
1140
|
+
opOperation('get', {
|
|
1141
|
+
responses: [
|
|
1142
|
+
{
|
|
1143
|
+
...opResponse(200, 'User', 'application/json'),
|
|
1144
|
+
headers: [{ name: 'etag', optional: false, type: scalarType('string') }],
|
|
1145
|
+
},
|
|
1146
|
+
],
|
|
1147
|
+
}),
|
|
1148
|
+
]),
|
|
1149
|
+
]);
|
|
1150
|
+
const output = generateOp(root, V);
|
|
1151
|
+
expect(output).toContain("ctx.set('etag', String(result.headers[\"etag\"]));");
|
|
1152
|
+
expect(output).toContain('ctx.body = await parseAndValidate(result.body, User, 500);');
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
it('validates a single schema when several mimes share a body shape', () => {
|
|
1156
|
+
const root = opRoot([
|
|
1157
|
+
opRoute('/art', [
|
|
1158
|
+
opOperation('get', {
|
|
1159
|
+
responses: [
|
|
1160
|
+
opResponseMulti(200, [
|
|
1161
|
+
{ contentType: 'image/png', bodyType: scalarType('binary') },
|
|
1162
|
+
{ contentType: 'image/jpeg', bodyType: scalarType('binary') },
|
|
1163
|
+
]),
|
|
1164
|
+
],
|
|
1165
|
+
}),
|
|
1166
|
+
]),
|
|
1167
|
+
]);
|
|
1168
|
+
const output = generateOp(root, V);
|
|
1169
|
+
expect(output).toContain('ctx.body = await parseAndValidate(result.body, _ZodBinary, 500);');
|
|
1170
|
+
// The helper and the zod import are gated on the generated text, so both follow.
|
|
1171
|
+
expect(output).toContain('const _ZodBinary = z.custom<Buffer>');
|
|
1172
|
+
expect(output).toContain("import { z } from 'zod';");
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
it('validates each status body in a multi-status switch', () => {
|
|
1176
|
+
const root = opRoot([
|
|
1177
|
+
opRoute('/art', [
|
|
1178
|
+
opOperation('get', {
|
|
1179
|
+
responses: [opResponse(200, 'Art', 'application/json'), opResponse(202, 'JobRef', 'application/json')],
|
|
1180
|
+
}),
|
|
1181
|
+
]),
|
|
1182
|
+
]);
|
|
1183
|
+
const output = generateOp(root, V);
|
|
1184
|
+
expect(output).toMatch(/case 200:[\s\S]*?ctx\.body = await parseAndValidate\(result\.body, Art, 500\);/);
|
|
1185
|
+
expect(output).toMatch(/case 202:[\s\S]*?ctx\.body = await parseAndValidate\(result\.body, JobRef, 500\);/);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
it('imports parseAndValidate for an operation with no request to validate', () => {
|
|
1189
|
+
expect(generateOp(respRoot('User'), V)).toContain("import { parseAndValidate } from '@maroonedsoftware/zod';");
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it('imports Interval once an interval body is validated', () => {
|
|
1193
|
+
const output = generateOp(respRoot(scalarType('interval')), V);
|
|
1194
|
+
expect(output).toContain("import { Interval } from 'luxon';");
|
|
1195
|
+
expect(output).toContain('parseAndValidate(result, _ZodInterval, 500)');
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
it('writes no body for a bodyless status', () => {
|
|
1199
|
+
const root = opRoot([opRoute('/users', [opOperation('delete', { responses: [opResponse(204)] })])]);
|
|
1200
|
+
expect(generateOp(root, V)).not.toContain('ctx.body');
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
// ── Bodies the schema cannot soundly re-parse ──
|
|
1204
|
+
|
|
1205
|
+
it('leaves a body unvalidated when the model has an Output variant', () => {
|
|
1206
|
+
const output = generateOp(respRoot('AuthToken'), { ...V, modelsWithOutput: new Set(['AuthToken']) });
|
|
1207
|
+
expect(output).toContain('ctx.body = result;');
|
|
1208
|
+
expect(output).not.toContain('parseAndValidate(result');
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
it('leaves an array unvalidated when the item model has an Output variant', () => {
|
|
1212
|
+
const root = respRoot(arrayType(refType('AuthToken')));
|
|
1213
|
+
const output = generateOp(root, { ...V, modelsWithOutput: new Set(['AuthToken']) });
|
|
1214
|
+
expect(output).toContain('ctx.body = result;');
|
|
1215
|
+
expect(output).not.toContain('parseAndValidate(result');
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
// `format(input=snake)` alone never reaches `modelsWithOutput` — that set seeds only from
|
|
1219
|
+
// `outputCase` — but its schema is just as much a transform pipe, so re-parsing what the
|
|
1220
|
+
// service returns would fail on every key. Regression guard for exactly that gap.
|
|
1221
|
+
it('leaves a body unvalidated when the model has a format() key transform', () => {
|
|
1222
|
+
const output = generateOp(respRoot('User'), { ...V, modelsWithTransform: new Set(['User']) });
|
|
1223
|
+
expect(output).toContain('ctx.body = result;');
|
|
1224
|
+
expect(output).not.toContain('parseAndValidate(result');
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
it('skips an inline object whose field references a transform model', () => {
|
|
1228
|
+
const root = respRoot(inlineObjectType([field('token', refType('AuthToken'))]));
|
|
1229
|
+
const output = generateOp(root, { ...V, modelsWithOutput: new Set(['AuthToken']) });
|
|
1230
|
+
expect(output).not.toContain('parseAndValidate(result');
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
it('skips a multi-status case whose model has an Output variant, validating the others', () => {
|
|
1234
|
+
const root = opRoot([
|
|
1235
|
+
opRoute('/art', [
|
|
1236
|
+
opOperation('get', {
|
|
1237
|
+
responses: [opResponse(200, 'Art', 'application/json'), opResponse(202, 'AuthToken', 'application/json')],
|
|
1238
|
+
}),
|
|
1239
|
+
]),
|
|
1240
|
+
]);
|
|
1241
|
+
const output = generateOp(root, { ...V, modelsWithOutput: new Set(['AuthToken']) });
|
|
1242
|
+
expect(output).toMatch(/case 200:[\s\S]*?ctx\.body = await parseAndValidate\(result\.body, Art, 500\);/);
|
|
1243
|
+
expect(output).toMatch(/case 202:[\s\S]*?ctx\.body = result\.body;/);
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
it('skips validation when the mimes carry different body types', () => {
|
|
1247
|
+
const root = opRoot([
|
|
1248
|
+
opRoute('/pets', [
|
|
1249
|
+
opOperation('get', {
|
|
1250
|
+
responses: [
|
|
1251
|
+
opResponseMulti(200, [
|
|
1252
|
+
{ contentType: 'application/json', bodyType: 'Pet' },
|
|
1253
|
+
{ contentType: 'text/csv', bodyType: scalarType('string') },
|
|
1254
|
+
]),
|
|
1255
|
+
],
|
|
1256
|
+
}),
|
|
1257
|
+
]),
|
|
1258
|
+
]);
|
|
1259
|
+
const output = generateOp(root, V);
|
|
1260
|
+
expect(output).toContain('ctx.body = result.body;');
|
|
1261
|
+
expect(output).not.toContain('parseAndValidate(result.body');
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
// renderIntersection falls back to `.and()` outside its `.extend()` fast path, and `.and()`
|
|
1265
|
+
// of two strict objects rejects every value — each side sees the other's keys as unknown.
|
|
1266
|
+
it('skips an intersection that renders through .and()', () => {
|
|
1267
|
+
const root = respRoot(
|
|
1268
|
+
intersectionType(inlineObjectType([field('a', scalarType('string'))]), inlineObjectType([field('b', scalarType('string'))])),
|
|
1269
|
+
);
|
|
1270
|
+
expect(generateOp(root, V)).not.toContain('parseAndValidate(result');
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it('validates an intersection that renders through .extend()', () => {
|
|
1274
|
+
const root = respRoot(intersectionType(refType('Base'), inlineObjectType([field('extra', scalarType('string'))])));
|
|
1275
|
+
const output = generateOp(root, V);
|
|
1276
|
+
expect(output).toContain('const resultType = Base.extend({');
|
|
1277
|
+
expect(output).toContain('ctx.body = await parseAndValidate(result, resultType, 500);');
|
|
1278
|
+
});
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1017
1281
|
describe('service inference', () => {
|
|
1018
1282
|
it('uses explicit service when declared', () => {
|
|
1019
1283
|
const root = opRoot([opRoute('/users', [opOperation('post', { service: 'LedgerService.updateNesting' })])]);
|