typespec-http-zod 0.28.0 → 0.30.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 +43 -0
- package/dist/src/api.js +342 -57
- package/dist/src/constraints.d.ts +15 -0
- package/dist/src/constraints.js +46 -12
- package/dist/src/lib.d.ts +37 -1
- package/dist/src/lib.js +80 -0
- package/dist/src/literal.d.ts +27 -0
- package/dist/src/literal.js +33 -0
- package/dist/src/registry.js +30 -7
- package/dist/src/results.js +2 -1
- package/dist/src/types.js +34 -10
- package/dist/src/uri-template.d.ts +0 -12
- package/dist/src/uri-template.js +3 -2
- package/dist/src/zod.d.ts +78 -0
- package/dist/src/zod.js +366 -35
- package/package.json +1 -1
package/dist/src/zod.d.ts
CHANGED
|
@@ -75,6 +75,50 @@ export declare function withDeclarationSite<T>(site: Type | undefined, run: () =
|
|
|
75
75
|
* walked, which does.
|
|
76
76
|
*/
|
|
77
77
|
export declare function refusalTarget(type: Type): Type;
|
|
78
|
+
/**
|
|
79
|
+
* **A doc becomes `.describe()`, which is the one place Zod keeps a description.**
|
|
80
|
+
*
|
|
81
|
+
* `@typespec/openapi3` writes every `getDoc` as a JSON Schema `description`, and nothing carried it
|
|
82
|
+
* into the validators: a surface publishing its input schema FROM them - an MCP server's
|
|
83
|
+
* `tools/list` - gave a model calling a tool bare patterns and enums with no word on what a field is
|
|
84
|
+
* for. Measured on one consumer: a tool refused 369 calls and accepted 98, most refusals coming from
|
|
85
|
+
* the client checking that published schema before it called.
|
|
86
|
+
*
|
|
87
|
+
* `.describe()` records the text in `z.globalRegistry`, which is a `globalThis` singleton shared by
|
|
88
|
+
* every copy of Zod in a process, and both `z.toJSONSchema` and Zod's Standard Schema converter read
|
|
89
|
+
* it by default. It returns a clone and changes nothing a parse decides.
|
|
90
|
+
*
|
|
91
|
+
* **It is always the OUTERMOST call at a position, and never on a bare reference.** Inside
|
|
92
|
+
* `.nullable()` the text lands in `anyOf[0]` rather than on the property; on an identifier inside
|
|
93
|
+
* `z.array()` or a union it would clone a declaration that other code finds by identity. So it is
|
|
94
|
+
* applied where a declaration's body, a property's value or a scalar's schema is finished, and
|
|
95
|
+
* nowhere else.
|
|
96
|
+
*
|
|
97
|
+
* Gated on `undefined` rather than on truthiness, as openapi3's `applyConstraint` is: an empty
|
|
98
|
+
* `@doc("")` is published as `""`, and so is it here.
|
|
99
|
+
*/
|
|
100
|
+
export declare function described(expression: string, doc: string | undefined): string;
|
|
101
|
+
/**
|
|
102
|
+
* The description a declared scalar publishes: its own doc, else the nearest one up its `extends`
|
|
103
|
+
* chain, and never a standard type's.
|
|
104
|
+
*
|
|
105
|
+
* Both halves are openapi3's, from `#getSchemaForScalar`: a derived scalar starts from its base's
|
|
106
|
+
* schema, description included, and overwrites what it declares itself - so `scalar B extends A`
|
|
107
|
+
* with no doc publishes A's. A standard scalar's description is deleted, because "standard types are
|
|
108
|
+
* going to be inlined in the spec and we don't want the description of the scalar to show up".
|
|
109
|
+
* Without that, `string`'s own "A sequence of textual characters." would sit on every `z.string()`.
|
|
110
|
+
*/
|
|
111
|
+
export declare function scalarDocOf(program: Program, scalar: Scalar): string | undefined;
|
|
112
|
+
/** A scalar's schema without its description, for a position that publishes its own. */
|
|
113
|
+
/**
|
|
114
|
+
* The scalars an `@encode` chain passes through, when it returns to one it already passed - and
|
|
115
|
+
* `undefined` when it ends at a type that crosses the wire.
|
|
116
|
+
*
|
|
117
|
+
* **The one guard for every walk that follows `@encode`.** The schema, the wire kind and the contract
|
|
118
|
+
* type each follow the chain by calling themselves on the encoded type, and each one overflowed the
|
|
119
|
+
* stack on `Ping -> Pong -> Ping`. A scalar encoded as itself is no cycle: the chain ends there.
|
|
120
|
+
*/
|
|
121
|
+
export declare function encodeCycleOf(program: Program, scalar: Scalar): readonly Scalar[] | undefined;
|
|
78
122
|
export declare function withSyntheticDeclarations<T>(hook: (name: string, build: () => string) => string, run: () => T): T;
|
|
79
123
|
/**
|
|
80
124
|
* Every property the model declares, **including the ones it inherits**.
|
|
@@ -108,6 +152,40 @@ export declare function effectiveIndexer(model: Model): Model["indexer"];
|
|
|
108
152
|
* for a real subtype - the same rule that decides whether a base may be sealed.
|
|
109
153
|
*/
|
|
110
154
|
export declare function discriminatedSubtypes(program: Program, model: Model): readonly Model[];
|
|
155
|
+
/**
|
|
156
|
+
* **How a `@discriminator` base is validated: a value whose discriminator names a subtype as that
|
|
157
|
+
* subtype, and any other value as the base model itself.**
|
|
158
|
+
*
|
|
159
|
+
* The document publishes the base as an object of its own properties carrying `discriminator:
|
|
160
|
+
* {propertyName, mapping}`, and every subtype as `allOf` that base. The mapping says which subtype a
|
|
161
|
+
* value naming it is; nothing in the document refuses a value naming none. This emitter dispatched
|
|
162
|
+
* on the mapping and stopped there, so `{ "kind": "wrongKind", "weight": 8 }` was refused for a
|
|
163
|
+
* `Dog` whose `kind` is `string | "golden"`, though `Dog` requires only `kind` and `weight`. That is
|
|
164
|
+
* the response `@typespec/http-specs` declares for `type/model/inheritance/enum-discriminator`,
|
|
165
|
+
* `single-discriminator` and `nested-discriminator`, and a handler returning it failed its own
|
|
166
|
+
* response contract (typespec-http-graphql, 2026-10-16).
|
|
167
|
+
*
|
|
168
|
+
* **A closed discriminator every value of which a subtype claims has no base arm**: a value outside
|
|
169
|
+
* it is refused by the base's own discriminator, and a value inside it always names a subtype. So a
|
|
170
|
+
* fixed enum fully covered by its subtypes is validated exactly as before.
|
|
171
|
+
*/
|
|
172
|
+
export interface DiscriminatedBase {
|
|
173
|
+
/** The discriminator's wire name. */
|
|
174
|
+
readonly propertyName: string;
|
|
175
|
+
/** The subtypes a `z.discriminatedUnion` dispatches to, each carrying literal discriminator values. */
|
|
176
|
+
readonly dispatched: readonly Model[];
|
|
177
|
+
/**
|
|
178
|
+
* Subtypes that are themselves bases validated with a base arm. A `z.union` has no literal values
|
|
179
|
+
* to dispatch on, so no `z.discriminatedUnion` can hold one; it sits beside the dispatch, where its
|
|
180
|
+
* own literal discriminator keeps it exclusive.
|
|
181
|
+
*/
|
|
182
|
+
readonly beside: readonly Model[];
|
|
183
|
+
/** Every value a subtype claims, which are the keys of the document's `mapping`. */
|
|
184
|
+
readonly claimed: readonly string[];
|
|
185
|
+
/** Whether a value no subtype claims is validated as the base model. */
|
|
186
|
+
readonly open: boolean;
|
|
187
|
+
}
|
|
188
|
+
export declare function discriminatedBaseOf(program: Program, model: Model): DiscriminatedBase | undefined;
|
|
111
189
|
export declare function wireKindOf(program: Program, type: Type): "number" | "boolean" | undefined;
|
|
112
190
|
/**
|
|
113
191
|
* A name as an object key - quoted when it is not a legal JavaScript identifier.
|
package/dist/src/zod.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { getDiscriminatedUnion, getDiscriminator, getEncode, resolveEncodedName, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
|
|
2
|
-
import { createMetadataInfo, isQueryParam, Visibility } from "@typespec/http";
|
|
3
|
-
import { applyConstraints } from "./constraints.js";
|
|
1
|
+
import { getDiscriminatedUnion, getDiscriminatedUnionFromInheritance, getDiscriminator, getDoc, getEncode, ignoreDiagnostics, resolveEncodedName, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
|
|
2
|
+
import { createMetadataInfo, getCookieParamOptions, getHeaderFieldName, getPathParamName, getQueryParamName, isCookieParam, isHeader, isPathParam, isQueryParam, Visibility, } from "@typespec/http";
|
|
3
|
+
import { applyConstraints, firstReportOf } from "./constraints.js";
|
|
4
|
+
import { literal } from "./literal.js";
|
|
4
5
|
import { reportDiagnostic } from "./lib.js";
|
|
5
6
|
/**
|
|
6
7
|
* What a walk returns after reporting a refusal.
|
|
@@ -406,10 +407,95 @@ const SCALARS = {
|
|
|
406
407
|
url: "z.url()",
|
|
407
408
|
};
|
|
408
409
|
function scalarToZod(program, scalar) {
|
|
410
|
+
return described(scalarSchemaOf(program, scalar), scalarDocOf(program, scalar));
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* **A doc becomes `.describe()`, which is the one place Zod keeps a description.**
|
|
414
|
+
*
|
|
415
|
+
* `@typespec/openapi3` writes every `getDoc` as a JSON Schema `description`, and nothing carried it
|
|
416
|
+
* into the validators: a surface publishing its input schema FROM them - an MCP server's
|
|
417
|
+
* `tools/list` - gave a model calling a tool bare patterns and enums with no word on what a field is
|
|
418
|
+
* for. Measured on one consumer: a tool refused 369 calls and accepted 98, most refusals coming from
|
|
419
|
+
* the client checking that published schema before it called.
|
|
420
|
+
*
|
|
421
|
+
* `.describe()` records the text in `z.globalRegistry`, which is a `globalThis` singleton shared by
|
|
422
|
+
* every copy of Zod in a process, and both `z.toJSONSchema` and Zod's Standard Schema converter read
|
|
423
|
+
* it by default. It returns a clone and changes nothing a parse decides.
|
|
424
|
+
*
|
|
425
|
+
* **It is always the OUTERMOST call at a position, and never on a bare reference.** Inside
|
|
426
|
+
* `.nullable()` the text lands in `anyOf[0]` rather than on the property; on an identifier inside
|
|
427
|
+
* `z.array()` or a union it would clone a declaration that other code finds by identity. So it is
|
|
428
|
+
* applied where a declaration's body, a property's value or a scalar's schema is finished, and
|
|
429
|
+
* nowhere else.
|
|
430
|
+
*
|
|
431
|
+
* Gated on `undefined` rather than on truthiness, as openapi3's `applyConstraint` is: an empty
|
|
432
|
+
* `@doc("")` is published as `""`, and so is it here.
|
|
433
|
+
*/
|
|
434
|
+
export function described(expression, doc) {
|
|
435
|
+
return doc === undefined ? expression : `${expression}.describe(${literal(doc)})`;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* The description a declared scalar publishes: its own doc, else the nearest one up its `extends`
|
|
439
|
+
* chain, and never a standard type's.
|
|
440
|
+
*
|
|
441
|
+
* Both halves are openapi3's, from `#getSchemaForScalar`: a derived scalar starts from its base's
|
|
442
|
+
* schema, description included, and overwrites what it declares itself - so `scalar B extends A`
|
|
443
|
+
* with no doc publishes A's. A standard scalar's description is deleted, because "standard types are
|
|
444
|
+
* going to be inlined in the spec and we don't want the description of the scalar to show up".
|
|
445
|
+
* Without that, `string`'s own "A sequence of textual characters." would sit on every `z.string()`.
|
|
446
|
+
*/
|
|
447
|
+
export function scalarDocOf(program, scalar) {
|
|
448
|
+
for (let current = scalar; current !== undefined && !program.checker.isStdType(current); current = current.baseScalar) {
|
|
449
|
+
const doc = getDoc(program, current);
|
|
450
|
+
if (doc !== undefined)
|
|
451
|
+
return doc;
|
|
452
|
+
}
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
/** A scalar's schema without its description, for a position that publishes its own. */
|
|
456
|
+
/**
|
|
457
|
+
* The scalars an `@encode` chain passes through, when it returns to one it already passed - and
|
|
458
|
+
* `undefined` when it ends at a type that crosses the wire.
|
|
459
|
+
*
|
|
460
|
+
* **The one guard for every walk that follows `@encode`.** The schema, the wire kind and the contract
|
|
461
|
+
* type each follow the chain by calling themselves on the encoded type, and each one overflowed the
|
|
462
|
+
* stack on `Ping -> Pong -> Ping`. A scalar encoded as itself is no cycle: the chain ends there.
|
|
463
|
+
*/
|
|
464
|
+
export function encodeCycleOf(program, scalar) {
|
|
465
|
+
const passed = [];
|
|
466
|
+
for (let current = scalar; current !== undefined;) {
|
|
467
|
+
if (passed.includes(current))
|
|
468
|
+
return [...passed, current];
|
|
469
|
+
passed.push(current);
|
|
470
|
+
const encoded = encodedTypeOf(program, current);
|
|
471
|
+
current = encoded === current ? undefined : encoded;
|
|
472
|
+
}
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
function scalarSchemaOf(program, scalar) {
|
|
409
476
|
// `scalar unixTimestamp32 is utcDateTime;` with `@encode(int32)` on the SCALAR, not on each use.
|
|
410
477
|
const encoded = encodedTypeOf(program, scalar);
|
|
411
478
|
if (encoded !== undefined && encoded !== scalar) {
|
|
412
|
-
|
|
479
|
+
const cycle = encodeCycleOf(program, scalar);
|
|
480
|
+
if (cycle !== undefined) {
|
|
481
|
+
// Reached once per use; reported once per scalar.
|
|
482
|
+
if (firstReportOf(program, "encode-cycle", scalar)) {
|
|
483
|
+
reportDiagnostic(program, {
|
|
484
|
+
code: "encode-cycle",
|
|
485
|
+
target: scalar,
|
|
486
|
+
format: {
|
|
487
|
+
type: scalar.name,
|
|
488
|
+
// From the first link on: `Ping` is encoded as `'Pong' -> 'Ping'`.
|
|
489
|
+
chain: cycle
|
|
490
|
+
.slice(1)
|
|
491
|
+
.map((link) => `'${link.name}'`)
|
|
492
|
+
.join(" -> "),
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
return UNREPRESENTABLE;
|
|
497
|
+
}
|
|
498
|
+
return applyConstraints(program, scalarSchemaOf(program, encoded), scalar);
|
|
413
499
|
}
|
|
414
500
|
let current = scalar;
|
|
415
501
|
// Walk the `extends` chain so a named scalar (`scalar chNumber extends string`) resolves to the
|
|
@@ -454,7 +540,7 @@ function scalarToZod(program, scalar) {
|
|
|
454
540
|
function enumToZod(_program, target) {
|
|
455
541
|
const values = [...target.members.values()].map((member) => member.value ?? member.name);
|
|
456
542
|
if (values.some((value) => typeof value !== "string")) {
|
|
457
|
-
return `z.union([${values.map((value) => `z.literal(${
|
|
543
|
+
return `z.union([${values.map((value) => `z.literal(${literal(value)})`).join(", ")}])`;
|
|
458
544
|
}
|
|
459
545
|
/**
|
|
460
546
|
* Every string vocabulary resolves through the generated constant rather than being restated.
|
|
@@ -475,7 +561,7 @@ function enumToZod(_program, target) {
|
|
|
475
561
|
*/
|
|
476
562
|
const shared = contractsPackage();
|
|
477
563
|
if (shared === undefined) {
|
|
478
|
-
return `z.enum([${values.map((value) =>
|
|
564
|
+
return `z.enum([${values.map((value) => literal(value)).join(", ")}])`;
|
|
479
565
|
}
|
|
480
566
|
noteExternalImport(shared, "SPEC_VOCABULARIES");
|
|
481
567
|
return `z.enum(SPEC_VOCABULARIES.${target.name})`;
|
|
@@ -534,7 +620,7 @@ function declaresDiscriminator(program, variant, property) {
|
|
|
534
620
|
*/
|
|
535
621
|
function discriminatedVariant(program, union, variantName, variant, options) {
|
|
536
622
|
const inner = typeToZod(program, variant);
|
|
537
|
-
const tag = `${objectKey(options.discriminatorPropertyName)}: z.literal(${
|
|
623
|
+
const tag = `${objectKey(options.discriminatorPropertyName)}: z.literal(${literal(variantName)})`;
|
|
538
624
|
// The variant already carries the discriminator, so the document publishes it and there is
|
|
539
625
|
// nothing to add.
|
|
540
626
|
if (options.envelope === "none")
|
|
@@ -587,7 +673,7 @@ function unionToZod(program, union) {
|
|
|
587
673
|
}
|
|
588
674
|
const members = [...discriminated.variants.entries()].map(([name, variant]) => discriminatedVariant(program, union, name, variant, options));
|
|
589
675
|
const property = options.discriminatorPropertyName;
|
|
590
|
-
return `z.discriminatedUnion(${
|
|
676
|
+
return `z.discriminatedUnion(${literal(property)}, [${members.join(", ")}])`;
|
|
591
677
|
}
|
|
592
678
|
const variants = [...union.variants.values()].map((variant) => variant.type);
|
|
593
679
|
const nullable = variants.some((type) => type.kind === "Intrinsic" && type.name === "null");
|
|
@@ -602,7 +688,7 @@ function unionToZod(program, union) {
|
|
|
602
688
|
}
|
|
603
689
|
const suffix = nullable ? ".nullable()" : "";
|
|
604
690
|
if (real.every((type) => type.kind === "String")) {
|
|
605
|
-
const literals = real.map((type) =>
|
|
691
|
+
const literals = real.map((type) => literal(type.value));
|
|
606
692
|
return `z.enum([${literals.join(", ")}])${suffix}`;
|
|
607
693
|
}
|
|
608
694
|
if (real.length === 1)
|
|
@@ -678,30 +764,130 @@ export function discriminatedSubtypes(program, model) {
|
|
|
678
764
|
return [];
|
|
679
765
|
return model.derivedModels.filter((derived) => includeDerivedModel(derived));
|
|
680
766
|
}
|
|
767
|
+
export function discriminatedBaseOf(program, model) {
|
|
768
|
+
const discriminator = getDiscriminator(program, model);
|
|
769
|
+
const subtypes = discriminatedSubtypes(program, model);
|
|
770
|
+
if (discriminator === undefined || subtypes.length === 0)
|
|
771
|
+
return undefined;
|
|
772
|
+
/**
|
|
773
|
+
* The compiler's own reading of which subtype claims which value, which is what openapi3 writes
|
|
774
|
+
* the `mapping` from.
|
|
775
|
+
*/
|
|
776
|
+
const claimed = [
|
|
777
|
+
...ignoreDiagnostics(getDiscriminatedUnionFromInheritance(model, discriminator)).variants.keys(),
|
|
778
|
+
];
|
|
779
|
+
const declared = discriminatorPropertyOf(program, model, discriminator.propertyName);
|
|
780
|
+
/**
|
|
781
|
+
* Undeclared, the discriminator is the `string` openapi3 adds to the base - open. Declared, it is
|
|
782
|
+
* closed only where its type is a finite set of strings.
|
|
783
|
+
*/
|
|
784
|
+
const values = declared === undefined ? undefined : closedValuesOf(declared.type);
|
|
785
|
+
const beside = subtypes.filter((subtype) => isValidatedWithBaseArm(program, subtype));
|
|
786
|
+
return {
|
|
787
|
+
propertyName: discriminator.propertyName,
|
|
788
|
+
dispatched: subtypes.filter((subtype) => !beside.includes(subtype)),
|
|
789
|
+
beside,
|
|
790
|
+
claimed,
|
|
791
|
+
open: values === undefined || values.some((value) => !claimed.includes(value)),
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
/** Whether a model is a base validated as a `z.union` rather than a `z.discriminatedUnion`. */
|
|
795
|
+
function isValidatedWithBaseArm(program, model) {
|
|
796
|
+
const base = discriminatedBaseOf(program, model);
|
|
797
|
+
return base !== undefined && (base.open || base.beside.length > 0);
|
|
798
|
+
}
|
|
799
|
+
/** The base's own discriminator property, matched by wire name as openapi3 matches it. */
|
|
800
|
+
function discriminatorPropertyOf(program, model, propertyName) {
|
|
801
|
+
return inheritedAndOwnProperties(model).find((property) => rawPropertyKey(program, property) === propertyName);
|
|
802
|
+
}
|
|
803
|
+
/** The strings a type admits, where they are finite; `undefined` where it admits any string. */
|
|
804
|
+
function closedValuesOf(type) {
|
|
805
|
+
if (type.kind === "String")
|
|
806
|
+
return [type.value];
|
|
807
|
+
if (type.kind === "EnumMember") {
|
|
808
|
+
const value = type.value ?? type.name;
|
|
809
|
+
return typeof value === "string" ? [value] : undefined;
|
|
810
|
+
}
|
|
811
|
+
const members = type.kind === "Enum"
|
|
812
|
+
? [...type.members.values()]
|
|
813
|
+
: type.kind === "Union"
|
|
814
|
+
? [...type.variants.values()].map((variant) => variant.type)
|
|
815
|
+
: undefined;
|
|
816
|
+
if (members === undefined)
|
|
817
|
+
return undefined;
|
|
818
|
+
const values = [];
|
|
819
|
+
for (const member of members) {
|
|
820
|
+
const closed = closedValuesOf(member);
|
|
821
|
+
if (closed === undefined)
|
|
822
|
+
return undefined;
|
|
823
|
+
values.push(...closed);
|
|
824
|
+
}
|
|
825
|
+
return values;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* The base arm's discriminator, refused for every value a subtype claims.
|
|
829
|
+
*
|
|
830
|
+
* **Without this a known kind falls through to the base.** `z.union` tries the dispatch and then the
|
|
831
|
+
* base arm, and a base with subtypes is never sealed, so `{kind: "sparrow", wingspan: 1, x: 1}` -
|
|
832
|
+
* refused by the sealed `Sparrow` - would be accepted as a `Bird` with `x` stripped. Guarded, a value
|
|
833
|
+
* naming a subtype is validated as that subtype and nothing else.
|
|
834
|
+
*
|
|
835
|
+
* `abort` keeps this refusal out of the error a caller reads: Zod reports the one arm that did not
|
|
836
|
+
* abort, which for a known kind is the subtype that was actually broken.
|
|
837
|
+
*/
|
|
838
|
+
function guardedDiscriminator(schema, base, model) {
|
|
839
|
+
if (base.claimed.length === 0)
|
|
840
|
+
return schema;
|
|
841
|
+
const test = base.claimed.map((value) => `value !== ${literal(value)}`).join(" && ");
|
|
842
|
+
const error = literal(`Validated as the subtype this '${base.propertyName}' names, not as '${model.name}'.`);
|
|
843
|
+
return `${schema}.refine((value) => ${test}, { abort: true, error: ${error} })`;
|
|
844
|
+
}
|
|
681
845
|
function modelToZod(program, model) {
|
|
682
846
|
/**
|
|
683
|
-
* **A `@discriminator` base is a CHOICE between its subtypes,
|
|
847
|
+
* **A `@discriminator` base is a CHOICE between its subtypes, and its own shape for any value
|
|
848
|
+
* naming none of them.** See {@link DiscriminatedBase}.
|
|
684
849
|
*
|
|
685
850
|
* `@discriminator("kind") model Bird { kind: string; wingspan: int32 }` with four models extending
|
|
686
851
|
* it reaches OpenAPI as a component carrying `discriminator: {propertyName, mapping}`, and the
|
|
687
852
|
* mapping is an instruction: validate the body against whichever subtype the discriminator names.
|
|
688
|
-
* Emitting the base's own properties
|
|
853
|
+
* Emitting the base's own properties alone produced `z.object({kind: z.string(), wingspan})`,
|
|
689
854
|
* which accepts `{kind: "eagle"}` while ignoring everything `Eagle` actually declares - a
|
|
690
855
|
* polymorphic endpoint validating none of its polymorphism.
|
|
691
856
|
*
|
|
692
|
-
* `z.discriminatedUnion` is the faithful encoding and composes both ways this
|
|
693
|
-
* Measured on Zod 4.4.3: a union may be an OPTION of another union, which is how
|
|
857
|
+
* `z.discriminatedUnion` is the faithful encoding of the dispatch and composes both ways this
|
|
858
|
+
* corpus needs. Measured on Zod 4.4.3: a union may be an OPTION of another union, which is how
|
|
694
859
|
* `Fish -> Shark -> SawShark` works; and an option carrying a getter that refers back to the union
|
|
695
860
|
* being defined does **not** throw, because the discriminator is read without enumerating the rest
|
|
696
861
|
* of the shape. `Eagle.friends?: Bird[]` is exactly that, and it decides whether polymorphism and
|
|
697
862
|
* recursion can coexist at all.
|
|
863
|
+
*
|
|
864
|
+
* **`z.discriminatedUnion` has no arm for "any other value"**, and `unionFallback` cannot add one:
|
|
865
|
+
* it retries the same options, each of which must carry literal discriminator values. So an open
|
|
866
|
+
* base is `z.union([dispatch, ...nested bases, base arm])`, every arm exclusive on the
|
|
867
|
+
* discriminator - each subtype by its literal, the base arm by {@link guardedDiscriminator}.
|
|
698
868
|
*/
|
|
699
|
-
const
|
|
700
|
-
if (
|
|
701
|
-
const
|
|
702
|
-
|
|
703
|
-
|
|
869
|
+
const base = discriminatedBaseOf(program, model);
|
|
870
|
+
if (base !== undefined) {
|
|
871
|
+
const arms = [];
|
|
872
|
+
if (base.dispatched.length > 0) {
|
|
873
|
+
const options = base.dispatched.map((derived) => typeToZod(program, derived));
|
|
874
|
+
arms.push(`z.discriminatedUnion(${literal(base.propertyName)}, [${options.join(", ")}])`);
|
|
875
|
+
}
|
|
876
|
+
arms.push(...base.beside.map((derived) => typeToZod(program, derived)));
|
|
877
|
+
if (base.open)
|
|
878
|
+
arms.push(modelObjectToZod(program, model, base));
|
|
879
|
+
return arms.length === 1 ? (arms[0] ?? UNREPRESENTABLE) : `z.union([${arms.join(", ")}])`;
|
|
704
880
|
}
|
|
881
|
+
return modelObjectToZod(program, model);
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* A model as the object of its own properties, inherited ones included.
|
|
885
|
+
*
|
|
886
|
+
* **Given a discriminated base, this is its base arm**: the same object, with its discriminator
|
|
887
|
+
* refused for every value a subtype claims, and - where the base does not declare the discriminator -
|
|
888
|
+
* the required `string` openapi3 adds to the base's properties in its place.
|
|
889
|
+
*/
|
|
890
|
+
function modelObjectToZod(program, model, base) {
|
|
705
891
|
const indexer = effectiveIndexer(model);
|
|
706
892
|
const indexerKey = indexer?.key.name;
|
|
707
893
|
const declared = inheritedAndOwnProperties(model);
|
|
@@ -816,13 +1002,23 @@ function modelToZod(program, model) {
|
|
|
816
1002
|
* parameters for the reason `renderSchemas` names both: one leaves the input side `unknown`.
|
|
817
1003
|
*/
|
|
818
1004
|
const annotation = deferred ? (annotateDeferred(property) ?? "") : "";
|
|
1005
|
+
const guarded = base !== undefined && rawPropertyKey(program, property) === base.propertyName
|
|
1006
|
+
? guardedDiscriminator(value, base, model)
|
|
1007
|
+
: value;
|
|
819
1008
|
return {
|
|
820
1009
|
deferred,
|
|
821
1010
|
text: deferred
|
|
822
|
-
? `\tget ${key}()${annotation} {\n\t\treturn ${
|
|
823
|
-
: `\t${key}: ${
|
|
1011
|
+
? `\tget ${key}()${annotation} {\n\t\treturn ${guarded};\n\t},`
|
|
1012
|
+
: `\t${key}: ${guarded},`,
|
|
824
1013
|
};
|
|
825
1014
|
});
|
|
1015
|
+
if (base !== undefined &&
|
|
1016
|
+
discriminatorPropertyOf(program, model, base.propertyName) === undefined) {
|
|
1017
|
+
members.push({
|
|
1018
|
+
deferred: false,
|
|
1019
|
+
text: `\t${objectKey(base.propertyName)}: ${guardedDiscriminator("z.string()", base, model)},`,
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
826
1022
|
const entries = members.map((member) => member.text);
|
|
827
1023
|
/**
|
|
828
1024
|
* A model-level `@refine` goes through the same path as every other constraint, so the predicate's
|
|
@@ -901,8 +1097,10 @@ const NUMERIC_EXPRESSIONS = ["z.number()", "z.int(", "z.int32(", "z.uint32("];
|
|
|
901
1097
|
export function wireKindOf(program, type) {
|
|
902
1098
|
if (type.kind === "Scalar") {
|
|
903
1099
|
const encoded = encodedTypeOf(program, type);
|
|
904
|
-
if (encoded !== undefined && encoded !== type)
|
|
905
|
-
|
|
1100
|
+
if (encoded !== undefined && encoded !== type) {
|
|
1101
|
+
// Reported by the schema walk; a chain with no end has no kind to decode into.
|
|
1102
|
+
return encodeCycleOf(program, type) === undefined ? wireKindOf(program, encoded) : undefined;
|
|
1103
|
+
}
|
|
906
1104
|
for (let current = type; current !== undefined; current = current.baseScalar) {
|
|
907
1105
|
const mapped = SCALARS[current.name];
|
|
908
1106
|
if (mapped === undefined)
|
|
@@ -945,7 +1143,16 @@ export function wireKindOf(program, type) {
|
|
|
945
1143
|
* Exported so there is one answer to this question. There were two sites and only one of them knew.
|
|
946
1144
|
*/
|
|
947
1145
|
export function objectKey(name) {
|
|
948
|
-
|
|
1146
|
+
/**
|
|
1147
|
+
* **`__proto__` is the one name a key cannot spell plainly.** In an object literal, `__proto__:`
|
|
1148
|
+
* - quoted or not - sets the object's prototype rather than declaring a key, so the emitted shape
|
|
1149
|
+
* had no such property: it vanished from the published JSON Schema, and under
|
|
1150
|
+
* `seal-object-schemas` every body carrying it was refused as an unrecognised key. A computed
|
|
1151
|
+
* key declares it, in a value and in a type alike. See `unenforceable-property-name`.
|
|
1152
|
+
*/
|
|
1153
|
+
if (name === "__proto__")
|
|
1154
|
+
return `[${literal(name)}]`;
|
|
1155
|
+
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : literal(name);
|
|
949
1156
|
}
|
|
950
1157
|
/**
|
|
951
1158
|
* A doc string as a JSDoc comment, indented, or `""` when there is none.
|
|
@@ -1063,9 +1270,56 @@ const PARAMETER_STYLE_ENCODINGS = new Set([
|
|
|
1063
1270
|
"ArrayEncoding.commaDelimited",
|
|
1064
1271
|
"ArrayEncoding.newlineDelimited",
|
|
1065
1272
|
]);
|
|
1273
|
+
/**
|
|
1274
|
+
* **A date-time header with no `@encode` is an RFC 7231 HTTP-date, as if it said
|
|
1275
|
+
* `@encode(DateTimeKnownEncoding.rfc7231)`.**
|
|
1276
|
+
*
|
|
1277
|
+
* That is TypeSpec's wire form for one, and `@typespec/openapi3` publishes it as
|
|
1278
|
+
* `format: http-date` from `applyEncoding`'s `isDateTimeHeader` branch, which this emitter did not
|
|
1279
|
+
* follow. The header fell through to the scalar's default, `z.iso.datetime({ offset: true })`, so a
|
|
1280
|
+
* request sending it as the document describes was refused with 400, and a handler setting it as an
|
|
1281
|
+
* HTTP-date failed its own response contract with 500 (issue #4). The explicit encoding already
|
|
1282
|
+
* emitted `z.string()`, and this is that.
|
|
1283
|
+
*
|
|
1284
|
+
* **The predicate is openapi3's: the header's own schema carries `format: date-time`.** That is
|
|
1285
|
+
* the standard `utcDateTime` or `offsetDateTime` itself. A declared scalar extending one is
|
|
1286
|
+
* published as a `$ref`, a list carries the format on its items, and `T | null` is an `anyOf`
|
|
1287
|
+
* under 3.1 and 3.2 - the versions this package grades against - so none of those is an HTTP-date
|
|
1288
|
+
* in the document, and none is here. (3.0 publishes the nullable one as `nullable: true` beside an
|
|
1289
|
+
* `http-date` format; the graded documents do not.)
|
|
1290
|
+
*/
|
|
1291
|
+
function isDateTimeHeader(program, property) {
|
|
1292
|
+
const type = property.type;
|
|
1293
|
+
return isHeader(program, property) && type.kind === "Scalar" && isStdDateTime(program, type);
|
|
1294
|
+
}
|
|
1295
|
+
function isStdDateTime(program, scalar) {
|
|
1296
|
+
const { checker } = program;
|
|
1297
|
+
return (scalar === checker.getStdType("utcDateTime") || scalar === checker.getStdType("offsetDateTime"));
|
|
1298
|
+
}
|
|
1299
|
+
/** Whether a property's value, or a scalar, is a date-time: one of the two, or built on one. */
|
|
1300
|
+
function isDateTime(program, target) {
|
|
1301
|
+
const type = target.kind === "ModelProperty" ? (nullableInnerOf(target.type) ?? target.type) : target;
|
|
1302
|
+
for (let current = type.kind === "Scalar" ? type : undefined; current !== undefined; current = current.baseScalar) {
|
|
1303
|
+
if (isStdDateTime(program, current))
|
|
1304
|
+
return true;
|
|
1305
|
+
}
|
|
1306
|
+
return false;
|
|
1307
|
+
}
|
|
1066
1308
|
export function encodedTypeOf(program, target) {
|
|
1067
1309
|
const encoded = getEncode(program, target);
|
|
1068
|
-
if (encoded === undefined)
|
|
1310
|
+
if (encoded === undefined) {
|
|
1311
|
+
return target.kind === "ModelProperty" && isDateTimeHeader(program, target)
|
|
1312
|
+
? program.checker.getStdType("string")
|
|
1313
|
+
: undefined;
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* **`rfc3339` on a date-time is the form it already has, so it changes nothing.** openapi3's
|
|
1317
|
+
* `mergeFormatAndEncoding` keeps `format: date-time` for it, and the type it reports, `string`,
|
|
1318
|
+
* emitted `z.string()`: the explicit spelling of the default dropped the date-time check the
|
|
1319
|
+
* document still states. Only the encodings that change the wire form - `rfc7231`,
|
|
1320
|
+
* `unixTimestamp` - change the type.
|
|
1321
|
+
*/
|
|
1322
|
+
if (encoded.encoding === "rfc3339" && isDateTime(program, target))
|
|
1069
1323
|
return undefined;
|
|
1070
1324
|
if (target.kind === "ModelProperty" &&
|
|
1071
1325
|
isQueryParam(program, target) &&
|
|
@@ -1091,9 +1345,26 @@ export function propertyToZod(program, property) {
|
|
|
1091
1345
|
// wrapper is kept: `@encode` changes what the value IS, not whether it may be absent.
|
|
1092
1346
|
const encoded = encodedTypeOf(program, property);
|
|
1093
1347
|
const declared = encoded ?? nullable ?? property.type;
|
|
1094
|
-
const
|
|
1095
|
-
|
|
1096
|
-
|
|
1348
|
+
const bare = nullable === undefined || encoded !== undefined;
|
|
1349
|
+
/**
|
|
1350
|
+
* **One description per position, and a property's own doc wins over its scalar's.**
|
|
1351
|
+
*
|
|
1352
|
+
* openapi3 publishes `subject: Subject` as `{ $ref: Subject, description: <the property's doc> }`,
|
|
1353
|
+
* and a reader takes the sibling over what the reference carries. A scalar here is inlined rather
|
|
1354
|
+
* than referenced, so where the property's value IS the scalar, its schema is taken without its
|
|
1355
|
+
* description and the one the position publishes is decided once: the property's doc, else the
|
|
1356
|
+
* scalar's. Otherwise a documented property of a documented scalar was written
|
|
1357
|
+
* `.describe(a).describe(b)`, which says the same thing in a way no one would write.
|
|
1358
|
+
*
|
|
1359
|
+
* Under `.nullable()` the scalar keeps its own, inside the wrapper: openapi3 publishes
|
|
1360
|
+
* `anyOf: [{ $ref: Subject }, { type: "null" }]` there, so the scalar's description belongs to the
|
|
1361
|
+
* branch and only the property's belongs to the property.
|
|
1362
|
+
*/
|
|
1363
|
+
const inlinedScalar = bare && declared.kind === "Scalar";
|
|
1364
|
+
const value = inlinedScalar ? scalarSchemaOf(program, declared) : typeToZod(program, declared);
|
|
1365
|
+
const constrained = applyConstraints(program, value, property);
|
|
1366
|
+
const base = bare ? constrained : `${constrained}.nullable()`;
|
|
1367
|
+
const description = getDoc(program, property) ?? (inlinedScalar ? scalarDocOf(program, declared) : undefined);
|
|
1097
1368
|
/**
|
|
1098
1369
|
* **Optionality is a function of the position too, not only of the `?` in the spec.**
|
|
1099
1370
|
*
|
|
@@ -1104,6 +1375,7 @@ export function propertyToZod(program, property) {
|
|
|
1104
1375
|
* predicate `@typespec/openapi3` uses to decide the `required` list.
|
|
1105
1376
|
*/
|
|
1106
1377
|
const optional = property.optional || isOptionalAt(program, property);
|
|
1378
|
+
reportInheritedName(program, property, optional);
|
|
1107
1379
|
/**
|
|
1108
1380
|
* **A default only applies where the document says the property may be absent.**
|
|
1109
1381
|
*
|
|
@@ -1125,7 +1397,7 @@ export function propertyToZod(program, property) {
|
|
|
1125
1397
|
target: property,
|
|
1126
1398
|
format: { name: property.name },
|
|
1127
1399
|
});
|
|
1128
|
-
return base;
|
|
1400
|
+
return described(base, description);
|
|
1129
1401
|
}
|
|
1130
1402
|
/**
|
|
1131
1403
|
* A TypeSpec default (`basis?: string = "s1159"`) becomes `.default(...)`, which in Zod also makes
|
|
@@ -1136,7 +1408,7 @@ export function propertyToZod(program, property) {
|
|
|
1136
1408
|
*/
|
|
1137
1409
|
const defaulted = defaultOf(program, property);
|
|
1138
1410
|
if (defaulted !== undefined)
|
|
1139
|
-
return `${base}.default(${defaulted})
|
|
1411
|
+
return described(`${base}.default(${defaulted})`, description);
|
|
1140
1412
|
/**
|
|
1141
1413
|
* **`.exactOptional()`, because the document says the KEY may be absent and nothing more.**
|
|
1142
1414
|
*
|
|
@@ -1155,7 +1427,66 @@ export function propertyToZod(program, property) {
|
|
|
1155
1427
|
* differential's serialisation axis is untouched, and `def.type` is still `"optional"`, so the
|
|
1156
1428
|
* shape describers read it unchanged.
|
|
1157
1429
|
*/
|
|
1158
|
-
return optional ? `${base}.exactOptional()` : base;
|
|
1430
|
+
return described(optional ? `${base}.exactOptional()` : base, description);
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* The names every JavaScript object inherits, which Zod reads in place of an absent property.
|
|
1434
|
+
* `__proto__` is among them and is the different case: Zod skips it outright.
|
|
1435
|
+
*/
|
|
1436
|
+
const INHERITED_NAMES = new Set(Object.getOwnPropertyNames(Object.prototype));
|
|
1437
|
+
/** Properties already reported, by declaration: one property renders once per visibility. */
|
|
1438
|
+
const reportedNames = new WeakMap();
|
|
1439
|
+
/**
|
|
1440
|
+
* **A wire name the validator cannot treat as an ordinary key is reported where it bites.** See
|
|
1441
|
+
* `unenforceable-property-name` and `optional-prototype-name` in `lib.ts` for why each is a
|
|
1442
|
+
* warning and what each costs.
|
|
1443
|
+
*
|
|
1444
|
+
* Reported here rather than from a walk over the program because `optional` is a fact about the
|
|
1445
|
+
* POSITION: a PATCH makes a required property optional, and only there does an inherited name
|
|
1446
|
+
* refuse its omission. Once per declaration, by its syntax node, because a visibility projection
|
|
1447
|
+
* hands this a different `ModelProperty` for the same one.
|
|
1448
|
+
*/
|
|
1449
|
+
function reportInheritedName(program, property, optional) {
|
|
1450
|
+
const name = wireNameOf(program, property);
|
|
1451
|
+
if (!INHERITED_NAMES.has(name))
|
|
1452
|
+
return;
|
|
1453
|
+
const unenforceable = name === "__proto__";
|
|
1454
|
+
if (!unenforceable && !optional)
|
|
1455
|
+
return;
|
|
1456
|
+
const declaration = property.node ?? property;
|
|
1457
|
+
let reported = reportedNames.get(program);
|
|
1458
|
+
if (reported === undefined)
|
|
1459
|
+
reportedNames.set(program, (reported = new WeakSet()));
|
|
1460
|
+
if (reported.has(declaration))
|
|
1461
|
+
return;
|
|
1462
|
+
reported.add(declaration);
|
|
1463
|
+
if (unenforceable) {
|
|
1464
|
+
reportDiagnostic(program, {
|
|
1465
|
+
code: "unenforceable-property-name",
|
|
1466
|
+
target: property,
|
|
1467
|
+
format: { name },
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
else {
|
|
1471
|
+
reportDiagnostic(program, {
|
|
1472
|
+
code: "optional-prototype-name",
|
|
1473
|
+
target: property,
|
|
1474
|
+
format: { name },
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
/** The key a property is validated under: its parameter name, or its name in a JSON body. */
|
|
1479
|
+
function wireNameOf(program, property) {
|
|
1480
|
+
if (isHeader(program, property))
|
|
1481
|
+
return getHeaderFieldName(program, property);
|
|
1482
|
+
if (isQueryParam(program, property))
|
|
1483
|
+
return getQueryParamName(program, property) ?? property.name;
|
|
1484
|
+
if (isPathParam(program, property))
|
|
1485
|
+
return getPathParamName(program, property) ?? property.name;
|
|
1486
|
+
if (isCookieParam(program, property)) {
|
|
1487
|
+
return getCookieParamOptions(program, property)?.name ?? property.name;
|
|
1488
|
+
}
|
|
1489
|
+
return rawPropertyKey(program, property);
|
|
1159
1490
|
}
|
|
1160
1491
|
/**
|
|
1161
1492
|
* A default value as a JS literal, or `undefined` where this emitter cannot render one.
|
|
@@ -1180,7 +1511,7 @@ export function propertyToZod(program, property) {
|
|
|
1180
1511
|
*/
|
|
1181
1512
|
function renderValue(value) {
|
|
1182
1513
|
if (value.valueKind === "StringValue")
|
|
1183
|
-
return
|
|
1514
|
+
return literal(value.value);
|
|
1184
1515
|
if (value.valueKind === "NumericValue")
|
|
1185
1516
|
return String(value.value.asNumber());
|
|
1186
1517
|
if (value.valueKind === "BooleanValue")
|
|
@@ -1189,7 +1520,7 @@ function renderValue(value) {
|
|
|
1189
1520
|
return "null";
|
|
1190
1521
|
if (value.valueKind === "EnumValue") {
|
|
1191
1522
|
const member = value.value;
|
|
1192
|
-
return
|
|
1523
|
+
return literal(member.value ?? member.name);
|
|
1193
1524
|
}
|
|
1194
1525
|
if (value.valueKind === "ArrayValue") {
|
|
1195
1526
|
const rendered = value.values.map(renderValue);
|
|
@@ -1203,7 +1534,7 @@ function renderValue(value) {
|
|
|
1203
1534
|
const rendered = renderValue(descriptor.value);
|
|
1204
1535
|
if (rendered === undefined)
|
|
1205
1536
|
return undefined;
|
|
1206
|
-
entries.push(`${
|
|
1537
|
+
entries.push(`${literal(name)}: ${rendered}`);
|
|
1207
1538
|
}
|
|
1208
1539
|
return `{${entries.join(", ")}}`;
|
|
1209
1540
|
}
|
|
@@ -1330,13 +1661,13 @@ export function typeToZodBody(program, type) {
|
|
|
1330
1661
|
case "Union":
|
|
1331
1662
|
return unionToZod(program, type);
|
|
1332
1663
|
case "String":
|
|
1333
|
-
return `z.literal(${
|
|
1664
|
+
return `z.literal(${literal(type.value)})`;
|
|
1334
1665
|
case "Number":
|
|
1335
1666
|
return `z.literal(${type.value})`;
|
|
1336
1667
|
case "Boolean":
|
|
1337
1668
|
return `z.literal(${String(type.value)})`;
|
|
1338
1669
|
case "EnumMember":
|
|
1339
|
-
return `z.literal(${
|
|
1670
|
+
return `z.literal(${literal(type.value ?? type.name)})`;
|
|
1340
1671
|
/**
|
|
1341
1672
|
* **A named union VARIANT is a type you can refer to, and it is not the union.**
|
|
1342
1673
|
*
|