typespec-http-zod 0.4.0 → 0.6.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/dist/src/api.d.ts +28 -0
- package/dist/src/api.js +81 -11
- package/dist/src/lib.d.ts +3 -3
- package/dist/src/lib.js +3 -3
- package/dist/src/types.js +20 -10
- package/dist/src/zod.d.ts +26 -0
- package/dist/src/zod.js +106 -11
- package/package.json +1 -1
package/dist/src/api.d.ts
CHANGED
|
@@ -211,6 +211,34 @@ export interface EmittedRoute {
|
|
|
211
211
|
readonly alternateResponseSchema: string | undefined;
|
|
212
212
|
}
|
|
213
213
|
export declare function collectRoutes(program: Program, registry: SchemaRegistry, service: HttpService): EmittedRoute[];
|
|
214
|
+
/**
|
|
215
|
+
* Emit only the external names a given file actually references.
|
|
216
|
+
*
|
|
217
|
+
* Both generated files can need them - `schemas.gen.ts` for the vocabulary tuples and `@refine`
|
|
218
|
+
* predicates, and `routes.gen.ts` when an inline params schema carries a refined scalar.
|
|
219
|
+
* Emitting the whole set into both would leave unused imports (which lint rejects); emitting it into
|
|
220
|
+
* neither is what left `routes.gen.ts` referencing `isParsableInstant` with no import at all.
|
|
221
|
+
*/
|
|
222
|
+
/**
|
|
223
|
+
* Whether emitted source references `name` as an identifier.
|
|
224
|
+
*
|
|
225
|
+
* **The name is ESCAPED before it becomes a pattern, and skipping that is a silent build break.**
|
|
226
|
+
* This was `new RegExp(`\\b${name}\\b`)`, interpolating an identifier straight into a pattern.
|
|
227
|
+
* `$` is a valid identifier character in JavaScript and in TypeSpec, and an anchor in a regular
|
|
228
|
+
* expression, so a name containing one fails to match ITSELF: measured, `foo$bar` and `$select` both
|
|
229
|
+
* report absent from source that references them verbatim.
|
|
230
|
+
*
|
|
231
|
+
* The direction matters. Reporting a name present that is absent yields an unused import, which lint
|
|
232
|
+
* refuses loudly. Reporting a name ABSENT that is present drops the import and leaves the emitted
|
|
233
|
+
* module referencing an undeclared identifier - the failure this function exists to prevent, which is
|
|
234
|
+
* how `routes.gen.ts` once referenced `isParsableInstant` with no import at all.
|
|
235
|
+
*
|
|
236
|
+
* Only `SPEC_VOCABULARIES` reaches here today, so the hole was latent rather than live. It sits on a
|
|
237
|
+
* general seam that any future name flows through, and the reasoning that made it look safe - an
|
|
238
|
+
* identifier absent from the text is genuinely not needed - is true of the QUESTION and false of the
|
|
239
|
+
* way it was being asked.
|
|
240
|
+
*/
|
|
241
|
+
export declare function referencedIn(source: string, name: string): boolean;
|
|
214
242
|
/**
|
|
215
243
|
* The identifiers a route's validators are declared under, by what the DOCUMENT calls each one.
|
|
216
244
|
*
|
package/dist/src/api.js
CHANGED
|
@@ -5,7 +5,7 @@ import { reportDiagnostic } from "./lib.js";
|
|
|
5
5
|
import { SchemaRegistry, TypeRegistry } from "./registry.js";
|
|
6
6
|
import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
|
|
7
7
|
import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
|
|
8
|
-
import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
|
|
8
|
+
import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
|
|
9
9
|
/**
|
|
10
10
|
* `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
|
|
11
11
|
* service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
|
|
@@ -365,6 +365,21 @@ function responseContentTypesOf(operation, status) {
|
|
|
365
365
|
}
|
|
366
366
|
return [...types];
|
|
367
367
|
}
|
|
368
|
+
/** The type a parameter actually crosses the wire as, after any `@encode` on the property. */
|
|
369
|
+
function encodedParameterType(program, property) {
|
|
370
|
+
return getEncode(program, property)?.type ?? property.type;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The wire kind of an ARRAY parameter's elements, or `undefined` where it is not such an array.
|
|
374
|
+
*
|
|
375
|
+
* `?ids=1,2,3` splits into strings, so each element needs the same decoding a lone scalar would.
|
|
376
|
+
*/
|
|
377
|
+
function elementKindOf(program, type) {
|
|
378
|
+
if (!isArrayType(type) || type.kind !== "Model")
|
|
379
|
+
return undefined;
|
|
380
|
+
const element = type.indexer?.value;
|
|
381
|
+
return element === undefined ? undefined : wireKindOf(program, element);
|
|
382
|
+
}
|
|
368
383
|
/** Whether a type is an array - TypeSpec models one as a `Model` carrying an integer indexer. */
|
|
369
384
|
function isArrayType(type) {
|
|
370
385
|
return type.kind === "Model" && type.indexer?.key.name === "integer";
|
|
@@ -583,12 +598,12 @@ function collectionDelimiterOf(program, parameter) {
|
|
|
583
598
|
* `zod.ts` decides what becomes `z.number()`, and a second copy of that list here would stop agreeing
|
|
584
599
|
* with it silently.
|
|
585
600
|
*/
|
|
586
|
-
function wireDecoded(declared) {
|
|
601
|
+
function wireDecoded(declared, kind, elementKind) {
|
|
587
602
|
const NUMBER = '(raw) => (typeof raw === "string" && raw.trim() !== "" && Number.isFinite(Number(raw)) ? Number(raw) : raw)';
|
|
588
603
|
const BOOLEAN = '(raw) => (raw === "true" ? true : raw === "false" ? false : raw)';
|
|
589
|
-
if (
|
|
604
|
+
if (kind === "number")
|
|
590
605
|
return `z.preprocess(${NUMBER}, ${declared})`;
|
|
591
|
-
if (
|
|
606
|
+
if (kind === "boolean")
|
|
592
607
|
return `z.preprocess(${BOOLEAN}, ${declared})`;
|
|
593
608
|
/**
|
|
594
609
|
* A list whose ELEMENTS are numeric or boolean - `?ids=1,2,3` after the collection split, or a
|
|
@@ -596,10 +611,8 @@ function wireDecoded(declared) {
|
|
|
596
611
|
* the same decoder applies per element. A non-array value is left alone; the split that produces
|
|
597
612
|
* the array runs outside this and may not have happened.
|
|
598
613
|
*/
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const inner = element[1] ?? "";
|
|
602
|
-
const decoder = inner.startsWith("z.number()") ? NUMBER : BOOLEAN;
|
|
614
|
+
if (elementKind !== undefined) {
|
|
615
|
+
const decoder = elementKind === "number" ? NUMBER : BOOLEAN;
|
|
603
616
|
return `z.preprocess((raw) => (Array.isArray(raw) ? raw.map(${decoder}) : raw), ${declared})`;
|
|
604
617
|
}
|
|
605
618
|
return declared;
|
|
@@ -688,7 +701,14 @@ function parameterSchemasOf(program, operation, registry) {
|
|
|
688
701
|
*/
|
|
689
702
|
const decoded = parameter.type === "header" && parameter.name.toLowerCase() === "content-type"
|
|
690
703
|
? mediaTypeDecoded(declared)
|
|
691
|
-
: wireDecoded(declared
|
|
704
|
+
: wireDecoded(declared,
|
|
705
|
+
/**
|
|
706
|
+
* `@encode` on the PROPERTY changes what crosses the wire, so the encoded type is
|
|
707
|
+
* the one to read. `@encode("unixTimestamp", int32) value: utcDateTime` is a number
|
|
708
|
+
* on the wire and a date in the spec, and reading the declared type alone stopped
|
|
709
|
+
* eighteen such parameters being decoded.
|
|
710
|
+
*/
|
|
711
|
+
wireKindOf(program, encodedParameterType(program, parameter.param)), elementKindOf(program, encodedParameterType(program, parameter.param)));
|
|
692
712
|
/**
|
|
693
713
|
* The boxing half of the same principle: an exploded array's single occurrence arrives as a
|
|
694
714
|
* bare string, so it is boxed into the one-element array the document describes before the
|
|
@@ -875,11 +895,40 @@ export function collectRoutes(program, registry, service) {
|
|
|
875
895
|
* Emitting the whole set into both would leave unused imports (which lint rejects); emitting it into
|
|
876
896
|
* neither is what left `routes.gen.ts` referencing `isParsableInstant` with no import at all.
|
|
877
897
|
*/
|
|
898
|
+
/**
|
|
899
|
+
* Whether emitted source references `name` as an identifier.
|
|
900
|
+
*
|
|
901
|
+
* **The name is ESCAPED before it becomes a pattern, and skipping that is a silent build break.**
|
|
902
|
+
* This was `new RegExp(`\\b${name}\\b`)`, interpolating an identifier straight into a pattern.
|
|
903
|
+
* `$` is a valid identifier character in JavaScript and in TypeSpec, and an anchor in a regular
|
|
904
|
+
* expression, so a name containing one fails to match ITSELF: measured, `foo$bar` and `$select` both
|
|
905
|
+
* report absent from source that references them verbatim.
|
|
906
|
+
*
|
|
907
|
+
* The direction matters. Reporting a name present that is absent yields an unused import, which lint
|
|
908
|
+
* refuses loudly. Reporting a name ABSENT that is present drops the import and leaves the emitted
|
|
909
|
+
* module referencing an undeclared identifier - the failure this function exists to prevent, which is
|
|
910
|
+
* how `routes.gen.ts` once referenced `isParsableInstant` with no import at all.
|
|
911
|
+
*
|
|
912
|
+
* Only `SPEC_VOCABULARIES` reaches here today, so the hole was latent rather than live. It sits on a
|
|
913
|
+
* general seam that any future name flows through, and the reasoning that made it look safe - an
|
|
914
|
+
* identifier absent from the text is genuinely not needed - is true of the QUESTION and false of the
|
|
915
|
+
* way it was being asked.
|
|
916
|
+
*/
|
|
917
|
+
export function referencedIn(source, name) {
|
|
918
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
919
|
+
/**
|
|
920
|
+
* Bounded by IDENTIFIER characters rather than `\b`, because `$` is one in JavaScript and not a
|
|
921
|
+
* word character in a regular expression - so `\b$select\b` never matches `$select`, there being
|
|
922
|
+
* no word boundary before a `$`. The two failure modes have to be closed together or the fix for
|
|
923
|
+
* one leaves the other.
|
|
924
|
+
*/
|
|
925
|
+
return new RegExp(`(?<![A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`).test(source);
|
|
926
|
+
}
|
|
878
927
|
function renderExternalImports(externals, ...bodies) {
|
|
879
928
|
const haystack = bodies.join("\n");
|
|
880
929
|
const lines = [];
|
|
881
930
|
for (const [module, names] of externals) {
|
|
882
|
-
const used = [...names].filter((name) =>
|
|
931
|
+
const used = [...names].filter((name) => referencedIn(haystack, name));
|
|
883
932
|
if (used.length > 0) {
|
|
884
933
|
lines.push(`import { ${used.toSorted().join(", ")} } from ${JSON.stringify(module)};\n`);
|
|
885
934
|
}
|
|
@@ -1389,11 +1438,32 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
1389
1438
|
* carrying internal models if you are not paying attention. It is invisible while there is one
|
|
1390
1439
|
* service, which is exactly why it is built this way before the second one lands rather than after.
|
|
1391
1440
|
*/
|
|
1441
|
+
/**
|
|
1442
|
+
* **Two `@service` namespaces wrote one `schemas.gen.ts`, and the second silently won.**
|
|
1443
|
+
*
|
|
1444
|
+
* Every service emitted into `emitterOutputDir` under the same file name, so a spec publishing an
|
|
1445
|
+
* internal surface and a public one produced a single file holding whichever was walked last.
|
|
1446
|
+
* Measured on two services with no per-service `output-dir`: zero diagnostics, one file, and the
|
|
1447
|
+
* first service's validators absent entirely. Nothing refused, nothing warned - a consumer would
|
|
1448
|
+
* find half their API had no validators at all.
|
|
1449
|
+
*
|
|
1450
|
+
* The rule is `@typespec/openapi3`'s, copied: its default output path is
|
|
1451
|
+
* `openapi.{service-name-if-multiple}...`, so a name is inserted only when there is more than one
|
|
1452
|
+
* service to tell apart. A single-service spec emits exactly where it always did.
|
|
1453
|
+
*
|
|
1454
|
+
* The directory is what carries it rather than the file name, because `EmittedService.outputDir`
|
|
1455
|
+
* is already the published seam a wrapping emitter writes its own files beside. Disambiguating
|
|
1456
|
+
* there means a server emitter follows without knowing this rule exists.
|
|
1457
|
+
*/
|
|
1458
|
+
const distinctServices = new Set(snapshots.map((snapshot) => snapshot.service.namespace.name));
|
|
1392
1459
|
for (const snapshot of snapshots) {
|
|
1393
1460
|
const service = snapshot.service;
|
|
1394
1461
|
const serviceName = service.namespace.name;
|
|
1395
1462
|
const perService = options.services?.[serviceName];
|
|
1396
|
-
const outputDir = perService?.["output-dir"] ??
|
|
1463
|
+
const outputDir = perService?.["output-dir"] ??
|
|
1464
|
+
(distinctServices.size > 1
|
|
1465
|
+
? resolvePath(context.emitterOutputDir, serviceName)
|
|
1466
|
+
: context.emitterOutputDir);
|
|
1397
1467
|
const resolved = {
|
|
1398
1468
|
contractsOutputDir: perService?.["contracts-output-dir"] ?? options["contracts-output-dir"],
|
|
1399
1469
|
contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -104,7 +104,7 @@ export interface EmitterOptions {
|
|
|
104
104
|
export declare const EmitterOptionsSchema: JSONSchemaType<EmitterOptions>;
|
|
105
105
|
export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
106
106
|
"unsupported-type": {
|
|
107
|
-
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
|
|
107
|
+
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
|
|
108
108
|
};
|
|
109
109
|
"unsupported-default": {
|
|
110
110
|
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
@@ -124,7 +124,7 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
124
124
|
}, EmitterOptions, never>;
|
|
125
125
|
export declare const reportDiagnostic: <C extends "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
126
126
|
"unsupported-type": {
|
|
127
|
-
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
|
|
127
|
+
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
|
|
128
128
|
};
|
|
129
129
|
"unsupported-default": {
|
|
130
130
|
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
@@ -143,7 +143,7 @@ export declare const reportDiagnostic: <C extends "empty-union" | "undeclared-di
|
|
|
143
143
|
};
|
|
144
144
|
}[C]>(program: import("@typespec/compiler").Program, diag: import("@typespec/compiler").DiagnosticReport<{
|
|
145
145
|
"unsupported-type": {
|
|
146
|
-
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
|
|
146
|
+
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
|
|
147
147
|
};
|
|
148
148
|
"unsupported-default": {
|
|
149
149
|
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
package/dist/src/lib.js
CHANGED
|
@@ -65,13 +65,13 @@ const diagnostics = {
|
|
|
65
65
|
"unsupported-type": {
|
|
66
66
|
severity: "error",
|
|
67
67
|
messages: {
|
|
68
|
-
default: paramMessage `Cannot emit ${"artefact"} for ${"kind"}: ${"why"}
|
|
68
|
+
default: paramMessage `Cannot emit ${"artefact"} for ${"kind"}: ${"why"}. Replace it with a type a value can inhabit. ${"remedy"}`,
|
|
69
69
|
},
|
|
70
70
|
},
|
|
71
71
|
"unsupported-default": {
|
|
72
72
|
severity: "error",
|
|
73
73
|
messages: {
|
|
74
|
-
default: paramMessage `Cannot emit a default value of ${"why"}. Scalars, arrays and objects are emitted as literals; a default built by a scalar constructor has no literal form. The property keeps its declared shape and loses only the fallback.`,
|
|
74
|
+
default: paramMessage `Cannot emit a default value of ${"why"}. Scalars, arrays and objects are emitted as literals; a default built by a scalar constructor has no literal form. Replace it with a literal value, or drop the default and let the property be optional. The property keeps its declared shape and loses only the fallback.`,
|
|
75
75
|
},
|
|
76
76
|
},
|
|
77
77
|
/**
|
|
@@ -107,7 +107,7 @@ const diagnostics = {
|
|
|
107
107
|
"empty-union": {
|
|
108
108
|
severity: "error",
|
|
109
109
|
messages: {
|
|
110
|
-
default: paramMessage `A union with no representable variants (${"why"}) has nothing to validate against.`,
|
|
110
|
+
default: paramMessage `A union with no representable variants (${"why"}) has nothing to validate against. Declare at least one variant this emitter can walk, or replace the union with the single type it reduces to.`,
|
|
111
111
|
},
|
|
112
112
|
},
|
|
113
113
|
/*
|
package/dist/src/types.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getDiscriminatedUnion, getEncode, isNeverType, resolveEncodedName, } from "@typespec/compiler";
|
|
2
2
|
import { reportDiagnostic } from "./lib.js";
|
|
3
|
-
import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, objectKey, } from "./zod.js";
|
|
3
|
+
import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
|
|
4
4
|
/**
|
|
5
5
|
* TypeSpec type -> plain TypeScript type text.
|
|
6
6
|
*
|
|
@@ -53,12 +53,17 @@ const SCALARS = {
|
|
|
53
53
|
* built on one cannot be used by accident, and the error diagnostic has already failed the compile.
|
|
54
54
|
*/
|
|
55
55
|
const UNREPRESENTABLE = "never";
|
|
56
|
-
/**
|
|
57
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Refuse `type`, pointing at somewhere the reader can go, and carry on so the rest is reported too.
|
|
58
|
+
*
|
|
59
|
+
* An intrinsic has no source node, so it is located by the declaration being walked instead - see
|
|
60
|
+
* `withDeclarationSite` in `zod.ts`, whose scope this walk shares.
|
|
61
|
+
*/
|
|
62
|
+
function refuse(program, type, why, remedy = "") {
|
|
58
63
|
reportDiagnostic(program, {
|
|
59
64
|
code: "unsupported-type",
|
|
60
|
-
target: type,
|
|
61
|
-
format: { artefact: "a TypeScript type", kind: type.kind, why },
|
|
65
|
+
target: refusalTarget(type),
|
|
66
|
+
format: { artefact: "a TypeScript type", kind: type.kind, why, remedy },
|
|
62
67
|
});
|
|
63
68
|
return UNREPRESENTABLE;
|
|
64
69
|
}
|
|
@@ -166,7 +171,12 @@ function modelToTs(program, model) {
|
|
|
166
171
|
* as a bare `Record<string, unknown>` - a type that satisfies any assertion about what the response
|
|
167
172
|
* contains, which is precisely the check this file exists to feed.
|
|
168
173
|
*/
|
|
169
|
-
|
|
174
|
+
// `Record<never>` states closedness rather than a value type - see `modelToZod`. A model that is
|
|
175
|
+
// only that is an object with no declared properties, not a dictionary of an unwalkable type.
|
|
176
|
+
if (indexerKey === "string" &&
|
|
177
|
+
indexer !== undefined &&
|
|
178
|
+
!isNeverType(indexer.value) &&
|
|
179
|
+
declared.length === 0) {
|
|
170
180
|
return `Record<string, ${typeToTs(program, indexer.value)}>`;
|
|
171
181
|
}
|
|
172
182
|
/**
|
|
@@ -185,7 +195,7 @@ function modelToTs(program, model) {
|
|
|
185
195
|
*/
|
|
186
196
|
const entries = declared
|
|
187
197
|
.filter((property) => !isNeverType(property.type))
|
|
188
|
-
.map((property) => `\t${propertyToTs(program, property)}`);
|
|
198
|
+
.map((property) => `\t${withDeclarationSite(property, () => propertyToTs(program, property))}`);
|
|
189
199
|
/**
|
|
190
200
|
* **The indexer is deliberately NOT intersected in.**
|
|
191
201
|
*
|
|
@@ -279,10 +289,10 @@ export function typeToTsBody(program, type) {
|
|
|
279
289
|
if (type.name === "unknown")
|
|
280
290
|
return "unknown";
|
|
281
291
|
if (type.name === "void" || type.name === "never") {
|
|
282
|
-
return refuse(program, type, `\`${type.name}\` has no runtime representation`);
|
|
292
|
+
return refuse(program, type, `\`${type.name}\` has no runtime representation`, "Give the position a type a value can inhabit. A `never` PROPERTY is dropped to match the document, and `Record<never>` seals the model, so neither of those reaches here.");
|
|
283
293
|
}
|
|
284
|
-
return refuse(program, type, `intrinsic "${type.name}"
|
|
294
|
+
return refuse(program, type, `intrinsic "${type.name}"`, "This emitter has no rule for that intrinsic. Replace it with a declared scalar or model.");
|
|
285
295
|
default:
|
|
286
|
-
return refuse(program, type, "no rule for this kind");
|
|
296
|
+
return refuse(program, type, "no rule for this kind", "Replace it with a model, scalar, enum or union, which are the kinds this emitter walks.");
|
|
287
297
|
}
|
|
288
298
|
}
|
package/dist/src/zod.d.ts
CHANGED
|
@@ -52,6 +52,14 @@ export declare function collectExternalImports<T>(walk: () => T): {
|
|
|
52
52
|
readonly imports: Map<string, Set<string>>;
|
|
53
53
|
readonly vocabularies: Map<string, readonly string[]>;
|
|
54
54
|
};
|
|
55
|
+
export declare function withDeclarationSite<T>(site: Type | undefined, run: () => T): T;
|
|
56
|
+
/**
|
|
57
|
+
* Where a refusal should point.
|
|
58
|
+
*
|
|
59
|
+
* The offending type, when it has a source location of its own. Otherwise the declaration being
|
|
60
|
+
* walked, which does.
|
|
61
|
+
*/
|
|
62
|
+
export declare function refusalTarget(type: Type): Type;
|
|
55
63
|
export declare function withSyntheticDeclarations<T>(hook: (name: string, build: () => string) => string, run: () => T): T;
|
|
56
64
|
/**
|
|
57
65
|
* Every property the model declares, **including the ones it inherits**.
|
|
@@ -85,6 +93,24 @@ export declare function effectiveIndexer(model: Model): Model["indexer"];
|
|
|
85
93
|
* for a real subtype - the same rule that decides whether a base may be sealed.
|
|
86
94
|
*/
|
|
87
95
|
export declare function discriminatedSubtypes(program: Program, model: Model): readonly Model[];
|
|
96
|
+
/**
|
|
97
|
+
* **What JSON kind a value of this type arrives as, for a parameter carried in text.**
|
|
98
|
+
*
|
|
99
|
+
* A path, query or header value is always a string on the wire, so a numeric or boolean parameter has
|
|
100
|
+
* to be decoded before the document's schema runs. Deciding WHICH decoder applies used to be done by
|
|
101
|
+
* inspecting the emitted Zod - `declared.startsWith("z.number()")` - which is a string about a string
|
|
102
|
+
* and fails silently the moment the expression is spelled any other way.
|
|
103
|
+
*
|
|
104
|
+
* It did. `@query size: 10 | 25 | 50` emits `z.union([z.literal(10), ...])`, which starts with
|
|
105
|
+
* neither, so no decoder was wrapped around it and the validator required the NUMBER 25 from a wire
|
|
106
|
+
* that can only deliver `"25"`. Measured: `?size=25` rejected, `size: 25` accepted. Every conformant
|
|
107
|
+
* caller got a 400, and no document comparison could see it, because both sides agree the parameter
|
|
108
|
+
* is a number - the disagreement is with the wire.
|
|
109
|
+
*
|
|
110
|
+
* Reading the TYPE answers the question the emitted text was standing in for. `SCALARS` remains the
|
|
111
|
+
* single list of what becomes a number, so this cannot drift from what `scalarToZod` emits.
|
|
112
|
+
*/
|
|
113
|
+
export declare function wireKindOf(program: Program, type: Type): "number" | "boolean" | undefined;
|
|
88
114
|
/**
|
|
89
115
|
* A name as an object key - quoted when it is not a legal JavaScript identifier.
|
|
90
116
|
*
|
package/dist/src/zod.js
CHANGED
|
@@ -275,12 +275,43 @@ export function collectExternalImports(walk) {
|
|
|
275
275
|
vocabularies = previousVocabularies;
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* The declaration currently being walked, used to locate a refusal that has nowhere of its own.
|
|
280
|
+
*
|
|
281
|
+
* An intrinsic like `never` has no source node, so pointing a diagnostic at it prints
|
|
282
|
+
* `<unknown location>:1:1`. A consumer compiling a large spec got 32 byte-identical messages with
|
|
283
|
+
* nothing to grep for and no way to tell which declaration caused which. The property or model being
|
|
284
|
+
* walked when the intrinsic was reached is the thing they can actually go and edit.
|
|
285
|
+
*
|
|
286
|
+
* Scoped rather than threaded, matching the other ambient facts in this walk.
|
|
287
|
+
*/
|
|
288
|
+
let declarationSite;
|
|
289
|
+
export function withDeclarationSite(site, run) {
|
|
290
|
+
const previous = declarationSite;
|
|
291
|
+
if (site !== undefined)
|
|
292
|
+
declarationSite = site;
|
|
293
|
+
try {
|
|
294
|
+
return run();
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
declarationSite = previous;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Where a refusal should point.
|
|
302
|
+
*
|
|
303
|
+
* The offending type, when it has a source location of its own. Otherwise the declaration being
|
|
304
|
+
* walked, which does.
|
|
305
|
+
*/
|
|
306
|
+
export function refusalTarget(type) {
|
|
307
|
+
return type.node === undefined ? (declarationSite ?? type) : type;
|
|
308
|
+
}
|
|
278
309
|
/** Refuse `type`, pointing at its declaration, and carry on so the rest is reported too. */
|
|
279
|
-
function refuse(program, type, why) {
|
|
310
|
+
function refuse(program, type, why, remedy = "") {
|
|
280
311
|
reportDiagnostic(program, {
|
|
281
312
|
code: "unsupported-type",
|
|
282
|
-
target: type,
|
|
283
|
-
format: { artefact: "Zod", kind: type.kind, why },
|
|
313
|
+
target: refusalTarget(type),
|
|
314
|
+
format: { artefact: "Zod", kind: type.kind, why, remedy },
|
|
284
315
|
});
|
|
285
316
|
return UNREPRESENTABLE;
|
|
286
317
|
}
|
|
@@ -474,7 +505,7 @@ function unionToZod(program, union) {
|
|
|
474
505
|
if (discriminated.defaultVariant !== undefined) {
|
|
475
506
|
// "Anything else" has no discriminator value to switch on. Refused by name rather than
|
|
476
507
|
// silently dropped - no corpus scenario exercises it, so guessing would be untested code.
|
|
477
|
-
return refuse(program, union, "a discriminated union with a default variant");
|
|
508
|
+
return refuse(program, union, "a discriminated union with a default variant", "Remove the default variant, or declare it as an ordinary variant with its own discriminator value.");
|
|
478
509
|
}
|
|
479
510
|
/**
|
|
480
511
|
* **Refused, not compensated for.** With no envelope the discriminator lives inside the
|
|
@@ -633,7 +664,7 @@ function modelToZod(program, model) {
|
|
|
633
664
|
* it once keyed on the model's *name*, and lost the indexer of anything derived from `Record`.
|
|
634
665
|
*/
|
|
635
666
|
if (indexerKey === "string" && indexer !== undefined && declared.length === 0) {
|
|
636
|
-
return applyConstraints(program, `z.record(z.string(), ${typeToZod(program, indexer.value)})`, model);
|
|
667
|
+
return applyConstraints(program, `z.record(z.string(), ${withDeclarationSite(model, () => typeToZod(program, indexer.value))})`, model);
|
|
637
668
|
}
|
|
638
669
|
/**
|
|
639
670
|
* Permissiveness, read from the INDEXER and from nothing else.
|
|
@@ -646,12 +677,23 @@ function modelToZod(program, model) {
|
|
|
646
677
|
* An indexer over something other than `unknown` is a typed catchall, where `.loose()` would drop
|
|
647
678
|
* that value type on the floor.
|
|
648
679
|
*/
|
|
649
|
-
|
|
680
|
+
/**
|
|
681
|
+
* **`Record<never>` is how a spec says "and nothing else", not a value type to walk.**
|
|
682
|
+
*
|
|
683
|
+
* `model A { ...Record<never>; name: string }` gives an indexer whose value is the `never`
|
|
684
|
+
* intrinsic. Read as an ordinary typed catchall it became `.catchall(z.never())`, which reached
|
|
685
|
+
* `typeToZod` and refused the whole compile - while `@typespec/openapi3` publishes the same model
|
|
686
|
+
* cleanly as `additionalProperties: {not: {}}`, with no diagnostic at all. `isSealed` already reads
|
|
687
|
+
* a `never` indexer as sealed; this stops the catchall branch consuming it first.
|
|
688
|
+
*/
|
|
689
|
+
const indexerValue = indexerKey === "string" && indexer !== undefined && !isNeverType(indexer.value)
|
|
690
|
+
? indexer.value
|
|
691
|
+
: undefined;
|
|
650
692
|
const suffix = indexerValue !== undefined && isUnknownType(indexerValue)
|
|
651
693
|
? ".loose()"
|
|
652
694
|
: indexerValue === undefined
|
|
653
695
|
? ""
|
|
654
|
-
: `.catchall(${typeToZod(program, indexerValue)})`;
|
|
696
|
+
: `.catchall(${withDeclarationSite(model, () => typeToZod(program, indexerValue))})`;
|
|
655
697
|
/**
|
|
656
698
|
* A request model REJECTS an unrecognised field rather than stripping it.
|
|
657
699
|
*
|
|
@@ -703,7 +745,7 @@ function modelToZod(program, model) {
|
|
|
703
745
|
.filter((property) => !isNeverType(property.type))
|
|
704
746
|
.map((property) => {
|
|
705
747
|
const key = propertyKey(program, property);
|
|
706
|
-
const { value, deferred } = captureBackEdges(() => propertyToZod(program, property));
|
|
748
|
+
const { value, deferred } = captureBackEdges(() => withDeclarationSite(property, () => propertyToZod(program, property)));
|
|
707
749
|
return {
|
|
708
750
|
deferred,
|
|
709
751
|
text: deferred ? `\tget ${key}() {\n\t\treturn ${value};\n\t},` : `\t${key}: ${value},`,
|
|
@@ -745,6 +787,59 @@ function modelToZod(program, model) {
|
|
|
745
787
|
: `z.object(${shape})${suffix}${strict}`;
|
|
746
788
|
return applyConstraints(program, body, model);
|
|
747
789
|
}
|
|
790
|
+
/**
|
|
791
|
+
* **What JSON kind a value of this type arrives as, for a parameter carried in text.**
|
|
792
|
+
*
|
|
793
|
+
* A path, query or header value is always a string on the wire, so a numeric or boolean parameter has
|
|
794
|
+
* to be decoded before the document's schema runs. Deciding WHICH decoder applies used to be done by
|
|
795
|
+
* inspecting the emitted Zod - `declared.startsWith("z.number()")` - which is a string about a string
|
|
796
|
+
* and fails silently the moment the expression is spelled any other way.
|
|
797
|
+
*
|
|
798
|
+
* It did. `@query size: 10 | 25 | 50` emits `z.union([z.literal(10), ...])`, which starts with
|
|
799
|
+
* neither, so no decoder was wrapped around it and the validator required the NUMBER 25 from a wire
|
|
800
|
+
* that can only deliver `"25"`. Measured: `?size=25` rejected, `size: 25` accepted. Every conformant
|
|
801
|
+
* caller got a 400, and no document comparison could see it, because both sides agree the parameter
|
|
802
|
+
* is a number - the disagreement is with the wire.
|
|
803
|
+
*
|
|
804
|
+
* Reading the TYPE answers the question the emitted text was standing in for. `SCALARS` remains the
|
|
805
|
+
* single list of what becomes a number, so this cannot drift from what `scalarToZod` emits.
|
|
806
|
+
*/
|
|
807
|
+
export function wireKindOf(program, type) {
|
|
808
|
+
if (type.kind === "Scalar") {
|
|
809
|
+
const encoded = encodedTypeOf(program, type);
|
|
810
|
+
if (encoded !== undefined && encoded !== type)
|
|
811
|
+
return wireKindOf(program, encoded);
|
|
812
|
+
for (let current = type; current !== undefined; current = current.baseScalar) {
|
|
813
|
+
const mapped = SCALARS[current.name];
|
|
814
|
+
if (mapped === undefined)
|
|
815
|
+
continue;
|
|
816
|
+
if (mapped.startsWith("z.number()"))
|
|
817
|
+
return "number";
|
|
818
|
+
if (mapped === "z.boolean()")
|
|
819
|
+
return "boolean";
|
|
820
|
+
return undefined;
|
|
821
|
+
}
|
|
822
|
+
return undefined;
|
|
823
|
+
}
|
|
824
|
+
// A literal is its own type, and a union of them is the enum form a parameter commonly takes.
|
|
825
|
+
if (type.kind === "Number")
|
|
826
|
+
return "number";
|
|
827
|
+
if (type.kind === "Boolean")
|
|
828
|
+
return "boolean";
|
|
829
|
+
if (type.kind === "Union") {
|
|
830
|
+
const kinds = new Set();
|
|
831
|
+
for (const variant of type.variants.values()) {
|
|
832
|
+
// `T | null` is still a T on the wire; the null arm needs no decoding.
|
|
833
|
+
if (variant.type.kind === "Intrinsic" && variant.type.name === "null")
|
|
834
|
+
continue;
|
|
835
|
+
kinds.add(wireKindOf(program, variant.type));
|
|
836
|
+
}
|
|
837
|
+
if (kinds.size !== 1)
|
|
838
|
+
return undefined;
|
|
839
|
+
return [...kinds][0];
|
|
840
|
+
}
|
|
841
|
+
return undefined;
|
|
842
|
+
}
|
|
748
843
|
/**
|
|
749
844
|
* A name as an object key - quoted when it is not a legal JavaScript identifier.
|
|
750
845
|
*
|
|
@@ -1060,10 +1155,10 @@ export function typeToZodBody(program, type) {
|
|
|
1060
1155
|
if (type.name === "unknown")
|
|
1061
1156
|
return "z.unknown()";
|
|
1062
1157
|
if (type.name === "void" || type.name === "never") {
|
|
1063
|
-
return refuse(program, type, `\`${type.name}\` has no runtime representation`);
|
|
1158
|
+
return refuse(program, type, `\`${type.name}\` has no runtime representation`, "Give the position a type a value can inhabit. A `never` PROPERTY is dropped to match the document, and `Record<never>` seals the model, so neither of those reaches here.");
|
|
1064
1159
|
}
|
|
1065
|
-
return refuse(program, type, `intrinsic "${type.name}"
|
|
1160
|
+
return refuse(program, type, `intrinsic "${type.name}"`, "This emitter has no rule for that intrinsic. Replace it with a declared scalar or model.");
|
|
1066
1161
|
default:
|
|
1067
|
-
return refuse(program, type, "no rule for this kind");
|
|
1162
|
+
return refuse(program, type, "no rule for this kind", "Replace it with a model, scalar, enum or union, which are the kinds this emitter walks.");
|
|
1068
1163
|
}
|
|
1069
1164
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typespec-http-zod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "TypeSpec emitter and library: turn an HTTP service definition into Zod validators and framework-free contract types that agree, keyword for keyword, with the OpenAPI document @typespec/openapi3 publishes from the same source.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codegen",
|