@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/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
- luxon: coveredRoots.some(
761
- r => rootNeedsScalar(r, 'datetime') || rootNeedsScalar(r, 'date') || rootNeedsScalar(r, 'time') || rootNeedsScalar(r, 'interval'),
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: 'decimal' } as any)).toThrow(/unmapped scalar 'decimal'/);
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
- // A response body is an annotation, not a schema — the handler never validates it,
953
- // so declaring the helper would leave it unused and trip `noUnusedLocals`.
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' })])]);