@contractkit/plugin-typescript 0.31.2 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$ci.log +5 -5
- package/.turbo/turbo-test$colon$ci.log +23 -21
- package/CHANGELOG.md +166 -0
- package/README.md +8 -0
- package/dist/codegen-contract.d.ts +7 -0
- package/dist/codegen-contract.d.ts.map +1 -1
- package/dist/codegen-mcp.d.ts.map +1 -1
- package/dist/codegen-operation.d.ts +12 -0
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/codegen-plain-types.d.ts.map +1 -1
- package/dist/codegen-revive.d.ts +42 -0
- package/dist/codegen-revive.d.ts.map +1 -0
- package/dist/codegen-sdk.d.ts +18 -6
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/decimal-runtime.d.ts +47 -0
- package/dist/decimal-runtime.d.ts.map +1 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +610 -49
- package/dist/index.js.map +1 -1
- package/dist/ts-render.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen-contract.ts +58 -3
- package/src/codegen-mcp.ts +5 -0
- package/src/codegen-operation.ts +132 -10
- package/src/codegen-plain-types.ts +42 -2
- package/src/codegen-revive.ts +304 -0
- package/src/codegen-sdk.ts +248 -37
- package/src/decimal-runtime.ts +50 -0
- package/src/index.ts +60 -3
- package/src/ts-render.ts +6 -0
- package/tests/codegen-contract.test.ts +124 -1
- package/tests/codegen-operation.test.ts +266 -2
- package/tests/codegen-sdk.test.ts +77 -6
- package/tests/codegen-server.test.ts +48 -0
- package/tests/helpers.ts +5 -0
- package/tests/pipeline.test.ts +35 -2
package/dist/ts-render.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,qFAAqF;AACrF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CAoC9F;
|
|
1
|
+
{"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,qFAAqF;AACrF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CAoC9F;AAsDD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BlI;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BpI"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contractkit/plugin-typescript",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.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,7 +27,7 @@
|
|
|
27
27
|
".": "./dist/index.js"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@contractkit/core": "0.
|
|
30
|
+
"@contractkit/core": "0.28.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@repo/config-typescript": "0.1.0",
|
package/src/codegen-contract.ts
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
} from '@contractkit/core';
|
|
24
24
|
import { escapeJsDocLines } from './ts-render.js';
|
|
25
25
|
import type { TsRenderTarget } from './ts-render.js';
|
|
26
|
+
import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
|
|
27
|
+
import { renderReviveFunctions, reviveFnName, DECIMAL_COERCE_DECL } from './codegen-revive.js';
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
30
|
* Maps a ContractKit object mode to its Zod constructor name.
|
|
@@ -60,6 +62,13 @@ export interface ContractCodegenContext {
|
|
|
60
62
|
* `generateContract`, whose Zod schemas are server-shaped by construction. Default `'client'`.
|
|
61
63
|
*/
|
|
62
64
|
target?: TsRenderTarget;
|
|
65
|
+
/** Model names that carry a `decimal`, directly or transitively. */
|
|
66
|
+
modelsWithDecimal?: Set<string>;
|
|
67
|
+
/**
|
|
68
|
+
* Emit `reviveX()` hydration functions alongside the schemas. Set only for SDK type files: a
|
|
69
|
+
* server handler receives decimals already parsed by `_ZodDecimal`, so it has nothing to revive.
|
|
70
|
+
*/
|
|
71
|
+
emitRevivers?: boolean;
|
|
63
72
|
}
|
|
64
73
|
|
|
65
74
|
// ─── Public entry point ────────────────────────────────────────────────────
|
|
@@ -142,6 +151,7 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
|
|
|
142
151
|
const needsBinary = rootNeedsScalar(root, 'binary');
|
|
143
152
|
const needsDatetime = rootNeedsScalar(root, 'datetime');
|
|
144
153
|
const needsJson = rootNeedsScalar(root, 'json');
|
|
154
|
+
const needsDecimal = rootNeedsScalar(root, 'decimal');
|
|
145
155
|
const externalRefs = collectExternalRefs(root);
|
|
146
156
|
const lines: string[] = [];
|
|
147
157
|
|
|
@@ -166,9 +176,14 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
|
|
|
166
176
|
if (needsDuration) luxonImports.push('Duration');
|
|
167
177
|
if (needsInterval) luxonImports.push('Interval');
|
|
168
178
|
if (luxonImports.length > 0) lines.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
|
|
179
|
+
if (needsDecimal) lines.push(DECIMAL_IMPORT);
|
|
169
180
|
for (const ref of allExternalRefs) {
|
|
170
181
|
const importPath = resolveImportPath(ref, context);
|
|
171
|
-
|
|
182
|
+
// A cross-file model that carries a decimal contributes its reviver too — the local
|
|
183
|
+
// reviver calls it rather than re-deriving the other file's shape.
|
|
184
|
+
const names =
|
|
185
|
+
context?.emitRevivers && context.modelsWithDecimal?.has(ref) ? `${ref}, ${reviveFnName(ref)}` : ref;
|
|
186
|
+
lines.push(`import { ${names} } from '${importPath}';`);
|
|
172
187
|
}
|
|
173
188
|
lines.push('');
|
|
174
189
|
if (needsBinary) {
|
|
@@ -184,21 +199,45 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
|
|
|
184
199
|
`const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
|
|
185
200
|
);
|
|
186
201
|
}
|
|
202
|
+
if (needsDecimal) {
|
|
203
|
+
lines.push(...DECIMAL_PRELUDE_LINES);
|
|
204
|
+
}
|
|
187
205
|
if (needsJson) {
|
|
188
206
|
lines.push(`type _JsonValue = string | number | boolean | null | _JsonValue[] | { [key: string]: _JsonValue };`);
|
|
189
207
|
lines.push(
|
|
190
208
|
`const _ZodJson: z.ZodType<_JsonValue> = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(_ZodJson), z.record(z.string(), _ZodJson)]));`,
|
|
191
209
|
);
|
|
192
210
|
}
|
|
193
|
-
if (needsBinary || needsDatetime || needsInterval || needsJson) lines.push('');
|
|
211
|
+
if (needsBinary || needsDatetime || needsInterval || needsDecimal || needsJson) lines.push('');
|
|
194
212
|
|
|
195
213
|
const modelsWithWriteonly = new Set(root.models.filter(m => m.fields.some(f => f.visibility === 'writeonly')).map(m => m.name));
|
|
196
214
|
const modelMap = new Map(root.models.map(m => [m.name, m]));
|
|
197
215
|
|
|
216
|
+
const reviveOpts =
|
|
217
|
+
context?.emitRevivers && context.modelsWithDecimal
|
|
218
|
+
? { modelsWithDecimal: context.modelsWithDecimal, modelsWithOutput: allModelsWithOutput, modelMap }
|
|
219
|
+
: undefined;
|
|
220
|
+
|
|
221
|
+
const bodyLines: string[] = [];
|
|
198
222
|
for (const model of topoSortModels(root.models)) {
|
|
199
|
-
|
|
223
|
+
bodyLines.push(...generateModel(model, context?.currentOutPath, allModelsWithInput, modelsWithWriteonly, modelMap, allModelsWithOutput));
|
|
224
|
+
if (reviveOpts) {
|
|
225
|
+
const revivers = renderReviveFunctions(model, reviveOpts);
|
|
226
|
+
if (revivers.length > 0) {
|
|
227
|
+
bodyLines.push('');
|
|
228
|
+
bodyLines.push(...revivers);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
bodyLines.push('');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Decided from the emitted revivers rather than from a predicate over the AST, so the
|
|
235
|
+
// declaration and its uses cannot drift apart and leave an unused local behind.
|
|
236
|
+
if (bodyLines.some(l => l.includes('__dec('))) {
|
|
237
|
+
lines.push(...DECIMAL_COERCE_DECL);
|
|
200
238
|
lines.push('');
|
|
201
239
|
}
|
|
240
|
+
lines.push(...bodyLines);
|
|
202
241
|
|
|
203
242
|
return lines.join('\n');
|
|
204
243
|
}
|
|
@@ -631,6 +670,22 @@ function renderScalar(s: ScalarTypeNode): string {
|
|
|
631
670
|
if (s.max !== undefined) inner += `.max(${s.max}n)`;
|
|
632
671
|
return `z.preprocess((val) => typeof val === 'string' ? BigInt(val.replace(/n$/, '')) : val, ${inner})`;
|
|
633
672
|
}
|
|
673
|
+
case 'decimal': {
|
|
674
|
+
// Deliberately no output `.transform()`: `isRevalidatable` in codegen-operation treats
|
|
675
|
+
// every scalar as idempotent under re-parse, which `server.validateResponses` relies on.
|
|
676
|
+
// Preprocess passes an already-`Decimal` value straight through, so parse(parse(x)) is
|
|
677
|
+
// stable. Modelling this on `_ZodInterval` — which does transform — would break that.
|
|
678
|
+
const checks: string[] = [];
|
|
679
|
+
if (s.scale !== undefined) checks.push(`v.decimalPlaces() <= ${s.scale}`);
|
|
680
|
+
if (s.min !== undefined) checks.push(`v.gte('${escapeString(String(s.min))}')`);
|
|
681
|
+
if (s.max !== undefined) checks.push(`v.lte('${escapeString(String(s.max))}')`);
|
|
682
|
+
if (checks.length === 0) return '_ZodDecimal';
|
|
683
|
+
const messageParts: string[] = [];
|
|
684
|
+
if (s.scale !== undefined) messageParts.push(`at most ${s.scale} decimal place${s.scale === 1 ? '' : 's'}`);
|
|
685
|
+
if (s.min !== undefined) messageParts.push(`at least ${s.min}`);
|
|
686
|
+
if (s.max !== undefined) messageParts.push(`at most ${s.max}`);
|
|
687
|
+
return `_ZodDecimal.refine((v) => ${checks.join(' && ')}, { message: 'Must be ${escapeString(messageParts.join(', '))}' })`;
|
|
688
|
+
}
|
|
634
689
|
case 'boolean':
|
|
635
690
|
return `z.preprocess((v) => v === 'true' ? true : v === 'false' ? false : v, z.boolean())`;
|
|
636
691
|
case 'date': {
|
package/src/codegen-mcp.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { resolveModifiers, emittedResponses } from '@contractkit/core';
|
|
|
3
3
|
import { renderType, renderInputType, pascalToDotCase } from './codegen-contract.js';
|
|
4
4
|
import { inferService, deriveModulePath, buildArgs, deriveBaseName } from './codegen-operation.js';
|
|
5
5
|
import { quoteKey, escapeSingleQuoted } from './ts-render.js';
|
|
6
|
+
import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
|
|
6
7
|
import { basename, dirname, relative } from 'node:path';
|
|
7
8
|
|
|
8
9
|
// ─── Options ────────────────────────────────────────────────────────────────
|
|
@@ -289,6 +290,9 @@ function scalarHelperLines(body: string): string[] {
|
|
|
289
290
|
`const _ZodDatetime = z.preprocess((val) => typeof val === 'string' ? DateTime.fromISO(val) : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be in ISO 8601 format' }));`,
|
|
290
291
|
);
|
|
291
292
|
}
|
|
293
|
+
if (body.includes('_ZodDecimal')) {
|
|
294
|
+
lines.push(...DECIMAL_PRELUDE_LINES);
|
|
295
|
+
}
|
|
292
296
|
if (body.includes('_ZodInterval')) {
|
|
293
297
|
lines.push(
|
|
294
298
|
`const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
|
|
@@ -430,6 +434,7 @@ export function generateMcpFile(root: OpRootNode, options: McpCodegenOptions = {
|
|
|
430
434
|
if (/\bInterval\b/.test(bodyWithHelpers)) luxon.push('Interval');
|
|
431
435
|
if (/\bDuration\b/.test(bodyWithHelpers)) luxon.push('Duration');
|
|
432
436
|
if (luxon.length > 0) imports.push(`import { ${luxon.join(', ')} } from 'luxon';`);
|
|
437
|
+
if (/\bDecimal\b/.test(bodyWithHelpers)) imports.push(DECIMAL_IMPORT);
|
|
433
438
|
|
|
434
439
|
imports.push(`import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js';`);
|
|
435
440
|
imports.push(`import type { McpToolHandler, McpToolHandlerMap, McpToolContext } from '@maroonedsoftware/mcp';`);
|
package/src/codegen-operation.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
modeToWrapper,
|
|
19
19
|
} from './codegen-contract.js';
|
|
20
20
|
import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted } from './ts-render.js';
|
|
21
|
+
import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
|
|
21
22
|
import { basename, dirname, relative } from 'path';
|
|
22
23
|
|
|
23
24
|
// ─── Content-type helpers ──────────────────────────────────────────────────
|
|
@@ -50,7 +51,15 @@ export function bodyTypesStructurallyEqual(a: ContractTypeNode, b: ContractTypeN
|
|
|
50
51
|
switch (a.kind) {
|
|
51
52
|
case 'scalar': {
|
|
52
53
|
const bb = b as typeof a;
|
|
53
|
-
return
|
|
54
|
+
return (
|
|
55
|
+
a.name === bb.name &&
|
|
56
|
+
a.min === bb.min &&
|
|
57
|
+
a.max === bb.max &&
|
|
58
|
+
a.len === bb.len &&
|
|
59
|
+
a.scale === bb.scale &&
|
|
60
|
+
a.regex === bb.regex &&
|
|
61
|
+
a.format === bb.format
|
|
62
|
+
);
|
|
54
63
|
}
|
|
55
64
|
case 'array': {
|
|
56
65
|
const bb = b as typeof a;
|
|
@@ -132,6 +141,18 @@ export interface OpCodegenOptions {
|
|
|
132
141
|
* from the generated router entirely.
|
|
133
142
|
*/
|
|
134
143
|
includeInternal?: boolean;
|
|
144
|
+
/**
|
|
145
|
+
* Re-parse the service result through its declared response schema before writing `ctx.body`,
|
|
146
|
+
* and write the parsed value. Requires the type file to hold Zod schemas (`server.zod`) —
|
|
147
|
+
* plain interfaces are types, with no runtime schema value to validate against. Default false.
|
|
148
|
+
*/
|
|
149
|
+
validateResponses?: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* Set of model names whose schema applies a `format(...)` key transform, directly or through a
|
|
152
|
+
* referenced model. Response bodies touching one are left unvalidated: the service returns the
|
|
153
|
+
* post-transform shape, which the schema itself cannot re-parse.
|
|
154
|
+
*/
|
|
155
|
+
modelsWithTransform?: Set<string>;
|
|
135
156
|
}
|
|
136
157
|
|
|
137
158
|
/**
|
|
@@ -185,6 +206,9 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
|
|
|
185
206
|
`const _ZodDatetime = z.preprocess((val) => typeof val === 'string' ? DateTime.fromISO(val) : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be in ISO 8601 format' }));`,
|
|
186
207
|
);
|
|
187
208
|
}
|
|
209
|
+
if (references('_ZodDecimal')) {
|
|
210
|
+
helpers.push(...DECIMAL_PRELUDE_LINES);
|
|
211
|
+
}
|
|
188
212
|
if (references('_ZodInterval')) {
|
|
189
213
|
helpers.push(
|
|
190
214
|
`const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
|
|
@@ -212,13 +236,20 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
|
|
|
212
236
|
body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
|
|
213
237
|
}
|
|
214
238
|
|
|
215
|
-
|
|
239
|
+
// Services and model names come from the AST, which over-approximates two ways: a model with an
|
|
240
|
+
// Input/Output variant contributes its base name even when only the variant is ever annotated,
|
|
241
|
+
// and `collectServices`/`collectTypes` walk every operation including the `internal` ones
|
|
242
|
+
// `includeInternal: false` drops. Filtering through `uses` — the same gate every symbol above
|
|
243
|
+
// goes through — keeps the import list to what the handlers actually reference, so generated
|
|
244
|
+
// code does not trip `noUnusedLocals` in the consuming project.
|
|
245
|
+
for (const svc of services.filter(uses)) {
|
|
216
246
|
const modulePath = root.services?.[svc] ?? root.meta[svc] ?? deriveModulePath(svc, options.servicePathTemplate);
|
|
217
247
|
body.push(`import { ${svc} } from '${modulePath}';`);
|
|
218
248
|
}
|
|
219
249
|
|
|
220
|
-
|
|
221
|
-
|
|
250
|
+
const usedTypes = types.filter(uses);
|
|
251
|
+
if (usedTypes.length > 0) {
|
|
252
|
+
body.push(...generateTypeImports(usedTypes, root.file, options));
|
|
222
253
|
}
|
|
223
254
|
|
|
224
255
|
// luxon is needed for date/time/datetime (DateTime), duration (Duration) and interval (Interval);
|
|
@@ -228,6 +259,12 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
|
|
|
228
259
|
body.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
|
|
229
260
|
}
|
|
230
261
|
|
|
262
|
+
// Same `uses` gate: `Decimal` appears in the `_ZodDecimal` helper and in service-result
|
|
263
|
+
// annotations via `serverTsScalar`, and the helper text is folded into `generated` above.
|
|
264
|
+
if (uses('Decimal')) {
|
|
265
|
+
body.push(DECIMAL_IMPORT);
|
|
266
|
+
}
|
|
267
|
+
|
|
231
268
|
if (uses('parseAndValidate')) {
|
|
232
269
|
body.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
|
|
233
270
|
}
|
|
@@ -383,9 +420,12 @@ function generateSingleStatusResult(
|
|
|
383
420
|
const hasRespHeaders = respHeaders.length > 0;
|
|
384
421
|
const headersAnnotation = hasRespHeaders ? renderHeadersAnnotation(respHeaders, options.modelsWithOutput) : '';
|
|
385
422
|
|
|
423
|
+
let bodySchema: string | undefined;
|
|
424
|
+
|
|
386
425
|
if (bodies.length === 1) {
|
|
387
426
|
const { annotation, prelude } = formatTypeAnnotation(bodies[0]!.bodyType, options.modelsWithOutput);
|
|
388
427
|
if (prelude) lines.push(` ${prelude}`);
|
|
428
|
+
bodySchema = responseBodySchema(bodies[0]!.bodyType, options, prelude ? 'resultType' : undefined);
|
|
389
429
|
lines.push(` const service = ctx.container.get(${className});`);
|
|
390
430
|
if (hasRespHeaders) {
|
|
391
431
|
lines.push(` const result: { body: ${annotation}; headers: ${headersAnnotation} } = ${call};`);
|
|
@@ -393,7 +433,9 @@ function generateSingleStatusResult(
|
|
|
393
433
|
lines.push(` const result: ${annotation} = ${call};`);
|
|
394
434
|
}
|
|
395
435
|
} else if (bodies.length > 1) {
|
|
396
|
-
const
|
|
436
|
+
const rendered = renderResponseMembers(resp!, options, { includeStatus: false, varPrefix: 'result' });
|
|
437
|
+
const { members, preludes } = rendered;
|
|
438
|
+
bodySchema = rendered.bodySchema;
|
|
397
439
|
for (const prelude of preludes) lines.push(` ${prelude}`);
|
|
398
440
|
lines.push(` const service = ctx.container.get(${className});`);
|
|
399
441
|
lines.push(` const result: ${members.join(' | ')} = ${call};`);
|
|
@@ -414,10 +456,10 @@ function generateSingleStatusResult(
|
|
|
414
456
|
|
|
415
457
|
if (bodies.length === 1) {
|
|
416
458
|
lines.push(` ctx.type = '${bodies[0]!.contentType}';`);
|
|
417
|
-
lines.push(` ctx.body = ${hasRespHeaders ? 'result.body' : 'result'};`);
|
|
459
|
+
lines.push(` ctx.body = ${responseBodyExpr(hasRespHeaders ? 'result.body' : 'result', bodySchema)};`);
|
|
418
460
|
} else if (bodies.length > 1) {
|
|
419
461
|
lines.push(` ctx.type = result.contentType;`);
|
|
420
|
-
lines.push(` ctx.body = result.body;`);
|
|
462
|
+
lines.push(` ctx.body = ${responseBodyExpr('result.body', bodySchema)};`);
|
|
421
463
|
}
|
|
422
464
|
|
|
423
465
|
return lines;
|
|
@@ -433,10 +475,13 @@ function generateMultiStatusResult(emitted: OpResponseNode[], className: string,
|
|
|
433
475
|
const members: string[] = [];
|
|
434
476
|
const preludes: string[] = [];
|
|
435
477
|
|
|
478
|
+
const bodySchemas = new Map<number, string | undefined>();
|
|
479
|
+
|
|
436
480
|
for (const resp of emitted) {
|
|
437
481
|
const rendered = renderResponseMembers(resp, options, { includeStatus: true, varPrefix: `result${resp.statusCode}` });
|
|
438
482
|
members.push(...rendered.members);
|
|
439
483
|
preludes.push(...rendered.preludes);
|
|
484
|
+
bodySchemas.set(resp.statusCode, rendered.bodySchema);
|
|
440
485
|
}
|
|
441
486
|
|
|
442
487
|
for (const prelude of preludes) lines.push(` ${prelude}`);
|
|
@@ -452,7 +497,7 @@ function generateMultiStatusResult(emitted: OpResponseNode[], className: string,
|
|
|
452
497
|
lines.push(...headerSetLines(resp.headers ?? [], ' '));
|
|
453
498
|
if (resp.bodies.length > 0) {
|
|
454
499
|
lines.push(` ctx.type = result.contentType;`);
|
|
455
|
-
lines.push(` ctx.body = result.body;`);
|
|
500
|
+
lines.push(` ctx.body = ${responseBodyExpr('result.body', bodySchemas.get(resp.statusCode))};`);
|
|
456
501
|
}
|
|
457
502
|
lines.push(` break;`);
|
|
458
503
|
}
|
|
@@ -473,7 +518,7 @@ function renderResponseMembers(
|
|
|
473
518
|
resp: OpResponseNode,
|
|
474
519
|
options: OpCodegenOptions,
|
|
475
520
|
opts: { includeStatus: boolean; varPrefix: string },
|
|
476
|
-
): { members: string[]; preludes: string[] } {
|
|
521
|
+
): { members: string[]; preludes: string[]; bodySchema?: string } {
|
|
477
522
|
const bodies = resp.bodies;
|
|
478
523
|
const headers = resp.headers ?? [];
|
|
479
524
|
const leading = opts.includeStatus ? [`status: ${resp.statusCode}`] : [];
|
|
@@ -488,10 +533,18 @@ function renderResponseMembers(
|
|
|
488
533
|
if (uniform) {
|
|
489
534
|
const { annotation, prelude } = formatTypeAnnotation(bodies[0]!.bodyType, options.modelsWithOutput, `${opts.varPrefix}Type`);
|
|
490
535
|
if (prelude) preludes.push(prelude);
|
|
536
|
+
const bodySchema = responseBodySchema(bodies[0]!.bodyType, options, prelude ? `${opts.varPrefix}Type` : undefined);
|
|
491
537
|
const contentType = bodies.map(b => `'${b.contentType}'`).join(' | ');
|
|
492
|
-
return {
|
|
538
|
+
return {
|
|
539
|
+
members: [`{ ${[...leading, `contentType: ${contentType}`, `body: ${annotation}`, ...trailing].join('; ')} }`],
|
|
540
|
+
preludes,
|
|
541
|
+
bodySchema,
|
|
542
|
+
};
|
|
493
543
|
}
|
|
494
544
|
|
|
545
|
+
// One member per mime: `contentType` and `body` stay correlated, so validating would need a
|
|
546
|
+
// second switch on `result.contentType` nested inside the status switch. Left unvalidated —
|
|
547
|
+
// note the absent `bodySchema` in the return below.
|
|
495
548
|
const members = bodies.map((b, i) => {
|
|
496
549
|
const { annotation, prelude } = formatTypeAnnotation(b.bodyType, options.modelsWithOutput, `${opts.varPrefix}Type${i}`);
|
|
497
550
|
if (prelude) preludes.push(prelude);
|
|
@@ -610,6 +663,10 @@ function serverTsScalar(name: ScalarTypeNode['name']): string {
|
|
|
610
663
|
return 'number';
|
|
611
664
|
case 'bigint':
|
|
612
665
|
return 'bigint';
|
|
666
|
+
case 'decimal':
|
|
667
|
+
// Unlike the date scalars, this matches `renderTsScalar`'s wire view — `_ZodDecimal`
|
|
668
|
+
// has no output transform, so `z.infer` is a `Decimal` on both sides.
|
|
669
|
+
return 'Decimal';
|
|
613
670
|
case 'boolean':
|
|
614
671
|
return 'boolean';
|
|
615
672
|
case 'date':
|
|
@@ -660,6 +717,71 @@ function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set
|
|
|
660
717
|
};
|
|
661
718
|
}
|
|
662
719
|
|
|
720
|
+
/**
|
|
721
|
+
* Whether a response body can be soundly re-parsed through the schema `renderType` emits for it.
|
|
722
|
+
*
|
|
723
|
+
* False for anything transitively touching a model with a `format(...)` key transform — the service
|
|
724
|
+
* returns the post-transform value while the schema expects the pre-transform one — and for an
|
|
725
|
+
* intersection outside `renderIntersection`'s `.extend()` fast path, where `.and()` of two strict
|
|
726
|
+
* objects rejects every value because each side sees the other's keys as unrecognized.
|
|
727
|
+
*/
|
|
728
|
+
function isRevalidatable(type: ContractTypeNode, modelsWithOutput?: Set<string>, modelsWithTransform?: Set<string>): boolean {
|
|
729
|
+
const rec = (t: ContractTypeNode): boolean => isRevalidatable(t, modelsWithOutput, modelsWithTransform);
|
|
730
|
+
switch (type.kind) {
|
|
731
|
+
case 'ref':
|
|
732
|
+
return !modelsWithOutput?.has(type.name) && !modelsWithTransform?.has(type.name);
|
|
733
|
+
case 'array':
|
|
734
|
+
return rec(type.item);
|
|
735
|
+
case 'tuple':
|
|
736
|
+
return type.items.every(rec);
|
|
737
|
+
case 'record':
|
|
738
|
+
return rec(type.key) && rec(type.value);
|
|
739
|
+
case 'intersection': {
|
|
740
|
+
// Mirrors renderIntersection: a lone member renders as itself, `ref & (ref|object)*`
|
|
741
|
+
// renders as an `.extend()` chain, and anything else falls back to `.and()`.
|
|
742
|
+
const [first, ...rest] = type.members;
|
|
743
|
+
if (!first) return true;
|
|
744
|
+
if (rest.length === 0) return rec(first);
|
|
745
|
+
const usesExtendChain = first.kind === 'ref' && rest.every(m => m.kind === 'ref' || m.kind === 'inlineObject');
|
|
746
|
+
return usesExtendChain && type.members.every(rec);
|
|
747
|
+
}
|
|
748
|
+
case 'union':
|
|
749
|
+
case 'discriminatedUnion':
|
|
750
|
+
return type.members.every(rec);
|
|
751
|
+
case 'inlineObject':
|
|
752
|
+
return type.fields.every(f => rec(f.type));
|
|
753
|
+
case 'lazy':
|
|
754
|
+
return rec(type.inner);
|
|
755
|
+
default:
|
|
756
|
+
// scalar, enum, literal — identity or idempotent under re-parse
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* The runtime schema a handler validates a response body against, or `undefined` when the body
|
|
763
|
+
* cannot be soundly re-parsed and validation must be skipped.
|
|
764
|
+
*
|
|
765
|
+
* @param preludeVar The schema const {@link formatTypeAnnotation} already emitted for this body
|
|
766
|
+
* (complex types only — `undefined` when it returned no prelude). Reusing it keeps a large object
|
|
767
|
+
* literal from appearing twice in the same handler.
|
|
768
|
+
*/
|
|
769
|
+
function responseBodySchema(bodyType: ContractTypeNode, options: OpCodegenOptions, preludeVar: string | undefined): string | undefined {
|
|
770
|
+
if (!options.validateResponses) return undefined;
|
|
771
|
+
if (!isRevalidatable(bodyType, options.modelsWithOutput, options.modelsWithTransform)) return undefined;
|
|
772
|
+
return preludeVar ?? renderType(bodyType);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* The `ctx.body = ...` right-hand side for a response body: the raw result expression, or a
|
|
777
|
+
* `parseAndValidate` of it. The `500` is deliberate — a service returning a shape its own contract
|
|
778
|
+
* rejects is a server fault, not a client one, and `@maroonedsoftware/zod` routes the field-level
|
|
779
|
+
* detail to `internalDetails` (log-only) rather than the response body at 5xx.
|
|
780
|
+
*/
|
|
781
|
+
function responseBodyExpr(value: string, schema: string | undefined): string {
|
|
782
|
+
return schema ? `await parseAndValidate(${value}, ${schema}, 500)` : value;
|
|
783
|
+
}
|
|
784
|
+
|
|
663
785
|
function generateParamValidation(
|
|
664
786
|
source: ParamSource | undefined,
|
|
665
787
|
ctxExpr: string,
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
} from './codegen-contract.js';
|
|
13
13
|
import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
|
|
14
14
|
import type { TsRenderTarget } from './ts-render.js';
|
|
15
|
+
import { DECIMAL_IMPORT, DECIMAL_CONFIG_LINE } from './decimal-runtime.js';
|
|
16
|
+
import { renderReviveFunctions, reviveFnName, DECIMAL_COERCE_DECL } from './codegen-revive.js';
|
|
15
17
|
|
|
16
18
|
// ─── Public entry point ────────────────────────────────────────────────────
|
|
17
19
|
|
|
@@ -44,10 +46,20 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
|
|
|
44
46
|
const externalOutputRefs = allModelsWithOutput.size > 0 ? collectExternalOutputRefs(root, allModelsWithOutput) : [];
|
|
45
47
|
const allExternalRefs = [...new Set([...externalRefs, ...externalInputRefs, ...externalOutputRefs])].sort();
|
|
46
48
|
|
|
47
|
-
//
|
|
49
|
+
// Not `import type`: `renderTsScalar` maps `decimal` to `Decimal` in this mode too, so the class
|
|
50
|
+
// is a real runtime dependency of any consumer holding one — same position as in
|
|
51
|
+
// `generateContract`, which emits it ahead of the external model refs.
|
|
52
|
+
const needsDecimal = rootNeedsScalar(root, 'decimal') || (context?.emitRevivers && context.modelsWithDecimal ? root.models.some(m => context.modelsWithDecimal!.has(m.name)) : false);
|
|
53
|
+
if (needsDecimal) lines.push(DECIMAL_IMPORT);
|
|
54
|
+
|
|
55
|
+
// Type-only imports for external references. A cross-file model carrying a decimal also
|
|
56
|
+
// contributes its reviver, which is a value and so needs a second, non-type import.
|
|
48
57
|
for (const ref of allExternalRefs) {
|
|
49
58
|
const importPath = resolveImportPath(ref, context);
|
|
50
59
|
lines.push(`import type { ${ref} } from '${importPath}';`);
|
|
60
|
+
if (context?.emitRevivers && context.modelsWithDecimal?.has(ref)) {
|
|
61
|
+
lines.push(`import { ${reviveFnName(ref)} } from '${importPath}';`);
|
|
62
|
+
}
|
|
51
63
|
}
|
|
52
64
|
if (allExternalRefs.length > 0) lines.push('');
|
|
53
65
|
|
|
@@ -62,10 +74,38 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
|
|
|
62
74
|
|
|
63
75
|
const modelMap = new Map(root.models.map(m => [m.name, m]));
|
|
64
76
|
|
|
77
|
+
const reviveOpts =
|
|
78
|
+
context?.emitRevivers && context.modelsWithDecimal
|
|
79
|
+
? { modelsWithDecimal: context.modelsWithDecimal, modelsWithOutput: allModelsWithOutput, modelMap }
|
|
80
|
+
: undefined;
|
|
81
|
+
|
|
82
|
+
const bodyLines: string[] = [];
|
|
65
83
|
for (const model of topoSortModels(root.models)) {
|
|
66
|
-
|
|
84
|
+
bodyLines.push(...generateModel(model, target, context?.currentOutPath, allModelsWithInput, allModelsWithOutput, modelMap));
|
|
85
|
+
if (reviveOpts) {
|
|
86
|
+
const revivers = renderReviveFunctions(model, reviveOpts);
|
|
87
|
+
if (revivers.length > 0) {
|
|
88
|
+
bodyLines.push('');
|
|
89
|
+
bodyLines.push(...revivers);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
bodyLines.push('');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Global decimal.js config belongs in any file holding a `Decimal`: there is no Zod schema in
|
|
96
|
+
// this mode, but `String(value)` and `JSON.stringify` still have to stay out of exponential form.
|
|
97
|
+
if (needsDecimal) {
|
|
98
|
+
lines.push('');
|
|
99
|
+
lines.push(DECIMAL_CONFIG_LINE);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Same rule as in `generateContract`: the helper is emitted only if the revivers actually
|
|
103
|
+
// reference it, so the two cannot drift and trip `noUnusedLocals`.
|
|
104
|
+
if (bodyLines.some(l => l.includes('__dec('))) {
|
|
105
|
+
lines.push(...DECIMAL_COERCE_DECL);
|
|
67
106
|
lines.push('');
|
|
68
107
|
}
|
|
108
|
+
lines.push(...bodyLines);
|
|
69
109
|
|
|
70
110
|
return lines.join('\n');
|
|
71
111
|
}
|