@contractkit/plugin-typescript 0.31.2 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$ci.log +5 -5
- package/.turbo/turbo-test$colon$ci.log +23 -21
- package/CHANGELOG.md +166 -0
- package/README.md +8 -0
- package/dist/codegen-contract.d.ts +7 -0
- package/dist/codegen-contract.d.ts.map +1 -1
- package/dist/codegen-mcp.d.ts.map +1 -1
- package/dist/codegen-operation.d.ts +12 -0
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/codegen-plain-types.d.ts.map +1 -1
- package/dist/codegen-revive.d.ts +42 -0
- package/dist/codegen-revive.d.ts.map +1 -0
- package/dist/codegen-sdk.d.ts +18 -6
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/decimal-runtime.d.ts +47 -0
- package/dist/decimal-runtime.d.ts.map +1 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +610 -49
- package/dist/index.js.map +1 -1
- package/dist/ts-render.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen-contract.ts +58 -3
- package/src/codegen-mcp.ts +5 -0
- package/src/codegen-operation.ts +132 -10
- package/src/codegen-plain-types.ts +42 -2
- package/src/codegen-revive.ts +304 -0
- package/src/codegen-sdk.ts +248 -37
- package/src/decimal-runtime.ts +50 -0
- package/src/index.ts +60 -3
- package/src/ts-render.ts +6 -0
- package/tests/codegen-contract.test.ts +124 -1
- package/tests/codegen-operation.test.ts +266 -2
- package/tests/codegen-sdk.test.ts +77 -6
- package/tests/codegen-server.test.ts +48 -0
- package/tests/helpers.ts +5 -0
- package/tests/pipeline.test.ts +35 -2
package/src/codegen-sdk.ts
CHANGED
|
@@ -7,12 +7,15 @@ import type {
|
|
|
7
7
|
OpResponseBodyNode,
|
|
8
8
|
OpResponseHeaderNode,
|
|
9
9
|
ContractTypeNode,
|
|
10
|
+
ModelNode,
|
|
10
11
|
ParamSource,
|
|
11
12
|
} from '@contractkit/core';
|
|
12
13
|
import { resolveModifiers, isJsonMime, classifyContentType, observableResponses, thrownResponses } from '@contractkit/core';
|
|
13
14
|
import { renderInputTsType, renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
|
|
14
15
|
import { pascalToDotCase, typeNeedsScalar } from './codegen-contract.js';
|
|
15
16
|
import { bodyTypesStructurallyEqual } from './codegen-operation.js';
|
|
17
|
+
import { reviveFnName, renderInlineReviver, typeReachesDecimal, DECIMAL_COERCE_DECL } from './codegen-revive.js';
|
|
18
|
+
import { DECIMAL_IMPORT, DECIMAL_CONFIG_LINE } from './decimal-runtime.js';
|
|
16
19
|
import { basename, dirname, relative } from 'path';
|
|
17
20
|
|
|
18
21
|
// ─── Body strategy ────────────────────────────────────────────────────────
|
|
@@ -81,6 +84,10 @@ export interface SdkCodegenOptions {
|
|
|
81
84
|
modelsWithInput?: Set<string>;
|
|
82
85
|
/** Set of model names that have Output variants (models with format(output=...)) */
|
|
83
86
|
modelsWithOutput?: Set<string>;
|
|
87
|
+
/** Model names carrying a `decimal`, whose response bodies need rehydrating client-side. */
|
|
88
|
+
modelsWithDecimal?: Set<string>;
|
|
89
|
+
/** Every model in scope, for resolving discriminated-union members inside an inline reviver. */
|
|
90
|
+
modelMap?: Map<string, ModelNode>;
|
|
84
91
|
/**
|
|
85
92
|
* Whether to emit SDK methods for operations marked `internal`. Defaults to `false` —
|
|
86
93
|
* internal ops are omitted from the SDK so consumers don't pick them up. Set to `true`
|
|
@@ -122,10 +129,29 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
|
|
|
122
129
|
const types = collectTypes(root, options.modelsWithInput, options.modelsWithOutput, includeInternal);
|
|
123
130
|
const clientClassName = options.clientClassName ?? deriveClientClassName(root.file);
|
|
124
131
|
|
|
125
|
-
//
|
|
132
|
+
// The method bodies are generated first so the imports can be read off them, the same way
|
|
133
|
+
// codegen-operation decides its imports from the code it just emitted. A reviver import that
|
|
134
|
+
// came from a predicate instead could drift and leave an unused local behind.
|
|
135
|
+
const inlineRevivers = new Map<string, string[]>();
|
|
136
|
+
const classBody: string[] = [];
|
|
137
|
+
for (const route of root.routes) {
|
|
138
|
+
for (const op of route.operations) {
|
|
139
|
+
const mods = resolveModifiers(route, op);
|
|
140
|
+
if (!includeInternal && mods.includes('internal')) continue;
|
|
141
|
+
classBody.push('');
|
|
142
|
+
if (mods.includes('deprecated')) classBody.push(' /** @deprecated */');
|
|
143
|
+
classBody.push(...generateMethod(route, op, root.file, options, inlineRevivers));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const inlineReviverDecls = [...inlineRevivers.values()].flat();
|
|
148
|
+
const decimalPrelude = decimalPreludeFor(inlineReviverDecls);
|
|
149
|
+
|
|
150
|
+
// Type-only imports, plus the model revivers the methods actually call.
|
|
126
151
|
if (types.length > 0) {
|
|
127
|
-
lines.push(...generateTypeImports(types, root.file, options));
|
|
152
|
+
lines.push(...generateTypeImports(types, root.file, options, usedRevivers(classBody)));
|
|
128
153
|
}
|
|
154
|
+
lines.push(...decimalPrelude.imports);
|
|
129
155
|
|
|
130
156
|
// SdkOptions import (from shared file) or inline fallback
|
|
131
157
|
if (options.sdkOptionsPath && options.outPath) {
|
|
@@ -229,6 +255,17 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
|
|
|
229
255
|
lines.push('');
|
|
230
256
|
}
|
|
231
257
|
|
|
258
|
+
if (decimalPrelude.decls.length > 0) {
|
|
259
|
+
lines.push('');
|
|
260
|
+
lines.push(...decimalPrelude.decls);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Wrappers for bodies with no `reviveX` of their own — an inline object, a record, a tuple.
|
|
264
|
+
for (const decl of inlineRevivers.values()) {
|
|
265
|
+
lines.push('');
|
|
266
|
+
lines.push(...decl);
|
|
267
|
+
}
|
|
268
|
+
|
|
232
269
|
// Client class
|
|
233
270
|
lines.push('/**');
|
|
234
271
|
const relFile = options.outPath ? relative(dirname(options.outPath), root.file) : root.file;
|
|
@@ -236,16 +273,7 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
|
|
|
236
273
|
lines.push(' */');
|
|
237
274
|
lines.push(`export class ${clientClassName} {`);
|
|
238
275
|
lines.push(' constructor(private fetch: SdkFetch) {}');
|
|
239
|
-
|
|
240
|
-
for (const route of root.routes) {
|
|
241
|
-
for (const op of route.operations) {
|
|
242
|
-
const mods = resolveModifiers(route, op);
|
|
243
|
-
if (!includeInternal && mods.includes('internal')) continue;
|
|
244
|
-
lines.push('');
|
|
245
|
-
if (mods.includes('deprecated')) lines.push(' /** @deprecated */');
|
|
246
|
-
lines.push(...generateMethod(route, op, root.file, options));
|
|
247
|
-
}
|
|
248
|
-
}
|
|
276
|
+
lines.push(...classBody);
|
|
249
277
|
|
|
250
278
|
lines.push('}');
|
|
251
279
|
lines.push('');
|
|
@@ -255,38 +283,84 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
|
|
|
255
283
|
|
|
256
284
|
/**
|
|
257
285
|
* Render the method-block lines for an operation file as if they were declared inside a
|
|
258
|
-
* client class.
|
|
259
|
-
* level, with leading blank lines between methods) plus the set of method names emitted —
|
|
260
|
-
* the caller uses the names to detect cross-file collisions when multiple files contribute
|
|
261
|
-
* to the same area-level client.
|
|
286
|
+
* client class.
|
|
262
287
|
*
|
|
263
288
|
* Skips operations marked `internal` unless `options.includeInternal` is set.
|
|
289
|
+
*
|
|
290
|
+
* @returns `lines`, one consolidated array pre-indented for class-body level with leading blank
|
|
291
|
+
* lines between methods; `methodNames`, used by the caller to detect cross-file collisions when
|
|
292
|
+
* several files contribute to the same area-level client; `preludeLines`, module-level
|
|
293
|
+
* declarations the methods reference (decimal revivers and their `__dec` helper) which the caller
|
|
294
|
+
* must splice in above the class; and `needsDecimalImport`, true when those declarations require
|
|
295
|
+
* `import { Decimal } from 'decimal.js'` in the emitting file.
|
|
264
296
|
*/
|
|
265
297
|
export function generateClientMethods(
|
|
266
298
|
root: OpRootNode,
|
|
267
299
|
options: SdkCodegenOptions,
|
|
268
|
-
): { lines: string[]; methodNames: string[] } {
|
|
300
|
+
): { lines: string[]; methodNames: string[]; preludeLines: string[]; needsDecimalImport: boolean } {
|
|
269
301
|
const lines: string[] = [];
|
|
270
302
|
const methodNames: string[] = [];
|
|
271
303
|
const includeInternal = options.includeInternal ?? false;
|
|
304
|
+
const inlineRevivers = new Map<string, string[]>();
|
|
272
305
|
for (const route of root.routes) {
|
|
273
306
|
for (const op of route.operations) {
|
|
274
307
|
const mods = resolveModifiers(route, op);
|
|
275
308
|
if (!includeInternal && mods.includes('internal')) continue;
|
|
276
309
|
lines.push('');
|
|
277
310
|
if (mods.includes('deprecated')) lines.push(' /** @deprecated */');
|
|
278
|
-
lines.push(...generateMethod(route, op, root.file, options));
|
|
311
|
+
lines.push(...generateMethod(route, op, root.file, options, inlineRevivers));
|
|
279
312
|
methodNames.push(deriveMethodName(op, route));
|
|
280
313
|
}
|
|
281
314
|
}
|
|
282
|
-
|
|
315
|
+
// Module-level declarations the methods reference, spliced above the class by the caller —
|
|
316
|
+
// the same shape `generateErrorBodyAliases` already uses.
|
|
317
|
+
const declLines = [...inlineRevivers.values()].flat();
|
|
318
|
+
const { decls } = decimalPreludeFor(declLines);
|
|
319
|
+
const preludeLines = [...(decls.length > 0 ? ['', ...decls] : []), ...[...inlineRevivers.values()].flatMap(decl => ['', ...decl])];
|
|
320
|
+
return { lines, methodNames, preludeLines, needsDecimalImport: decls.length > 0 };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Declarations a client file needs for the inline revivers it carries.
|
|
325
|
+
*
|
|
326
|
+
* An inline wrapper calls `__dec`, which is file-local to the *types* module and not exported, so
|
|
327
|
+
* a client file that has one needs its own copy — along with the decimal.js import and the global
|
|
328
|
+
* config, since nothing else in the file necessarily pulls them in.
|
|
329
|
+
*/
|
|
330
|
+
function decimalPreludeFor(declLines: string[]): { imports: string[]; decls: string[] } {
|
|
331
|
+
if (!declLines.some(l => l.includes('__dec('))) return { imports: [], decls: [] };
|
|
332
|
+
return { imports: [DECIMAL_IMPORT], decls: [DECIMAL_CONFIG_LINE, '', ...DECIMAL_COERCE_DECL] };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Model reviver names referenced by generated method bodies. `__revive…` wrappers are local. */
|
|
336
|
+
function usedRevivers(lines: string[]): string[] {
|
|
337
|
+
const found = new Set<string>();
|
|
338
|
+
for (const m of lines.join('\n').matchAll(/\brevive[A-Z]\w*/g)) found.add(m[0]);
|
|
339
|
+
return [...found].sort();
|
|
283
340
|
}
|
|
284
341
|
|
|
285
342
|
// ─── Method generation ────────────────────────────────────────────────────
|
|
286
343
|
|
|
287
|
-
function generateMethod(
|
|
344
|
+
function generateMethod(
|
|
345
|
+
route: OpRouteNode,
|
|
346
|
+
op: OpOperationNode,
|
|
347
|
+
file: string,
|
|
348
|
+
options: SdkCodegenOptions,
|
|
349
|
+
inlineRevivers?: Map<string, string[]>,
|
|
350
|
+
): string[] {
|
|
351
|
+
const revive: ReviveContext | undefined =
|
|
352
|
+
options.modelsWithDecimal && options.modelsWithDecimal.size > 0 && inlineRevivers
|
|
353
|
+
? {
|
|
354
|
+
modelsWithDecimal: options.modelsWithDecimal,
|
|
355
|
+
modelsWithOutput: options.modelsWithOutput,
|
|
356
|
+
modelMap: options.modelMap,
|
|
357
|
+
inlineRevivers,
|
|
358
|
+
nameHint: '',
|
|
359
|
+
}
|
|
360
|
+
: undefined;
|
|
288
361
|
const lines: string[] = [];
|
|
289
362
|
const methodName = deriveMethodName(op, route);
|
|
363
|
+
const mRevive = hint(revive, `${methodName.charAt(0).toUpperCase()}${methodName.slice(1)}`);
|
|
290
364
|
const httpMethod = op.method.toUpperCase();
|
|
291
365
|
const { modelsWithInput, modelsWithOutput } = options;
|
|
292
366
|
|
|
@@ -456,23 +530,23 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, o
|
|
|
456
530
|
lines.push(` switch (result.status) {`);
|
|
457
531
|
for (const resp of rest) {
|
|
458
532
|
lines.push(` case ${resp.statusCode}:`);
|
|
459
|
-
lines.push(...sdkReturnLines(resp, modelsWithOutput, ' ', true));
|
|
533
|
+
lines.push(...sdkReturnLines(resp, modelsWithOutput, ' ', true, mRevive));
|
|
460
534
|
}
|
|
461
535
|
lines.push(` default:`);
|
|
462
|
-
lines.push(...sdkReturnLines(fallback!, modelsWithOutput, ' ', true));
|
|
536
|
+
lines.push(...sdkReturnLines(fallback!, modelsWithOutput, ' ', true, mRevive));
|
|
463
537
|
lines.push(` }`);
|
|
464
538
|
} else if (primaryBodies.length > 1) {
|
|
465
|
-
lines.push(...sdkReturnLines(primaryResponse!, modelsWithOutput, ' ', false));
|
|
539
|
+
lines.push(...sdkReturnLines(primaryResponse!, modelsWithOutput, ' ', false, mRevive));
|
|
466
540
|
} else if (hasRespHeaders) {
|
|
467
541
|
const headerEntries = sdkHeaderEntries(respHeaders);
|
|
468
542
|
if (isVoid) {
|
|
469
543
|
lines.push(` return { headers: { ${headerEntries} } };`);
|
|
470
544
|
} else {
|
|
471
|
-
lines.push(` const data = ${sdkReadExpr(primaryBodies[0]!, modelsWithOutput)};`);
|
|
545
|
+
lines.push(` const data = ${sdkReadExpr(primaryBodies[0]!, modelsWithOutput, hint(mRevive, primaryResponse!.statusCode))};`);
|
|
472
546
|
lines.push(` return { data, headers: { ${headerEntries} } };`);
|
|
473
547
|
}
|
|
474
548
|
} else if (!isVoid) {
|
|
475
|
-
lines.push(` return ${sdkReadExpr(primaryBodies[0]!, modelsWithOutput)};`);
|
|
549
|
+
lines.push(` return ${sdkReadExpr(primaryBodies[0]!, modelsWithOutput, hint(mRevive, primaryResponse!.statusCode))};`);
|
|
476
550
|
}
|
|
477
551
|
|
|
478
552
|
lines.push(' }');
|
|
@@ -490,12 +564,75 @@ function sdkDataType(body: OpResponseBodyNode, modelsWithOutput?: Set<string>):
|
|
|
490
564
|
return renderOutputTsType(body.bodyType, modelsWithOutput);
|
|
491
565
|
}
|
|
492
566
|
|
|
493
|
-
/**
|
|
494
|
-
|
|
567
|
+
/**
|
|
568
|
+
* How a client reads one response body off the `Response`.
|
|
569
|
+
*
|
|
570
|
+
* A decimal-bearing body is wrapped in its reviver: `parseJson` is a bare cast, so without this the
|
|
571
|
+
* type would say `Decimal` while the runtime held a string. Bodies with no decimal are emitted
|
|
572
|
+
* byte-identically to before.
|
|
573
|
+
*/
|
|
574
|
+
function sdkReadExpr(body: OpResponseBodyNode, modelsWithOutput?: Set<string>, revive?: ReviveContext): string {
|
|
495
575
|
const category = classifyContentType(body.contentType);
|
|
496
576
|
if (category === 'text') return 'await result.text()';
|
|
497
577
|
if (category === 'binary') return 'await result.blob()';
|
|
498
|
-
|
|
578
|
+
const tsType = renderOutputTsType(body.bodyType, modelsWithOutput);
|
|
579
|
+
const read = `await parseJson<${tsType}>(result)`;
|
|
580
|
+
const reviver = reviveExprFor(body.bodyType, revive);
|
|
581
|
+
if (!reviver) return read;
|
|
582
|
+
// `.map` over an array of refs rather than a wrapper function: the reviver returns the same
|
|
583
|
+
// object it mutated, so the mapped array holds the same elements.
|
|
584
|
+
return reviver.kind === 'array' ? `(${read}).map(${reviver.name})` : `${reviver.name}(${read})`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Extend the inline-reviver name with one more path segment.
|
|
589
|
+
*
|
|
590
|
+
* Built up compositionally — method, then status, then mime index — because each of those lives in
|
|
591
|
+
* a different function, and the resulting name has to be distinct per body: two operations with
|
|
592
|
+
* different inline decimal bodies must not share one wrapper.
|
|
593
|
+
*/
|
|
594
|
+
function hint(revive: ReviveContext | undefined, segment: string | number): ReviveContext | undefined {
|
|
595
|
+
if (!revive) return undefined;
|
|
596
|
+
return { ...revive, nameHint: `${revive.nameHint}${segment}` };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** What `sdkReadExpr` needs to decide whether a body has to be revived, and with which function. */
|
|
600
|
+
interface ReviveContext {
|
|
601
|
+
modelsWithDecimal: Set<string>;
|
|
602
|
+
modelsWithOutput?: Set<string>;
|
|
603
|
+
/** Inline reviver declarations accumulated for the current file, keyed by function name. */
|
|
604
|
+
inlineRevivers: Map<string, string[]>;
|
|
605
|
+
modelMap?: Map<string, ModelNode>;
|
|
606
|
+
/** Distinguishes the inline wrapper emitted for each body. */
|
|
607
|
+
nameHint: string;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** The reviver to apply to a response body, or `null` when the body holds no decimal. */
|
|
611
|
+
function reviveExprFor(bodyType: ContractTypeNode, ctx: ReviveContext | undefined): { name: string; kind: 'value' | 'array' } | null {
|
|
612
|
+
if (!ctx || ctx.modelsWithDecimal.size === 0) return null;
|
|
613
|
+
const opts = { modelsWithDecimal: ctx.modelsWithDecimal, modelsWithOutput: ctx.modelsWithOutput, modelMap: ctx.modelMap };
|
|
614
|
+
if (!typeReachesDecimal(bodyType, opts)) return null;
|
|
615
|
+
|
|
616
|
+
const refName = (t: ContractTypeNode): string | null => (t.kind === 'ref' ? t.name : t.kind === 'lazy' ? refName(t.inner) : null);
|
|
617
|
+
const pick = (name: string) => reviveFnName(name, ctx.modelsWithOutput?.has(name) ? 'output' : 'base');
|
|
618
|
+
|
|
619
|
+
const direct = refName(bodyType);
|
|
620
|
+
if (direct && ctx.modelsWithDecimal.has(direct)) return { name: pick(direct), kind: 'value' };
|
|
621
|
+
|
|
622
|
+
if (bodyType.kind === 'array') {
|
|
623
|
+
const item = refName(bodyType.item);
|
|
624
|
+
if (item && ctx.modelsWithDecimal.has(item)) return { name: pick(item), kind: 'array' };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Anything else — an inline object, a record, a tuple — has no `reviveX` to call, so the file
|
|
628
|
+
// gets a wrapper of its own.
|
|
629
|
+
const fnName = `__revive${ctx.nameHint}`;
|
|
630
|
+
if (!ctx.inlineRevivers.has(fnName)) {
|
|
631
|
+
const decl = renderInlineReviver(fnName, renderOutputTsType(bodyType, ctx.modelsWithOutput), bodyType, opts);
|
|
632
|
+
if (!decl) return null;
|
|
633
|
+
ctx.inlineRevivers.set(fnName, decl);
|
|
634
|
+
}
|
|
635
|
+
return { name: fnName, kind: 'value' };
|
|
499
636
|
}
|
|
500
637
|
|
|
501
638
|
function renderSdkHeadersShape(headers: OpResponseHeaderNode[], modelsWithOutput?: Set<string>): string {
|
|
@@ -535,7 +672,13 @@ function sdkResponseMembers(resp: OpResponseNode, modelsWithOutput: Set<string>
|
|
|
535
672
|
}
|
|
536
673
|
|
|
537
674
|
/** The `return` statement(s) that build one response's member of the return union. */
|
|
538
|
-
function sdkReturnLines(
|
|
675
|
+
function sdkReturnLines(
|
|
676
|
+
resp: OpResponseNode,
|
|
677
|
+
modelsWithOutput: Set<string> | undefined,
|
|
678
|
+
indent: string,
|
|
679
|
+
includeStatus: boolean,
|
|
680
|
+
revive?: ReviveContext,
|
|
681
|
+
): string[] {
|
|
539
682
|
const bodies = resp.bodies;
|
|
540
683
|
const headers = resp.headers ?? [];
|
|
541
684
|
const leading = includeStatus ? [`status: ${resp.statusCode}`] : [];
|
|
@@ -545,7 +688,12 @@ function sdkReturnLines(resp: OpResponseNode, modelsWithOutput: Set<string> | un
|
|
|
545
688
|
return [`${indent}return { ${[...leading, ...trailing].join(', ')} };`];
|
|
546
689
|
}
|
|
547
690
|
if (bodies.length === 1) {
|
|
548
|
-
const fields = [
|
|
691
|
+
const fields = [
|
|
692
|
+
...leading,
|
|
693
|
+
`contentType: '${bodies[0]!.contentType}'`,
|
|
694
|
+
`data: ${sdkReadExpr(bodies[0]!, modelsWithOutput, hint(revive, resp.statusCode))}`,
|
|
695
|
+
...trailing,
|
|
696
|
+
];
|
|
549
697
|
return [`${indent}return { ${fields.join(', ')} };`];
|
|
550
698
|
}
|
|
551
699
|
|
|
@@ -553,19 +701,34 @@ function sdkReturnLines(resp: OpResponseNode, modelsWithOutput: Set<string> | un
|
|
|
553
701
|
if (dataTypes.every(t => t === dataTypes[0])) {
|
|
554
702
|
// Every mime reads the same way, so only the label has to come off the wire.
|
|
555
703
|
const cast = bodies.map(b => `'${b.contentType}'`).join(' | ');
|
|
556
|
-
const fields = [
|
|
704
|
+
const fields = [
|
|
705
|
+
...leading,
|
|
706
|
+
`contentType: readContentType(result) as ${cast}`,
|
|
707
|
+
`data: ${sdkReadExpr(bodies[0]!, modelsWithOutput, hint(revive, resp.statusCode))}`,
|
|
708
|
+
...trailing,
|
|
709
|
+
];
|
|
557
710
|
return [`${indent}return { ${fields.join(', ')} };`];
|
|
558
711
|
}
|
|
559
712
|
|
|
560
713
|
// The mimes read differently, so the client has to dispatch on what actually came back.
|
|
561
714
|
const lines = [`${indent}switch (readContentType(result)) {`];
|
|
562
|
-
for (const body of bodies.slice(1)) {
|
|
563
|
-
const fields = [
|
|
715
|
+
for (const [i, body] of bodies.slice(1).entries()) {
|
|
716
|
+
const fields = [
|
|
717
|
+
...leading,
|
|
718
|
+
`contentType: '${body.contentType}'`,
|
|
719
|
+
`data: ${sdkReadExpr(body, modelsWithOutput, hint(revive, `${resp.statusCode}_${i + 1}`))}`,
|
|
720
|
+
...trailing,
|
|
721
|
+
];
|
|
564
722
|
lines.push(`${indent} case '${body.contentType}':`);
|
|
565
723
|
lines.push(`${indent} return { ${fields.join(', ')} };`);
|
|
566
724
|
}
|
|
567
725
|
const first = bodies[0]!;
|
|
568
|
-
const fallbackFields = [
|
|
726
|
+
const fallbackFields = [
|
|
727
|
+
...leading,
|
|
728
|
+
`contentType: '${first.contentType}'`,
|
|
729
|
+
`data: ${sdkReadExpr(first, modelsWithOutput, hint(revive, `${resp.statusCode}_0`))}`,
|
|
730
|
+
...trailing,
|
|
731
|
+
];
|
|
569
732
|
lines.push(`${indent} default:`);
|
|
570
733
|
lines.push(`${indent} return { ${fallbackFields.join(', ')} };`);
|
|
571
734
|
lines.push(`${indent}}`);
|
|
@@ -1026,7 +1189,7 @@ function collectTypeNodeRefs(type: ContractTypeNode, out: Set<string>): void {
|
|
|
1026
1189
|
|
|
1027
1190
|
// ─── Type import resolution ───────────────────────────────────────────────
|
|
1028
1191
|
|
|
1029
|
-
function generateTypeImports(types: string[], opFile: string, options: SdkCodegenOptions): string[] {
|
|
1192
|
+
function generateTypeImports(types: string[], opFile: string, options: SdkCodegenOptions, revivers: string[] = []): string[] {
|
|
1030
1193
|
const lines: string[] = [];
|
|
1031
1194
|
const { modelOutPaths, outPath } = options;
|
|
1032
1195
|
|
|
@@ -1051,6 +1214,9 @@ function generateTypeImports(types: string[], opFile: string, options: SdkCodege
|
|
|
1051
1214
|
rel = rel.replace(/\.ts$/, '.js');
|
|
1052
1215
|
if (!rel.startsWith('.')) rel = './' + rel;
|
|
1053
1216
|
lines.push(`import type { ${names.sort().join(', ')} } from '${rel}';`);
|
|
1217
|
+
// Revivers are values, so they need a second, non-type import from the same module.
|
|
1218
|
+
const fromHere = revivers.filter(r => modelOutPaths.get(reviverModelName(r, names)) === typeOutPath);
|
|
1219
|
+
if (fromHere.length > 0) lines.push(`import { ${fromHere.sort().join(', ')} } from '${rel}';`);
|
|
1054
1220
|
}
|
|
1055
1221
|
|
|
1056
1222
|
for (const type of unresolved) {
|
|
@@ -1065,6 +1231,18 @@ function generateTypeImports(types: string[], opFile: string, options: SdkCodege
|
|
|
1065
1231
|
return lines;
|
|
1066
1232
|
}
|
|
1067
1233
|
|
|
1234
|
+
/**
|
|
1235
|
+
* The model a reviver belongs to. `reviveInvoiceOutput` can come from either `Invoice` (with an
|
|
1236
|
+
* `Output` variant) or a model literally called `InvoiceOutput`, so the names actually imported
|
|
1237
|
+
* from the module decide it.
|
|
1238
|
+
*/
|
|
1239
|
+
function reviverModelName(reviver: string, namesInModule: string[]): string {
|
|
1240
|
+
const stem = reviver.replace(/^revive/, '');
|
|
1241
|
+
if (namesInModule.includes(stem)) return stem;
|
|
1242
|
+
const base = stem.replace(/Output$/, '');
|
|
1243
|
+
return namesInModule.includes(base) ? base : stem;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1068
1246
|
function deriveTypeImportPath(file: string, template?: string): string {
|
|
1069
1247
|
const base =
|
|
1070
1248
|
file
|
|
@@ -1180,6 +1358,7 @@ export function generateSdkOptions(): string {
|
|
|
1180
1358
|
const SCAFFOLD_DEP_VERSIONS = {
|
|
1181
1359
|
zod: '^4.3.6',
|
|
1182
1360
|
luxon: '^3.5.0',
|
|
1361
|
+
decimalJs: '^10.4.3',
|
|
1183
1362
|
typesLuxon: '^3.4.2',
|
|
1184
1363
|
typescript: '^6.0.3',
|
|
1185
1364
|
} as const;
|
|
@@ -1188,8 +1367,10 @@ const SCAFFOLD_DEP_VERSIONS = {
|
|
|
1188
1367
|
export interface SdkScaffoldDeps {
|
|
1189
1368
|
/** Zod schema files are emitted (`config.zod`) — the SDK imports `zod`. */
|
|
1190
1369
|
zod: boolean;
|
|
1191
|
-
/** Any covered model uses a `date`/`time`/`datetime`/`interval` scalar — the SDK imports `luxon`. */
|
|
1370
|
+
/** Any covered model uses a `date`/`time`/`datetime`/`duration`/`interval` scalar — the SDK imports `luxon`. */
|
|
1192
1371
|
luxon: boolean;
|
|
1372
|
+
/** Any covered model uses a `decimal` scalar — the SDK imports `decimal.js`. */
|
|
1373
|
+
decimal: boolean;
|
|
1193
1374
|
}
|
|
1194
1375
|
|
|
1195
1376
|
/**
|
|
@@ -1201,6 +1382,8 @@ export function generateSdkPackageJson(input: { name: string; deps: SdkScaffoldD
|
|
|
1201
1382
|
const dependencies: Record<string, string> = {};
|
|
1202
1383
|
if (input.deps.zod) dependencies.zod = SCAFFOLD_DEP_VERSIONS.zod;
|
|
1203
1384
|
if (input.deps.luxon) dependencies.luxon = SCAFFOLD_DEP_VERSIONS.luxon;
|
|
1385
|
+
// No `@types/` half — decimal.js ships its own declarations.
|
|
1386
|
+
if (input.deps.decimal) dependencies['decimal.js'] = SCAFFOLD_DEP_VERSIONS.decimalJs;
|
|
1204
1387
|
|
|
1205
1388
|
const devDependencies: Record<string, string> = { typescript: SCAFFOLD_DEP_VERSIONS.typescript };
|
|
1206
1389
|
if (input.deps.luxon) devDependencies['@types/luxon'] = SCAFFOLD_DEP_VERSIONS.typesLuxon;
|
|
@@ -1330,6 +1513,10 @@ export function generateAreaClient(input: AreaClientInput): string {
|
|
|
1330
1513
|
|
|
1331
1514
|
// ── Merge inputs across all inline files ────────────────────────────────
|
|
1332
1515
|
const collectedMethodLines: string[] = [];
|
|
1516
|
+
const collectedRevivePrelude: string[] = [];
|
|
1517
|
+
let areaNeedsDecimalImport = false;
|
|
1518
|
+
/** Reviver value imports, grouped the same way `typesByImportPath` groups the type imports. */
|
|
1519
|
+
const reviversByImportPath = new Map<string, Set<string>>();
|
|
1333
1520
|
// Aliases are keyed off method names, which already collide-check below, so a Set is enough.
|
|
1334
1521
|
const collectedErrorAliases = new Set<string>();
|
|
1335
1522
|
const seenMethods = new Set<string>();
|
|
@@ -1343,7 +1530,9 @@ export function generateAreaClient(input: AreaClientInput): string {
|
|
|
1343
1530
|
|
|
1344
1531
|
for (const inline of inlineFiles) {
|
|
1345
1532
|
const includeInternal = inline.codegenOptions.includeInternal ?? false;
|
|
1346
|
-
const { lines: methodLines, methodNames } = generateClientMethods(inline.root, inline.codegenOptions);
|
|
1533
|
+
const { lines: methodLines, methodNames, preludeLines, needsDecimalImport } = generateClientMethods(inline.root, inline.codegenOptions);
|
|
1534
|
+
collectedRevivePrelude.push(...preludeLines);
|
|
1535
|
+
if (needsDecimalImport) areaNeedsDecimalImport = true;
|
|
1347
1536
|
for (const name of methodNames) {
|
|
1348
1537
|
if (seenMethods.has(name)) {
|
|
1349
1538
|
throw new Error(
|
|
@@ -1360,6 +1549,19 @@ export function generateAreaClient(input: AreaClientInput): string {
|
|
|
1360
1549
|
if (sdkNeedsQueryString(inline.root, includeInternal)) needsQueryString = true;
|
|
1361
1550
|
if (sdkNeedsReadContentType(inline.root, includeInternal)) needsReadContentType = true;
|
|
1362
1551
|
|
|
1552
|
+
// Revivers this file's methods call, resolved against the same modelOutPaths. Derived from
|
|
1553
|
+
// the emitted lines, exactly as `generateSdk` does, so the two paths cannot disagree.
|
|
1554
|
+
for (const reviver of usedRevivers(methodLines)) {
|
|
1555
|
+
const stem = reviver.replace(/^revive/, '');
|
|
1556
|
+
const modelOut = inline.codegenOptions.modelOutPaths?.get(stem) ?? inline.codegenOptions.modelOutPaths?.get(stem.replace(/Output$/, ''));
|
|
1557
|
+
if (!modelOut) continue;
|
|
1558
|
+
let rel = relative(dirname(outPath), modelOut).replace(/\.ts$/, '.js');
|
|
1559
|
+
if (!rel.startsWith('.')) rel = './' + rel;
|
|
1560
|
+
const set = reviversByImportPath.get(rel) ?? new Set<string>();
|
|
1561
|
+
set.add(reviver);
|
|
1562
|
+
reviversByImportPath.set(rel, set);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1363
1565
|
// Resolve each file's type refs against THIS file's modelOutPaths, but
|
|
1364
1566
|
// produce import paths relative to the area client's outPath (not the
|
|
1365
1567
|
// contributing file's outPath, which pointed at the now-defunct sdk.ts).
|
|
@@ -1406,11 +1608,15 @@ export function generateAreaClient(input: AreaClientInput): string {
|
|
|
1406
1608
|
for (const path of [...typesByImportPath.keys()].sort()) {
|
|
1407
1609
|
const names = [...typesByImportPath.get(path)!].sort();
|
|
1408
1610
|
lines.push(`import type { ${names.join(', ')} } from '${path}';`);
|
|
1611
|
+
const revivers = reviversByImportPath.get(path);
|
|
1612
|
+
if (revivers && revivers.size > 0) lines.push(`import { ${[...revivers].sort().join(', ')} } from '${path}';`);
|
|
1409
1613
|
}
|
|
1410
1614
|
for (const t of [...unresolvedTypes].sort()) {
|
|
1411
1615
|
lines.push(`import type { ${t} } from './${pascalToDotCase(t)}.js';`);
|
|
1412
1616
|
}
|
|
1413
1617
|
|
|
1618
|
+
if (areaNeedsDecimalImport) lines.push(DECIMAL_IMPORT);
|
|
1619
|
+
|
|
1414
1620
|
// Leaf client imports (subareas only — top-level clients live next to sdk.ts).
|
|
1415
1621
|
const importedClients = new Set<string>();
|
|
1416
1622
|
for (const sc of subareaClients) {
|
|
@@ -1426,6 +1632,11 @@ export function generateAreaClient(input: AreaClientInput): string {
|
|
|
1426
1632
|
lines.push('');
|
|
1427
1633
|
}
|
|
1428
1634
|
|
|
1635
|
+
if (collectedRevivePrelude.length > 0) {
|
|
1636
|
+
lines.push(...collectedRevivePrelude);
|
|
1637
|
+
lines.push('');
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1429
1640
|
// ── <Area>Client class ──────────────────────────────────────────────────
|
|
1430
1641
|
lines.push(`export class ${className} {`);
|
|
1431
1642
|
for (const sc of subareaClients) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The emitted decimal.js runtime shared by every generator that renders a `decimal` scalar.
|
|
3
|
+
*
|
|
4
|
+
* `_ZodBinary`/`_ZodDatetime`/`_ZodInterval` are duplicated as literals across codegen-contract,
|
|
5
|
+
* codegen-operation and codegen-mcp, and have already drifted once (the three files emit their
|
|
6
|
+
* luxon import lists in two different orders). The decimal runtime is defined once here so the
|
|
7
|
+
* three call sites cannot disagree about it; each still decides *whether* to emit it with its own
|
|
8
|
+
* detection strategy, which is the part that legitimately differs between them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The decimal.js import — **named, not default**.
|
|
13
|
+
*
|
|
14
|
+
* `decimal.d.ts` declares `Decimal` three times over: a class, a namespace, and a function, with
|
|
15
|
+
* `export default Decimal` alongside. Under the `NodeNext` resolution the scaffolded SDK uses
|
|
16
|
+
* (`module: NodeNext` + `"type": "module"`), the default export resolves to the *namespace*
|
|
17
|
+
* meaning, so `import Decimal from 'decimal.js'` fails to compile with "Cannot use namespace
|
|
18
|
+
* 'Decimal' as a type" and "Property 'set' does not exist". The named import binds the merged
|
|
19
|
+
* class and is the only form that typechecks.
|
|
20
|
+
*/
|
|
21
|
+
export const DECIMAL_IMPORT = `import { Decimal } from 'decimal.js';`;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Global decimal.js configuration, emitted in **every** file that imports `Decimal` — plain-types
|
|
25
|
+
* mode included, where there is no Zod schema but `String(value)` and `JSON.stringify` still run.
|
|
26
|
+
*
|
|
27
|
+
* Load-bearing, not cosmetic. decimal.js switches to exponential
|
|
28
|
+
* notation outside `toExpNeg`/`toExpPos` (defaults -7/21), so without it `new Decimal('0.00000001')`
|
|
29
|
+
* serializes as `"1e-8"` and any peer validating `^-?\d+(\.\d+)?$` rejects it. It is the only lever
|
|
30
|
+
* that reaches the `JSON.stringify` Koa runs over `ctx.body`, which we do not otherwise control.
|
|
31
|
+
*
|
|
32
|
+
*/
|
|
33
|
+
export const DECIMAL_CONFIG_LINE = `Decimal.set({ toExpNeg: -9e15, toExpPos: 9e15 });`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The Zod schema for a `decimal`.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately has no output `.transform()`. `isRevalidatable` in codegen-operation treats every
|
|
39
|
+
* scalar as idempotent under re-parse — the assumption `server.validateResponses` rests on — and
|
|
40
|
+
* preprocess passes an already-`Decimal` value through untouched, so it holds.
|
|
41
|
+
*
|
|
42
|
+
* A raw JSON number fails validation rather than being coerced: by the time one reaches us it has
|
|
43
|
+
* already been through an IEEE-754 double, which is the loss this scalar exists to prevent. Bad
|
|
44
|
+
* strings are returned unchanged from preprocess rather than throwing, so they surface as an
|
|
45
|
+
* ordinary Zod issue instead of a `DecimalError` escaping the parse.
|
|
46
|
+
*/
|
|
47
|
+
export const DECIMAL_ZOD_SCHEMA_LINE = `const _ZodDecimal = z.preprocess((val) => { if (typeof val !== 'string') return val; try { return new Decimal(val); } catch { return val; } }, z.custom<Decimal>((val) => Decimal.isDecimal(val), { message: 'Must be an exact decimal sent as a quoted string, e.g. "1250.00"' }));`;
|
|
48
|
+
|
|
49
|
+
/** The decimal runtime for a file that also holds Zod schemas, in emission order. */
|
|
50
|
+
export const DECIMAL_PRELUDE_LINES: readonly string[] = [DECIMAL_CONFIG_LINE, DECIMAL_ZOD_SCHEMA_LINE];
|