typespec-http-zod 0.15.1 → 0.17.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 +99 -6
- package/dist/src/types.js +19 -0
- package/dist/src/zod.js +12 -7
- package/package.json +1 -1
package/dist/src/api.js
CHANGED
|
@@ -601,6 +601,29 @@ function alternateSchemaFor(operation, status, registry) {
|
|
|
601
601
|
* document does not state AND reject what a server actually receives, since `c.req.parseBody()`
|
|
602
602
|
* hands back a `File`. The type of a value is positional, exactly as its visibility is.
|
|
603
603
|
*/
|
|
604
|
+
/**
|
|
605
|
+
* The TS type for a **multipart** request body, built from the same parts as its validator.
|
|
606
|
+
*
|
|
607
|
+
* **`requestBodyOf` returns a type only for `bodyKind === "single"`, so a multipart body reached the
|
|
608
|
+
* request type not at all.** Measured: `UploadInput` was `{ "Content-Type": "multipart/form-data" }`
|
|
609
|
+
* while the validator beside it was `z.object({ file: ..., alt: ... })` and the document published
|
|
610
|
+
* every part. Two artefacts from one program, contradicting each other.
|
|
611
|
+
*
|
|
612
|
+
* **Built from `body.parts`, not from the parts model.** Walking the model types each part as its
|
|
613
|
+
* `HttpPart<T>` wrapper - `file: {}` - where the validator unwraps to `T`. Mirroring
|
|
614
|
+
* {@link multipartSchemaOf} part for part, including `multi` as an array, `optional`, and a binary
|
|
615
|
+
* part as `unknown`, is what makes the two agree by construction rather than by coincidence.
|
|
616
|
+
*/
|
|
617
|
+
function multipartTsOf(body, registry) {
|
|
618
|
+
const entries = body.parts.map((part) => {
|
|
619
|
+
const type = part.body?.type;
|
|
620
|
+
const inner = type === undefined || isBinaryPart(type) ? "unknown" : registry.expressionFor(type);
|
|
621
|
+
const value = part.multi === true ? `(${inner})[]` : inner;
|
|
622
|
+
const optional = part.optional === true ? "?" : "";
|
|
623
|
+
return `\t${objectKey(part.name ?? "")}${optional}: ${value};`;
|
|
624
|
+
});
|
|
625
|
+
return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
|
|
626
|
+
}
|
|
604
627
|
function multipartSchemaOf(body, registry) {
|
|
605
628
|
const entries = body.parts.map((part) => {
|
|
606
629
|
const type = part.body?.type;
|
|
@@ -619,7 +642,27 @@ function multipartSchemaOf(body, registry) {
|
|
|
619
642
|
const name = named !== undefined && named.kind === "Model" && named.name !== "" ? named.name : undefined;
|
|
620
643
|
return name === undefined ? shape : registry.declareNamed(name, () => shape);
|
|
621
644
|
}
|
|
622
|
-
/**
|
|
645
|
+
/**
|
|
646
|
+
* `bytes`, or a model built on `File` - the parts openapi3 publishes as an empty schema.
|
|
647
|
+
*
|
|
648
|
+
* **A narrowed type for a `File` part was tried in `0.17.0` and reverted; do not try it again
|
|
649
|
+
* without reading this.** The ask was reasonable - `input.file.name` needs a cast today - and every
|
|
650
|
+
* way of granting it breaks something load-bearing:
|
|
651
|
+
*
|
|
652
|
+
* - `z.custom<FileLike>()` throws in `z.toJSONSchema` ("Custom types cannot be represented"),
|
|
653
|
+
* which silently removes those schemas from the differential that compares this emitter's
|
|
654
|
+
* validators against the document. Measured: four corpus schemas dropped out.
|
|
655
|
+
* - `z.unknown().refine(guard)` serialises fine and narrows correctly, but REJECTS a value the
|
|
656
|
+
* published contract permits, and `test/vocabulary.test.ts` refuses it by class for that reason.
|
|
657
|
+
* - Narrowing the type without a check makes the two artefacts disagree and asserts a shape nothing
|
|
658
|
+
* established.
|
|
659
|
+
*
|
|
660
|
+
* The root cause is upstream and visible in this package's own fixtures: `@typespec/openapi3`
|
|
661
|
+
* publishes a bare `{}` for a File part even in 3.1, where `contentMediaType` could express it, and
|
|
662
|
+
* even when the part declares a specific content type. **The day the document says what the part is,
|
|
663
|
+
* the validator may say it too and the type follows for free.** Until then `unknown` is the only
|
|
664
|
+
* honest answer, and a consumer narrows at the boundary.
|
|
665
|
+
*/
|
|
623
666
|
function isBinaryPart(type) {
|
|
624
667
|
if (type.kind === "Scalar") {
|
|
625
668
|
for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
|
|
@@ -660,7 +703,9 @@ export function isRawBinaryMediaType(contentTypes) {
|
|
|
660
703
|
/** The declared request body type, if the operation takes one. */
|
|
661
704
|
function requestBodyOf(operation) {
|
|
662
705
|
const body = operation.parameters.body;
|
|
663
|
-
|
|
706
|
+
if (body?.bodyKind === "single")
|
|
707
|
+
return body.type;
|
|
708
|
+
return undefined;
|
|
664
709
|
}
|
|
665
710
|
/**
|
|
666
711
|
* Parameters are emitted as their **own** schemas rather than folded into the body, because the two
|
|
@@ -1421,6 +1466,9 @@ function collectRequestTypes(program, registry, service) {
|
|
|
1421
1466
|
if (successStatusOf(operation) === undefined)
|
|
1422
1467
|
continue;
|
|
1423
1468
|
const body = requestBodyOf(operation);
|
|
1469
|
+
/** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
|
|
1470
|
+
const multipartBody = operation.parameters.body;
|
|
1471
|
+
const multipart = multipartBody?.bodyKind === "multipart" ? { parts: multipartBody.parts } : undefined;
|
|
1424
1472
|
/**
|
|
1425
1473
|
* Headers are part of the input too.
|
|
1426
1474
|
*
|
|
@@ -1466,9 +1514,11 @@ function collectRequestTypes(program, registry, service) {
|
|
|
1466
1514
|
operationId: operationIdOf(program, operation.operation),
|
|
1467
1515
|
bodyRef: rawBody !== undefined
|
|
1468
1516
|
? `{\n\t${rawBody}: ${rawBodyType};\n}`
|
|
1469
|
-
:
|
|
1470
|
-
?
|
|
1471
|
-
:
|
|
1517
|
+
: multipart !== undefined
|
|
1518
|
+
? multipartTsOf(multipart, registry)
|
|
1519
|
+
: body === undefined
|
|
1520
|
+
? undefined
|
|
1521
|
+
: registry.expressionFor(body),
|
|
1472
1522
|
extraProperties,
|
|
1473
1523
|
});
|
|
1474
1524
|
}
|
|
@@ -1563,6 +1613,10 @@ type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
|
|
1563
1613
|
* mutable one. Mutability is not a fact about a wire shape: it says nothing about what a caller
|
|
1564
1614
|
* receives. Making the TARGET readonly accepts both, which is the permissive direction; the reverse
|
|
1565
1615
|
* would reject every view a producer actually returns.
|
|
1616
|
+
*
|
|
1617
|
+
* No branch for functions, deliberately: the wire vocabulary is primitives, objects and arrays, so
|
|
1618
|
+
* a call signature cannot appear here. Mapping one would yield \`{}\`, so the day something does
|
|
1619
|
+
* carry a method - a narrowed multipart file part is the near miss - this needs one.
|
|
1566
1620
|
*/
|
|
1567
1621
|
type Produced<T> = T extends readonly (infer Element)[]
|
|
1568
1622
|
? readonly Produced<Element>[]
|
|
@@ -1625,7 +1679,7 @@ function renderWireAssertions(schemas, types, entries, contractsPackage) {
|
|
|
1625
1679
|
}
|
|
1626
1680
|
if (paired.length === 0)
|
|
1627
1681
|
return undefined;
|
|
1628
|
-
const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<z.infer<typeof ${pair.identifier}
|
|
1682
|
+
const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.infer<typeof ${pair.identifier}>>, Contracts.${pair.name}>>;`);
|
|
1629
1683
|
return `${generatedBanner()}
|
|
1630
1684
|
import type { z } from "zod";
|
|
1631
1685
|
import type * as Contracts from "${contractsPackage}";
|
|
@@ -1642,6 +1696,45 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
|
|
|
1642
1696
|
? true
|
|
1643
1697
|
: ["the emitted validator and the shared wire type describe different shapes"];
|
|
1644
1698
|
|
|
1699
|
+
/**
|
|
1700
|
+
* A shape with its index signatures removed, at every depth.
|
|
1701
|
+
*
|
|
1702
|
+
* **The two sides of this contract differ on exactly one axis, deliberately.** For an open model the
|
|
1703
|
+
* validator's output carries \`[key: string]: unknown\`, because a loose parse really does pass
|
|
1704
|
+
* unknown keys through. The shared wire type does not, because it is a FLOOR - the properties a
|
|
1705
|
+
* producer must supply - and an index signature there is an obligation on the producer rather than a
|
|
1706
|
+
* description of the payload. TypeScript gives an interface no implicit index signature, so carrying
|
|
1707
|
+
* it made the published shape unsatisfiable by most domain types.
|
|
1708
|
+
*
|
|
1709
|
+
* So this comparison is over DECLARED PROPERTIES, the only claim both sides make. **That the
|
|
1710
|
+
* validator is open wherever the document is open has not stopped being checked** - it is asserted
|
|
1711
|
+
* directly by the emitter's own suite now, instead of falling out of this comparison as a side
|
|
1712
|
+
* effect. Every other kind of drift still fails here exactly as before.
|
|
1713
|
+
*
|
|
1714
|
+
* Applied to the INFERRED side only. If the shared type ever grew an index signature of its own that
|
|
1715
|
+
* is a real disagreement, and this still catches it.
|
|
1716
|
+
*
|
|
1717
|
+
* Deep, because openness recurs: a nested open model puts one at every level, which is why a single
|
|
1718
|
+
* spread never repaired the shape it was supposed to repair.
|
|
1719
|
+
*/
|
|
1720
|
+
type Declared<T> = T extends (...args: never[]) => unknown
|
|
1721
|
+
? T
|
|
1722
|
+
: T extends readonly (infer Element)[]
|
|
1723
|
+
? T extends Element[]
|
|
1724
|
+
? Declared<Element>[]
|
|
1725
|
+
: readonly Declared<Element>[]
|
|
1726
|
+
: T extends object
|
|
1727
|
+
? {
|
|
1728
|
+
[K in keyof T as string extends K
|
|
1729
|
+
? never
|
|
1730
|
+
: number extends K
|
|
1731
|
+
? never
|
|
1732
|
+
: symbol extends K
|
|
1733
|
+
? never
|
|
1734
|
+
: K]: Declared<T[K]>;
|
|
1735
|
+
}
|
|
1736
|
+
: T;
|
|
1737
|
+
|
|
1645
1738
|
/**
|
|
1646
1739
|
* \u26a0\ufe0f **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
|
|
1647
1740
|
* 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
|
@@ -207,6 +207,25 @@ function modelToTs(program, model) {
|
|
|
207
207
|
* Permissiveness is a statement about the VALIDATOR - what it lets through - not about what a
|
|
208
208
|
* producer must contain. The contract type is the floor: these fields, at least.
|
|
209
209
|
*/
|
|
210
|
+
/**
|
|
211
|
+
* **`0.16.0` added the catchall here and `0.17.0` reverted it. Do not add it again.**
|
|
212
|
+
*
|
|
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.
|
|
219
|
+
*
|
|
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/`.
|
|
224
|
+
*
|
|
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.
|
|
228
|
+
*/
|
|
210
229
|
return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
|
|
211
230
|
}
|
|
212
231
|
/**
|
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.17.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",
|