typespec-http-zod 0.17.0 → 0.19.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 +119 -25
- package/dist/src/lib.d.ts +10 -1
- package/dist/src/lib.js +13 -0
- package/dist/src/types.js +17 -1
- package/package.json +1 -1
package/dist/src/api.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, resolvePath, } from "@typespec/compiler";
|
|
1
|
+
import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, NoTarget, resolvePath, } from "@typespec/compiler";
|
|
2
2
|
import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
|
|
3
3
|
import { getOperationId, resolveOperationId } from "@typespec/openapi";
|
|
4
4
|
import { reportDiagnostic } from "./lib.js";
|
|
@@ -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},`;
|
|
@@ -643,26 +655,60 @@ function multipartSchemaOf(body, registry) {
|
|
|
643
655
|
return name === undefined ? shape : registry.declareNamed(name, () => shape);
|
|
644
656
|
}
|
|
645
657
|
/**
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
* **
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
* -
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
* The
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
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.
|
|
665
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
|
+
}
|
|
711
|
+
/** `bytes`, or a model built on `File` - the parts openapi3 publishes as an empty schema. */
|
|
666
712
|
function isBinaryPart(type) {
|
|
667
713
|
if (type.kind === "Scalar") {
|
|
668
714
|
for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
|
|
@@ -1214,8 +1260,20 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
|
|
|
1214
1260
|
*
|
|
1215
1261
|
* `z.infer` of `.optional()` gives `p?: T | undefined`, which under `exactOptionalPropertyTypes`
|
|
1216
1262
|
* admits an explicitly-undefined property. **JSON has no `undefined`**, and the document says the
|
|
1217
|
-
* same thing by leaving the property out of `required`, so the inferred type
|
|
1218
|
-
*
|
|
1263
|
+
* same thing by leaving the property out of `required`, so the inferred type is wider than the
|
|
1264
|
+
* wire and a consumer reading a payload had to strip it at the boundary.
|
|
1265
|
+
*
|
|
1266
|
+
* **It does NOT match the contract type, and this docblock used to claim it did.** The
|
|
1267
|
+
* contract types in `requests.gen.ts` keep `?: T | undefined` on purpose: they are the floor a
|
|
1268
|
+
* PRODUCER supplies, and `{ p: undefined }` serialises identically to omitting `p`, so refusing
|
|
1269
|
+
* it would cost every producer a conditional spread and buy the wire nothing. Measured when this
|
|
1270
|
+
* was "fixed" to agree: `(ctx, input) => ok(input)` - return what you were given - stopped
|
|
1271
|
+
* compiling.
|
|
1272
|
+
*
|
|
1273
|
+
* So the two surfaces differ on this axis deliberately, exactly as they differ on openness: this
|
|
1274
|
+
* one is the narrow view of what ARRIVES, the contract type is the permissive floor for what you
|
|
1275
|
+
* may SEND. A reader comparing them will find them unequal and should - the emitted assertion
|
|
1276
|
+
* pairs the contract against raw `z.infer`, not against this.
|
|
1219
1277
|
*
|
|
1220
1278
|
* Derived from the same schema rather than hand-written beside it, so there is still one source of
|
|
1221
1279
|
* truth: a mapped type cannot drift from the thing it maps.
|
|
@@ -1617,8 +1675,14 @@ type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
|
|
1617
1675
|
* No branch for functions, deliberately: the wire vocabulary is primitives, objects and arrays, so
|
|
1618
1676
|
* a call signature cannot appear here. Mapping one would yield \`{}\`, so the day something does
|
|
1619
1677
|
* carry a method - a narrowed multipart file part is the near miss - this needs one.
|
|
1678
|
+
*
|
|
1679
|
+
* **Exported, because a consumer needs it for a NAMED type and could not reach it.** It was applied
|
|
1680
|
+
* to \`WireOutputs\` and declared locally, so a codebase whose layers hand back \`readonly T[]\` had
|
|
1681
|
+
* the producer view sitting in the file it was already importing and no way to name it. Measured on
|
|
1682
|
+
* one service: 2 contract methods returning \`readonly T[]\` and 35 readonly array properties, none
|
|
1683
|
+
* of them expressible against the published shape.
|
|
1620
1684
|
*/
|
|
1621
|
-
type Produced<T> = T extends readonly (infer Element)[]
|
|
1685
|
+
export type Produced<T> = T extends readonly (infer Element)[]
|
|
1622
1686
|
? readonly Produced<Element>[]
|
|
1623
1687
|
: T extends object
|
|
1624
1688
|
? { readonly [K in keyof T]: Produced<T[K]> }
|
|
@@ -1826,6 +1890,36 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
1826
1890
|
* there means a server emitter follows without knowing this rule exists.
|
|
1827
1891
|
*/
|
|
1828
1892
|
const distinctServices = new Set(snapshots.map((snapshot) => snapshot.service.namespace.name));
|
|
1893
|
+
/**
|
|
1894
|
+
* **Every service must seal the same way, because `@typespec/openapi3` has no per-service
|
|
1895
|
+
* options.**
|
|
1896
|
+
*
|
|
1897
|
+
* This option exists only so that two emitters answering the same question about the same models
|
|
1898
|
+
* give the same answer - it is stated twice on purpose, and its own docblock says to set it to
|
|
1899
|
+
* whatever openapi3 is set to. openapi3 applies ONE value to the whole program. So services that
|
|
1900
|
+
* seal differently cannot both be mirrored: whichever way openapi3 is configured, at least one
|
|
1901
|
+
* service publishes a document that disagrees with the validator generated beside it. Sealed here
|
|
1902
|
+
* and silent there refuses a payload the document permits; the reverse publishes a strictness the
|
|
1903
|
+
* runtime does not enforce.
|
|
1904
|
+
*
|
|
1905
|
+
* Reported once for the program rather than once per service, because the fault is the
|
|
1906
|
+
* DISAGREEMENT and no single service is the wrong one.
|
|
1907
|
+
*/
|
|
1908
|
+
const sealings = new Map();
|
|
1909
|
+
for (const snapshot of snapshots) {
|
|
1910
|
+
const name = snapshot.service.namespace.name;
|
|
1911
|
+
const perService = options.services?.[name];
|
|
1912
|
+
sealings.set(name, perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false);
|
|
1913
|
+
}
|
|
1914
|
+
if (new Set(sealings.values()).size > 1) {
|
|
1915
|
+
const sealed = [...sealings.entries()].filter(([, seals]) => seals).map(([name]) => name);
|
|
1916
|
+
const open = [...sealings.entries()].filter(([, seals]) => !seals).map(([name]) => name);
|
|
1917
|
+
reportDiagnostic(context.program, {
|
|
1918
|
+
code: "unmirrorable-seal",
|
|
1919
|
+
target: NoTarget,
|
|
1920
|
+
format: { sealed: sealed.join(", "), open: open.join(", ") },
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1829
1923
|
for (const snapshot of snapshots) {
|
|
1830
1924
|
beginOperationIds();
|
|
1831
1925
|
const service = snapshot.service;
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -125,6 +125,9 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
125
125
|
"empty-union": {
|
|
126
126
|
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
127
127
|
};
|
|
128
|
+
"unmirrorable-seal": {
|
|
129
|
+
readonly default: import("@typespec/compiler").CallableMessage<["sealed", "open"]>;
|
|
130
|
+
};
|
|
128
131
|
"unknown-key-vocabulary": {
|
|
129
132
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
|
|
130
133
|
};
|
|
@@ -138,7 +141,7 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
138
141
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
139
142
|
};
|
|
140
143
|
}, EmitterOptions, never>;
|
|
141
|
-
export declare const reportDiagnostic: <C extends "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
144
|
+
export declare const reportDiagnostic: <C extends "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
142
145
|
"unsupported-type": {
|
|
143
146
|
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
|
|
144
147
|
};
|
|
@@ -151,6 +154,9 @@ export declare const reportDiagnostic: <C extends "duplicate-declaration" | "dup
|
|
|
151
154
|
"empty-union": {
|
|
152
155
|
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
153
156
|
};
|
|
157
|
+
"unmirrorable-seal": {
|
|
158
|
+
readonly default: import("@typespec/compiler").CallableMessage<["sealed", "open"]>;
|
|
159
|
+
};
|
|
154
160
|
"unknown-key-vocabulary": {
|
|
155
161
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
|
|
156
162
|
};
|
|
@@ -176,6 +182,9 @@ export declare const reportDiagnostic: <C extends "duplicate-declaration" | "dup
|
|
|
176
182
|
"empty-union": {
|
|
177
183
|
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
178
184
|
};
|
|
185
|
+
"unmirrorable-seal": {
|
|
186
|
+
readonly default: import("@typespec/compiler").CallableMessage<["sealed", "open"]>;
|
|
187
|
+
};
|
|
179
188
|
"unknown-key-vocabulary": {
|
|
180
189
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
|
|
181
190
|
};
|
package/dist/src/lib.js
CHANGED
|
@@ -127,6 +127,19 @@ const diagnostics = {
|
|
|
127
127
|
* consumer can iterate a closed key set at run time; a typo would emit no tuple at all, and "the
|
|
128
128
|
* vocabulary is missing" looks exactly like "the vocabulary is empty" at the point it is used.
|
|
129
129
|
*/
|
|
130
|
+
/**
|
|
131
|
+
* Two services sealing differently, which `@typespec/openapi3` cannot mirror.
|
|
132
|
+
*
|
|
133
|
+
* An error rather than a warning, on the same footing as every other divergence here: the output
|
|
134
|
+
* is a document and a validator that contradict each other about whether a property may exist,
|
|
135
|
+
* and shipping that is worse than failing the build.
|
|
136
|
+
*/
|
|
137
|
+
"unmirrorable-seal": {
|
|
138
|
+
severity: "error",
|
|
139
|
+
messages: {
|
|
140
|
+
default: paramMessage `'seal-object-schemas' resolves to true for ${"sealed"} and false for ${"open"}, and '@typespec/openapi3' has no per-service options - it applies one value to the whole program. So one of these services would publish a document that disagrees with the validator emitted beside it. Give every service the same value, or split the surfaces into separate compiles.`,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
130
143
|
"unknown-key-vocabulary": {
|
|
131
144
|
severity: "error",
|
|
132
145
|
messages: {
|
package/dist/src/types.js
CHANGED
|
@@ -256,7 +256,23 @@ export function propertyToTs(program, property, wireName) {
|
|
|
256
256
|
*/
|
|
257
257
|
if (property.defaultValue !== undefined)
|
|
258
258
|
return `${key}: ${value};`;
|
|
259
|
-
|
|
259
|
+
/**
|
|
260
|
+
* **`?: T | undefined`, and dropping the `| undefined` was tried in `0.19.0` and reverted.**
|
|
261
|
+
*
|
|
262
|
+
* It looks wrong - JSON has no `undefined`, so a wire type saying `?: T` reads more honest, and
|
|
263
|
+
* it would make this walk agree with `Exact<z.infer<...>>` on the schemas side. **Measured, it
|
|
264
|
+
* breaks the most ordinary handler there is.** `createWidget: (ctx, input) => ok(input)` - return
|
|
265
|
+
* what you were given - stopped compiling, because what a handler RECEIVES carries
|
|
266
|
+
* `?: T | undefined` and what it would then have to SUPPLY did not.
|
|
267
|
+
*
|
|
268
|
+
* That is the difference from the index signature and from `readonly`, which look like the same
|
|
269
|
+
* class and are not: removing those two makes MORE values assignable, and removing this one makes
|
|
270
|
+
* fewer. `{ retiredOn: undefined }` serialises to `{}`, identical to omitting the key, so
|
|
271
|
+
* permitting it costs the wire nothing and refusing it costs every producer a conditional spread.
|
|
272
|
+
*
|
|
273
|
+
* The two emitted surfaces differ here on purpose, exactly as they do on openness:
|
|
274
|
+
* `schemas.gen.ts` exports the narrow received view, this walk emits the permissive floor.
|
|
275
|
+
*/
|
|
260
276
|
return property.optional ? `${key}?: ${value} | undefined;` : `${key}: ${value};`;
|
|
261
277
|
}
|
|
262
278
|
/** Installed by the registry so a named model is declared once and referenced everywhere else. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typespec-http-zod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|