@contractkit/plugin-typescript 0.33.3 → 0.34.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 +4 -4
- package/.turbo/turbo-test$colon$ci.log +26 -25
- package/CHANGELOG.md +267 -0
- package/dist/codegen-contract.d.ts +22 -6
- package/dist/codegen-contract.d.ts.map +1 -1
- package/dist/codegen-mcp.d.ts.map +1 -1
- 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 +38 -4
- package/dist/codegen-revive.d.ts.map +1 -1
- package/dist/codegen-sdk.d.ts +2 -0
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +408 -198
- package/dist/index.js.map +1 -1
- package/dist/path-utils.d.ts +9 -0
- package/dist/path-utils.d.ts.map +1 -1
- package/dist/ts-render.d.ts +14 -0
- package/dist/ts-render.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/codegen-contract.ts +84 -68
- package/src/codegen-mcp.ts +7 -7
- package/src/codegen-operation.ts +32 -19
- package/src/codegen-plain-types.ts +15 -8
- package/src/codegen-revive.ts +140 -20
- package/src/codegen-sdk.ts +322 -63
- package/src/index.ts +46 -4
- package/src/path-utils.ts +10 -0
- package/src/ts-render.ts +26 -0
- package/tests/codegen-contract.test.ts +87 -32
- package/tests/codegen-operation.test.ts +68 -8
- package/tests/codegen-plain-types.test.ts +13 -5
- package/tests/codegen-sdk.test.ts +265 -10
- package/tests/codegen-server.test.ts +43 -1
- package/tests/helpers.ts +7 -2
- package/tests/pipeline.test.ts +51 -4
- package/tests/ts-render.test.ts +29 -0
package/src/codegen-sdk.ts
CHANGED
|
@@ -8,13 +8,23 @@ import type {
|
|
|
8
8
|
OpResponseHeaderNode,
|
|
9
9
|
ContractTypeNode,
|
|
10
10
|
ModelNode,
|
|
11
|
+
OpParamNode,
|
|
11
12
|
ParamSource,
|
|
13
|
+
ScalarTypeNode,
|
|
12
14
|
} from '@contractkit/core';
|
|
13
|
-
import { resolveModifiers, isJsonMime, classifyContentType, observableResponses, thrownResponses } from '@contractkit/core';
|
|
14
|
-
import {
|
|
15
|
+
import { resolveModifiers, isJsonMime, classifyContentType, observableResponses, thrownResponses, PATH_PARAM_RE_G, toIdentifier } from '@contractkit/core';
|
|
16
|
+
import {
|
|
17
|
+
renderInputTsType,
|
|
18
|
+
renderOutputTsType,
|
|
19
|
+
quoteKey,
|
|
20
|
+
headerNameToProperty,
|
|
21
|
+
escapeJsDocLines,
|
|
22
|
+
sourceLink,
|
|
23
|
+
JSON_VALUE_TYPE_DECL,
|
|
24
|
+
} from './ts-render.js';
|
|
15
25
|
import { pascalToDotCase, typeNeedsScalar } from './codegen-contract.js';
|
|
16
26
|
import { bodyTypesStructurallyEqual } from './codegen-operation.js';
|
|
17
|
-
import { reviveFnName, renderInlineReviver, typeReachesDecimal,
|
|
27
|
+
import { reviveFnName, renderInlineReviver, typeReachesDecimal, coerceDeclsFor, coerceLuxonImports } from './codegen-revive.js';
|
|
18
28
|
import { DECIMAL_IMPORT, DECIMAL_CONFIG_LINE } from './decimal-runtime.js';
|
|
19
29
|
import { basename, dirname, relative } from 'path';
|
|
20
30
|
|
|
@@ -86,6 +96,8 @@ export interface SdkCodegenOptions {
|
|
|
86
96
|
modelsWithOutput?: Set<string>;
|
|
87
97
|
/** Model names carrying a `decimal`, whose response bodies need rehydrating client-side. */
|
|
88
98
|
modelsWithDecimal?: Set<string>;
|
|
99
|
+
/** Model names carrying a `bigint`, directly or transitively. Selects the JSON reviver. */
|
|
100
|
+
modelsWithBigInt?: Set<string>;
|
|
89
101
|
/** Every model in scope, for resolving discriminated-union members inside an inline reviver. */
|
|
90
102
|
modelMap?: Map<string, ModelNode>;
|
|
91
103
|
/**
|
|
@@ -139,19 +151,31 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
|
|
|
139
151
|
const mods = resolveModifiers(route, op);
|
|
140
152
|
if (!includeInternal && mods.includes('internal')) continue;
|
|
141
153
|
classBody.push('');
|
|
142
|
-
if (mods.includes('deprecated')) classBody.push(' /** @deprecated */');
|
|
143
154
|
classBody.push(...generateMethod(route, op, root.file, options, inlineRevivers));
|
|
144
155
|
}
|
|
145
156
|
}
|
|
146
157
|
|
|
147
158
|
const inlineReviverDecls = [...inlineRevivers.values()].flat();
|
|
148
159
|
const decimalPrelude = decimalPreludeFor(inlineReviverDecls);
|
|
160
|
+
// Computed here rather than at its splice point below, so the import filter can see the
|
|
161
|
+
// aliases: an error-body alias is a genuine reference to a model type.
|
|
162
|
+
const errorAliases = generateErrorBodyAliases(root, options);
|
|
149
163
|
|
|
150
164
|
// Type-only imports, plus the model revivers the methods actually call.
|
|
151
|
-
|
|
152
|
-
|
|
165
|
+
//
|
|
166
|
+
// `collectTypes` walks the AST and so reports every model a request body names, including one
|
|
167
|
+
// carried by a `multipart/form-data` body — which `buildMethodParams` types as `FormData`, so
|
|
168
|
+
// the model is never mentioned in the emitted code and its import is unused. Rather than
|
|
169
|
+
// special-casing multipart in `collectTypes`, which validating multipart bodies would later
|
|
170
|
+
// have to undo, keep only the types the emitted text actually names. This is the same
|
|
171
|
+
// text-derived idiom the reviver imports already use just below.
|
|
172
|
+
const referenced = referencedTypes(types, [...classBody, ...errorAliases, ...inlineReviverDecls]);
|
|
173
|
+
if (referenced.length > 0) {
|
|
174
|
+
lines.push(...generateTypeImports(referenced, root.file, options, usedRevivers(classBody)));
|
|
153
175
|
}
|
|
154
176
|
lines.push(...decimalPrelude.imports);
|
|
177
|
+
const headerLuxon = headerLuxonImport(classBody, decimalPrelude.imports);
|
|
178
|
+
if (headerLuxon) lines.push(headerLuxon);
|
|
155
179
|
|
|
156
180
|
// SdkOptions import (from shared file) or inline fallback
|
|
157
181
|
if (options.sdkOptionsPath && options.outPath) {
|
|
@@ -162,7 +186,11 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
|
|
|
162
186
|
lines.push(`import type { SdkFetch${jsonImport} } from '${rel}';`);
|
|
163
187
|
const valueImports: string[] = [];
|
|
164
188
|
if (sdkNeedsBigIntReplacer(root, includeInternal)) valueImports.push('bigIntReplacer');
|
|
165
|
-
|
|
189
|
+
// Aliased rather than emitted at each call site: the method bodies are identical either
|
|
190
|
+
// way, and the import line is where the choice is legible.
|
|
191
|
+
if (sdkParsesJsonResponse(root, includeInternal)) {
|
|
192
|
+
valueImports.push(sdkResponsesUseBigInt(root, options, includeInternal) ? 'parseJsonWithBigInt as parseJson' : 'parseJson');
|
|
193
|
+
}
|
|
166
194
|
if (sdkNeedsQueryString(root, includeInternal)) valueImports.push('buildQueryString');
|
|
167
195
|
if (sdkNeedsReadContentType(root, includeInternal)) valueImports.push('readContentType');
|
|
168
196
|
if (valueImports.length > 0) {
|
|
@@ -239,7 +267,11 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
|
|
|
239
267
|
lines.push('}');
|
|
240
268
|
lines.push('');
|
|
241
269
|
lines.push('export async function parseJson<T>(res: Response): Promise<T> {');
|
|
242
|
-
lines.push(
|
|
270
|
+
lines.push(
|
|
271
|
+
sdkResponsesUseBigInt(root, options, includeInternal)
|
|
272
|
+
? ' return JSON.parse(await res.text(), bigIntReviver) as T;'
|
|
273
|
+
: ' return JSON.parse(await res.text()) as T;',
|
|
274
|
+
);
|
|
243
275
|
lines.push('}');
|
|
244
276
|
}
|
|
245
277
|
|
|
@@ -249,7 +281,6 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
|
|
|
249
281
|
|
|
250
282
|
lines.push('');
|
|
251
283
|
|
|
252
|
-
const errorAliases = generateErrorBodyAliases(root, options);
|
|
253
284
|
if (errorAliases.length > 0) {
|
|
254
285
|
lines.push(...errorAliases);
|
|
255
286
|
lines.push('');
|
|
@@ -268,8 +299,7 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
|
|
|
268
299
|
|
|
269
300
|
// Client class
|
|
270
301
|
lines.push('/**');
|
|
271
|
-
|
|
272
|
-
lines.push(` * generated from [${basename(root.file)}](file://./${relFile})`);
|
|
302
|
+
lines.push(` * generated from ${sourceLink(basename(root.file), options.outPath, root.file)}`);
|
|
273
303
|
lines.push(' */');
|
|
274
304
|
lines.push(`export class ${clientClassName} {`);
|
|
275
305
|
lines.push(' constructor(private fetch: SdkFetch) {}');
|
|
@@ -307,7 +337,6 @@ export function generateClientMethods(
|
|
|
307
337
|
const mods = resolveModifiers(route, op);
|
|
308
338
|
if (!includeInternal && mods.includes('internal')) continue;
|
|
309
339
|
lines.push('');
|
|
310
|
-
if (mods.includes('deprecated')) lines.push(' /** @deprecated */');
|
|
311
340
|
lines.push(...generateMethod(route, op, root.file, options, inlineRevivers));
|
|
312
341
|
methodNames.push(deriveMethodName(op, route));
|
|
313
342
|
}
|
|
@@ -328,11 +357,51 @@ export function generateClientMethods(
|
|
|
328
357
|
* config, since nothing else in the file necessarily pulls them in.
|
|
329
358
|
*/
|
|
330
359
|
function decimalPreludeFor(declLines: string[]): { imports: string[]; decls: string[] } {
|
|
331
|
-
|
|
332
|
-
return { imports: [
|
|
360
|
+
const decls = coerceDeclsFor(declLines);
|
|
361
|
+
if (decls.length === 0) return { imports: [], decls: [] };
|
|
362
|
+
|
|
363
|
+
const imports: string[] = [];
|
|
364
|
+
const preamble: string[] = [];
|
|
365
|
+
if (declLines.some(l => l.includes('__dec('))) {
|
|
366
|
+
imports.push(DECIMAL_IMPORT);
|
|
367
|
+
// The global config keeps decimals out of exponential notation; only decimal.js needs it.
|
|
368
|
+
preamble.push(DECIMAL_CONFIG_LINE, '');
|
|
369
|
+
}
|
|
370
|
+
const luxon = coerceLuxonImports(declLines);
|
|
371
|
+
if (luxon.length > 0) imports.push(`import { ${luxon.join(', ')} } from 'luxon';`);
|
|
372
|
+
|
|
373
|
+
return { imports, decls: [...preamble, ...decls] };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The `luxon` import a client needs for its response-header coercions, or undefined.
|
|
378
|
+
*
|
|
379
|
+
* Header conversions call `DateTime.fromISO` and friends directly rather than through a reviver,
|
|
380
|
+
* so they are invisible to `decimalPreludeFor`. Decided from the emitted method bodies, like every
|
|
381
|
+
* other import in these files, and skipped when the reviver prelude already brought luxon in.
|
|
382
|
+
*/
|
|
383
|
+
function headerLuxonImport(methodLines: string[], existingImports: string[]): string | undefined {
|
|
384
|
+
if (existingImports.some(l => l.includes("from 'luxon'"))) return undefined;
|
|
385
|
+
const needed = ['DateTime', 'Duration'].filter(c => methodLines.some(l => l.includes(`${c}.from`)));
|
|
386
|
+
return needed.length > 0 ? `import { ${needed.join(', ')} } from 'luxon';` : undefined;
|
|
333
387
|
}
|
|
334
388
|
|
|
335
389
|
/** Model reviver names referenced by generated method bodies. `__revive…` wrappers are local. */
|
|
390
|
+
/**
|
|
391
|
+
* Narrow a collected type list to the names the emitted code actually mentions.
|
|
392
|
+
*
|
|
393
|
+
* Boundaries are spelled out rather than using `\b`, so a model name containing `-`, `.` or `$`
|
|
394
|
+
* is matched correctly. A name appearing only inside a doc string counts as a reference and the
|
|
395
|
+
* import is kept: an unnecessary import is untidy, a missing one does not compile.
|
|
396
|
+
*/
|
|
397
|
+
function referencedTypes(types: string[], emitted: string[]): string[] {
|
|
398
|
+
const haystack = emitted.join('\n');
|
|
399
|
+
return types.filter(t => {
|
|
400
|
+
const escaped = t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
401
|
+
return new RegExp(`(?<![A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`).test(haystack);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
336
405
|
function usedRevivers(lines: string[]): string[] {
|
|
337
406
|
const found = new Set<string>();
|
|
338
407
|
for (const m of lines.join('\n').matchAll(/\brevive[A-Z]\w*/g)) found.add(m[0]);
|
|
@@ -360,6 +429,8 @@ function generateMethod(
|
|
|
360
429
|
: undefined;
|
|
361
430
|
const lines: string[] = [];
|
|
362
431
|
const methodName = deriveMethodName(op, route);
|
|
432
|
+
/** Identifies the operation in a codegen rejection, which the CLI scopes to this plugin. */
|
|
433
|
+
const where = `${op.method.toUpperCase()} ${route.path}`;
|
|
363
434
|
const mRevive = hint(revive, `${methodName.charAt(0).toUpperCase()}${methodName.slice(1)}`);
|
|
364
435
|
const httpMethod = op.method.toUpperCase();
|
|
365
436
|
const { modelsWithInput, modelsWithOutput } = options;
|
|
@@ -408,11 +479,16 @@ function generateMethod(
|
|
|
408
479
|
// JSDoc
|
|
409
480
|
const desc = op.description ?? route.description;
|
|
410
481
|
const errorBodyName = thrown.some(r => r.bodies.length > 0) ? errorBodyTypeName(route, op) : undefined;
|
|
411
|
-
|
|
482
|
+
// `@deprecated` belongs in this block rather than in one of its own: TypeScript honours only
|
|
483
|
+
// the JSDoc comment adjacent to the declaration, so a separate `/** @deprecated */` above a
|
|
484
|
+
// description block is dropped by editors entirely. Tag order mirrors the router's.
|
|
485
|
+
const deprecated = resolveModifiers(route, op).includes('deprecated');
|
|
486
|
+
if (op.name || desc || errorBodyName || deprecated) {
|
|
412
487
|
const tags: string[] = [];
|
|
413
488
|
if (op.name) tags.push(`@name ${op.name}`);
|
|
414
489
|
if (desc) tags.push(`@description ${desc}`);
|
|
415
490
|
if (errorBodyName) tags.push(`@throws {SdkError<${errorBodyName}>} on ${thrown.map(r => r.statusCode).join(', ')}`);
|
|
491
|
+
if (deprecated) tags.push('@deprecated');
|
|
416
492
|
const contentLines = tags.flatMap(t => escapeJsDocLines(t));
|
|
417
493
|
if (contentLines.length === 1) {
|
|
418
494
|
lines.push(` /** ${contentLines[0]} */`);
|
|
@@ -530,15 +606,15 @@ function generateMethod(
|
|
|
530
606
|
lines.push(` switch (result.status) {`);
|
|
531
607
|
for (const resp of rest) {
|
|
532
608
|
lines.push(` case ${resp.statusCode}:`);
|
|
533
|
-
lines.push(...sdkReturnLines(resp, modelsWithOutput, ' ', true, mRevive));
|
|
609
|
+
lines.push(...sdkReturnLines(resp, modelsWithOutput, ' ', true, mRevive, where));
|
|
534
610
|
}
|
|
535
611
|
lines.push(` default:`);
|
|
536
|
-
lines.push(...sdkReturnLines(fallback!, modelsWithOutput, ' ', true, mRevive));
|
|
612
|
+
lines.push(...sdkReturnLines(fallback!, modelsWithOutput, ' ', true, mRevive, where));
|
|
537
613
|
lines.push(` }`);
|
|
538
614
|
} else if (primaryBodies.length > 1) {
|
|
539
|
-
lines.push(...sdkReturnLines(primaryResponse!, modelsWithOutput, ' ', false, mRevive));
|
|
615
|
+
lines.push(...sdkReturnLines(primaryResponse!, modelsWithOutput, ' ', false, mRevive, where));
|
|
540
616
|
} else if (hasRespHeaders) {
|
|
541
|
-
const headerEntries = sdkHeaderEntries(respHeaders);
|
|
617
|
+
const headerEntries = sdkHeaderEntries(respHeaders, where);
|
|
542
618
|
if (isVoid) {
|
|
543
619
|
lines.push(` return { headers: { ${headerEntries} } };`);
|
|
544
620
|
} else {
|
|
@@ -599,6 +675,8 @@ function hint(revive: ReviveContext | undefined, segment: string | number): Revi
|
|
|
599
675
|
/** What `sdkReadExpr` needs to decide whether a body has to be revived, and with which function. */
|
|
600
676
|
interface ReviveContext {
|
|
601
677
|
modelsWithDecimal: Set<string>;
|
|
678
|
+
/** Which scalars need rehydrating; see `ReviveCodegenOptions.revivableScalars`. */
|
|
679
|
+
revivableScalars?: ReadonlySet<ScalarTypeNode['name']>;
|
|
602
680
|
modelsWithOutput?: Set<string>;
|
|
603
681
|
/** Inline reviver declarations accumulated for the current file, keyed by function name. */
|
|
604
682
|
inlineRevivers: Map<string, string[]>;
|
|
@@ -607,10 +685,15 @@ interface ReviveContext {
|
|
|
607
685
|
nameHint: string;
|
|
608
686
|
}
|
|
609
687
|
|
|
610
|
-
/** The reviver to apply to a response body, or `null` when the body holds
|
|
688
|
+
/** The reviver to apply to a response body, or `null` when the body holds nothing to rehydrate. */
|
|
611
689
|
function reviveExprFor(bodyType: ContractTypeNode, ctx: ReviveContext | undefined): { name: string; kind: 'value' | 'array' } | null {
|
|
612
690
|
if (!ctx || ctx.modelsWithDecimal.size === 0) return null;
|
|
613
|
-
const opts = {
|
|
691
|
+
const opts = {
|
|
692
|
+
modelsWithDecimal: ctx.modelsWithDecimal,
|
|
693
|
+
revivableScalars: ctx.revivableScalars,
|
|
694
|
+
modelsWithOutput: ctx.modelsWithOutput,
|
|
695
|
+
modelMap: ctx.modelMap,
|
|
696
|
+
};
|
|
614
697
|
if (!typeReachesDecimal(bodyType, opts)) return null;
|
|
615
698
|
|
|
616
699
|
const refName = (t: ContractTypeNode): string | null => (t.kind === 'ref' ? t.name : t.kind === 'lazy' ? refName(t.inner) : null);
|
|
@@ -642,8 +725,79 @@ function renderSdkHeadersShape(headers: OpResponseHeaderNode[], modelsWithOutput
|
|
|
642
725
|
return `{ ${fields.join('; ')} }`;
|
|
643
726
|
}
|
|
644
727
|
|
|
645
|
-
|
|
646
|
-
|
|
728
|
+
/**
|
|
729
|
+
* The value expression for one response header, matching the type `renderSdkHeadersShape` gives it.
|
|
730
|
+
*
|
|
731
|
+
* Header values always arrive as strings, but the shape is typed from the contract — so a header
|
|
732
|
+
* declared `int` was typed `number` and assigned `string | undefined`, and a required one was
|
|
733
|
+
* typed `T` and assigned `T | undefined`. Both are `TS2322`, in opposite directions.
|
|
734
|
+
*
|
|
735
|
+
* `null` is what `Headers.get` returns for an absent header. A required header is asserted rather
|
|
736
|
+
* than defaulted, because the contract says the service always sends it; an optional one maps the
|
|
737
|
+
* absence onto `undefined`, which is what its `?` in the shape means.
|
|
738
|
+
*
|
|
739
|
+
* Anything that is not one of the scalars below cannot be read from a header at all, and is
|
|
740
|
+
* rejected here rather than emitted as code that does not compile. The list is shared with the
|
|
741
|
+
* Python SDK, except that temporals need coercion there — `renderPyType` maps them to `datetime`
|
|
742
|
+
* objects, while `renderOutputTsType` maps them to `string`.
|
|
743
|
+
*/
|
|
744
|
+
function sdkHeaderEntry(h: OpResponseHeaderNode, where: string): string {
|
|
745
|
+
const raw = `result.headers.get('${h.name}')`;
|
|
746
|
+
const key = quoteKey(headerNameToProperty(h.name));
|
|
747
|
+
const scalar = h.type.kind === 'scalar' ? h.type.name : undefined;
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Wrap a conversion so an optional header maps an absent value onto `undefined`.
|
|
751
|
+
*
|
|
752
|
+
* The value is asserted non-null in both branches. TypeScript does not carry a narrowing from
|
|
753
|
+
* `get(x) === null` across a *second* `get(x)` call, and the conversions below take a `string`
|
|
754
|
+
* — so without the assertion the optional form is a TS2345 even though the ternary has
|
|
755
|
+
* already excluded null.
|
|
756
|
+
*/
|
|
757
|
+
const convert = (expr: (v: string) => string) =>
|
|
758
|
+
`${key}: ${h.optional ? `${raw} === null ? undefined : ${expr(`${raw}!`)}` : expr(`${raw}!`)}`;
|
|
759
|
+
|
|
760
|
+
switch (scalar) {
|
|
761
|
+
case 'string':
|
|
762
|
+
case 'email':
|
|
763
|
+
case 'url':
|
|
764
|
+
case 'uuid':
|
|
765
|
+
case 'interval':
|
|
766
|
+
case 'unknown':
|
|
767
|
+
return `${key}: ${raw}${h.optional ? ' ?? undefined' : '!'}`;
|
|
768
|
+
// Temporals are Luxon objects since the SDK started reviving them, so the shape
|
|
769
|
+
// `renderOutputTsType` produces says `DateTime` and the raw string no longer satisfies it.
|
|
770
|
+
// The format for date and time comes from the contract, as it does in the reviver.
|
|
771
|
+
case 'datetime':
|
|
772
|
+
return convert(v => `DateTime.fromISO(${v})`);
|
|
773
|
+
case 'duration':
|
|
774
|
+
return convert(v => `Duration.fromISO(${v})`);
|
|
775
|
+
case 'date':
|
|
776
|
+
return convert(v => `DateTime.fromFormat(${v}, '${(h.type.kind === 'scalar' && h.type.format) || 'yyyy-MM-dd'}')`);
|
|
777
|
+
case 'time':
|
|
778
|
+
return convert(v => `DateTime.fromFormat(${v}, '${(h.type.kind === 'scalar' && h.type.format) || 'HH:mm:ss'}')`);
|
|
779
|
+
case 'number':
|
|
780
|
+
case 'int':
|
|
781
|
+
return `${key}: ${h.optional ? `${raw} === null ? undefined : Number(${raw})` : `Number(${raw})`}`;
|
|
782
|
+
case 'boolean':
|
|
783
|
+
return `${key}: ${h.optional ? `${raw} === null ? undefined : ${raw} === 'true'` : `${raw} === 'true'`}`;
|
|
784
|
+
case 'bigint':
|
|
785
|
+
return convert(v => `BigInt(${v})`);
|
|
786
|
+
default:
|
|
787
|
+
throw new Error(
|
|
788
|
+
`Response header '${h.name}' on ${where} is declared as ${describeHeaderType(h.type)}, which cannot be read from an HTTP header. ` +
|
|
789
|
+
`Header values arrive as strings — declare it as string, email, url, uuid, a date/time type, int, number, boolean or bigint.`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** A short, contract-facing description of a header type, for the rejection message above. */
|
|
795
|
+
function describeHeaderType(type: ContractTypeNode): string {
|
|
796
|
+
return type.kind === 'scalar' ? `the '${type.name}' scalar` : type.kind === 'ref' ? `the model '${type.name}'` : `${type.kind === 'array' ? 'an' : 'a'} ${type.kind}`;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function sdkHeaderEntries(headers: OpResponseHeaderNode[], where: string): string {
|
|
800
|
+
return headers.map(h => sdkHeaderEntry(h, where)).join(', ');
|
|
647
801
|
}
|
|
648
802
|
|
|
649
803
|
/**
|
|
@@ -677,12 +831,13 @@ function sdkReturnLines(
|
|
|
677
831
|
modelsWithOutput: Set<string> | undefined,
|
|
678
832
|
indent: string,
|
|
679
833
|
includeStatus: boolean,
|
|
680
|
-
revive
|
|
834
|
+
revive: ReviveContext | undefined,
|
|
835
|
+
where: string,
|
|
681
836
|
): string[] {
|
|
682
837
|
const bodies = resp.bodies;
|
|
683
838
|
const headers = resp.headers ?? [];
|
|
684
839
|
const leading = includeStatus ? [`status: ${resp.statusCode}`] : [];
|
|
685
|
-
const trailing = headers.length > 0 ? [`headers: { ${sdkHeaderEntries(headers)} }`] : [];
|
|
840
|
+
const trailing = headers.length > 0 ? [`headers: { ${sdkHeaderEntries(headers, where)} }`] : [];
|
|
686
841
|
|
|
687
842
|
if (bodies.length === 0) {
|
|
688
843
|
return [`${indent}return { ${[...leading, ...trailing].join(', ')} };`];
|
|
@@ -769,10 +924,26 @@ export function generateErrorBodyAliases(root: OpRootNode, options: SdkCodegenOp
|
|
|
769
924
|
|
|
770
925
|
// ─── URL building ─────────────────────────────────────────────────────────
|
|
771
926
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
927
|
+
/**
|
|
928
|
+
* Render a route path as the template-literal body that produces the request URL.
|
|
929
|
+
*
|
|
930
|
+
* `params` says where each value lives. `buildMethodParams` spreads a `params { … }` block across
|
|
931
|
+
* the signature, so those interpolate by bare name; but for `params: SomeModel` it emits a single
|
|
932
|
+
* argument called `params`, and interpolating the bare name then refers to nothing that exists.
|
|
933
|
+
*
|
|
934
|
+
* The placeholder pattern matches what the `.ck` grammar allows rather than just
|
|
935
|
+
* `[a-zA-Z_]\w*`, so a hyphenated `{payment-id}` is interpolated instead of being left in the URL
|
|
936
|
+
* verbatim. Such a name is not a valid property accessor either, hence the bracket form.
|
|
937
|
+
*/
|
|
938
|
+
function buildUrlExpression(path: string, params?: ParamSource): string {
|
|
939
|
+
return path.replace(PATH_PARAM_RE_G, (_m, name: string) => {
|
|
940
|
+
// Spread across the signature: interpolate the identifier `buildMethodParams` bound.
|
|
941
|
+
if (!params || params.kind === 'params') return `\${encodeURIComponent(${toIdentifier(name)})}`;
|
|
942
|
+
// Behind one `params` argument: read the model's field, which keeps its declared spelling
|
|
943
|
+
// and so may need bracket access. `String(...)` because that field may be typed something
|
|
944
|
+
// `encodeURIComponent` does not accept.
|
|
945
|
+
const access = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? `params.${name}` : `params[${JSON.stringify(name)}]`;
|
|
946
|
+
return `\${encodeURIComponent(String(${access}))}`;
|
|
776
947
|
});
|
|
777
948
|
}
|
|
778
949
|
|
|
@@ -791,7 +962,7 @@ function buildMethodParams(route: OpRouteNode, op: OpOperationNode, modelsWithIn
|
|
|
791
962
|
if (route.params) {
|
|
792
963
|
if (route.params.kind === 'params') {
|
|
793
964
|
for (const p of route.params.nodes) {
|
|
794
|
-
params.push({ name: p.name, type: renderInputTsType(p.type, modelsWithInput), optional: false });
|
|
965
|
+
params.push({ name: toIdentifier(p.name), type: renderInputTsType(p.type, modelsWithInput), optional: false });
|
|
795
966
|
}
|
|
796
967
|
} else if (route.params.kind === 'ref') {
|
|
797
968
|
const typeName = modelsWithInput?.has(route.params.name) ? `${route.params.name}Input` : route.params.name;
|
|
@@ -835,33 +1006,55 @@ function buildMethodParams(route: OpRouteNode, op: OpOperationNode, modelsWithIn
|
|
|
835
1006
|
params.push({ name: 'options', type: `{ contentType: ${ctUnion} }`, optional: false });
|
|
836
1007
|
}
|
|
837
1008
|
|
|
838
|
-
// Query (request-side — use Input variants)
|
|
839
|
-
if (op.query)
|
|
840
|
-
|
|
841
|
-
const fields = op.query.nodes.map(p => `${quoteKey(p.name)}?: ${renderInputTsType(p.type, modelsWithInput)}`).join('; ');
|
|
842
|
-
params.push({ name: 'query', type: `{ ${fields} }`, optional: true });
|
|
843
|
-
} else if (op.query.kind === 'ref') {
|
|
844
|
-
const typeName = modelsWithInput?.has(op.query.name) ? `${op.query.name}Input` : op.query.name;
|
|
845
|
-
params.push({ name: 'query', type: typeName, optional: true });
|
|
846
|
-
} else {
|
|
847
|
-
params.push({ name: 'query', type: renderInputTsType(op.query.node, modelsWithInput), optional: true });
|
|
848
|
-
}
|
|
849
|
-
}
|
|
1009
|
+
// Query and custom headers (request-side — use Input variants)
|
|
1010
|
+
if (op.query) params.push(inlineArgParam('query', op.query, modelsWithInput));
|
|
1011
|
+
if (op.headers) params.push(inlineArgParam('customHeaders', op.headers, modelsWithInput));
|
|
850
1012
|
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1013
|
+
return normaliseOptionalOrder(params);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* One whole-object argument built from a `query:` or `headers:` source.
|
|
1018
|
+
*
|
|
1019
|
+
* A field is optional only when the contract says so — with `?`, or by carrying a default, which
|
|
1020
|
+
* the caller may equally omit. The argument itself is optional only when every field is, since a
|
|
1021
|
+
* caller cannot omit an object that must supply something. Both were previously hardcoded
|
|
1022
|
+
* optional, which was wrong in both directions at once: it let a caller omit a value the router
|
|
1023
|
+
* demanded, and typed a required field as one you could leave out.
|
|
1024
|
+
*
|
|
1025
|
+
* A `ref` or a whole type node stays optional. Deciding needs the model's own fields, which
|
|
1026
|
+
* `buildMethodParams` has no access to.
|
|
1027
|
+
*/
|
|
1028
|
+
function inlineArgParam(name: string, source: ParamSource, modelsWithInput?: Set<string>): MethodParam {
|
|
1029
|
+
if (source.kind === 'params') {
|
|
1030
|
+
const isOptional = (p: OpParamNode) => Boolean(p.optional) || p.default !== undefined;
|
|
1031
|
+
const fields = source.nodes
|
|
1032
|
+
.map(p => `${quoteKey(p.name)}${isOptional(p) ? '?' : ''}: ${renderInputTsType(p.type, modelsWithInput)}`)
|
|
1033
|
+
.join('; ');
|
|
1034
|
+
return { name, type: `{ ${fields} }`, optional: source.nodes.every(isOptional) };
|
|
1035
|
+
}
|
|
1036
|
+
if (source.kind === 'ref') {
|
|
1037
|
+
const typeName = modelsWithInput?.has(source.name) ? `${source.name}Input` : source.name;
|
|
1038
|
+
return { name, type: typeName, optional: true };
|
|
862
1039
|
}
|
|
1040
|
+
return { name, type: renderInputTsType(source.node, modelsWithInput), optional: true };
|
|
1041
|
+
}
|
|
863
1042
|
|
|
864
|
-
|
|
1043
|
+
/**
|
|
1044
|
+
* Clear `optional` on every parameter before the last required one.
|
|
1045
|
+
*
|
|
1046
|
+
* Parameter order is path, then body, then query, then customHeaders. An optional parameter
|
|
1047
|
+
* cannot precede a required one in TypeScript (`TS1016`), so an operation with an all-optional
|
|
1048
|
+
* `query:` and an all-required `headers:` would emit `async m(query?: Q, customHeaders: H)` and
|
|
1049
|
+
* fail to compile. Widening the earlier argument to required is the only option that keeps the
|
|
1050
|
+
* positional order the call sites depend on.
|
|
1051
|
+
*
|
|
1052
|
+
* This could not arise while every query and header argument was hardcoded optional; it becomes
|
|
1053
|
+
* reachable the moment optionality is read from the contract.
|
|
1054
|
+
*/
|
|
1055
|
+
function normaliseOptionalOrder(params: MethodParam[]): MethodParam[] {
|
|
1056
|
+
const lastRequired = params.reduce((last, p, i) => (p.optional ? last : i), -1);
|
|
1057
|
+
return params.map((p, i) => (i < lastRequired ? { ...p, optional: false } : p));
|
|
865
1058
|
}
|
|
866
1059
|
|
|
867
1060
|
// ─── Method name inference ────────────────────────────────────────────────
|
|
@@ -1118,8 +1311,8 @@ function sdkNeedsBigIntReplacer(root: OpRootNode, includeInternal = false): bool
|
|
|
1118
1311
|
return false;
|
|
1119
1312
|
}
|
|
1120
1313
|
|
|
1121
|
-
/** True if any public operation parses a JSON response body
|
|
1122
|
-
function
|
|
1314
|
+
/** True if any public operation parses a JSON response body, and so calls `parseJson`. */
|
|
1315
|
+
function sdkParsesJsonResponse(root: OpRootNode, includeInternal = false): boolean {
|
|
1123
1316
|
for (const route of root.routes) {
|
|
1124
1317
|
for (const op of route.operations) {
|
|
1125
1318
|
if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
|
|
@@ -1132,6 +1325,55 @@ function sdkNeedsBigIntReviver(root: OpRootNode, includeInternal = false): boole
|
|
|
1132
1325
|
return false;
|
|
1133
1326
|
}
|
|
1134
1327
|
|
|
1328
|
+
/**
|
|
1329
|
+
* True if a public operation's JSON *response* carries a `bigint`, so the client needs the
|
|
1330
|
+
* `123n` reviver when parsing one.
|
|
1331
|
+
*
|
|
1332
|
+
* Response bodies only: the request side is handled by `bigIntReplacer` on the way out. This is
|
|
1333
|
+
* the test the reviver always needed and never made — it was applied to every client that read
|
|
1334
|
+
* any JSON at all, so a contract with no bigint anywhere still had a legitimate string like
|
|
1335
|
+
* "123n" silently converted.
|
|
1336
|
+
*/
|
|
1337
|
+
function sdkResponsesUseBigInt(root: OpRootNode, options: SdkCodegenOptions, includeInternal = false): boolean {
|
|
1338
|
+
const tainted = options.modelsWithBigInt;
|
|
1339
|
+
const reachesBigInt = (type: ContractTypeNode): boolean => {
|
|
1340
|
+
// `typeNeedsScalar` stops at a `ref` leaf, so the transitive answer has to come from the
|
|
1341
|
+
// precomputed set — a bigint two models down still arrives on the wire as `123n`.
|
|
1342
|
+
switch (type.kind) {
|
|
1343
|
+
case 'ref':
|
|
1344
|
+
return tainted?.has(type.name) ?? false;
|
|
1345
|
+
case 'array':
|
|
1346
|
+
return reachesBigInt(type.item);
|
|
1347
|
+
case 'lazy':
|
|
1348
|
+
return reachesBigInt(type.inner);
|
|
1349
|
+
case 'tuple':
|
|
1350
|
+
return type.items.some(reachesBigInt);
|
|
1351
|
+
case 'record':
|
|
1352
|
+
return reachesBigInt(type.value);
|
|
1353
|
+
case 'union':
|
|
1354
|
+
case 'discriminatedUnion':
|
|
1355
|
+
case 'intersection':
|
|
1356
|
+
return type.members.some(reachesBigInt);
|
|
1357
|
+
case 'inlineObject':
|
|
1358
|
+
return type.fields.some(f => reachesBigInt(f.type));
|
|
1359
|
+
default:
|
|
1360
|
+
return typeNeedsScalar(type, 'bigint');
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
for (const route of root.routes) {
|
|
1365
|
+
for (const op of route.operations) {
|
|
1366
|
+
if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
|
|
1367
|
+
for (const resp of op.responses) {
|
|
1368
|
+
for (const body of resp.bodies) {
|
|
1369
|
+
if (classifyContentType(body.contentType) === 'json' && reachesBigInt(body.bodyType)) return true;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
return false;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1135
1377
|
function sdkNeedsJson(root: OpRootNode, includeInternal = false): boolean {
|
|
1136
1378
|
for (const route of root.routes) {
|
|
1137
1379
|
for (const op of route.operations) {
|
|
@@ -1345,7 +1587,20 @@ export function generateSdkOptions(): string {
|
|
|
1345
1587
|
" return qs ? `?${qs}` : '';",
|
|
1346
1588
|
'}',
|
|
1347
1589
|
'',
|
|
1590
|
+
'/**',
|
|
1591
|
+
' * Read a JSON response body.',
|
|
1592
|
+
' *',
|
|
1593
|
+
' * No reviver: `bigIntReviver` matches any string of the form `123n` anywhere in the',
|
|
1594
|
+
' * document, so a contract with no bigint field would still have a legitimate string like',
|
|
1595
|
+
' * "123n" silently turned into a BigInt. Clients whose contracts do use bigint import',
|
|
1596
|
+
' * `parseJsonWithBigInt` under this name instead.',
|
|
1597
|
+
' */',
|
|
1348
1598
|
'export async function parseJson<T>(res: Response): Promise<T> {',
|
|
1599
|
+
' return JSON.parse(await res.text()) as T;',
|
|
1600
|
+
'}',
|
|
1601
|
+
'',
|
|
1602
|
+
'/** `parseJson` for contracts that declare a bigint, applying the `123n` reviver. */',
|
|
1603
|
+
'export async function parseJsonWithBigInt<T>(res: Response): Promise<T> {',
|
|
1349
1604
|
' return JSON.parse(await res.text(), bigIntReviver) as T;',
|
|
1350
1605
|
'}',
|
|
1351
1606
|
'',
|
|
@@ -1524,6 +1779,7 @@ export function generateAreaClient(input: AreaClientInput): string {
|
|
|
1524
1779
|
const unresolvedTypes = new Set<string>();
|
|
1525
1780
|
let needsJson = false;
|
|
1526
1781
|
let needsBigIntReplacer = false;
|
|
1782
|
+
let needsParseJson = false;
|
|
1527
1783
|
let needsBigIntReviver = false;
|
|
1528
1784
|
let needsQueryString = false;
|
|
1529
1785
|
let needsReadContentType = false;
|
|
@@ -1545,7 +1801,8 @@ export function generateAreaClient(input: AreaClientInput): string {
|
|
|
1545
1801
|
for (const alias of generateErrorBodyAliases(inline.root, inline.codegenOptions)) collectedErrorAliases.add(alias);
|
|
1546
1802
|
if (sdkNeedsJson(inline.root, includeInternal)) needsJson = true;
|
|
1547
1803
|
if (sdkNeedsBigIntReplacer(inline.root, includeInternal)) needsBigIntReplacer = true;
|
|
1548
|
-
if (
|
|
1804
|
+
if (sdkParsesJsonResponse(inline.root, includeInternal)) needsParseJson = true;
|
|
1805
|
+
if (sdkResponsesUseBigInt(inline.root, inline.codegenOptions, includeInternal)) needsBigIntReviver = true;
|
|
1549
1806
|
if (sdkNeedsQueryString(inline.root, includeInternal)) needsQueryString = true;
|
|
1550
1807
|
if (sdkNeedsReadContentType(inline.root, includeInternal)) needsReadContentType = true;
|
|
1551
1808
|
|
|
@@ -1565,11 +1822,11 @@ export function generateAreaClient(input: AreaClientInput): string {
|
|
|
1565
1822
|
// Resolve each file's type refs against THIS file's modelOutPaths, but
|
|
1566
1823
|
// produce import paths relative to the area client's outPath (not the
|
|
1567
1824
|
// contributing file's outPath, which pointed at the now-defunct sdk.ts).
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
inline.codegenOptions.modelsWithOutput,
|
|
1572
|
-
|
|
1825
|
+
// Filtered against this file's own emitted text for the same reason `generateSdk` filters
|
|
1826
|
+
// its own — a multipart body's model is collected but never named in the output.
|
|
1827
|
+
const typesForFile = referencedTypes(
|
|
1828
|
+
collectTypes(inline.root, inline.codegenOptions.modelsWithInput, inline.codegenOptions.modelsWithOutput, includeInternal),
|
|
1829
|
+
[...methodLines, ...generateErrorBodyAliases(inline.root, inline.codegenOptions), ...preludeLines],
|
|
1573
1830
|
);
|
|
1574
1831
|
const { modelOutPaths } = inline.codegenOptions;
|
|
1575
1832
|
if (modelOutPaths) {
|
|
@@ -1598,7 +1855,7 @@ export function generateAreaClient(input: AreaClientInput): string {
|
|
|
1598
1855
|
lines.push(`import type { SdkFetch${jsonImport} } from '${sdkOptionsRel}';`);
|
|
1599
1856
|
const valueImports: string[] = [];
|
|
1600
1857
|
if (needsBigIntReplacer) valueImports.push('bigIntReplacer');
|
|
1601
|
-
if (
|
|
1858
|
+
if (needsParseJson) valueImports.push(needsBigIntReviver ? 'parseJsonWithBigInt as parseJson' : 'parseJson');
|
|
1602
1859
|
if (needsQueryString) valueImports.push('buildQueryString');
|
|
1603
1860
|
if (needsReadContentType) valueImports.push('readContentType');
|
|
1604
1861
|
if (valueImports.length > 0) {
|
|
@@ -1616,6 +1873,8 @@ export function generateAreaClient(input: AreaClientInput): string {
|
|
|
1616
1873
|
}
|
|
1617
1874
|
|
|
1618
1875
|
if (areaNeedsDecimalImport) lines.push(DECIMAL_IMPORT);
|
|
1876
|
+
const areaHeaderLuxon = headerLuxonImport(collectedMethodLines, collectedRevivePrelude);
|
|
1877
|
+
if (areaHeaderLuxon) lines.push(areaHeaderLuxon);
|
|
1619
1878
|
|
|
1620
1879
|
// Leaf client imports (subareas only — top-level clients live next to sdk.ts).
|
|
1621
1880
|
const importedClients = new Set<string>();
|