typespec-http-zod 0.17.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.
Files changed (2) hide show
  1. package/dist/src/api.js +67 -21
  2. 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 || 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.
665
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
+ }
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.17.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",