@contractkit/plugin-typescript 0.31.2 → 0.32.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.31.2",
3
+ "version": "0.32.0",
4
4
  "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -27,11 +27,11 @@
27
27
  ".": "./dist/index.js"
28
28
  },
29
29
  "dependencies": {
30
- "@contractkit/core": "0.26.1"
30
+ "@contractkit/core": "0.27.0"
31
31
  },
32
32
  "devDependencies": {
33
- "@repo/config-typescript": "0.1.0",
34
- "@repo/config-eslint": "0.3.1"
33
+ "@repo/config-eslint": "0.3.1",
34
+ "@repo/config-typescript": "0.1.0"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsup src/index.ts --format esm --sourcemap --dts && tsc --emitDeclarationOnly --declaration",
@@ -132,6 +132,18 @@ export interface OpCodegenOptions {
132
132
  * from the generated router entirely.
133
133
  */
134
134
  includeInternal?: boolean;
135
+ /**
136
+ * Re-parse the service result through its declared response schema before writing `ctx.body`,
137
+ * and write the parsed value. Requires the type file to hold Zod schemas (`server.zod`) —
138
+ * plain interfaces are types, with no runtime schema value to validate against. Default false.
139
+ */
140
+ validateResponses?: boolean;
141
+ /**
142
+ * Set of model names whose schema applies a `format(...)` key transform, directly or through a
143
+ * referenced model. Response bodies touching one are left unvalidated: the service returns the
144
+ * post-transform shape, which the schema itself cannot re-parse.
145
+ */
146
+ modelsWithTransform?: Set<string>;
135
147
  }
136
148
 
137
149
  /**
@@ -212,13 +224,20 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
212
224
  body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
213
225
  }
214
226
 
215
- for (const svc of services) {
227
+ // Services and model names come from the AST, which over-approximates two ways: a model with an
228
+ // Input/Output variant contributes its base name even when only the variant is ever annotated,
229
+ // and `collectServices`/`collectTypes` walk every operation including the `internal` ones
230
+ // `includeInternal: false` drops. Filtering through `uses` — the same gate every symbol above
231
+ // goes through — keeps the import list to what the handlers actually reference, so generated
232
+ // code does not trip `noUnusedLocals` in the consuming project.
233
+ for (const svc of services.filter(uses)) {
216
234
  const modulePath = root.services?.[svc] ?? root.meta[svc] ?? deriveModulePath(svc, options.servicePathTemplate);
217
235
  body.push(`import { ${svc} } from '${modulePath}';`);
218
236
  }
219
237
 
220
- if (types.length > 0) {
221
- body.push(...generateTypeImports(types, root.file, options));
238
+ const usedTypes = types.filter(uses);
239
+ if (usedTypes.length > 0) {
240
+ body.push(...generateTypeImports(usedTypes, root.file, options));
222
241
  }
223
242
 
224
243
  // luxon is needed for date/time/datetime (DateTime), duration (Duration) and interval (Interval);
@@ -383,9 +402,12 @@ function generateSingleStatusResult(
383
402
  const hasRespHeaders = respHeaders.length > 0;
384
403
  const headersAnnotation = hasRespHeaders ? renderHeadersAnnotation(respHeaders, options.modelsWithOutput) : '';
385
404
 
405
+ let bodySchema: string | undefined;
406
+
386
407
  if (bodies.length === 1) {
387
408
  const { annotation, prelude } = formatTypeAnnotation(bodies[0]!.bodyType, options.modelsWithOutput);
388
409
  if (prelude) lines.push(` ${prelude}`);
410
+ bodySchema = responseBodySchema(bodies[0]!.bodyType, options, prelude ? 'resultType' : undefined);
389
411
  lines.push(` const service = ctx.container.get(${className});`);
390
412
  if (hasRespHeaders) {
391
413
  lines.push(` const result: { body: ${annotation}; headers: ${headersAnnotation} } = ${call};`);
@@ -393,7 +415,9 @@ function generateSingleStatusResult(
393
415
  lines.push(` const result: ${annotation} = ${call};`);
394
416
  }
395
417
  } else if (bodies.length > 1) {
396
- const { members, preludes } = renderResponseMembers(resp!, options, { includeStatus: false, varPrefix: 'result' });
418
+ const rendered = renderResponseMembers(resp!, options, { includeStatus: false, varPrefix: 'result' });
419
+ const { members, preludes } = rendered;
420
+ bodySchema = rendered.bodySchema;
397
421
  for (const prelude of preludes) lines.push(` ${prelude}`);
398
422
  lines.push(` const service = ctx.container.get(${className});`);
399
423
  lines.push(` const result: ${members.join(' | ')} = ${call};`);
@@ -414,10 +438,10 @@ function generateSingleStatusResult(
414
438
 
415
439
  if (bodies.length === 1) {
416
440
  lines.push(` ctx.type = '${bodies[0]!.contentType}';`);
417
- lines.push(` ctx.body = ${hasRespHeaders ? 'result.body' : 'result'};`);
441
+ lines.push(` ctx.body = ${responseBodyExpr(hasRespHeaders ? 'result.body' : 'result', bodySchema)};`);
418
442
  } else if (bodies.length > 1) {
419
443
  lines.push(` ctx.type = result.contentType;`);
420
- lines.push(` ctx.body = result.body;`);
444
+ lines.push(` ctx.body = ${responseBodyExpr('result.body', bodySchema)};`);
421
445
  }
422
446
 
423
447
  return lines;
@@ -433,10 +457,13 @@ function generateMultiStatusResult(emitted: OpResponseNode[], className: string,
433
457
  const members: string[] = [];
434
458
  const preludes: string[] = [];
435
459
 
460
+ const bodySchemas = new Map<number, string | undefined>();
461
+
436
462
  for (const resp of emitted) {
437
463
  const rendered = renderResponseMembers(resp, options, { includeStatus: true, varPrefix: `result${resp.statusCode}` });
438
464
  members.push(...rendered.members);
439
465
  preludes.push(...rendered.preludes);
466
+ bodySchemas.set(resp.statusCode, rendered.bodySchema);
440
467
  }
441
468
 
442
469
  for (const prelude of preludes) lines.push(` ${prelude}`);
@@ -452,7 +479,7 @@ function generateMultiStatusResult(emitted: OpResponseNode[], className: string,
452
479
  lines.push(...headerSetLines(resp.headers ?? [], ' '));
453
480
  if (resp.bodies.length > 0) {
454
481
  lines.push(` ctx.type = result.contentType;`);
455
- lines.push(` ctx.body = result.body;`);
482
+ lines.push(` ctx.body = ${responseBodyExpr('result.body', bodySchemas.get(resp.statusCode))};`);
456
483
  }
457
484
  lines.push(` break;`);
458
485
  }
@@ -473,7 +500,7 @@ function renderResponseMembers(
473
500
  resp: OpResponseNode,
474
501
  options: OpCodegenOptions,
475
502
  opts: { includeStatus: boolean; varPrefix: string },
476
- ): { members: string[]; preludes: string[] } {
503
+ ): { members: string[]; preludes: string[]; bodySchema?: string } {
477
504
  const bodies = resp.bodies;
478
505
  const headers = resp.headers ?? [];
479
506
  const leading = opts.includeStatus ? [`status: ${resp.statusCode}`] : [];
@@ -488,10 +515,18 @@ function renderResponseMembers(
488
515
  if (uniform) {
489
516
  const { annotation, prelude } = formatTypeAnnotation(bodies[0]!.bodyType, options.modelsWithOutput, `${opts.varPrefix}Type`);
490
517
  if (prelude) preludes.push(prelude);
518
+ const bodySchema = responseBodySchema(bodies[0]!.bodyType, options, prelude ? `${opts.varPrefix}Type` : undefined);
491
519
  const contentType = bodies.map(b => `'${b.contentType}'`).join(' | ');
492
- return { members: [`{ ${[...leading, `contentType: ${contentType}`, `body: ${annotation}`, ...trailing].join('; ')} }`], preludes };
520
+ return {
521
+ members: [`{ ${[...leading, `contentType: ${contentType}`, `body: ${annotation}`, ...trailing].join('; ')} }`],
522
+ preludes,
523
+ bodySchema,
524
+ };
493
525
  }
494
526
 
527
+ // One member per mime: `contentType` and `body` stay correlated, so validating would need a
528
+ // second switch on `result.contentType` nested inside the status switch. Left unvalidated —
529
+ // note the absent `bodySchema` in the return below.
495
530
  const members = bodies.map((b, i) => {
496
531
  const { annotation, prelude } = formatTypeAnnotation(b.bodyType, options.modelsWithOutput, `${opts.varPrefix}Type${i}`);
497
532
  if (prelude) preludes.push(prelude);
@@ -660,6 +695,71 @@ function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set
660
695
  };
661
696
  }
662
697
 
698
+ /**
699
+ * Whether a response body can be soundly re-parsed through the schema `renderType` emits for it.
700
+ *
701
+ * False for anything transitively touching a model with a `format(...)` key transform — the service
702
+ * returns the post-transform value while the schema expects the pre-transform one — and for an
703
+ * intersection outside `renderIntersection`'s `.extend()` fast path, where `.and()` of two strict
704
+ * objects rejects every value because each side sees the other's keys as unrecognized.
705
+ */
706
+ function isRevalidatable(type: ContractTypeNode, modelsWithOutput?: Set<string>, modelsWithTransform?: Set<string>): boolean {
707
+ const rec = (t: ContractTypeNode): boolean => isRevalidatable(t, modelsWithOutput, modelsWithTransform);
708
+ switch (type.kind) {
709
+ case 'ref':
710
+ return !modelsWithOutput?.has(type.name) && !modelsWithTransform?.has(type.name);
711
+ case 'array':
712
+ return rec(type.item);
713
+ case 'tuple':
714
+ return type.items.every(rec);
715
+ case 'record':
716
+ return rec(type.key) && rec(type.value);
717
+ case 'intersection': {
718
+ // Mirrors renderIntersection: a lone member renders as itself, `ref & (ref|object)*`
719
+ // renders as an `.extend()` chain, and anything else falls back to `.and()`.
720
+ const [first, ...rest] = type.members;
721
+ if (!first) return true;
722
+ if (rest.length === 0) return rec(first);
723
+ const usesExtendChain = first.kind === 'ref' && rest.every(m => m.kind === 'ref' || m.kind === 'inlineObject');
724
+ return usesExtendChain && type.members.every(rec);
725
+ }
726
+ case 'union':
727
+ case 'discriminatedUnion':
728
+ return type.members.every(rec);
729
+ case 'inlineObject':
730
+ return type.fields.every(f => rec(f.type));
731
+ case 'lazy':
732
+ return rec(type.inner);
733
+ default:
734
+ // scalar, enum, literal — identity or idempotent under re-parse
735
+ return true;
736
+ }
737
+ }
738
+
739
+ /**
740
+ * The runtime schema a handler validates a response body against, or `undefined` when the body
741
+ * cannot be soundly re-parsed and validation must be skipped.
742
+ *
743
+ * @param preludeVar The schema const {@link formatTypeAnnotation} already emitted for this body
744
+ * (complex types only — `undefined` when it returned no prelude). Reusing it keeps a large object
745
+ * literal from appearing twice in the same handler.
746
+ */
747
+ function responseBodySchema(bodyType: ContractTypeNode, options: OpCodegenOptions, preludeVar: string | undefined): string | undefined {
748
+ if (!options.validateResponses) return undefined;
749
+ if (!isRevalidatable(bodyType, options.modelsWithOutput, options.modelsWithTransform)) return undefined;
750
+ return preludeVar ?? renderType(bodyType);
751
+ }
752
+
753
+ /**
754
+ * The `ctx.body = ...` right-hand side for a response body: the raw result expression, or a
755
+ * `parseAndValidate` of it. The `500` is deliberate — a service returning a shape its own contract
756
+ * rejects is a server fault, not a client one, and `@maroonedsoftware/zod` routes the field-level
757
+ * detail to `internalDetails` (log-only) rather than the response body at 5xx.
758
+ */
759
+ function responseBodyExpr(value: string, schema: string | undefined): string {
760
+ return schema ? `await parseAndValidate(${value}, ${schema}, 500)` : value;
761
+ }
762
+
663
763
  function generateParamValidation(
664
764
  source: ParamSource | undefined,
665
765
  ctxExpr: string,
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  hashFingerprint,
21
21
  collectTransitiveModelRefs,
22
22
  collectTypeRefs,
23
+ computeModelsWithCaseTransform,
23
24
  } from '@contractkit/core';
24
25
  import {
25
26
  generateSdk,
@@ -73,6 +74,15 @@ export interface ServerConfig {
73
74
  servicePathTemplate?: string;
74
75
  /** Whether to emit handlers for `internal` operations. Default true. */
75
76
  includeInternal?: boolean;
77
+ /**
78
+ * When true, each handler re-parses the service result through its declared response schema
79
+ * before writing `ctx.body`, and writes the parsed value. Requires `zod: true` — without it
80
+ * `output.types` emits plain interfaces, which are types with no runtime schema value.
81
+ *
82
+ * A body that transitively references a model with `format(input=...)`/`format(output=...)`, and
83
+ * a status whose several mimes carry different body types, are left unvalidated. Default false.
84
+ */
85
+ validateResponses?: boolean;
76
86
  }
77
87
 
78
88
  /** TypeScript SDK client output: the client class, per-area operation clients, and their types. */
@@ -182,6 +192,15 @@ export function createTypescriptPlugin(config: TypescriptPluginConfig, rootDir:
182
192
  };
183
193
  }
184
194
 
195
+ /** Reject config combinations that would generate code that cannot compile or cannot run. */
196
+ function assertValidConfig(config: TypescriptPluginConfig): void {
197
+ if (config.server?.validateResponses && !config.server.zod) {
198
+ throw new Error(
199
+ '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.',
200
+ );
201
+ }
202
+ }
203
+
185
204
  /**
186
205
  * Shared orchestration. Each sub-generator (server / sdk / zod / types) contributes a
187
206
  * set of cacheable units (per-file fingerprints) plus a set of always-regenerated global
@@ -196,6 +215,7 @@ async function runTypescriptCodegen(
196
215
  config: TypescriptPluginConfig,
197
216
  rootDir: string,
198
217
  ): Promise<void> {
218
+ assertValidConfig(config);
199
219
  const manifestPath = resolve(ctx.cacheDir, CACHE_MANIFEST_FILENAME);
200
220
  const prevManifest: IncrementalManifest = ctx.cacheEnabled ? readManifest(manifestPath) : emptyIncrementalManifest(TYPESCRIPT_CODEGEN_VERSION);
201
221
 
@@ -322,6 +342,10 @@ function collectServerOutput(
322
342
  const serverBase = resolve(rootDir, config.baseDir ?? '.');
323
343
  const modelsWithInput = inputs.modelsWithInput as Set<string>;
324
344
  const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
345
+ // Not `modelsWithOutput`: that set seeds only from `format(output=...)`, because only that case
346
+ // needs an `Output` type alias. A `format(input=...)`-only model is just as untouchable for
347
+ // response validation — its schema's input casing is not what the service hands back.
348
+ const modelsWithTransform = computeModelsWithCaseTransform(inputs.contractRoots.flatMap(r => r.models));
325
349
  const modelMap = buildModelMap(inputs.contractRoots);
326
350
  const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
327
351
  const commonRoot = commonDir(allFiles, rootDir);
@@ -391,6 +415,10 @@ function collectServerOutput(
391
415
  modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
392
416
  servicePathTemplate: config.servicePathTemplate ?? null,
393
417
  includeInternal: config.includeInternal ?? true,
418
+ // Not covered by `sub`: adding `format(input=snake)` to a *different* .ck file changes
419
+ // this router's output with no change to `root` or the config.
420
+ modelsWithTransform: sliceModelSet(refs, new Set(), modelsWithTransform),
421
+ validateResponses: config.validateResponses ?? false,
394
422
  sub: subConfigKey,
395
423
  });
396
424
  units.push({
@@ -405,7 +433,9 @@ function collectServerOutput(
405
433
  modelOutPaths: serverModelOutPaths,
406
434
  modelsWithInput,
407
435
  modelsWithOutput,
436
+ modelsWithTransform,
408
437
  includeInternal: config.includeInternal,
438
+ validateResponses: config.validateResponses,
409
439
  }),
410
440
  },
411
441
  ],
@@ -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,190 @@ 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
+ // A service returning a shape its own contract rejects is a server fault, not a client one.
1099
+ // 500 also diverts the field-level detail to `internalDetails`, keeping it off the wire.
1100
+ it('throws 500, not the parseAndValidate default of 400', () => {
1101
+ const output = generateOp(respRoot('User'), V);
1102
+ expect(output).toContain('parseAndValidate(result, User, 500)');
1103
+ expect(output).not.toContain('parseAndValidate(result, User)');
1104
+ });
1105
+
1106
+ it('wraps an array response body in z.array', () => {
1107
+ const root = respRoot(arrayType(refType('User')));
1108
+ expect(generateOp(root, V)).toContain('ctx.body = await parseAndValidate(result, z.array(User), 500);');
1109
+ });
1110
+
1111
+ it('validates a scalar response body against the rendered scalar schema', () => {
1112
+ const root = respRoot(scalarType('string'), 'text/plain');
1113
+ expect(generateOp(root, V)).toContain('ctx.body = await parseAndValidate(result, z.string(), 500);');
1114
+ });
1115
+
1116
+ it('reuses the extracted schema const for a complex inline body', () => {
1117
+ const root = respRoot(inlineObjectType([field('total', scalarType('int'))]));
1118
+ const output = generateOp(root, V);
1119
+ expect(output).toContain('const resultType = z.strictObject({');
1120
+ expect(output).toContain('ctx.body = await parseAndValidate(result, resultType, 500);');
1121
+ });
1122
+
1123
+ it('validates result.body when the status declares headers', () => {
1124
+ const root = opRoot([
1125
+ opRoute('/users', [
1126
+ opOperation('get', {
1127
+ responses: [
1128
+ {
1129
+ ...opResponse(200, 'User', 'application/json'),
1130
+ headers: [{ name: 'etag', optional: false, type: scalarType('string') }],
1131
+ },
1132
+ ],
1133
+ }),
1134
+ ]),
1135
+ ]);
1136
+ const output = generateOp(root, V);
1137
+ expect(output).toContain("ctx.set('etag', String(result.headers[\"etag\"]));");
1138
+ expect(output).toContain('ctx.body = await parseAndValidate(result.body, User, 500);');
1139
+ });
1140
+
1141
+ it('validates a single schema when several mimes share a body shape', () => {
1142
+ const root = opRoot([
1143
+ opRoute('/art', [
1144
+ opOperation('get', {
1145
+ responses: [
1146
+ opResponseMulti(200, [
1147
+ { contentType: 'image/png', bodyType: scalarType('binary') },
1148
+ { contentType: 'image/jpeg', bodyType: scalarType('binary') },
1149
+ ]),
1150
+ ],
1151
+ }),
1152
+ ]),
1153
+ ]);
1154
+ const output = generateOp(root, V);
1155
+ expect(output).toContain('ctx.body = await parseAndValidate(result.body, _ZodBinary, 500);');
1156
+ // The helper and the zod import are gated on the generated text, so both follow.
1157
+ expect(output).toContain('const _ZodBinary = z.custom<Buffer>');
1158
+ expect(output).toContain("import { z } from 'zod';");
1159
+ });
1160
+
1161
+ it('validates each status body in a multi-status switch', () => {
1162
+ const root = opRoot([
1163
+ opRoute('/art', [
1164
+ opOperation('get', {
1165
+ responses: [opResponse(200, 'Art', 'application/json'), opResponse(202, 'JobRef', 'application/json')],
1166
+ }),
1167
+ ]),
1168
+ ]);
1169
+ const output = generateOp(root, V);
1170
+ expect(output).toMatch(/case 200:[\s\S]*?ctx\.body = await parseAndValidate\(result\.body, Art, 500\);/);
1171
+ expect(output).toMatch(/case 202:[\s\S]*?ctx\.body = await parseAndValidate\(result\.body, JobRef, 500\);/);
1172
+ });
1173
+
1174
+ it('imports parseAndValidate for an operation with no request to validate', () => {
1175
+ expect(generateOp(respRoot('User'), V)).toContain("import { parseAndValidate } from '@maroonedsoftware/zod';");
1176
+ });
1177
+
1178
+ it('imports Interval once an interval body is validated', () => {
1179
+ const output = generateOp(respRoot(scalarType('interval')), V);
1180
+ expect(output).toContain("import { Interval } from 'luxon';");
1181
+ expect(output).toContain('parseAndValidate(result, _ZodInterval, 500)');
1182
+ });
1183
+
1184
+ it('writes no body for a bodyless status', () => {
1185
+ const root = opRoot([opRoute('/users', [opOperation('delete', { responses: [opResponse(204)] })])]);
1186
+ expect(generateOp(root, V)).not.toContain('ctx.body');
1187
+ });
1188
+
1189
+ // ── Bodies the schema cannot soundly re-parse ──
1190
+
1191
+ it('leaves a body unvalidated when the model has an Output variant', () => {
1192
+ const output = generateOp(respRoot('AuthToken'), { ...V, modelsWithOutput: new Set(['AuthToken']) });
1193
+ expect(output).toContain('ctx.body = result;');
1194
+ expect(output).not.toContain('parseAndValidate(result');
1195
+ });
1196
+
1197
+ it('leaves an array unvalidated when the item model has an Output variant', () => {
1198
+ const root = respRoot(arrayType(refType('AuthToken')));
1199
+ const output = generateOp(root, { ...V, modelsWithOutput: new Set(['AuthToken']) });
1200
+ expect(output).toContain('ctx.body = result;');
1201
+ expect(output).not.toContain('parseAndValidate(result');
1202
+ });
1203
+
1204
+ // `format(input=snake)` alone never reaches `modelsWithOutput` — that set seeds only from
1205
+ // `outputCase` — but its schema is just as much a transform pipe, so re-parsing what the
1206
+ // service returns would fail on every key. Regression guard for exactly that gap.
1207
+ it('leaves a body unvalidated when the model has a format() key transform', () => {
1208
+ const output = generateOp(respRoot('User'), { ...V, modelsWithTransform: new Set(['User']) });
1209
+ expect(output).toContain('ctx.body = result;');
1210
+ expect(output).not.toContain('parseAndValidate(result');
1211
+ });
1212
+
1213
+ it('skips an inline object whose field references a transform model', () => {
1214
+ const root = respRoot(inlineObjectType([field('token', refType('AuthToken'))]));
1215
+ const output = generateOp(root, { ...V, modelsWithOutput: new Set(['AuthToken']) });
1216
+ expect(output).not.toContain('parseAndValidate(result');
1217
+ });
1218
+
1219
+ it('skips a multi-status case whose model has an Output variant, validating the others', () => {
1220
+ const root = opRoot([
1221
+ opRoute('/art', [
1222
+ opOperation('get', {
1223
+ responses: [opResponse(200, 'Art', 'application/json'), opResponse(202, 'AuthToken', 'application/json')],
1224
+ }),
1225
+ ]),
1226
+ ]);
1227
+ const output = generateOp(root, { ...V, modelsWithOutput: new Set(['AuthToken']) });
1228
+ expect(output).toMatch(/case 200:[\s\S]*?ctx\.body = await parseAndValidate\(result\.body, Art, 500\);/);
1229
+ expect(output).toMatch(/case 202:[\s\S]*?ctx\.body = result\.body;/);
1230
+ });
1231
+
1232
+ it('skips validation when the mimes carry different body types', () => {
1233
+ const root = opRoot([
1234
+ opRoute('/pets', [
1235
+ opOperation('get', {
1236
+ responses: [
1237
+ opResponseMulti(200, [
1238
+ { contentType: 'application/json', bodyType: 'Pet' },
1239
+ { contentType: 'text/csv', bodyType: scalarType('string') },
1240
+ ]),
1241
+ ],
1242
+ }),
1243
+ ]),
1244
+ ]);
1245
+ const output = generateOp(root, V);
1246
+ expect(output).toContain('ctx.body = result.body;');
1247
+ expect(output).not.toContain('parseAndValidate(result.body');
1248
+ });
1249
+
1250
+ // renderIntersection falls back to `.and()` outside its `.extend()` fast path, and `.and()`
1251
+ // of two strict objects rejects every value — each side sees the other's keys as unknown.
1252
+ it('skips an intersection that renders through .and()', () => {
1253
+ const root = respRoot(
1254
+ intersectionType(inlineObjectType([field('a', scalarType('string'))]), inlineObjectType([field('b', scalarType('string'))])),
1255
+ );
1256
+ expect(generateOp(root, V)).not.toContain('parseAndValidate(result');
1257
+ });
1258
+
1259
+ it('validates an intersection that renders through .extend()', () => {
1260
+ const root = respRoot(intersectionType(refType('Base'), inlineObjectType([field('extra', scalarType('string'))])));
1261
+ const output = generateOp(root, V);
1262
+ expect(output).toContain('const resultType = Base.extend({');
1263
+ expect(output).toContain('ctx.body = await parseAndValidate(result, resultType, 500);');
1264
+ });
1265
+ });
1266
+
1017
1267
  describe('service inference', () => {
1018
1268
  it('uses explicit service when declared', () => {
1019
1269
  const root = opRoot([opRoute('/users', [opOperation('post', { service: 'LedgerService.updateNesting' })])]);
@@ -216,6 +216,54 @@ describe('createTypescriptPlugin (server)', () => {
216
216
  });
217
217
  });
218
218
 
219
+ describe('validateResponses', () => {
220
+ const userRoot = () =>
221
+ opRoot(
222
+ [opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])],
223
+ '/project/contracts/users.ck',
224
+ );
225
+
226
+ it('throws when validateResponses is set without zod', async () => {
227
+ const plugin = createTypescriptPlugin({ server: { validateResponses: true } }, '/project');
228
+ await expect(plugin.generateTargets!(inputs(), makeCtx('/project'))).rejects.toThrow(/validateResponses requires server\.zod/);
229
+ });
230
+
231
+ it('accepts validateResponses alongside zod', async () => {
232
+ const plugin = createTypescriptPlugin({ server: { zod: true, validateResponses: true } }, '/project');
233
+ await expect(plugin.generateTargets!(inputs(), makeCtx('/project'))).resolves.toBeUndefined();
234
+ });
235
+
236
+ it('threads validateResponses into the generated router', async () => {
237
+ const plugin = createTypescriptPlugin({ server: { zod: true, validateResponses: true } }, '/project');
238
+ const ctx = makeCtx('/project');
239
+ await plugin.generateTargets!(inputs([userRoot()]), ctx);
240
+ const router = [...ctx.emitted.entries()].find(([p]) => p.endsWith('.router.ts'))![1];
241
+ expect(router).toContain('ctx.body = await parseAndValidate(result, User, 500);');
242
+ });
243
+
244
+ it('leaves routers unvalidated without the flag', async () => {
245
+ const plugin = createTypescriptPlugin({ server: { zod: true } }, '/project');
246
+ const ctx = makeCtx('/project');
247
+ await plugin.generateTargets!(inputs([userRoot()]), ctx);
248
+ const router = [...ctx.emitted.entries()].find(([p]) => p.endsWith('.router.ts'))![1];
249
+ expect(router).toContain('ctx.body = result;');
250
+ });
251
+
252
+ // The set is derived from the contract roots, not handed in via `inputs`, so a
253
+ // `format(input=...)` model reaches the router codegen and suppresses its validation.
254
+ it('derives modelsWithTransform from the contract roots', async () => {
255
+ const plugin = createTypescriptPlugin({ server: { zod: true, validateResponses: true } }, '/project');
256
+ const ctx = makeCtx('/project');
257
+ const contracts = [
258
+ contractRoot([model('User', [field('id', scalarType('uuid'))], { inputCase: 'snake' })], '/project/contracts/users.ck'),
259
+ ];
260
+ await plugin.generateTargets!({ ...inputs([userRoot()]), contractRoots: contracts } as never, ctx);
261
+ const router = [...ctx.emitted.entries()].find(([p]) => p.endsWith('.router.ts'))![1];
262
+ expect(router).toContain('ctx.body = result;');
263
+ expect(router).not.toContain('parseAndValidate(result');
264
+ });
265
+ });
266
+
219
267
  describe('default plugin export', () => {
220
268
  it('plugin has name "typescript"', async () => {
221
269
  const { default: plugin } = await import('../src/index.js');