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 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 || isBinaryPart(type) ? "unknown" : registry.expressionFor(type);
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 || isBinaryPart(type) ? "z.unknown()" : registry.expressionFor(type);
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
- * `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.
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 was wider than both
1218
- * the contract and the wire and a consumer had to strip it at the boundary.
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
- // `exactOptionalPropertyTypes` is on, and Zod's `.optional()` infers `T | undefined`.
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.17.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",