typespec-http-zod 0.16.0 → 0.18.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.js +112 -3
- package/dist/src/types.js +15 -17
- package/dist/src/zod.js +12 -7
- package/package.json +1 -1
package/dist/src/api.js
CHANGED
|
@@ -617,7 +617,13 @@ function alternateSchemaFor(operation, status, registry) {
|
|
|
617
617
|
function multipartTsOf(body, registry) {
|
|
618
618
|
const entries = body.parts.map((part) => {
|
|
619
619
|
const type = part.body?.type;
|
|
620
|
-
const inner = type === undefined
|
|
620
|
+
const inner = type === undefined
|
|
621
|
+
? "unknown"
|
|
622
|
+
: isFilePart(type)
|
|
623
|
+
? MULTIPART_FILE
|
|
624
|
+
: isBinaryPart(type)
|
|
625
|
+
? "unknown"
|
|
626
|
+
: registry.expressionFor(type);
|
|
621
627
|
const value = part.multi === true ? `(${inner})[]` : inner;
|
|
622
628
|
const optional = part.optional === true ? "?" : "";
|
|
623
629
|
return `\t${objectKey(part.name ?? "")}${optional}: ${value};`;
|
|
@@ -627,7 +633,13 @@ function multipartTsOf(body, registry) {
|
|
|
627
633
|
function multipartSchemaOf(body, registry) {
|
|
628
634
|
const entries = body.parts.map((part) => {
|
|
629
635
|
const type = part.body?.type;
|
|
630
|
-
const inner = type === undefined
|
|
636
|
+
const inner = type === undefined
|
|
637
|
+
? "z.unknown()"
|
|
638
|
+
: isFilePart(type)
|
|
639
|
+
? `z.unknown().refine(${MULTIPART_FILE_REFINEMENT})`
|
|
640
|
+
: isBinaryPart(type)
|
|
641
|
+
? "z.unknown()"
|
|
642
|
+
: registry.expressionFor(type);
|
|
631
643
|
// `multi` is `HttpPart<T>[]` - the same part repeated, which arrives as an array.
|
|
632
644
|
const value = part.multi === true ? `z.array(${inner})` : inner;
|
|
633
645
|
return `\t${objectKey(part.name ?? "")}: ${part.optional === true ? `${value}.optional()` : value},`;
|
|
@@ -642,6 +654,60 @@ function multipartSchemaOf(body, registry) {
|
|
|
642
654
|
const name = named !== undefined && named.kind === "Model" && named.name !== "" ? named.name : undefined;
|
|
643
655
|
return name === undefined ? shape : registry.declareNamed(name, () => shape);
|
|
644
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* The shape a multipart file part actually arrives as, spelled structurally.
|
|
659
|
+
*
|
|
660
|
+
* **Structural rather than `File`, because emitted output must depend on no ambient library.**
|
|
661
|
+
* `File` is reachable only through `lib.dom` or `@types/node`, and this package's own
|
|
662
|
+
* emitted-output typecheck runs with `types: []` precisely so a dependency like that cannot creep
|
|
663
|
+
* in. The three members are the ones a handler needs and the ones {@link MULTIPART_FILE_REFINEMENT}
|
|
664
|
+
* actually verifies - claiming more would be asserting what nothing established.
|
|
665
|
+
*
|
|
666
|
+
* `arrayBuffer` is a property rather than a method on purpose: `Declared<>` maps the inferred side
|
|
667
|
+
* of the wire assertion, and a mapped type turns a method into a function-typed property, so
|
|
668
|
+
* spelling it this way on both sides is what keeps the two identical.
|
|
669
|
+
*/
|
|
670
|
+
const MULTIPART_FILE = "{ name: string; type: string; arrayBuffer: () => Promise<ArrayBuffer> }";
|
|
671
|
+
/**
|
|
672
|
+
* The check that makes {@link MULTIPART_FILE} established rather than asserted.
|
|
673
|
+
*
|
|
674
|
+
* **Why this is derivable from the contract, which is the rule `test/vocabulary.test.ts` enforces.**
|
|
675
|
+
* `@typespec/openapi3` publishes a bare `{}` for a `HttpPart<File>`, in 3.1 and even where the part
|
|
676
|
+
* declares a content type. That is OpenAPI's IDIOM for binary content in a multipart body rather
|
|
677
|
+
* than a statement that any value is acceptable, and the transport says the same: Hono types a
|
|
678
|
+
* multipart part as `string | File` and nothing else. So this refuses exactly one thing - a text
|
|
679
|
+
* field where the spec declared a file - which is malformed against the spec that produced the
|
|
680
|
+
* document. A spec meaning "either" writes `HttpPart<File | string>`, so nothing becomes
|
|
681
|
+
* inexpressible; the declaration simply means what it says.
|
|
682
|
+
*
|
|
683
|
+
* **`z.unknown().refine()` rather than `z.custom<T>()`, and the difference is not stylistic.**
|
|
684
|
+
* Measured on zod 4.4.3: `z.toJSONSchema(z.custom(...))` throws `Custom types cannot be represented
|
|
685
|
+
* in JSON Schema`, which silently took four corpus schemas out of the differential comparing these
|
|
686
|
+
* validators against the document. Refined `z.unknown()` serialises to `{}` - exactly what openapi3
|
|
687
|
+
* publishes - so that comparison keeps working. Zod 4 narrows on a type predicate, so the inferred
|
|
688
|
+
* type is the same either way.
|
|
689
|
+
*
|
|
690
|
+
* Every member the type claims is verified, so the narrowing is sound.
|
|
691
|
+
*/
|
|
692
|
+
const MULTIPART_FILE_REFINEMENT = `(value): value is ${MULTIPART_FILE} => typeof value === "object" && value !== null && "name" in value && typeof value.name === "string" && "type" in value && typeof value.type === "string" && "arrayBuffer" in value && typeof value.arrayBuffer === "function"`;
|
|
693
|
+
/**
|
|
694
|
+
* A part declared as a `File`, as opposed to raw `bytes`.
|
|
695
|
+
*
|
|
696
|
+
* **Narrower than {@link isBinaryPart} deliberately.** A `File` part is declared to be a file and
|
|
697
|
+
* `c.req.parseBody()` produces one, so the contract supports saying so. `bytes` says only that the
|
|
698
|
+
* payload is binary; a client may legitimately send it as an ordinary form field, which arrives as a
|
|
699
|
+
* string. Widening this to cover `bytes` would refuse payloads that pass today, on an inference the
|
|
700
|
+
* spec never made.
|
|
701
|
+
*/
|
|
702
|
+
function isFilePart(type) {
|
|
703
|
+
if (type.kind !== "Model")
|
|
704
|
+
return false;
|
|
705
|
+
for (let model = type; model !== undefined; model = model.baseModel) {
|
|
706
|
+
if (model.name === "File")
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
645
711
|
/** `bytes`, or a model built on `File` - the parts openapi3 publishes as an empty schema. */
|
|
646
712
|
function isBinaryPart(type) {
|
|
647
713
|
if (type.kind === "Scalar") {
|
|
@@ -1593,6 +1659,10 @@ type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
|
|
1593
1659
|
* mutable one. Mutability is not a fact about a wire shape: it says nothing about what a caller
|
|
1594
1660
|
* receives. Making the TARGET readonly accepts both, which is the permissive direction; the reverse
|
|
1595
1661
|
* would reject every view a producer actually returns.
|
|
1662
|
+
*
|
|
1663
|
+
* No branch for functions, deliberately: the wire vocabulary is primitives, objects and arrays, so
|
|
1664
|
+
* a call signature cannot appear here. Mapping one would yield \`{}\`, so the day something does
|
|
1665
|
+
* carry a method - a narrowed multipart file part is the near miss - this needs one.
|
|
1596
1666
|
*/
|
|
1597
1667
|
type Produced<T> = T extends readonly (infer Element)[]
|
|
1598
1668
|
? readonly Produced<Element>[]
|
|
@@ -1655,7 +1725,7 @@ function renderWireAssertions(schemas, types, entries, contractsPackage) {
|
|
|
1655
1725
|
}
|
|
1656
1726
|
if (paired.length === 0)
|
|
1657
1727
|
return undefined;
|
|
1658
|
-
const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<z.infer<typeof ${pair.identifier}
|
|
1728
|
+
const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.infer<typeof ${pair.identifier}>>, Contracts.${pair.name}>>;`);
|
|
1659
1729
|
return `${generatedBanner()}
|
|
1660
1730
|
import type { z } from "zod";
|
|
1661
1731
|
import type * as Contracts from "${contractsPackage}";
|
|
@@ -1672,6 +1742,45 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
|
|
|
1672
1742
|
? true
|
|
1673
1743
|
: ["the emitted validator and the shared wire type describe different shapes"];
|
|
1674
1744
|
|
|
1745
|
+
/**
|
|
1746
|
+
* A shape with its index signatures removed, at every depth.
|
|
1747
|
+
*
|
|
1748
|
+
* **The two sides of this contract differ on exactly one axis, deliberately.** For an open model the
|
|
1749
|
+
* validator's output carries \`[key: string]: unknown\`, because a loose parse really does pass
|
|
1750
|
+
* unknown keys through. The shared wire type does not, because it is a FLOOR - the properties a
|
|
1751
|
+
* producer must supply - and an index signature there is an obligation on the producer rather than a
|
|
1752
|
+
* description of the payload. TypeScript gives an interface no implicit index signature, so carrying
|
|
1753
|
+
* it made the published shape unsatisfiable by most domain types.
|
|
1754
|
+
*
|
|
1755
|
+
* So this comparison is over DECLARED PROPERTIES, the only claim both sides make. **That the
|
|
1756
|
+
* validator is open wherever the document is open has not stopped being checked** - it is asserted
|
|
1757
|
+
* directly by the emitter's own suite now, instead of falling out of this comparison as a side
|
|
1758
|
+
* effect. Every other kind of drift still fails here exactly as before.
|
|
1759
|
+
*
|
|
1760
|
+
* Applied to the INFERRED side only. If the shared type ever grew an index signature of its own that
|
|
1761
|
+
* is a real disagreement, and this still catches it.
|
|
1762
|
+
*
|
|
1763
|
+
* Deep, because openness recurs: a nested open model puts one at every level, which is why a single
|
|
1764
|
+
* spread never repaired the shape it was supposed to repair.
|
|
1765
|
+
*/
|
|
1766
|
+
type Declared<T> = T extends (...args: never[]) => unknown
|
|
1767
|
+
? T
|
|
1768
|
+
: T extends readonly (infer Element)[]
|
|
1769
|
+
? T extends Element[]
|
|
1770
|
+
? Declared<Element>[]
|
|
1771
|
+
: readonly Declared<Element>[]
|
|
1772
|
+
: T extends object
|
|
1773
|
+
? {
|
|
1774
|
+
[K in keyof T as string extends K
|
|
1775
|
+
? never
|
|
1776
|
+
: number extends K
|
|
1777
|
+
? never
|
|
1778
|
+
: symbol extends K
|
|
1779
|
+
? never
|
|
1780
|
+
: K]: Declared<T[K]>;
|
|
1781
|
+
}
|
|
1782
|
+
: T;
|
|
1783
|
+
|
|
1675
1784
|
/**
|
|
1676
1785
|
* \u26a0\ufe0f **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
|
|
1677
1786
|
* type when the two disagree, and a type alias to an odd type compiles fine \u2014 66 assertions that
|
package/dist/src/types.js
CHANGED
|
@@ -208,27 +208,25 @@ function modelToTs(program, model) {
|
|
|
208
208
|
* producer must contain. The contract type is the floor: these fields, at least.
|
|
209
209
|
*/
|
|
210
210
|
/**
|
|
211
|
-
*
|
|
211
|
+
* **`0.16.0` added the catchall here and `0.17.0` reverted it. Do not add it again.**
|
|
212
212
|
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* the
|
|
216
|
-
*
|
|
217
|
-
*
|
|
213
|
+
* The argument for it was that the document says `additionalProperties` and the validator says
|
|
214
|
+
* `.loose()`, so the type should say it too. Both of those state what is TOLERATED ON ARRIVAL. On
|
|
215
|
+
* the type it reads as an obligation on whoever PRODUCES the value, and TypeScript gives an
|
|
216
|
+
* interface no implicit index signature (microsoft/TypeScript#15300) - only a type alias gets one
|
|
217
|
+
* - so whether a consumer could satisfy our published shape came to depend on which keyword their
|
|
218
|
+
* own domain type happened to use, in files this emitter does not own.
|
|
218
219
|
*
|
|
219
|
-
* **
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* the
|
|
220
|
+
* Measured on one consumer's document: **58 of 101 components open, 26 of those nesting another
|
|
221
|
+
* open component**, so the obligation cost a structural deep copy of the response tree per
|
|
222
|
+
* response. One spread satisfies one level and does not reach the next, which is the whole shape
|
|
223
|
+
* of the failure - see `test/contractshape/`.
|
|
223
224
|
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
225
|
+
* What arrives is still described honestly, on the surface derived from the validator:
|
|
226
|
+
* `schemas.gen.ts` exports `Exact<z.infer<typeof x>>`, which carries the index signature because
|
|
227
|
+
* a `.loose()` parse really does. Two surfaces, two directions, neither lying.
|
|
226
228
|
*/
|
|
227
|
-
|
|
228
|
-
? [`\t[key: string]: ${typeToTs(program, indexer.value)};`]
|
|
229
|
-
: [];
|
|
230
|
-
const all = [...entries, ...catchall];
|
|
231
|
-
return all.length === 0 ? "{}" : `{\n${all.join("\n")}\n}`;
|
|
229
|
+
return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
|
|
232
230
|
}
|
|
233
231
|
/**
|
|
234
232
|
* A property line, including its optionality marker.
|
package/dist/src/zod.js
CHANGED
|
@@ -783,8 +783,13 @@ function modelToZod(program, model) {
|
|
|
783
783
|
* Measured on Zod 4.4.3: `.strict()`, `.loose()` and `.catchall()` all read `shape` eagerly, so
|
|
784
784
|
* each one fires the getter during module initialisation and throws `Cannot access 'X' before
|
|
785
785
|
* initialization` - the very thing the getter exists to avoid. `z.strictObject` and
|
|
786
|
-
* `z.looseObject` take the same shape without reading it.
|
|
787
|
-
*
|
|
786
|
+
* `z.looseObject` take the same shape without reading it.
|
|
787
|
+
*
|
|
788
|
+
* **So the constructor form is used wherever it exists, not only where a cycle forces it.** It is
|
|
789
|
+
* also what Zod 4 asks for: its own types say "Consider `z.looseObject(A.shape)` instead" on
|
|
790
|
+
* `.loose()` and `.strict()`, and `.passthrough()` is deprecated outright. Emitting one spelling
|
|
791
|
+
* means a model that gains a back edge later does not silently change form, and the eager-`shape`
|
|
792
|
+
* hazard above stops being something this walk has to keep reasoning about.
|
|
788
793
|
*
|
|
789
794
|
* **A TYPED catchall has no constructor form, and that used to be a refusal.** There is nowhere
|
|
790
795
|
* safe to put the getter - `.catchall()` reads `shape` eagerly - so the cycle was reported as
|
|
@@ -796,11 +801,11 @@ function modelToZod(program, model) {
|
|
|
796
801
|
const lazily = members.some((member) => member.deferred);
|
|
797
802
|
const constructible = suffix === "" || suffix === ".loose()";
|
|
798
803
|
const shape = entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
|
|
799
|
-
const body =
|
|
800
|
-
?
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
+
const body = constructible
|
|
805
|
+
? `${suffix === ".loose()" ? "z.looseObject" : strict === ".strict()" ? "z.strictObject" : "z.object"}(${shape})`
|
|
806
|
+
: lazily
|
|
807
|
+
? `z.lazy(() => z.object(${shape})${suffix}${strict})`
|
|
808
|
+
: `z.object(${shape})${suffix}${strict}`;
|
|
804
809
|
return applyConstraints(program, body, model);
|
|
805
810
|
}
|
|
806
811
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typespec-http-zod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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",
|