@contractkit/plugin-typescript 0.31.1 → 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/.turbo/turbo-build$colon$ci.log +5 -5
- package/.turbo/turbo-test$colon$ci.log +18 -18
- package/CHANGELOG.md +91 -0
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/codegen-operation.d.ts +12 -0
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +73 -10
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/codegen-operation.ts +109 -9
- package/src/index.ts +30 -0
- package/tests/codegen-operation.test.ts +252 -2
- package/tests/codegen-server.test.ts +48 -0
- package/tests/helpers.ts +5 -0
- package/tests/pipeline.test.ts +11 -2
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contractkit/plugin-typescript",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
|
|
5
|
+
"license": "MIT",
|
|
5
6
|
"author": {
|
|
6
7
|
"name": "Marooned Software",
|
|
7
8
|
"url": "https://github.com/MaroonedSoftware/contractkit"
|
|
@@ -26,7 +27,7 @@
|
|
|
26
27
|
".": "./dist/index.js"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
|
-
"@contractkit/core": "0.
|
|
30
|
+
"@contractkit/core": "0.27.0"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"@repo/config-eslint": "0.3.1",
|
package/src/codegen-operation.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
|
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 {
|
|
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
|
-
//
|
|
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,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');
|
package/tests/helpers.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
LiteralTypeNode,
|
|
12
12
|
UnionTypeNode,
|
|
13
13
|
DiscriminatedUnionTypeNode,
|
|
14
|
+
IntersectionTypeNode,
|
|
14
15
|
ModelRefTypeNode,
|
|
15
16
|
InlineObjectTypeNode,
|
|
16
17
|
LazyTypeNode,
|
|
@@ -64,6 +65,10 @@ export function discriminatedUnionType(discriminator: string, ...members: Contra
|
|
|
64
65
|
return { kind: 'discriminatedUnion', discriminator, members };
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
export function intersectionType(...members: ContractTypeNode[]): IntersectionTypeNode {
|
|
69
|
+
return { kind: 'intersection', members };
|
|
70
|
+
}
|
|
71
|
+
|
|
67
72
|
export function refType(name: string): ModelRefTypeNode {
|
|
68
73
|
return { kind: 'ref', name };
|
|
69
74
|
}
|