typespec-http-zod 0.30.0 → 0.32.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,17 +1,20 @@
1
- import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolveEncodedName, resolvePath, walkPropertiesInherited, } from "@typespec/compiler";
2
- import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
1
+ import { emitFile, getDiscriminatedUnion, getDiscriminator, getDoc, getDocData, getEncode, getLocationContext, getSummary, getTypeName, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolvePath, } from "@typespec/compiler";
2
+ import { getAuthenticationForOperation, getVisibilitySuffix, isOrExtendsHttpFile, Visibility, } from "@typespec/http";
3
3
  import { getOperationId, resolveOperationId } from "@typespec/openapi";
4
+ import { firstReportOf } from "./constraints.js";
4
5
  import { reportDiagnostic } from "./lib.js";
6
+ import { openApiVersionsOf, publishesOpenApi30AndLater } from "./openapi-versions.js";
5
7
  import { literal } from "./literal.js";
6
- import { SchemaRegistry, TypeRegistry } from "./registry.js";
8
+ import { beginServiceView, parameterVisibilityOf, publishedComponents, publishedTypes, requestVisibilityOf, unreachedTypes, withServiceView, } from "./published.js";
9
+ import { isSingleObjectLiteral, SchemaRegistry, TypeRegistry } from "./registry.js";
7
10
  import { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
8
11
  import { propertyTypeToTs } from "./types.js";
9
12
  import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
10
13
  import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
11
- import { EXPLODED_QUERY_SOURCE, explodedQueryCall, needsExpansionDecoding, routeTemplateOf, URI_EXPANSION_SOURCE, uriExpansionCall, } from "./uri-template.js";
14
+ import { EXPLODED_QUERY_SOURCE, explodedQueryCall, eachReader, NO_READERS, needsExpansionDecoding, routeTemplateOf, URI_EXPANSION_SOURCE, uriExpansionCall, WIRE_READ_OBJECT_SOURCE, WIRE_READ_AS_SOURCE, WIRE_READ_SOURCE, WIRE_ITSELF_SOURCE, WIRE_READERS_SOURCE, wireItselfCall, wireReadObjectCall, } from "./uri-template.js";
12
15
  export { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
13
16
  export { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
14
- import { collectExternalImports, encodedTypeOf, isPayloadProperty, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
17
+ import { collectExternalImports, encodedTypeOf, externalReferenceOf, holdsRawBytes, isExclusive, isTypelessMember, nullableInnerOf, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, objectOfModel, reachesItselfThroughUnions, refuseExternalReference, refuseSelfReachingUnion, atItem, bindingIndexerOf, currentVisibility, dispatchedSubtypesOf, readsFromText, renderedVisibilityOf, rootVisibilityOf, withContractsPackage, withSealedObjects, withMetadataAnnotationsIgnored, requiredReadOnlyIn, reportUnpublishable, withRequestReading, withMultipartBinary, withParameterPosition, UNREPRESENTABLE, withVisibility, } from "./zod.js";
15
18
  /**
16
19
  * `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
17
20
  * service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
@@ -322,15 +325,18 @@ function resolveResponses(program, operation) {
322
325
  const entry = byStatus.get(key) ?? {
323
326
  status,
324
327
  body: undefined,
328
+ ignoresMetadata: false,
325
329
  contentTypes: new Set(),
326
330
  headers: [],
327
331
  streamed: false,
328
332
  file: false,
329
333
  };
330
334
  for (const content of response.responses) {
335
+ reportNeverBody(program, operation, content.body, `${key} response`);
331
336
  const body = presentBody(content.body);
332
337
  if (entry.body === undefined && body?.bodyKind === "single") {
333
338
  entry.body = body.type;
339
+ entry.ignoresMetadata = ignoresMetadata(body);
334
340
  }
335
341
  /**
336
342
  * **An `Http.File` response's body is its contents, served as bytes.** The same reading
@@ -360,11 +366,12 @@ function resolveResponses(program, operation) {
360
366
  return {
361
367
  status: entry.status,
362
368
  body: entry.body,
369
+ ignoresMetadata: entry.ignoresMetadata,
363
370
  contentTypes,
364
371
  headers: entry.headers,
365
372
  binary: entry.file ||
366
373
  (entry.body !== undefined &&
367
- isBinaryPart(entry.body) &&
374
+ isBinaryPart(program, entry.body) &&
368
375
  isRawBinaryMediaType(contentTypes)),
369
376
  streamed: entry.streamed,
370
377
  textual: !entry.file && entry.body !== undefined && isStringType(entry.body),
@@ -376,13 +383,17 @@ function inPrecedenceOrder(responses) {
376
383
  return responses.toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status) ||
377
384
  statusOrderOf(a.status) - statusOrderOf(b.status));
378
385
  }
386
+ /** A response header's contract type: a header, as a parameter is - see `withParameterPosition`. */
387
+ function headerTypeOf(program, property) {
388
+ return withParameterPosition(true, () => propertyTypeToTs(program, property));
389
+ }
379
390
  /**
380
391
  * Every response the operation declares, rendered as Zod, in OpenAPI's precedence order.
381
392
  *
382
393
  * Every header is rendered before any body, and bodies in precedence order, which is the order this
383
394
  * has always declared components in: the emitted files do not move because the resolution moved.
384
395
  *
385
- * Call inside `withVisibility(program, Visibility.Read, ...)`: a response is read at `Read`, and
396
+ * Call inside `withVisibility(Visibility.Read, ...)`: a response is read at `Read`, and
386
397
  * resolving it under a request visibility declares components under the wrong suffix.
387
398
  */
388
399
  function responsesOf(program, operation, registry) {
@@ -396,7 +407,7 @@ function responsesOf(program, operation, registry) {
396
407
  * alone published `string`, so a handler satisfying its own result type was refused by its
397
408
  * own header check and the arm could not be returned without a cast.
398
409
  */
399
- type: propertyTypeToTs(program, property),
410
+ type: headerTypeOf(program, property),
400
411
  /**
401
412
  * **The Zod expression, which used to be discarded here.** Every REQUEST parameter goes
402
413
  * through `expressionForProperty` a few hundred lines below; a response header went
@@ -414,7 +425,7 @@ function responsesOf(program, operation, registry) {
414
425
  ? undefined
415
426
  : response.binary
416
427
  ? "z.unknown()"
417
- : registry.expressionFor(response.body),
428
+ : withMetadataAnnotationsIgnored(response.ignoresMetadata, () => registry.expressionFor(response.body, true)),
418
429
  contentTypes: response.contentTypes,
419
430
  headers,
420
431
  binary: response.binary,
@@ -607,6 +618,54 @@ export function errorBodyOf(program, service) {
607
618
  function bodyTypeOf(type) {
608
619
  return isVoidType(type) || isNeverType(type) ? undefined : type;
609
620
  }
621
+ /**
622
+ * **A body declared `never` is refused**, as `never-body`, once per declaration.
623
+ *
624
+ * `never` has no values, so the body cannot exist - and `@typespec/openapi3`, whose body paths test
625
+ * only for `void`, publishes it as `content: { "application/json": { schema: {} } }`: any value. A
626
+ * validator can agree with that or with the spec, not both, so the declaration is refused the way
627
+ * `undeclared-discriminator` refuses a discriminator the document cannot state. `void` says "no
628
+ * body", and openapi3 publishes it as none.
629
+ */
630
+ /**
631
+ * **A request holding a required read-only property, where openapi3 publishes 3.0 and a later
632
+ * version** - see `ambiguous-read-only`. The 3.0 document lets the request leave it out and the later
633
+ * one does not, so no request validator agrees with both.
634
+ */
635
+ function reportAmbiguousReadOnly(program, type, requested) {
636
+ if (!publishesOpenApi30AndLater(program))
637
+ return;
638
+ const property = requiredReadOnlyIn(program, type, rootVisibilityOf(program, type, requested), new Set());
639
+ if (property === undefined || !firstReportOf(program, "ambiguous-read-only", type))
640
+ return;
641
+ reportDiagnostic(program, {
642
+ code: "ambiguous-read-only",
643
+ target: type,
644
+ format: {
645
+ type: getTypeName(type),
646
+ property: property.model === undefined
647
+ ? property.name
648
+ : `${getTypeName(property.model)}.${property.name}`,
649
+ later: openApiVersionsOf(program)
650
+ .filter((version) => !version.startsWith("3.0."))
651
+ .map((version) => `OpenAPI ${version}`)
652
+ .join(" and "),
653
+ view: `${getVisibilitySuffix(requested, Visibility.Read)}<${getTypeName(property.model ?? type)}>`,
654
+ },
655
+ });
656
+ }
657
+ function reportNeverBody(program, operation, body, position) {
658
+ if (body?.bodyKind !== "single" || !isNeverType(body.type))
659
+ return;
660
+ const target = body.property ?? operation.operation;
661
+ if (!firstReportOf(program, "never-body", target))
662
+ return;
663
+ reportDiagnostic(program, {
664
+ code: "never-body",
665
+ target,
666
+ format: { operationId: operationIdOf(program, operation.operation), position },
667
+ });
668
+ }
610
669
  /**
611
670
  * A declared body as the wire carries it: `undefined` where it is `void` or `never`, so no media
612
671
  * type is listed for it either.
@@ -616,9 +675,8 @@ function bodyTypeOf(type) {
616
675
  * the media types off it, so a 409 with no body was published as `contentTypes:
617
676
  * ["application/json"]` and `schema: undefined`, which a consumer reads as a body it cannot validate,
618
677
  * and a `void` request body reached the Zod walk and refused the compile. openapi3 publishes no
619
- * `content` for `void`. For `never` it publishes `schema: {}` - any value - because its body paths
620
- * test only for `void`, while it omits a `never` PROPERTY from every schema; a type with no values is
621
- * no body here, and `reference.md` records the divergence.
678
+ * `content` for `void`. A `never` body is refused, as `never-body`, so no artefact states a body the
679
+ * document publishes as any value; see {@link reportNeverBody}.
622
680
  */
623
681
  function presentBody(body) {
624
682
  return body?.bodyKind === "single" && bodyTypeOf(body.type) === undefined ? undefined : body;
@@ -627,6 +685,19 @@ function presentBody(body) {
627
685
  function requestBodyParameterOf(operation) {
628
686
  return presentBody(operation.parameters.body);
629
687
  }
688
+ /**
689
+ * **The operation's parameters as the document publishes them: every one but a `never`.** openapi3
690
+ * leaves a parameter of type `never` out of the operation (`getParameterOrRef`), required or not and
691
+ * wherever it is, so `?a=1` beside `@query a?: never` is an extra parameter the document accepts.
692
+ * Read whole, each was validated as `z.never()`, which refused it, was typed `a?: never` in the
693
+ * handler's input, and was reported as `unsupported-type` at `<unknown location>:1:1` under a remedy
694
+ * saying a `never` could not get there. A `never` body property is dropped the same way - see
695
+ * `publishedPropertiesOf`. A `never` sharing its wire name with another parameter leaves the name
696
+ * to that one, which is the one the document publishes.
697
+ */
698
+ function publishedParametersOf(operation) {
699
+ return operation.parameters.parameters.filter((parameter) => !isNeverType(parameter.param.type));
700
+ }
630
701
  /**
631
702
  * The media types this operation's success response can be served as.
632
703
  *
@@ -661,18 +732,21 @@ function responseContentTypesOf(operation, status) {
661
732
  * expanded reads this type, so none of them can disagree with the schema it feeds.
662
733
  */
663
734
  function encodedParameterType(program, property) {
664
- return encodedTypeOf(program, property) ?? property.type;
735
+ const type = encodedTypeOf(program, property) ?? property.type;
736
+ /**
737
+ * **A parameter cannot carry `null` on the wire, so `T | null` arrives as a `T`.** Read as the
738
+ * union, `?ids=1,2` for `ids?: int32[] | null` was never split or decoded, and a form-exploded
739
+ * `filter?: Filter | null` was never gathered, so its keys were stripped.
740
+ */
741
+ return nullableInnerOf(type) ?? type;
665
742
  }
666
743
  /**
667
- * The wire kind of an ARRAY parameter's elements, or `undefined` where it is not such an array.
744
+ * An ARRAY parameter's element type, or `undefined` where it is not such an array.
668
745
  *
669
- * `?ids=1,2,3` splits into strings, so each element needs the same decoding a lone scalar would.
746
+ * `?ids=1,2,3` splits into strings, so each element is read from text as a lone scalar is.
670
747
  */
671
- function elementKindOf(program, type) {
672
- if (!isArrayType(type) || type.kind !== "Model")
673
- return undefined;
674
- const element = type.indexer?.value;
675
- return element === undefined ? undefined : wireKindOf(program, element);
748
+ function elementOf(type) {
749
+ return isArrayType(type) && type.kind === "Model" ? type.indexer?.value : undefined;
676
750
  }
677
751
  /** Whether a type is an array - TypeSpec models one as a `Model` carrying an integer indexer. */
678
752
  function isArrayType(type) {
@@ -708,25 +782,42 @@ function isArrayType(type) {
708
782
  * {@link multipartSchemaOf} part for part, including `multi` as an array, `optional`, and a binary
709
783
  * part as `unknown`, is what makes the two agree by construction rather than by coincidence.
710
784
  */
711
- function multipartTsOf(body, registry) {
785
+ function multipartTsOf(program, body, registry) {
712
786
  const entries = body.parts.map((part) => {
713
787
  const type = part.body?.type;
714
788
  const inner = type === undefined
715
789
  ? "unknown"
716
- : isFilePart(type)
790
+ : isFilePart(program, type)
717
791
  ? MULTIPART_FILE
718
- : isBinaryPart(type)
792
+ : isBinaryPart(program, type)
719
793
  ? "unknown"
720
- : registry.expressionFor(type);
794
+ : withMetadataAnnotationsIgnored(ignoresMetadata(part.body), () => withMultipartBinary(holdsRawBytes(program, type), () => registry.expressionFor(type, true)));
721
795
  const value = part.multi === true ? `(${inner})[]` : inner;
722
796
  const optional = part.optional === true ? "?" : "";
723
797
  return `\t${objectKey(part.name ?? "")}${optional}: ${value};`;
724
798
  });
725
- return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
799
+ const shape = entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
800
+ // Declared under the component's name, as its validator is - see `multipartSchemaOf`.
801
+ const name = multipartNameOf(body);
802
+ return name === undefined || body.type === undefined
803
+ ? shape
804
+ : registry.declareNamed(name, () => shape, body.type);
805
+ }
806
+ /** The component openapi3 publishes a multipart body as: its model's name, where it has one. */
807
+ function multipartNameOf(body) {
808
+ const named = body.type;
809
+ return named !== undefined && named.kind === "Model" && named.name !== ""
810
+ ? named.name
811
+ : undefined;
726
812
  }
727
813
  function multipartSchemaOf(program, body, registry) {
728
814
  const entries = body.parts.map((part) => {
729
815
  const type = part.body?.type;
816
+ // `.exactOptional()` for the same reason as a model property: `parseBody` yields present keys
817
+ // only, so an absent part is an absent key rather than an explicit `undefined`.
818
+ const entry = (value) => `\t${objectKey(part.name ?? "")}: ${part.optional === true ? `${value}.exactOptional()` : value},`;
819
+ if (isJsonListPart(program, part))
820
+ return entry(jsonListDecoded(jsonListOf(registry, part)));
730
821
  /**
731
822
  * **A form carries text, so a part that means a number or a boolean is decoded first**, by the
732
823
  * same rule {@link wireDecoded} applies to path, query and header values: `"0.5"` becomes
@@ -737,18 +828,31 @@ function multipartSchemaOf(program, body, registry) {
737
828
  */
738
829
  const inner = type === undefined
739
830
  ? "z.unknown()"
740
- : isFilePart(type)
831
+ : isFilePart(program, type)
741
832
  ? `z.unknown().refine(${MULTIPART_FILE_REFINEMENT})`
742
- : isBinaryPart(type)
833
+ : isBinaryPart(program, type)
743
834
  ? "z.unknown()"
744
- : isJsonPart(part)
745
- ? jsonTextDecoded(registry.expressionFor(type))
746
- : wireDecoded(registry.expressionFor(type), wireKindOf(program, type), elementKindOf(program, type));
747
- // `multi` is `HttpPart<T>[]` - the same part repeated, which arrives as an array.
748
- const value = part.multi === true ? `z.array(${inner})` : inner;
749
- // `.exactOptional()` for the same reason as a model property: `parseBody` yields present keys
750
- // only, so an absent part is an absent key rather than an explicit `undefined`.
751
- return `\t${objectKey(part.name ?? "")}: ${part.optional === true ? `${value}.exactOptional()` : value},`;
835
+ : holdsRawBytes(program, type)
836
+ ? /**
837
+ * Any value but what its other members refuse, and no text to read - unless the
838
+ * document publishes the part as JSON alone, `HttpPart<bytes | null>`, where its
839
+ * text is JSON and `x` is refused.
840
+ */
841
+ withMultipartBinary(true, () => isJsonPart(part)
842
+ ? jsonTextDecoded(registry.expressionFor(type, true))
843
+ : registry.expressionFor(type, true))
844
+ : withMetadataAnnotationsIgnored(ignoresMetadata(part.body), () => isJsonPart(part)
845
+ ? jsonTextDecoded(registry.expressionFor(type, true))
846
+ : isJsonOrTextPart(part)
847
+ ? jsonOrTextDecoded(registry.expressionFor(type, true), textPartDecoded(program, registry, type))
848
+ : textPartDecoded(program, registry, type));
849
+ if (part.multi !== true)
850
+ return entry(inner);
851
+ // `multi` is `HttpPart<T>[]` - the same part repeated, which the document publishes as a list,
852
+ // and one whose text may be JSON is that list in JSON as well - see `mayBeJsonList`.
853
+ return entry(isJsonOrTextPart(part)
854
+ ? jsonOrTextDecoded(jsonListOf(registry, part), `z.array(${inner})`)
855
+ : `z.array(${inner})`);
752
856
  });
753
857
  const shape = entries.length === 0 ? "z.object({})" : `z.object({\n${entries.join("\n")}\n})`;
754
858
  /**
@@ -756,9 +860,124 @@ function multipartSchemaOf(program, body, registry) {
756
860
  * for it. Emitted inline the schema is correct and the component still has no validator, which the
757
861
  * differential reports - rightly - as a contract the runtime cannot check.
758
862
  */
759
- const named = body.type;
760
- const name = named !== undefined && named.kind === "Model" && named.name !== "" ? named.name : undefined;
761
- return name === undefined ? shape : registry.declareNamed(name, () => shape);
863
+ const name = multipartNameOf(body);
864
+ return name === undefined || body.type === undefined
865
+ ? shape
866
+ : registry.declareNamed(name, () => shape, body.type);
867
+ }
868
+ /**
869
+ * **What a text part's value is read as: never `null`, which text does not spell**, so `T | null`
870
+ * is read as a `T`, as a parameter's is - see `encodedParameterType`. Read as the union, `h=1&h=2`
871
+ * for `int32[] | null` under `text/plain` reached `z.array(z.int32())` as `["1", "2"]` unread, and
872
+ * was refused where the document reads `[1, 2]`.
873
+ */
874
+ function asText(type) {
875
+ return nullableInnerOf(type) ?? type;
876
+ }
877
+ /** The parts read from text - see {@link EmittedRoute.textParts}. */
878
+ function textPartsOf(program, body) {
879
+ return body.parts.flatMap((part) => {
880
+ const type = part.body?.type;
881
+ if (part.name === undefined || type === undefined)
882
+ return [];
883
+ if (isFilePart(program, type) || isBinaryPart(program, type) || holdsRawBytes(program, type)) {
884
+ return [];
885
+ }
886
+ return [{ name: part.name, json: isJsonPart(part) }];
887
+ });
888
+ }
889
+ /**
890
+ * **A text part of a union holding a list is one value, or several sent as as many parts**, read as a
891
+ * URL-encoded form's field is - see `readerOf`: `int32 | int32[]` under `text/plain` is `5` sent
892
+ * once and `[1, 2]` sent as `1` and `2`, and `int32 | string[]` reads a lone `a` as the list `["a"]`.
893
+ * Read against the whole union, as one text is, the parts reached it unread and were refused where
894
+ * the document reads `[1, 2]`. Rendered as a part's schema is, where a parameter's refuses a tuple.
895
+ */
896
+ function textPartDecoded(program, registry, type) {
897
+ const declared = registry.expressionFor(type, true);
898
+ const inner = asText(type);
899
+ const forms = inner.kind === "Union" ? wireFormsOf(inner) : [];
900
+ const lists = forms.filter((each) => elementOf(each) !== undefined);
901
+ if (lists.length === 0)
902
+ return wireDecoded(program, registry, declared, inner);
903
+ const each = lists.map((list) => atItem(program, list, () => registry.expressionFor(elementOf(list), true)));
904
+ const one = forms.some((form) => elementOf(form) === undefined) ? declared : undefined;
905
+ return `z.preprocess((raw) => wireReadAs(raw, ${eachReader(each, one, declared)}), ${declared})`;
906
+ }
907
+ /**
908
+ * **A part the document publishes as a list under a media type that is not JSON alone is that many
909
+ * parts**, one per element, however the spec declared it: `HttpPart<{ @header contentType:
910
+ * "text/plain"; @body body: int32[] }>` is published exactly as `HttpPart<int32>[]` is, `{ type:
911
+ * array, items: int32 }` with `contentType: text/plain`, and text spells no list. Read as one part,
912
+ * a lone `5` reached `z.array` as `"5"` and was refused, where the same part declared repeated read
913
+ * it as `[5]`. A JSON part's text may spell the whole list as well - see {@link isJsonListPart}.
914
+ */
915
+ function repeatsAsText(part) {
916
+ const type = part.body?.type;
917
+ return (part.multi !== true &&
918
+ type !== undefined &&
919
+ !isJsonPart(part) &&
920
+ isArrayType(nullableInnerOf(type) ?? type));
921
+ }
922
+ /**
923
+ * **A JSON part the document publishes as a list is one list or its elements, however it is
924
+ * declared.** `HttpPart<int32[]>` and `HttpPart<{ @header contentType: "application/json"; @body
925
+ * body: int32 }>[]` are published alike, `{ type: array, items: int32 }` with `contentType:
926
+ * application/json`, and OAS 3.1 does not say whether such a part is sent once, as the list's JSON,
927
+ * or once per element, each element's. So the document admits both: `[1,2]` is the list, and `5`,
928
+ * or `1` and `2` sent as two parts, its elements. Read as declared, the first refused `5` and the
929
+ * two parts, and the second `[1,2]`. A file or raw bytes is no JSON to read.
930
+ */
931
+ function isJsonListPart(program, part) {
932
+ const type = part.body?.type;
933
+ return (type !== undefined &&
934
+ isJsonPart(part) &&
935
+ !isFilePart(program, type) &&
936
+ !isBinaryPart(program, type) &&
937
+ !holdsRawBytes(program, type) &&
938
+ (part.multi === true || isArrayType(nullableInnerOf(type) ?? type)));
939
+ }
940
+ /**
941
+ * Whether one text of a part the document publishes as a list may be the whole list in JSON: a JSON
942
+ * list part, or one that is text or JSON, published as a list - declared repeated, or as a list under
943
+ * a media type that is not JSON alone. See {@link isJsonListPart} and {@link repeatsAsText}.
944
+ */
945
+ function mayBeJsonList(program, part) {
946
+ return (isJsonListPart(program, part) ||
947
+ (isJsonOrTextPart(part) && (part.multi === true || repeatsAsText(part))));
948
+ }
949
+ /** The schema of the list a part is published as, read from JSON: a repeated part's, its elements'. */
950
+ function jsonListOf(registry, part) {
951
+ const declared = withMetadataAnnotationsIgnored(ignoresMetadata(part.body), () => registry.expressionFor(part.body.type, true));
952
+ return part.multi === true ? `z.array(${declared})` : declared;
953
+ }
954
+ /**
955
+ * **A repeated part sent once is the one-element list the document describes**, and a form hands it
956
+ * over as the one value: `parseBody({ all: true })` makes a list only of a repeated key, so one file
957
+ * sent to a multi-file field reached `z.array` as a file and was refused. Boxed for the request
958
+ * only - the component stays the document's schema, where a lone value is no list - and only what a
959
+ * form delivers for one part, text or a file, so a value no form sends is judged as it is.
960
+ *
961
+ * A part repeats where the document says it does: declared `HttpPart<T>[]`, or published as a list
962
+ * under a text media type - see {@link repeatsAsText}. **One whose text may be JSON is boxed only
963
+ * where its text is not the JSON list the part accepts** - see {@link mayBeJsonList}: `[1,2]` is
964
+ * that list, and `5` the one element.
965
+ */
966
+ function multipartRequestOf(program, registry, body, schema) {
967
+ const named = body.parts.filter((part) => part.name !== undefined);
968
+ const jsonOrText = named.filter((part) => mayBeJsonList(program, part));
969
+ const boxed = named.filter((part) => !mayBeJsonList(program, part) && (part.multi === true || repeatsAsText(part)));
970
+ if (boxed.length === 0 && jsonOrText.length === 0)
971
+ return schema;
972
+ const one = `typeof value === "string" || (value !== null && typeof value === "object" && !Array.isArray(value) && "arrayBuffer" in value)`;
973
+ const always = boxed.length === 0
974
+ ? ""
975
+ : ` for (const key of ${literal(boxed.map((part) => part.name))}) { const value = boxed[key]; if (${one}) boxed[key] = [value]; }`;
976
+ const lists = jsonOrText.map((part) => `[${literal(part.name)}, ${jsonListOf(registry, part)}]`);
977
+ const unlessJson = lists.length === 0
978
+ ? ""
979
+ : ` for (const [key, list] of [${lists.join(", ")}] as const) { const value = boxed[key]; if (typeof value === "string") { try { if (list.safeParse(JSON.parse(value)).success) continue; } catch { /* text, one element */ } } if (${one}) boxed[key] = [value]; }`;
980
+ return `z.preprocess((raw) => { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return raw; const boxed: globalThis.Record<string, unknown> = { ...raw };${always}${unlessJson} return boxed; }, ${schema})`;
762
981
  }
763
982
  /**
764
983
  * The shape a multipart file part actually arrives as, spelled structurally.
@@ -773,7 +992,7 @@ function multipartSchemaOf(program, body, registry) {
773
992
  * of the wire assertion, and a mapped type turns a method into a function-typed property, so
774
993
  * spelling it this way on both sides is what keeps the two identical.
775
994
  */
776
- const MULTIPART_FILE = "{ name: string; type: string; arrayBuffer: () => Promise<ArrayBuffer> }";
995
+ const MULTIPART_FILE = "{ name: string; type: string; arrayBuffer: () => globalThis.Promise<ArrayBuffer> }";
777
996
  /**
778
997
  * The check that makes {@link MULTIPART_FILE} established rather than asserted.
779
998
  *
@@ -805,14 +1024,9 @@ const MULTIPART_FILE_REFINEMENT = `(value): value is ${MULTIPART_FILE} => typeof
805
1024
  * string. Widening this to cover `bytes` would refuse payloads that pass today, on an inference the
806
1025
  * spec never made.
807
1026
  */
808
- function isFilePart(type) {
809
- if (type.kind !== "Model")
810
- return false;
811
- for (let model = type; model !== undefined; model = model.baseModel) {
812
- if (model.name === "File")
813
- return true;
814
- }
815
- return false;
1027
+ function isFilePart(program, type) {
1028
+ // `Http.File` as `@typespec/http` decides it - a spec's own `model File` is a JSON part.
1029
+ return type.kind === "Model" && isOrExtendsHttpFile(program, type);
816
1030
  }
817
1031
  /** `bytes`, or a model built on `File` - the parts openapi3 publishes as an empty schema. */
818
1032
  /**
@@ -833,10 +1047,413 @@ function isJsonPart(part) {
833
1047
  * string passed on, so a part whose schema admits a string still refuses malformed JSON. A value that
834
1048
  * is not text is left alone for the schema to judge.
835
1049
  */
1050
+ /**
1051
+ * **A part whose type mixes text with JSON** - `HttpPart<int32 | Address>` - which the document
1052
+ * publishes with `contentType: "text/plain, application/json"`: the client chooses per request, and
1053
+ * the form hands over the text either way. JSON is read where the text is JSON the schema accepts, and
1054
+ * the text is read as text otherwise; read as text alone, `{"city":"x"}` reached `Address` as a
1055
+ * string and was refused. Any JSON value, not an object alone: `"all"`, quoted, is the JSON string
1056
+ * `HttpPart<Nums | "all">` accepts, and ` 1` the number.
1057
+ */
1058
+ function isJsonOrTextPart(part) {
1059
+ const contentTypes = part.body?.contentTypes ?? [];
1060
+ const json = (contentType) => /^application\/(?:[\w.+-]+\+)?json$/i.test(contentType);
1061
+ return contentTypes.some(json) && !contentTypes.every(json);
1062
+ }
1063
+ /**
1064
+ * **A body sent as a URL-encoded form, read from the text it carries** - see
1065
+ * {@link EmittedRoute.formRequestSchema}. A form holds text, `n=5&b=true`, and the document's schema
1066
+ * describes the values it spells, so each field is read against its own schema as a parameter's is,
1067
+ * and a key sent more than once is the list it stands for. `only` where the form is the one media
1068
+ * type the body is declared under. `undefined` where no form is, or nothing in the body reads.
1069
+ *
1070
+ * **Each object the body can be is one reading of the form, and the body's whole schema judges
1071
+ * every reading**, as the document does: it accepts a form where some reading of its text is a value
1072
+ * of the schema it publishes. Read member by member against each member's own schema, a union was
1073
+ * rebuilt here beside the one the document publishes, and only a plain union of models ever was:
1074
+ * `DU` with no envelope, `DU | null`, `{ DA, string }`, `{ PU, DB }` and `DA | DB | DU` were
1075
+ * validated as JSON, so `kind=a&n=5` was refused, "expected number, received string", where the
1076
+ * document reads `{ kind: "a", n: 5 }`. A `@oneOf` judged member by member counted differently too:
1077
+ * `x=5` against `PA { x?: int32 }` and `PB { x?: string }` matched both, each by its own reading, and
1078
+ * was refused where each reading the document takes matches exactly one.
1079
+ */
1080
+ function formBodyOf(program, registry, operation, body, type, declared) {
1081
+ const contentTypes = body?.contentTypes ?? [];
1082
+ const form = (contentType) => mediaTypeEssenceOf(contentType) === "application/x-www-form-urlencoded";
1083
+ if (!contentTypes.some(form))
1084
+ return undefined;
1085
+ const only = contentTypes.every(form);
1086
+ const readings = formReadingsOf(program, registry, type);
1087
+ if (!readings.some(readsText))
1088
+ return undefined;
1089
+ const ambiguous = ambiguousFormKeyOf(program, type);
1090
+ if (ambiguous !== undefined) {
1091
+ reportDiagnostic(program, {
1092
+ code: "ambiguous-form-reading",
1093
+ target: ambiguous.target,
1094
+ format: {
1095
+ operation: operation.operation.name,
1096
+ union: getTypeName(ambiguous.union),
1097
+ member: getTypeName(ambiguous.member),
1098
+ other: getTypeName(ambiguous.other),
1099
+ key: ambiguous.key,
1100
+ first: ambiguous.first,
1101
+ second: ambiguous.second,
1102
+ },
1103
+ });
1104
+ return { schema: UNREPRESENTABLE, only };
1105
+ }
1106
+ /**
1107
+ * **A reading that reads nothing is the form as sent, and stays one**: `kind=c&n=5` is a `DC`
1108
+ * whose `n` is a string, and read only as `DA` reads it - `n` the number `5` - it was refused by
1109
+ * `DC | DA`, which accepts it as text.
1110
+ */
1111
+ const read = [
1112
+ ...new Set(readings.map((readers) => readsText(readers) ? `z.preprocess(${wireReadObjectCall(readers)}, ${declared})` : declared)),
1113
+ ];
1114
+ return {
1115
+ schema: read.length === 1 ? read[0] : `z.union([${read.join(", ")}])`,
1116
+ only,
1117
+ };
1118
+ }
1119
+ /**
1120
+ * **Where a `@oneOf` sent as a URL-encoded form depends on a reading no validator can choose.** The
1121
+ * document accepts a form where some reading of its text matches exactly one member: each key sent
1122
+ * once is a value its text spells - a number, a boolean, the text itself - or the one-element list of
1123
+ * one. A member reads each key as the first of those its schema accepts, and the whole union judges
1124
+ * that reading - see `formBodyOf`. That agrees with the document unless a member reads a key one way
1125
+ * that another member accepts, while accepting another reading the other refuses, and the other's
1126
+ * own reading of the form leaves a rival matching too: then which reading matches one member alone
1127
+ * depends on every other key and member. `x=5` against `M { x?: int32 | string }` and
1128
+ * `N { x?: int32 }` is `{ x: "5" }`, `M` alone, but read as `5` it matched both and was refused;
1129
+ * open, `x=all` against `A { x?: int32 | "all" }` and `B { y?: boolean }` is `{ x: ["all"] }`, `B`
1130
+ * alone. Finding that reading means trying every combination of every key's readings, exponential in
1131
+ * the keys, and a record's keys are the sender's to choose - so the body is refused instead, as
1132
+ * `ambiguous-form-reading`. Where no member tells another's readings apart, or the other's reading
1133
+ * rules every rival out by a key the rival requires - a `kind` literal - some member's reading is
1134
+ * the document's, and the body is validated. See {@link tellsApart}.
1135
+ *
1136
+ * A discriminated union is no such `oneOf`: its members' discriminators differ, so no reading
1137
+ * matches two of them. Nor is a union a form cannot be more than one object of.
1138
+ */
1139
+ function ambiguousFormKeyOf(program, type) {
1140
+ const visit = (each, via) => {
1141
+ if (each.kind !== "Union" || via.has(each))
1142
+ return undefined;
1143
+ const members = [...each.variants.values()].map((variant) => variant.type);
1144
+ if (isExclusive(program, each) && getDiscriminatedUnion(program, each)[0] === undefined) {
1145
+ const groups = members
1146
+ .map((member) => modelsOf(member, new Set()).map((model) => keyReadingsOf(program, model)))
1147
+ .filter((group) => group.length > 0);
1148
+ for (const [i, group] of groups.entries()) {
1149
+ for (const [j, other] of groups.entries()) {
1150
+ if (i === j)
1151
+ continue;
1152
+ const rivals = groups.filter((_, k) => k !== j).flat();
1153
+ for (const first of group) {
1154
+ for (const second of other) {
1155
+ const found = tellsApart(first, second, rivals);
1156
+ if (found !== undefined)
1157
+ return { union: each, ...found };
1158
+ }
1159
+ }
1160
+ }
1161
+ }
1162
+ }
1163
+ for (const member of members) {
1164
+ const found = visit(member, new Set([...via, each]));
1165
+ if (found !== undefined)
1166
+ return found;
1167
+ }
1168
+ return undefined;
1169
+ };
1170
+ return visit(type, new Set());
1171
+ }
1172
+ /** The objects a member of a union is: itself, or each a union of them reaches. */
1173
+ function modelsOf(type, seen) {
1174
+ if (type.kind === "Model")
1175
+ return isArrayType(type) ? [] : [type];
1176
+ if (type.kind !== "Union" || seen.has(type))
1177
+ return [];
1178
+ seen.add(type);
1179
+ return [...type.variants.values()].flatMap((variant) => modelsOf(variant.type, seen));
1180
+ }
1181
+ const SPELLED = 1;
1182
+ const ITSELF = 2;
1183
+ const SPELLED_LIST = 4;
1184
+ const ITSELF_LIST = 8;
1185
+ const NO_READING = { numeric: 0, boolean: 0, other: 0 };
1186
+ const EVERY_READING = { numeric: 15, boolean: 15, other: ITSELF | ITSELF_LIST };
1187
+ /** Each class of text, and the value a text of it spells. */
1188
+ const TEXT_CLASSES = [
1189
+ ["numeric", "number"],
1190
+ ["boolean", "boolean"],
1191
+ ["other", ""],
1192
+ ];
1193
+ /** A model's reading of a key: its own, or every undeclared key's. */
1194
+ function keyOf(readings, key) {
1195
+ return (key === undefined ? undefined : readings.keys.get(key)) ?? readings.rest;
1196
+ }
1197
+ /**
1198
+ * The reading a model's reader takes of a text of one class: the first its schema accepts - see
1199
+ * `wireRead` - or the text as sent where it reads none. `0` where the reader takes one the schema
1200
+ * refuses, which nothing here relies on.
1201
+ */
1202
+ function choiceOf(reading, kind) {
1203
+ return reading.read ? lowestBit(reading.readings[kind]) : ITSELF;
1204
+ }
1205
+ /**
1206
+ * **A key `member` reads one way where `other` accepts that reading and refuses another `member`
1207
+ * accepts** - and `other`'s own reading of the form no rival of it accepts, so the validator reads
1208
+ * the form only as the two do - see {@link ambiguousFormKeyOf}. Where every rival requires a key
1209
+ * whose reading by `other` it refuses, `other`'s reading matches `other` alone, and the form is
1210
+ * accepted as the document accepts it.
1211
+ */
1212
+ function tellsApart(member, other, rivals) {
1213
+ const rescued = rivals.every((rival) => [...rival.keys].some(([key, reading]) => reading.required &&
1214
+ TEXT_CLASSES.every(([kind]) => {
1215
+ const choice = choiceOf(keyOf(other, key), kind);
1216
+ return (reading.readings[kind] === 0 ||
1217
+ (choice !== 0 && (reading.readings[kind] & choice) === 0));
1218
+ })));
1219
+ if (rescued)
1220
+ return undefined;
1221
+ const keys = [...new Set([...member.keys.keys(), ...other.keys.keys()])];
1222
+ for (const key of [...keys, undefined]) {
1223
+ const mine = keyOf(member, key);
1224
+ const yours = keyOf(other, key);
1225
+ for (const [kind, spelled] of TEXT_CLASSES) {
1226
+ const accepted = mine.readings[kind];
1227
+ const choice = choiceOf(mine, kind) & accepted;
1228
+ const refused = accepted & ~yours.readings[kind];
1229
+ if (choice === 0 || (yours.readings[kind] & choice) === 0 || refused === 0)
1230
+ continue;
1231
+ return {
1232
+ member: member.model,
1233
+ other: other.model,
1234
+ target: mine.property ?? yours.property ?? member.model,
1235
+ key: key === undefined ? "a key neither declares" : `'${key}'`,
1236
+ first: describeReading(choice, spelled),
1237
+ second: describeReading(lowestBit(refused), spelled),
1238
+ };
1239
+ }
1240
+ }
1241
+ return undefined;
1242
+ }
1243
+ function lowestBit(mask) {
1244
+ return mask & -mask;
1245
+ }
1246
+ function describeReading(bit, spelled) {
1247
+ switch (bit) {
1248
+ case SPELLED:
1249
+ return `the ${spelled} it spells`;
1250
+ case ITSELF:
1251
+ return "itself";
1252
+ case SPELLED_LIST:
1253
+ return `the list of the ${spelled} it spells`;
1254
+ default:
1255
+ return "the list of itself";
1256
+ }
1257
+ }
1258
+ /** Each key a model declares, read as its validator holds it, and every other key's reading. */
1259
+ function keyReadingsOf(program, model) {
1260
+ return withVisibility(renderedVisibilityOf(program, model, currentVisibility()), () => {
1261
+ const object = objectOfModel(program, model);
1262
+ const keys = new Map();
1263
+ const none = { readings: NO_READING, read: false, required: false };
1264
+ if (object.kind !== "object")
1265
+ return { model, keys, rest: none };
1266
+ for (const member of object.members) {
1267
+ if (member.property === undefined)
1268
+ continue;
1269
+ keys.set(member.key, {
1270
+ readings: formReadingsOfType(program, encodedParameterType(program, member.property)),
1271
+ read: true,
1272
+ required: member.property.optional !== true,
1273
+ property: member.property,
1274
+ });
1275
+ }
1276
+ const rest = object.rest;
1277
+ return {
1278
+ model,
1279
+ keys,
1280
+ rest: rest === "sealed"
1281
+ ? none
1282
+ : typeof rest === "string"
1283
+ ? { readings: EVERY_READING, read: false, required: false }
1284
+ : rest.kind === "type"
1285
+ ? { readings: formReadingsOfType(program, rest.type), read: true, required: false }
1286
+ : rest.kind === "never"
1287
+ ? none
1288
+ : { readings: EVERY_READING, read: true, required: false },
1289
+ };
1290
+ });
1291
+ }
1292
+ /**
1293
+ * **The readings of a key's text a value of `type` accepts** - see {@link FormReadings}. A string
1294
+ * accepts any text as itself; a string literal its own text; a number or a boolean the value a text
1295
+ * spells; a list the one-element list of what its element accepts; a value of any type every
1296
+ * reading. A scalar that reads no number or boolean - a date, `bytes` - is text, as a string is.
1297
+ */
1298
+ function formReadingsOfType(program, type) {
1299
+ let numeric = 0;
1300
+ let boolean = 0;
1301
+ let other = 0;
1302
+ const seen = new Set();
1303
+ // A literal's own text, in the class of text it is.
1304
+ const literalText = (value, itself) => {
1305
+ if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(value))
1306
+ numeric |= itself;
1307
+ else if (value === "true" || value === "false")
1308
+ boolean |= itself;
1309
+ else
1310
+ other |= itself;
1311
+ };
1312
+ const visit = (each, list) => {
1313
+ if (seen.has(each))
1314
+ return;
1315
+ seen.add(each);
1316
+ const [spelled, itself] = list ? [SPELLED_LIST, ITSELF_LIST] : [SPELLED, ITSELF];
1317
+ if (each.kind === "UnionVariant")
1318
+ return visit(each.type, list);
1319
+ if (each.kind === "Union") {
1320
+ for (const variant of each.variants.values())
1321
+ visit(variant.type, list);
1322
+ return;
1323
+ }
1324
+ const element = elementOf(each);
1325
+ if (element !== undefined) {
1326
+ // A list of lists is no text a key spells.
1327
+ if (!list)
1328
+ visit(element, true);
1329
+ return;
1330
+ }
1331
+ if (isTypelessMember(program, each)) {
1332
+ numeric |= spelled | itself;
1333
+ boolean |= spelled | itself;
1334
+ other |= itself;
1335
+ return;
1336
+ }
1337
+ switch (each.kind) {
1338
+ case "String":
1339
+ return literalText(each.value, itself);
1340
+ case "Number":
1341
+ numeric |= spelled;
1342
+ return;
1343
+ case "Boolean":
1344
+ boolean |= spelled;
1345
+ return;
1346
+ case "Enum":
1347
+ for (const member of each.members.values())
1348
+ visit(member, list);
1349
+ return;
1350
+ case "EnumMember":
1351
+ if (typeof each.value === "number")
1352
+ numeric |= spelled;
1353
+ else
1354
+ literalText(each.value ?? each.name, itself);
1355
+ return;
1356
+ case "Scalar":
1357
+ if (readsFromText(program, each)) {
1358
+ if (isBooleanScalar(each))
1359
+ boolean |= spelled;
1360
+ else
1361
+ numeric |= spelled;
1362
+ }
1363
+ else {
1364
+ numeric |= itself;
1365
+ boolean |= itself;
1366
+ other |= itself;
1367
+ }
1368
+ return;
1369
+ default:
1370
+ return;
1371
+ }
1372
+ };
1373
+ visit(type, false);
1374
+ return { numeric, boolean, other };
1375
+ }
1376
+ /** Whether a scalar is `boolean`, or declared on it. */
1377
+ function isBooleanScalar(scalar) {
1378
+ for (let each = scalar; each !== undefined; each = each.baseScalar) {
1379
+ if (each.name === "boolean")
1380
+ return true;
1381
+ }
1382
+ return false;
1383
+ }
1384
+ /** Whether readers read anything, or leave every value the text it is. */
1385
+ function readsText(readers) {
1386
+ return readers.values !== null || readers.fields !== null || readers.by !== undefined;
1387
+ }
1388
+ /**
1389
+ * **Every object a URL-encoded form can stand for in a body of this type, as the readers of its
1390
+ * text**: a model's own, and each model a union reaches, a union nested in it included. A member that
1391
+ * is no object - `string` or `int32` beside a model, a list, `null` - is never what a form is, so it
1392
+ * adds no reading, and its schema refuses the object as the document's does. A form is never `null`.
1393
+ */
1394
+ function formReadingsOf(program, registry, type) {
1395
+ if (type.kind === "Model") {
1396
+ return isArrayType(type)
1397
+ ? []
1398
+ : [withParameterPosition(true, () => readersOf(program, registry, type, true))];
1399
+ }
1400
+ if (type.kind !== "Union")
1401
+ return [];
1402
+ const variants = [...type.variants.values()].map((variant) => variant.type);
1403
+ const [discriminated] = getDiscriminatedUnion(program, type);
1404
+ if (discriminated === undefined) {
1405
+ return variants.flatMap((variant) => formReadingsOf(program, registry, variant));
1406
+ }
1407
+ /**
1408
+ * **A discriminated union is read as the variant its discriminator names**, as a discriminated
1409
+ * base is - see `readersOf` - so a key two variants declare differently is read as the one sent.
1410
+ * With no envelope the variant is the object. With one, the object is its envelope, `{ kind, value
1411
+ * }`, and `value` is read as the variant: `kind=i&value=5` is `{ kind: "i", value: 5 }` where the
1412
+ * variant is an `int32`, and `value=5` under `kind=l` the list `[5]`.
1413
+ */
1414
+ const { envelope, discriminatorPropertyName, envelopePropertyName } = discriminated.options;
1415
+ const byVariant = [...discriminated.variants].map(([value, variant]) => {
1416
+ if (envelope === "none") {
1417
+ return [value, formReadingsOf(program, registry, variant)[0] ?? NO_READERS];
1418
+ }
1419
+ const reader = readerOf(program, registry, variant, undefined, true);
1420
+ return [
1421
+ value,
1422
+ reader === null ? NO_READERS : { values: null, fields: { [envelopePropertyName]: reader } },
1423
+ ];
1424
+ });
1425
+ /**
1426
+ * A variant that is a discriminated base dispatches on a discriminator of its own, which one
1427
+ * dispatch does not reach: each variant is then a reading of its own, judged by the whole union.
1428
+ */
1429
+ if (byVariant.some(([, readers]) => readers.by !== undefined)) {
1430
+ return variants.flatMap((variant) => formReadingsOf(program, registry, variant));
1431
+ }
1432
+ if (!byVariant.some(([, readers]) => readsText(readers)))
1433
+ return [NO_READERS];
1434
+ return [
1435
+ {
1436
+ values: null,
1437
+ fields: null,
1438
+ by: { property: discriminatorPropertyName, readers: Object.fromEntries(byVariant) },
1439
+ },
1440
+ ];
1441
+ }
1442
+ function jsonOrTextDecoded(declared, asText) {
1443
+ return `z.preprocess((raw) => { if (typeof raw !== "string") return raw; try { const parsed: unknown = JSON.parse(raw); if (${declared}.safeParse(parsed).success) return parsed; } catch { /* text, read as text */ } return raw; }, ${asText})`;
1444
+ }
1445
+ /**
1446
+ * **A JSON list part, decoded from one text as the whole list or from several as its elements** -
1447
+ * see {@link isJsonListPart}. A lone text is the list here, as the component publishes it; the
1448
+ * request boxes one that is not into the one element it is, before this applies.
1449
+ */
1450
+ function jsonListDecoded(declared) {
1451
+ return `z.preprocess((raw, ctx) => { try { if (typeof raw === "string") return JSON.parse(raw); if (Array.isArray(raw)) return raw.map((each: unknown) => (typeof each === "string" ? JSON.parse(each) : each)); } catch { ctx.addIssue({ code: "custom", message: "Invalid JSON in a multipart part" }); return z.NEVER; } return raw; }, ${declared})`;
1452
+ }
836
1453
  function jsonTextDecoded(declared) {
837
1454
  return `z.preprocess((raw, ctx) => { if (typeof raw !== "string") return raw; try { return JSON.parse(raw); } catch { ctx.addIssue({ code: "custom", message: "Invalid JSON in a multipart part" }); return z.NEVER; } }, ${declared})`;
838
1455
  }
839
- function isBinaryPart(type) {
1456
+ function isBinaryPart(program, type) {
840
1457
  if (type.kind === "Scalar") {
841
1458
  for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
842
1459
  if (scalar.name === "bytes")
@@ -844,13 +1461,7 @@ function isBinaryPart(type) {
844
1461
  }
845
1462
  return false;
846
1463
  }
847
- if (type.kind !== "Model")
848
- return false;
849
- for (let model = type; model !== undefined; model = model.baseModel) {
850
- if (model.name === "File")
851
- return true;
852
- }
853
- return false;
1464
+ return type.kind === "Model" && isOrExtendsHttpFile(program, type);
854
1465
  }
855
1466
  /**
856
1467
  * Whether a body served under these media types is RAW BINARY rather than a JSON value.
@@ -884,6 +1495,17 @@ function isParseableMediaType(type) {
884
1495
  essence === "application/x-www-form-urlencoded" ||
885
1496
  essence.startsWith("multipart/"));
886
1497
  }
1498
+ /**
1499
+ * Whether a body is rendered with its metadata annotations ignored, as openapi3 renders it: an
1500
+ * explicit `@body` whose type carries them - see `withMetadataAnnotationsIgnored`.
1501
+ */
1502
+ function ignoresMetadata(body) {
1503
+ return (body !== undefined &&
1504
+ "isExplicit" in body &&
1505
+ body.isExplicit === true &&
1506
+ "containsMetadataAnnotations" in body &&
1507
+ body.containsMetadataAnnotations === true);
1508
+ }
887
1509
  /** The declared request body type, if the operation takes one. */
888
1510
  function requestBodyOf(operation) {
889
1511
  const body = requestBodyParameterOf(operation);
@@ -923,6 +1545,11 @@ const COLLECTION_DELIMITERS = {
923
1545
  "ArrayEncoding.commaDelimited": ",",
924
1546
  "ArrayEncoding.newlineDelimited": "\n",
925
1547
  };
1548
+ /** A parameter's array encoding - the `style` openapi3 publishes for it - or `undefined`. */
1549
+ function arrayEncodingOf(program, property) {
1550
+ const encoding = getEncode(program, property)?.encoding;
1551
+ return encoding !== undefined && encoding in COLLECTION_DELIMITERS ? encoding : undefined;
1552
+ }
926
1553
  /**
927
1554
  * Whether the parameter crosses the wire as a list, which is what makes a delimiter meaningful.
928
1555
  *
@@ -932,8 +1559,34 @@ const COLLECTION_DELIMITERS = {
932
1559
  * schema, so every request was refused. Only a query parameter keeps its list under an array
933
1560
  * encoding, and only there is a split undone.
934
1561
  */
935
- function isCollectionParameter(program, property) {
936
- return isArrayType(encodedParameterType(program, property));
1562
+ function isCollectionParameter(wire) {
1563
+ return isArrayType(wire);
1564
+ }
1565
+ /**
1566
+ * **The forms a parameter's text can take: one per shape its type's members have.** A union of a
1567
+ * scalar and a list - `int32 | string[]` - is `5` or `a,b` on the wire, and each is undone its own
1568
+ * way: the scalar members read as text, each list split, each record or model expanded. Undone only
1569
+ * as the scalar it might be, `a,b` was refused. A type of one shape is one form, itself.
1570
+ */
1571
+ function wireFormsOf(type) {
1572
+ if (type.kind !== "Union")
1573
+ return [type];
1574
+ const members = [];
1575
+ const visit = (each) => {
1576
+ if (each.kind === "UnionVariant")
1577
+ return visit(each.type);
1578
+ if (each.kind === "Union")
1579
+ return [...each.variants.values()].forEach((v) => visit(v.type));
1580
+ if (!(each.kind === "Intrinsic" && each.name === "null"))
1581
+ members.push(each);
1582
+ };
1583
+ visit(type);
1584
+ const shaped = members.filter((member) => expansionShapeOf(member) !== "scalar");
1585
+ if (shaped.length === 0)
1586
+ return [type];
1587
+ const scalar = members.some((member) => expansionShapeOf(member) === "scalar");
1588
+ // The union itself stands for its scalar members: a scalar's form, read against the whole schema.
1589
+ return [...(scalar ? [type] : []), ...shaped];
937
1590
  }
938
1591
  /**
939
1592
  * Whether the values arrive one per OCCURRENCE (RFC 6570 explode) rather than joined into one.
@@ -944,10 +1597,16 @@ function isCollectionParameter(program, property) {
944
1597
  * refused a conformant request while accepting the same list sent twice, which no document
945
1598
  * describes. Found by the first consumer's `?topics=ip` (#1).
946
1599
  */
947
- function isExplodedCollection(program, parameter) {
948
- return (isCollectionParameter(program, parameter.param) &&
949
- (parameter.type === "query" || parameter.type === "header") &&
950
- parameter.explode === true);
1600
+ function isExplodedCollection(program, parameter, wire) {
1601
+ /**
1602
+ * **A query only.** A header is `simple` style, which joins a list with commas exploded or not -
1603
+ * explode changes how a `simple` OBJECT is written, never a list - so an exploded header list
1604
+ * boxed whole was `["1,2"]`, refused where the document reads `[1, 2]`.
1605
+ */
1606
+ return (isCollectionParameter(wire) &&
1607
+ parameter.type === "query" &&
1608
+ parameter.explode === true &&
1609
+ arrayEncodingOf(program, parameter.param) === undefined);
951
1610
  }
952
1611
  /**
953
1612
  * The delimiter a parameter's values were joined with, or `undefined` when they were not joined.
@@ -956,17 +1615,24 @@ function isExplodedCollection(program, parameter) {
956
1615
  * `form` style, which OpenAPI joins with a comma, and a header list is `simple` style, also comma.
957
1616
  * Those are the two that looked correct because the document and the validator agreed.
958
1617
  */
959
- function collectionDelimiterOf(program, parameter) {
960
- if (!isCollectionParameter(program, parameter.param))
961
- return undefined;
962
- if (parameter.type !== "query" && parameter.type !== "header")
1618
+ function collectionDelimiterOf(program, parameter, wire) {
1619
+ if (!isCollectionParameter(wire))
963
1620
  return undefined;
964
- if (parameter.explode === true)
1621
+ // A cookie is `form` with `explode: false` in the document, which joins a list with commas.
1622
+ if (parameter.type !== "query" && parameter.type !== "header" && parameter.type !== "cookie") {
965
1623
  return undefined;
966
- const encoding = getEncode(program, parameter.param)?.encoding;
967
- if (encoding !== undefined && encoding in COLLECTION_DELIMITERS) {
968
- return COLLECTION_DELIMITERS[encoding];
969
1624
  }
1625
+ /**
1626
+ * **An array encoding is a `style`, and it wins over `explode`.** openapi3 writes the encoding's
1627
+ * style - `pipeDelimited` - and writes `explode: true` only by leaving it out, which for every
1628
+ * style but `form` means NOT exploded. So the document joins the list with the encoding's
1629
+ * delimiter, `?xp=1|2`, explode or not; boxed as one occurrence, that was refused.
1630
+ */
1631
+ const encoding = arrayEncodingOf(program, parameter.param);
1632
+ if (encoding !== undefined)
1633
+ return COLLECTION_DELIMITERS[encoding];
1634
+ if (parameter.type === "query" && parameter.explode === true)
1635
+ return undefined;
970
1636
  return ",";
971
1637
  }
972
1638
  /**
@@ -997,26 +1663,25 @@ function collectionDelimiterOf(program, parameter) {
997
1663
  * the document justifies. Same rule as the collection split above - decode the wire form, then apply
998
1664
  * the document's schema to the result, never instead of it.
999
1665
  *
1000
- * Derived from the emitted expression rather than a restated list of scalar names: `SCALARS` in
1001
- * `zod.ts` decides what becomes `z.number()`, and a second copy of that list here would stop agreeing
1002
- * with it silently.
1003
- */
1004
- function wireDecoded(declared, kind, elementKind) {
1005
- const NUMBER = '(raw) => (typeof raw === "string" && raw.trim() !== "" && Number.isFinite(Number(raw)) ? Number(raw) : raw)';
1006
- const BOOLEAN = '(raw) => (raw === "true" ? true : raw === "false" ? false : raw)';
1007
- if (kind === "number")
1008
- return `z.preprocess(${NUMBER}, ${declared})`;
1009
- if (kind === "boolean")
1010
- return `z.preprocess(${BOOLEAN}, ${declared})`;
1666
+ * **Read against the schema, not by a kind decided from the type** - see `WIRE_READ_SOURCE`. A
1667
+ * kind could not say `int32 | boolean`, a numeric enum, or `int32 | "all"`, where `"5"` is `5` and
1668
+ * `"all"` is text; each of them refused a value the document accepts. Only a type with a value text
1669
+ * cannot be read at all - every string - is left without a reader, and its validator is unchanged.
1670
+ */
1671
+ function wireDecoded(program, registry, declared, type) {
1672
+ if (readsFromText(program, type)) {
1673
+ return `z.preprocess((raw) => wireRead(raw, ${declared}), ${declared})`;
1674
+ }
1011
1675
  /**
1012
- * A list whose ELEMENTS are numeric or boolean - `?ids=1,2,3` after the collection split, or a
1013
- * repeated `?id=1&id=2`. The elements are strings for exactly the same reason the scalar is, so
1014
- * the same decoder applies per element. A non-array value is left alone; the split that produces
1015
- * the array runs outside this and may not have happened.
1676
+ * A list whose ELEMENTS are read from text - `?ids=1,2,3` after the collection split, or a
1677
+ * repeated `?id=1&id=2` - each against the element's own schema, as a lone scalar is. A non-array
1678
+ * value is left alone; the split that produces the array runs outside this and may not have
1679
+ * happened.
1016
1680
  */
1017
- if (elementKind !== undefined) {
1018
- const decoder = elementKind === "number" ? NUMBER : BOOLEAN;
1019
- return `z.preprocess((raw) => (Array.isArray(raw) ? raw.map(${decoder}) : raw), ${declared})`;
1681
+ const element = elementOf(type);
1682
+ if (element !== undefined && readsFromText(program, element)) {
1683
+ const schema = atItem(program, type, () => registry.expressionFor(element));
1684
+ return `z.preprocess((raw) => (Array.isArray(raw) ? raw.map((item) => wireRead(item, ${schema})) : raw), ${declared})`;
1020
1685
  }
1021
1686
  return declared;
1022
1687
  }
@@ -1122,10 +1787,11 @@ literalQueryNames) {
1122
1787
  let accept;
1123
1788
  /** Form-exploded records and models, gathered back under their names around the query object. */
1124
1789
  const gathered = [];
1125
- const queryNames = operation.parameters.parameters
1790
+ const parameters = publishedParametersOf(operation);
1791
+ const queryNames = parameters
1126
1792
  .filter((parameter) => parameter.type === "query")
1127
1793
  .map((parameter) => parameter.name);
1128
- for (const parameter of operation.parameters.parameters) {
1794
+ for (const parameter of parameters) {
1129
1795
  /**
1130
1796
  * **`cookie` is here because `@typespec/http` puts it here**, and excluding it was silent.
1131
1797
  * `HttpOperationParameter` discriminates on `"header" | "cookie" | "query" | "path"`, and this
@@ -1157,113 +1823,198 @@ literalQueryNames) {
1157
1823
  * The TypeSpec property name is an authoring convenience that does not survive to the contract.
1158
1824
  */
1159
1825
  /**
1160
- * **Two parameters of one location and one wire name are one value on the wire**, and both
1161
- * declarations apply to it: their schemas are intersected and the value is decoded once, by
1162
- * the first declaration's wire form, so each judges the same decoded value. Decoded by each
1163
- * and then intersected, `@query("x") a: string` beside `@query("x") b: int32` would hand Zod
1164
- * `"1"` and `1` to merge, which it does not refuse but throws on. An object literal cannot
1165
- * hold the key twice either. `shared-input-key` reports it.
1826
+ * **Two parameters of one location and one wire name are one value on the wire, and the
1827
+ * document publishes the first.** openapi3 writes one parameter per name and location, so
1828
+ * `@query("x") a: string` beside `@query("x") b: int32` is published as `x: string`, and `?x=abc`
1829
+ * is valid. Intersected, as both declarations, it was refused. `shared-input-key` reports it.
1166
1830
  */
1167
- const sharing = operation.parameters.parameters.filter((other) => other.type === parameter.type && other.name === parameter.name);
1168
- if (sharing[0] !== parameter)
1831
+ const first = parameters.find((other) => sameWireName(other, parameter));
1832
+ if (first !== parameter)
1169
1833
  continue;
1170
- const delimiter = collectionDelimiterOf(program, parameter);
1171
- const declared = sharing
1172
- .map((other) => registry.expressionForProperty(other.param))
1173
- .reduce((first, other) => `${first}.and(${other})`);
1174
- /**
1175
- * The wire form is decoded before it is validated, never instead of it. `z.preprocess` undoes
1176
- * exactly the flattening the document's `style` describes and then applies the schema the
1177
- * document publishes, so every constraint on the list - `minItems`, the element type - still
1178
- * runs. Splitting in the handler instead would have left those unenforced.
1179
- */
1834
+ // A header another one names in other case is refused, as `never` - see `headerCaseCollisionOf`.
1835
+ if (headerCaseCollisionOf(program, operation, parameter)) {
1836
+ const refused = parameter.param.optional ? "z.never().exactOptional()" : "z.never()";
1837
+ add(parameter.type, parameter.name, refused);
1838
+ add("header-without-accept", parameter.name, refused);
1839
+ continue;
1840
+ }
1180
1841
  /**
1181
- * Decoded BEFORE the split is wrapped around it, so the two nest in wire order: the outer
1182
- * preprocess turns `"1,2,3"` into `["1","2","3"]`, the inner one turns those into numbers, and
1183
- * the document's schema then validates the result. Wrapping the other way round would hand the
1184
- * element decoder an unsplit string.
1842
+ * A parameter published as a reference to another document states no schema - openapi3 writes
1843
+ * the reference and nothing else - and is refused, as a type published so is.
1185
1844
  */
1186
- const decoded = parameter.type === "header" && parameter.name.toLowerCase() === "content-type"
1187
- ? mediaTypeDecoded(declared)
1188
- : wireDecoded(declared,
1845
+ const external = externalReferenceOf(parameter.param);
1846
+ if (external !== undefined) {
1847
+ const refused = refuseExternalReference(program, parameter.param, external);
1848
+ const value = parameter.param.optional ? `${refused}.exactOptional()` : refused;
1849
+ add(parameter.type, parameter.name, value);
1850
+ if (parameter.type === "header")
1851
+ add("header-without-accept", parameter.name, value);
1852
+ continue;
1853
+ }
1854
+ // At the visibility its shared component is published at - see `parameterVisibilityOf`.
1855
+ withVisibility(parameterVisibilityOf(parameter.param) ?? currentVisibility(), () => {
1856
+ const declared = registry.expressionForProperty(parameter.param);
1189
1857
  /**
1190
- * `@encode` on the PROPERTY changes what crosses the wire, so the encoded type is
1191
- * the one to read. `@encode("unixTimestamp", int32) value: utcDateTime` is a number
1192
- * on the wire and a date in the spec, and reading the declared type alone stopped
1193
- * eighteen such parameters being decoded.
1858
+ * Each form the value can take, undone its own way and validated against the WHOLE schema - see
1859
+ * `wireFormsOf`. The first form the schema accepts is the value, as the first reading `wireRead`
1860
+ * finds is.
1194
1861
  */
1195
- wireKindOf(program, encodedParameterType(program, parameter.param)), elementKindOf(program, encodedParameterType(program, parameter.param)));
1196
- /**
1197
- * The boxing half of the same principle: an exploded array's single occurrence arrives as a
1198
- * bare string, so it is boxed into the one-element array the document describes before the
1199
- * schema runs - every constraint on the list still applies to the result. Several
1200
- * occurrences arrive already an array and pass through untouched, exactly as the delimiter
1201
- * arm leaves a pre-split value alone.
1202
- */
1203
- const boxedOrSplit = isExplodedCollection(program, parameter)
1204
- ? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
1205
- : delimiter === undefined
1206
- ? decoded
1207
- : `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${literal(delimiter)}) : raw), ${decoded})`;
1208
- /**
1209
- * **An RFC 6570 expansion is undone OUTSIDE everything above**, because it is the first thing the
1210
- * wire did: `array.a.b` becomes `["a", "b"]`, then the element decoder and the document's schema
1211
- * run on that. See {@link uriExpansionCall}.
1212
- */
1213
- const wire = encodedParameterType(program, parameter.param);
1214
- const shape = expansionShapeOf(wire);
1215
- const values = recordValuesOf(program, wire);
1216
- let expression = boxedOrSplit;
1217
- if (parameter.type === "path") {
1218
- const segment = decodedSegmentOf(program, parameter, segments);
1219
- if (segment !== undefined) {
1220
- expression = `z.preprocess(${uriExpansionCall({ ...segment, name: parameter.name, shape, values })}, ${decoded})`;
1221
- }
1222
- }
1223
- else if (parameter.type === "query" && shape === "record") {
1224
- if (parameter.explode === true) {
1862
+ /**
1863
+ * `@encode` on the PROPERTY changes what crosses the wire, so the encoded type is the one to
1864
+ * read - see below.
1865
+ */
1866
+ const wires = wireFormsOf(encodedParameterType(program, parameter.param));
1867
+ // The members a form-exploded query gathers back under its name - see `formOf`.
1868
+ const objectForms = parameter.type === "query" && parameter.explode === true
1869
+ ? wires.filter((wire) => expansionShapeOf(wire) === "record")
1870
+ : [];
1871
+ /**
1872
+ * **A list or a scalar beside them is the parameter sent as itself**, `?x=1&x=2`, which the
1873
+ * objects' gatherer would take as a key of its own - see `WIRE_ITSELF_SOURCE`. Then, as for a
1874
+ * union of models, the query is gathered once and each member reads it its own way.
1875
+ */
1876
+ const itself = objectForms.length > 0 && wires.length > objectForms.length;
1877
+ const gatheredOnce = objectForms.length > 1 || itself;
1878
+ const gatheredOthers = [
1879
+ ...queryNames.filter((name) => name !== parameter.name),
1880
+ ...literalQueryNames,
1881
+ ];
1882
+ const formOf = (wire) => {
1883
+ const delimiter = collectionDelimiterOf(program, parameter, wire);
1884
+ /**
1885
+ * The wire form is decoded before it is validated, never instead of it. `z.preprocess` undoes
1886
+ * exactly the flattening the document's `style` describes and then applies the schema the
1887
+ * document publishes, so every constraint on the list - `minItems`, the element type - still
1888
+ * runs. Splitting in the handler instead would have left those unenforced.
1889
+ */
1890
+ /**
1891
+ * Decoded BEFORE the split is wrapped around it, so the two nest in wire order: the outer
1892
+ * preprocess turns `"1,2,3"` into `["1","2","3"]`, the inner one turns those into numbers, and
1893
+ * the document's schema then validates the result. Wrapping the other way round would hand the
1894
+ * element decoder an unsplit string.
1895
+ */
1896
+ const decoded = parameter.type === "header" && parameter.name.toLowerCase() === "content-type"
1897
+ ? mediaTypeDecoded(declared)
1898
+ : wireDecoded(program, registry, declared, wire);
1225
1899
  /**
1226
- * `{?param*}` over an object expands to the object's own keys, so there is no `param` key for
1227
- * this entry to read; the gatherer around the query object puts one back.
1900
+ * The boxing half of the same principle: an exploded array's single occurrence arrives as a
1901
+ * bare string, so it is boxed into the one-element array the document describes before the
1902
+ * schema runs - every constraint on the list still applies to the result. Several
1903
+ * occurrences arrive already an array and pass through untouched, exactly as the delimiter
1904
+ * arm leaves a pre-split value alone.
1228
1905
  */
1229
- const model = wire.kind === "Model" && wire.indexer === undefined ? wire : undefined;
1230
- const properties = model === undefined ? [] : modelPropertiesOf(model);
1906
+ const boxedOrSplit = isExplodedCollection(program, parameter, wire)
1907
+ ? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
1908
+ : delimiter === undefined
1909
+ ? decoded
1910
+ : `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${literal(delimiter)}) : raw), ${decoded})`;
1911
+ /**
1912
+ * **An RFC 6570 expansion is undone OUTSIDE everything above**, because it is the first thing the
1913
+ * wire did: `array.a.b` becomes `["a", "b"]`, then the element decoder and the document's schema
1914
+ * run on that. See {@link uriExpansionCall}.
1915
+ */
1916
+ const shape = expansionShapeOf(wire);
1917
+ // A key repeats only in a form-exploded query - see `readersOf`.
1918
+ const readers = readersOf(program, registry, wire, false);
1919
+ let expression = boxedOrSplit;
1920
+ if (parameter.type === "path") {
1921
+ const segment = decodedSegmentOf(program, parameter, segments, wire);
1922
+ if (segment !== undefined) {
1923
+ // A value of any type is the shape its expansion spells - see `URI_EXPANSION_SOURCE`.
1924
+ const any = shape === "scalar" && takesAnyValue(program, wire);
1925
+ expression = `z.preprocess(${uriExpansionCall({
1926
+ ...segment,
1927
+ name: parameter.name,
1928
+ shape: any ? "any" : shape,
1929
+ readers: any ? { values: "z.unknown()", fields: null } : readers,
1930
+ })}, ${decoded})`;
1931
+ }
1932
+ }
1933
+ else if ((parameter.type === "header" || parameter.type === "cookie") &&
1934
+ shape === "record") {
1935
+ /**
1936
+ * **A header or a cookie carries a record or a model as text too**, and nothing read it: the
1937
+ * validator met `"a,1,b,2"` where the document's schema describes `{a: 1, b: 2}`, and refused
1938
+ * every value. A header is `simple` style - `a,1,b,2`, or `a=1,b=2` exploded - and a cookie is
1939
+ * `form` without explode, as openapi3 publishes it: the same expansion as a query's, undone
1940
+ * the same way.
1941
+ */
1942
+ const explode = parameter.type === "header" && parameter.explode === true;
1943
+ expression = `z.preprocess(${uriExpansionCall({ prefix: "", suffix: "", operator: "", name: parameter.name, explode, shape, readers })}, ${decoded})`;
1944
+ }
1945
+ else if (parameter.type === "query" && shape === "record") {
1946
+ if (parameter.explode === true) {
1947
+ /**
1948
+ * `{?param*}` over an object expands to the object's own keys, so there is no `param` key for
1949
+ * this entry to read; the gatherer around the query object puts one back. A union of
1950
+ * models is gathered once, below, and each member reads the gathered values its own way.
1951
+ */
1952
+ const repeated = readersOf(program, registry, wire, true);
1953
+ if (gatheredOnce) {
1954
+ expression = `z.preprocess(${wireReadObjectCall(repeated)}, ${expression})`;
1955
+ }
1956
+ else {
1957
+ const model = wire.kind === "Model" && wire.indexer === undefined ? wire : undefined;
1958
+ gathered.push(explodedQueryCall({
1959
+ name: parameter.name,
1960
+ others: gatheredOthers,
1961
+ keys: model === undefined ? undefined : explodedKeysOf(program, model),
1962
+ itself: false,
1963
+ readers: repeated,
1964
+ }));
1965
+ }
1966
+ }
1967
+ else {
1968
+ expression = `z.preprocess(${uriExpansionCall({ prefix: "", suffix: "", operator: "", name: parameter.name, explode: false, shape, readers })}, ${decoded})`;
1969
+ }
1970
+ }
1971
+ else if (itself) {
1972
+ // What arrived under the name, where the objects' gatherer took the query.
1973
+ expression = `z.preprocess(${wireItselfCall()}, ${expression})`;
1974
+ }
1975
+ return expression;
1976
+ };
1977
+ /**
1978
+ * `@encode` on the PROPERTY changes what crosses the wire, so the encoded type is the one to
1979
+ * read. `@encode("unixTimestamp", int32) value: utcDateTime` is a number on the wire and a date
1980
+ * in the spec, and reading the declared type alone stopped eighteen such parameters being
1981
+ * decoded.
1982
+ */
1983
+ const forms = wires.map(formOf);
1984
+ if (gatheredOnce) {
1985
+ // One gatherer for every member, over every key one of them declares - see `formOf`.
1986
+ const keyed = objectForms.map((wire) => wire.kind === "Model" && wire.indexer === undefined
1987
+ ? explodedKeysOf(program, wire)
1988
+ : undefined);
1231
1989
  gathered.push(explodedQueryCall({
1232
1990
  name: parameter.name,
1233
- others: [...queryNames.filter((name) => name !== parameter.name), ...literalQueryNames],
1234
- keys: model === undefined
1991
+ others: gatheredOthers,
1992
+ keys: keyed.includes(undefined)
1235
1993
  ? undefined
1236
- : properties.map((property) => resolveEncodedName(program, property, "application/json")),
1237
- numbers: properties
1238
- .filter((property) => wireKindOf(program, encodedParameterType(program, property)) === "number")
1239
- .map((property) => resolveEncodedName(program, property, "application/json")),
1240
- booleans: properties
1241
- .filter((property) => wireKindOf(program, encodedParameterType(program, property)) === "boolean")
1242
- .map((property) => resolveEncodedName(program, property, "application/json")),
1243
- values,
1994
+ : [...new Set(keyed.flatMap((keys) => keys ?? []))],
1995
+ itself,
1996
+ readers: NO_READERS,
1244
1997
  }));
1245
1998
  }
1246
- else {
1247
- expression = `z.preprocess(${uriExpansionCall({ prefix: "", suffix: "", operator: "", name: parameter.name, explode: false, shape, values })}, ${decoded})`;
1999
+ const expression = forms.length === 1 ? forms[0] : `z.union([${forms.join(", ")}])`;
2000
+ add(parameter.type, parameter.name, expression);
2001
+ /**
2002
+ * **On a route several operations share, `accept` SELECTS the operation - it is not a
2003
+ * constraint on one of them.** Each member declares its own literal, so validating against any
2004
+ * single member's would 400 every request asking for one of the others, and 400 is the wrong
2005
+ * answer twice over: the request is well-formed, and the contract's answer to "I cannot serve
2006
+ * what you asked for" is 406. `selectContentType` decides it instead, so the negotiated route
2007
+ * validates every OTHER header and leaves this one to the negotiation.
2008
+ */
2009
+ if (parameter.type === "header" && parameter.name.toLowerCase() === "accept") {
2010
+ const declared = parameter.param.type;
2011
+ if (declared.kind === "String")
2012
+ accept = { name: parameter.name, value: declared.value };
1248
2013
  }
1249
- }
1250
- add(parameter.type, parameter.name, expression);
1251
- /**
1252
- * **On a route several operations share, `accept` SELECTS the operation - it is not a
1253
- * constraint on one of them.** Each member declares its own literal, so validating against any
1254
- * single member's would 400 every request asking for one of the others, and 400 is the wrong
1255
- * answer twice over: the request is well-formed, and the contract's answer to "I cannot serve
1256
- * what you asked for" is 406. `selectContentType` decides it instead, so the negotiated route
1257
- * validates every OTHER header and leaves this one to the negotiation.
1258
- */
1259
- if (parameter.type === "header" && parameter.name.toLowerCase() === "accept") {
1260
- const declared = parameter.param.type;
1261
- if (declared.kind === "String")
1262
- accept = { name: parameter.name, value: declared.value };
1263
- }
1264
- else if (parameter.type === "header") {
1265
- add("header-without-accept", parameter.name, expression);
1266
- }
2014
+ else if (parameter.type === "header") {
2015
+ add("header-without-accept", parameter.name, expression);
2016
+ }
2017
+ });
1267
2018
  }
1268
2019
  const fileContentType = fileContentTypeOf(operation);
1269
2020
  if (fileContentType !== undefined) {
@@ -1300,32 +2051,159 @@ literalQueryNames) {
1300
2051
  * string`, and validated by `z.string()`; shaped from the declared list it was expanded into
1301
2052
  * `["a", "b"]` first, and every request was refused.
1302
2053
  */
1303
- function decodedSegmentOf(program, parameter, segments) {
2054
+ function decodedSegmentOf(program, parameter, segments, wire) {
1304
2055
  const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
1305
- return segment !== undefined &&
1306
- needsExpansionDecoding(segment, expansionShapeOf(encodedParameterType(program, parameter.param)))
2056
+ if (segment === undefined)
2057
+ return undefined;
2058
+ // Decoded where any form the value can take is - see `wireFormsOf`.
2059
+ const forms = wire === undefined ? wireFormsOf(encodedParameterType(program, parameter.param)) : [wire];
2060
+ return forms.some((form) => needsExpansionDecoding(segment, expansionShapeOf(form)))
1307
2061
  ? segment
1308
2062
  : undefined;
1309
2063
  }
2064
+ /**
2065
+ * **Whether a value of the type may be anything**: `unknown`, a scalar with no standard base, or a
2066
+ * union holding one - published as `{}` or with `{}` among its members - so its text may spell a list
2067
+ * or an object as well as a scalar.
2068
+ */
2069
+ function takesAnyValue(program, type) {
2070
+ if (type.kind === "UnionVariant")
2071
+ return takesAnyValue(program, type.type);
2072
+ if (type.kind === "Union") {
2073
+ return [...type.variants.values()].some((variant) => takesAnyValue(program, variant.type));
2074
+ }
2075
+ return isTypelessMember(program, type);
2076
+ }
1310
2077
  /** What a parameter's value decodes into once its expansion is undone. */
1311
2078
  function expansionShapeOf(type) {
1312
2079
  if (isArrayType(type))
1313
2080
  return "list";
1314
2081
  return type.kind === "Model" ? "record" : "scalar";
1315
2082
  }
1316
- /** How a record's values decode, read from its indexer the way a scalar's kind is read. */
1317
- function recordValuesOf(program, type) {
1318
- if (type.kind !== "Model" || isArrayType(type) || type.indexer === undefined)
1319
- return "";
1320
- return wireKindOf(program, type.indexer.value) ?? "";
1321
- }
1322
- /** A model's properties, its base model's first, as the document lists them. */
1323
- function modelPropertiesOf(model) {
1324
- const chain = [];
1325
- for (let current = model; current !== undefined; current = current.baseModel) {
1326
- chain.unshift(current);
2083
+ /**
2084
+ * The schemas a record's or a model's values are read from text against - see `wireRead`: every
2085
+ * value of a record against its indexer's, each key of a model against its property's. A value
2086
+ * text cannot spell anything else, a string, needs none.
2087
+ *
2088
+ * `repeats` says whether a key may arrive more than once, a list as its repeated values: a
2089
+ * form-exploded query object, and a URL-encoded form. There a lone occurrence is boxed into the list
2090
+ * it stands for. Anywhere else a value is one piece of text, which spells no list: boxed in a header,
2091
+ * a cookie, a path or a non-exploded query, `l,1` was `{ l: [1] }` where the document reads
2092
+ * `{ l: "1" }` and refuses it, and `l,1,l,2` dropped the first value.
2093
+ */
2094
+ function readersOf(program, registry, type, repeats) {
2095
+ if (type.kind !== "Model" || isArrayType(type))
2096
+ return NO_READERS;
2097
+ /**
2098
+ * **Every key no member names is read as the indexer that holds it, the topmost in the chain** -
2099
+ * see `bindingIndexerOf`. Read as the model's own, an inherited `...Record<int32>` read nothing and
2100
+ * a subtype's narrower indexer read what the base's, which the validator enforces, accepts.
2101
+ */
2102
+ const binding = bindingIndexerOf(type);
2103
+ const values = binding === undefined || isNeverType(binding.value)
2104
+ ? null
2105
+ : readerOf(program, registry, binding.value, undefined, repeats);
2106
+ /**
2107
+ * **Each key is read as the validator holds it**: the object the model is rendered as, each member
2108
+ * its most derived declaration at its position. Read first-wins from the base down, `Sub { x?:
2109
+ * string }` under `Base { x?: int32 | string }` read `"5"` as `5`, and `Sub` refused it.
2110
+ *
2111
+ * **Where an indexer reads every key, a declared key is read as its own property says** - text
2112
+ * included, so `s?: string` beside `...Record<int32 | string>` stays `"5"` rather than taking the
2113
+ * indexer's reading and becoming `5`.
2114
+ */
2115
+ const fields = {};
2116
+ withVisibility(renderedVisibilityOf(program, type, currentVisibility()), () => {
2117
+ const object = objectOfModel(program, type);
2118
+ if (object.kind !== "object")
2119
+ return;
2120
+ for (const member of object.members) {
2121
+ if (member.property === undefined)
2122
+ continue;
2123
+ const reader = readerOf(program, registry, encodedParameterType(program, member.property), { property: member.property, position: member.position }, repeats, values !== null);
2124
+ if (reader !== null)
2125
+ fields[member.key] = reader;
2126
+ }
2127
+ });
2128
+ /**
2129
+ * **A discriminated base is read as the subtype its discriminator names**: a header `Snake`
2130
+ * carrying `kind,cobra,length,5` is a `Cobra`, whose `length` is a number, and `Pet` carrying
2131
+ * `kind,cat,x,5` a `Cat`, whose `x` is a string though `Dog`'s is a number. Read by one table for
2132
+ * every subtype, a key two of them declare was read as the first's.
2133
+ */
2134
+ const discriminator = getDiscriminator(program, type);
2135
+ const dispatched = dispatchedSubtypesOf(program, type);
2136
+ return {
2137
+ values,
2138
+ fields: Object.keys(fields).length === 0 ? null : fields,
2139
+ ...(discriminator === undefined || dispatched.length === 0
2140
+ ? {}
2141
+ : {
2142
+ by: {
2143
+ property: discriminator.propertyName,
2144
+ readers: Object.fromEntries(dispatched.map(([value, subtype]) => [
2145
+ value,
2146
+ readersOf(program, registry, subtype, repeats),
2147
+ ])),
2148
+ },
2149
+ }),
2150
+ };
2151
+ }
2152
+ /**
2153
+ * The reader for one value of a record or a model, or `null` for none: a list boxed and read element
2154
+ * by element where a key repeats, a value text can spell as something else read against its schema -
2155
+ * the member's, at its position, where there is one - and text, which needs no reading unless
2156
+ * `always`.
2157
+ */
2158
+ function readerOf(program, registry, type, member, repeats, always = false) {
2159
+ const whole = () => member === undefined
2160
+ ? withParameterPosition(true, () => registry.expressionFor(type))
2161
+ : registry.expressionForProperty(member.property, member.position);
2162
+ // Text is never `null`, so a `| null` beside a list leaves the list to read.
2163
+ const inner = nullableInnerOf(type) ?? type;
2164
+ const element = elementOf(inner);
2165
+ if (element !== undefined) {
2166
+ // One piece of text spells no list, so it is left to the schema to refuse.
2167
+ if (!repeats)
2168
+ return always ? whole() : null;
2169
+ return eachReader(atItem(program, inner, () => withParameterPosition(true, () => registry.expressionFor(element))));
1327
2170
  }
1328
- return chain.flatMap((current) => [...current.properties.values()]);
2171
+ /**
2172
+ * **A union holding a list is one value or several**: `e?: int32 | int32[]` arrives as `?e=1` or
2173
+ * `?e=1&e=2`. Several are read as the list's elements; one is read against the whole type, which
2174
+ * takes it as the one value. Read as text alone, the repeated key reached the schema unread and was
2175
+ * refused where the document reads `[1, 2]`. Where a key does not repeat, it is only ever one.
2176
+ */
2177
+ const forms = inner.kind === "Union" ? wireFormsOf(inner) : [];
2178
+ const lists = forms.filter((each) => elementOf(each) !== undefined);
2179
+ if (lists.length > 0) {
2180
+ if (!repeats)
2181
+ return whole();
2182
+ /**
2183
+ * **Every list member's elements, and the whole value to choose by**: `int32[] | string[]`
2184
+ * reads `?ll=1&ll=x` as the strings, the reading the whole accepts, where its first member's
2185
+ * elements made it `[1, "x"]`, and refused. One value is the one value only where a member is
2186
+ * not a list; `?ll=x` is the list `["x"]`.
2187
+ */
2188
+ const each = lists.map((list) => atItem(program, list, () => withParameterPosition(true, () => registry.expressionFor(elementOf(list)))));
2189
+ const one = forms.some((each) => elementOf(each) === undefined) ? whole() : undefined;
2190
+ return eachReader(each, one, whole());
2191
+ }
2192
+ if (!always && !readsFromText(program, type))
2193
+ return null;
2194
+ return whole();
2195
+ }
2196
+ /**
2197
+ * **The keys a form-exploded model is gathered by: the members its validator declares**, at the
2198
+ * visibility it is rendered at. Every property was a key, so `?secret=x` on a GET gathered a
2199
+ * create-only property the document does not publish there, and a sealed model refused the request
2200
+ * the document reads as `{}`.
2201
+ */
2202
+ function explodedKeysOf(program, model) {
2203
+ return withVisibility(renderedVisibilityOf(program, model, currentVisibility()), () => {
2204
+ const object = objectOfModel(program, model);
2205
+ return object.kind === "object" ? object.members.map((member) => member.key) : [];
2206
+ });
1329
2207
  }
1330
2208
  /**
1331
2209
  * **The media type an `Http.File` body declares, as the input it is.** A file's `contentType` is the
@@ -1340,7 +2218,7 @@ function fileContentTypeOf(operation) {
1340
2218
  const body = requestBodyParameterOf(operation);
1341
2219
  if (body?.bodyKind !== "file")
1342
2220
  return undefined;
1343
- const declared = operation.parameters.parameters.some((parameter) => parameter.type === "header" && parameter.name.toLowerCase() === "content-type");
2221
+ const declared = publishedParametersOf(operation).some((parameter) => parameter.type === "header" && parameter.name.toLowerCase() === "content-type");
1344
2222
  return declared ? undefined : body.contentTypeProperty;
1345
2223
  }
1346
2224
  /**
@@ -1354,9 +2232,14 @@ function fileContentTypeOf(operation) {
1354
2232
  * handler receives.
1355
2233
  */
1356
2234
  /**
1357
- * The keys a merged body would contribute to the handler's input: each property of the body SENT,
1358
- * at the operation's request visibility, under its wire name, or each named part of a multipart
1359
- * body. A read-only property is not sent, so it contributes nothing.
2235
+ * The keys a merged body would contribute to the handler's input: each key of the object the body's
2236
+ * validator declares, under its wire name, or each named part of a multipart body.
2237
+ *
2238
+ * **Read from that object, at the visibility and in the context it is rendered in**, so the two
2239
+ * cannot disagree. A read-only property is one openapi3 shares into a request that has nothing else
2240
+ * request-specific, as `readOnly` and still required under OAS 3.1's reading: read at the request
2241
+ * visibility it dropped out, so `@path accountId` beside a body requiring `accountId` was merged with
2242
+ * it and one of the two lost.
1360
2243
  */
1361
2244
  function mergedBodyKeysOf(program, operation) {
1362
2245
  const body = requestBodyParameterOf(operation);
@@ -1366,9 +2249,55 @@ function mergedBodyKeysOf(program, operation) {
1366
2249
  const type = requestBodyOf(operation);
1367
2250
  if (type?.kind !== "Model")
1368
2251
  return [];
1369
- return withVisibility(program, resolveRequestVisibility(program, operation.operation, operation.verb), () => [...walkPropertiesInherited(type)]
1370
- .filter((property) => isPayloadProperty(program, property))
1371
- .map((property) => resolveEncodedName(program, property, "application/json")));
2252
+ const requested = requestVisibilityOf(program, operation);
2253
+ return withVisibility(rootVisibilityOf(program, type, requested), () => withMetadataAnnotationsIgnored(ignoresMetadata(body), () => {
2254
+ const object = objectOfModel(program, type);
2255
+ return object.kind === "object" ? object.members.map((member) => member.key) : [];
2256
+ }));
2257
+ }
2258
+ /**
2259
+ * **Whether two parameters name one value on the wire**: one location and one name - a header's name
2260
+ * read without its case, as HTTP reads it (RFC 9110 section 5.1).
2261
+ */
2262
+ function sameWireName(one, other) {
2263
+ return (one.type === other.type &&
2264
+ (one.type === "header"
2265
+ ? one.name.toLowerCase() === other.name.toLowerCase()
2266
+ : one.name === other.name));
2267
+ }
2268
+ /**
2269
+ * **Two headers whose names differ only in case are one header**, and openapi3 publishes them as two
2270
+ * parameters of the one value - `x-foo: int32` and `X-Foo: string` - which every value has to
2271
+ * satisfy at once and a server hands over once. `header-case-collision`, an error, reported once per
2272
+ * pair, and both validate nothing; validated as the last, `X-Foo: abc` was served where the
2273
+ * document refuses it.
2274
+ */
2275
+ const headerCaseCollisionsReported = new WeakMap();
2276
+ function headerCaseCollisionOf(program, operation, parameter) {
2277
+ if (parameter.type !== "header")
2278
+ return false;
2279
+ const other = publishedParametersOf(operation).find((each) => each.type === "header" &&
2280
+ each.name !== parameter.name &&
2281
+ each.name.toLowerCase() === parameter.name.toLowerCase());
2282
+ if (other === undefined)
2283
+ return false;
2284
+ const names = [parameter.name, other.name].toSorted();
2285
+ const reported = headerCaseCollisionsReported.get(program) ?? new Set();
2286
+ headerCaseCollisionsReported.set(program, reported);
2287
+ const key = `${operation.operation.name}\u0000${names.join("\u0000")}`;
2288
+ if (!reported.has(key)) {
2289
+ reported.add(key);
2290
+ reportDiagnostic(program, {
2291
+ code: "header-case-collision",
2292
+ target: parameter.param,
2293
+ format: {
2294
+ operation: operation.operation.name,
2295
+ first: names[0] ?? "",
2296
+ second: names[1] ?? "",
2297
+ },
2298
+ });
2299
+ }
2300
+ return true;
1372
2301
  }
1373
2302
  const INPUT_LOCATION_NOUNS = {
1374
2303
  path: "path parameter",
@@ -1395,7 +2324,7 @@ function sharedInputKeysOf(program, operation) {
1395
2324
  if (resolved !== undefined)
1396
2325
  return resolved;
1397
2326
  const inputs = [];
1398
- for (const parameter of operation.parameters.parameters) {
2327
+ for (const parameter of publishedParametersOf(operation)) {
1399
2328
  if (parameter.type === "path" ||
1400
2329
  parameter.type === "query" ||
1401
2330
  parameter.type === "header" ||
@@ -1440,7 +2369,13 @@ function sharedInputKeysOf(program, operation) {
1440
2369
  if (reported.has(said))
1441
2370
  return;
1442
2371
  reported.add(said);
1443
- reportDiagnostic(program, { code: "shared-input-key", target: input.target, format });
2372
+ reportDiagnostic(program, {
2373
+ code: "shared-input-key",
2374
+ // One location, one value on the wire: the document publishes the first declaration.
2375
+ messageId: first.location === input.location ? "repeated" : "default",
2376
+ target: input.target,
2377
+ format,
2378
+ });
1444
2379
  });
1445
2380
  sharedInputKeys.set(operation, shared);
1446
2381
  return shared;
@@ -1448,6 +2383,7 @@ function sharedInputKeysOf(program, operation) {
1448
2383
  const sharedInputKeys = new WeakMap();
1449
2384
  const sharedInputKeysReported = new WeakMap();
1450
2385
  function requestBodyPlacementOf(program, operation) {
2386
+ reportNeverBody(program, operation, operation.parameters.body, "request");
1451
2387
  const requestType = requestBodyOf(operation);
1452
2388
  const bodyParameter = requestBodyParameterOf(operation);
1453
2389
  const contentTypes = [...(bodyParameter?.contentTypes ?? [])];
@@ -1457,7 +2393,7 @@ function requestBodyPlacementOf(program, operation) {
1457
2393
  * A webhook's MAC covers exactly what arrived, so a parsed-and-re-serialised object would
1458
2394
  * verify a different string and every delivery would fail authentication.
1459
2395
  */
1460
- const bytes = requestType?.kind === "Scalar" && requestType.name === "bytes";
2396
+ const bytes = requestType?.kind === "Scalar" && program.checker.isStdType(requestType, "bytes");
1461
2397
  /**
1462
2398
  * **A body declared only under media types nothing can parse into it is handed over raw.** A
1463
2399
  * model as `application/xml` has no serialisation this emitter can derive, and the server has no
@@ -1542,10 +2478,11 @@ function requestBodyPlacementOf(program, operation) {
1542
2478
  * other.** `PUT /renames/{name}` with a body `{ name }` handed the handler one `name`, the body's,
1543
2479
  * and the path's was lost with nothing reported - in `parameters/spread`'s composite request, and
1544
2480
  * in any spec whose body repeats a path or query parameter. The document states the two
1545
- * separately; named, the handler is given both. A read-only property is not in the body sent, so
1546
- * `@path id` beside a model whose `id` is read-only collides with nothing and still merges.
2481
+ * separately; named, the handler is given both. The body's keys are the keys its validator
2482
+ * declares - see `mergedBodyKeysOf` - so a read-only `id` shared into the request, still required
2483
+ * under OAS 3.1's reading, collides with `@path id` as any other key does.
1547
2484
  */
1548
- const parameterNames = new Set(operation.parameters.parameters.map((parameter) => parameter.name));
2485
+ const parameterNames = new Set(publishedParametersOf(operation).map((parameter) => parameter.name));
1549
2486
  const collides = rawBodyProperty === undefined &&
1550
2487
  mergedBodyKeysOf(program, operation).some((key) => parameterNames.has(key));
1551
2488
  const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
@@ -1577,7 +2514,11 @@ function requestBodyPlacementOf(program, operation) {
1577
2514
  };
1578
2515
  }
1579
2516
  export function collectRoutes(program, registry, service) {
2517
+ return withServiceView(program, service.namespace, () => collectServiceRoutes(program, registry, service));
2518
+ }
2519
+ function collectServiceRoutes(program, registry, service) {
1580
2520
  const routes = [];
2521
+ reportUnpublishable(program, service.namespace);
1581
2522
  {
1582
2523
  for (const operation of service.operations) {
1583
2524
  /**
@@ -1600,14 +2541,16 @@ export function collectRoutes(program, registry, service) {
1600
2541
  */
1601
2542
  const streamed = streamedTypeOf(program, operation.operation.returnType);
1602
2543
  if (streamed !== undefined) {
1603
- withVisibility(program, Visibility.Read, () => registry.expressionFor(streamed));
2544
+ withVisibility(Visibility.Read, () => registry.expressionFor(streamed));
1604
2545
  }
1605
2546
  // `@requestVisibility` when the operation states one, otherwise the verb's default - the
1606
2547
  // same resolution `@typespec/openapi3` performs before emitting a request schema.
1607
- const requestVisibility = resolveRequestVisibility(program, operation.operation, operation.verb);
2548
+ const requestVisibility = requestVisibilityOf(program, operation);
1608
2549
  const { rawBodyProperty, rawBodyBinary, rawBodyUnvalidated, bodyProperty, optionalBody } = requestBodyPlacementOf(program, operation);
1609
2550
  const authentication = authenticationFor(program, operation);
1610
- const template = routeTemplateOf(operation.uriTemplate, operation.parameters.parameters
2551
+ const template = routeTemplateOf(operation.uriTemplate,
2552
+ // Every path parameter, a `never` included: the template still holds its expression.
2553
+ operation.parameters.parameters
1611
2554
  .filter((parameter) => parameter.type === "path")
1612
2555
  .map((parameter) => ({
1613
2556
  name: parameter.name,
@@ -1641,7 +2584,7 @@ export function collectRoutes(program, registry, service) {
1641
2584
  .map((parameter) => parameter.name),
1642
2585
  pathSegments: template.segments,
1643
2586
  sharedInputKeys: sharedInputKeysOf(program, operation),
1644
- decodedPathParameters: operation.parameters.parameters
2587
+ decodedPathParameters: publishedParametersOf(operation)
1645
2588
  .filter((parameter) => decodedSegmentOf(program, parameter, template.segments) !== undefined)
1646
2589
  .map((parameter) => parameter.name),
1647
2590
  literalQuery: template.literalQuery,
@@ -1669,17 +2612,29 @@ export function collectRoutes(program, registry, service) {
1669
2612
  * one and not the other, and `@visibility` can add or remove properties outright. Walking
1670
2613
  * both at one visibility gives whichever was seen first to both.
1671
2614
  */
1672
- requestSchema: withVisibility(program, requestVisibility, () => {
2615
+ ...withVisibility(requestVisibility, () => withRequestReading(program, requestVisibility, () => {
1673
2616
  // Multipart first: its body has no single type to walk, and skipping it left every
1674
2617
  // such route mounted with nothing checking the request at all.
1675
2618
  const parameterBody = requestBodyParameterOf(operation);
1676
2619
  if (parameterBody?.bodyKind === "multipart") {
1677
- return multipartSchemaOf(program, parameterBody, registry);
2620
+ return {
2621
+ requestSchema: multipartRequestOf(program, registry, parameterBody, multipartSchemaOf(program, parameterBody, registry)),
2622
+ textParts: textPartsOf(program, parameterBody),
2623
+ };
1678
2624
  }
1679
- return requestType === undefined ? undefined : registry.expressionFor(requestType);
1680
- }),
2625
+ if (requestType === undefined)
2626
+ return { requestSchema: undefined };
2627
+ reportAmbiguousReadOnly(program, requestType, requestVisibility);
2628
+ const declared = withMetadataAnnotationsIgnored(ignoresMetadata(parameterBody), () => registry.expressionFor(requestType, true));
2629
+ const form = formBodyOf(program, registry, operation, parameterBody, requestType, declared);
2630
+ return form === undefined
2631
+ ? { requestSchema: declared }
2632
+ : form.only
2633
+ ? { requestSchema: form.schema }
2634
+ : { requestSchema: declared, formRequestSchema: form.schema };
2635
+ })),
1681
2636
  ...(() => {
1682
- const split = withVisibility(program, requestVisibility, () => parameterSchemasOf(program, operation, registry, template.segments, template.literalQuery.map(([name]) => name)));
2637
+ const split = withVisibility(requestVisibility, () => parameterSchemasOf(program, operation, registry, template.segments, template.literalQuery.map(([name]) => name)));
1683
2638
  return {
1684
2639
  cookieSchema: split.cookie,
1685
2640
  pathSchema: split.path,
@@ -1696,7 +2651,7 @@ export function collectRoutes(program, registry, service) {
1696
2651
  * visibility declared six components under the wrong suffix - measured:
1697
2652
  * type/model/visibility gained six divergences.
1698
2653
  */
1699
- responses: withVisibility(program, Visibility.Read, () => responsesOf(program, operation, registry)),
2654
+ responses: withVisibility(Visibility.Read, () => responsesOf(program, operation, registry)),
1700
2655
  rawBodyProperty,
1701
2656
  rawBodyBinary,
1702
2657
  rawBodyUnvalidated,
@@ -1911,7 +2866,8 @@ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
1911
2866
  if (d.structural === undefined) {
1912
2867
  return `\nexport const ${d.identifier} = ${compiled(d.source, compileSchemas)};\nexport type ${d.typeName} = z.infer<typeof ${d.identifier}>;\n`;
1913
2868
  }
1914
- const declared = d.structural.startsWith("{")
2869
+ // One object literal is an interface; a union of them - an envelope's wrappers - is not.
2870
+ const declared = isSingleObjectLiteral(d.structural)
1915
2871
  ? `export interface ${d.typeName} ${d.structural}`
1916
2872
  : `export type ${d.typeName} = ${d.structural};`;
1917
2873
  /**
@@ -2033,11 +2989,27 @@ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
2033
2989
  * reason `z` is imported only when used: an unused declaration fails the lint a generated file has
2034
2990
  * to pass. Declared rather than imported, so this file still depends on no runtime.
2035
2991
  */
2036
- const referenced = identifiersIn(routeDeclarations.join(""));
2992
+ const referenced = identifiersIn([...declarations, ...routeDeclarations].join(""));
2993
+ // Both decoders read their values through `wireRead`, so it is declared beside either.
2994
+ const expands = referenced.has("uriExpansion") ||
2995
+ referenced.has("explodedQuery") ||
2996
+ referenced.has("wireReadObject");
2997
+ // And `wireReadAs` reads through `wireRead`, which a multipart text part calls on its own.
2998
+ const readsAs = expands || referenced.has("wireReadAs");
2999
+ if (readsAs || referenced.has("wireRead"))
3000
+ parts.push(WIRE_READ_SOURCE);
3001
+ if (readsAs)
3002
+ parts.push(WIRE_READ_AS_SOURCE);
3003
+ if (expands)
3004
+ parts.push(WIRE_READERS_SOURCE);
2037
3005
  if (referenced.has("uriExpansion"))
2038
3006
  parts.push(URI_EXPANSION_SOURCE);
2039
3007
  if (referenced.has("explodedQuery"))
2040
3008
  parts.push(EXPLODED_QUERY_SOURCE);
3009
+ if (referenced.has("wireReadObject"))
3010
+ parts.push(WIRE_READ_OBJECT_SOURCE);
3011
+ if (referenced.has("wireItself"))
3012
+ parts.push(WIRE_ITSELF_SOURCE);
2041
3013
  if (routeDeclarations.length > 0) {
2042
3014
  parts.push(`
2043
3015
  /** One declared response: its status key, its body's schema, its media types and its headers. */
@@ -2063,14 +3035,55 @@ type ResponseArm = {
2063
3035
  * keeps it, so the generated file does not fill up with aliases that alias nothing.
2064
3036
  */
2065
3037
  const isIdentifier = (expression) => /^[A-Za-z_$][\w$]*$/.test(expression);
2066
- const schemaConst = (operationId, suffix) => `${operationId}${suffix}`;
3038
+ /**
3039
+ * The identifier a route's validator is exported under: the operation's id and the part it checks.
3040
+ *
3041
+ * **Never one of the helpers the module declares beside it.** An operation named `exploded` has a
3042
+ * query validator called `explodedQuery`, which is also the name of the function that gathers an
3043
+ * exploded query object, and the module did not parse. Such a name takes a trailing `_`, as a model
3044
+ * named after an emitted helper does.
3045
+ */
3046
+ const schemaConst = (operationId, suffix, taken) => {
3047
+ /**
3048
+ * **And always an identifier, and never one already taken.** `@operationId("get-widget")` is a
3049
+ * valid id and not a valid name: `export const get-widgetQuery` did not parse. Each character no
3050
+ * identifier may hold becomes `_`, and a name another operation already took - `get_widget`
3051
+ * beside `get-widget` - takes one more.
3052
+ */
3053
+ let name = `${operationId.replace(/[^\w$]/g, "_").replace(/^(\d)/, "_$1")}${suffix}`;
3054
+ while (EMITTED_FUNCTIONS.has(name) || taken.has(name))
3055
+ name = `${name}_`;
3056
+ taken.add(name);
3057
+ return name;
3058
+ };
3059
+ /**
3060
+ * Text written inside a block comment, which `*\/` would end: `@operationId("list*\/items")` is a
3061
+ * valid id, and the comment naming it closed early and left the rest of the line as code.
3062
+ */
3063
+ function commentSafe(text) {
3064
+ // And printable ASCII, as every generated file is: a letter outside it is written as its escape.
3065
+ return text
3066
+ .replaceAll("*/", "*\\/")
3067
+ .replace(/[^\x20-\x7e]/gu, (each) => `\\u{${(each.codePointAt(0) ?? 0).toString(16)}}`);
3068
+ }
3069
+ /** The functions a schemas module may declare for itself - see `helperSources`. */
3070
+ const EMITTED_FUNCTIONS = new Set([
3071
+ "uriExpansion",
3072
+ "explodedQuery",
3073
+ "wireRead",
3074
+ "wireReadAs",
3075
+ "wireReaderOf",
3076
+ "wireReadObject",
3077
+ "wireItself",
3078
+ "wireSentAsItself",
3079
+ ]);
2067
3080
  /** `[the name to refer to it by, the `const` line to emit]` - the line is absent when none is needed. */
2068
- function namedSchema(operationId, suffix, expression, compileSchemas) {
3081
+ function namedSchema(operationId, suffix, expression, compileSchemas, taken) {
2069
3082
  if (expression === undefined)
2070
3083
  return { name: undefined, declaration: undefined };
2071
3084
  if (isIdentifier(expression))
2072
3085
  return { name: expression, declaration: undefined };
2073
- const name = schemaConst(operationId, suffix);
3086
+ const name = schemaConst(operationId, suffix, taken);
2074
3087
  /**
2075
3088
  * **Exported, because a schema nothing outside this file can name is a schema nothing can
2076
3089
  * check.** These are the validators the server actually enforces on every request - the path,
@@ -2127,10 +3140,11 @@ function sharedSlots(routes) {
2127
3140
  function nameRouteSchemas(routes, compileSchemas) {
2128
3141
  const shared = sharedSlots(routes);
2129
3142
  const declarations = [];
3143
+ const taken = new Set();
2130
3144
  const names = new Map();
2131
3145
  for (const route of routes) {
2132
3146
  const declare = (suffix, expression) => {
2133
- const resolved = namedSchema(route.operationId, suffix, expression, compileSchemas);
3147
+ const resolved = namedSchema(route.operationId, suffix, expression, compileSchemas, taken);
2134
3148
  if (resolved.declaration !== undefined)
2135
3149
  declarations.push(resolved.declaration);
2136
3150
  return resolved.name;
@@ -2151,6 +3165,9 @@ function nameRouteSchemas(routes, compileSchemas) {
2151
3165
  const header = declare("Header", negotiating ? route.negotiatedHeaderSchema : route.headerSchema);
2152
3166
  // A raw body is read as text, never parsed, so it has no validator - the bytes are the contract.
2153
3167
  const body = route.rawBodyProperty === undefined ? declare("Body", route.requestSchema) : undefined;
3168
+ const formBody = route.rawBodyProperty === undefined
3169
+ ? declare("FormBody", route.formRequestSchema)
3170
+ : undefined;
2154
3171
  /**
2155
3172
  * One identifier per distinct body, suffixed by the status that first carries it. Two statuses
2156
3173
  * sharing one inline body share its declaration rather than emitting it twice.
@@ -2180,7 +3197,7 @@ function nameRouteSchemas(routes, compileSchemas) {
2180
3197
  }
2181
3198
  return { status: response.status, schema: declaredBodies.get(response.schema), headers };
2182
3199
  });
2183
- const responses = schemaConst(route.operationId, "Responses");
3200
+ const responses = schemaConst(route.operationId, "Responses", taken);
2184
3201
  declarations.push(`export const ${responses} = ${responseArmsOf(route, arms)} satisfies readonly ResponseArm[];`);
2185
3202
  names.set(route.operationId, {
2186
3203
  operationId: route.operationId,
@@ -2191,6 +3208,7 @@ function nameRouteSchemas(routes, compileSchemas) {
2191
3208
  selectedAccept: negotiating ? route.accept : undefined,
2192
3209
  cookie,
2193
3210
  body,
3211
+ ...(formBody === undefined ? {} : { formBody }),
2194
3212
  arms,
2195
3213
  responses,
2196
3214
  });
@@ -2262,11 +3280,10 @@ function collectRequestTypes(program, registry, service) {
2262
3280
  * caller satisfying the published type sent read-only fields and a strict validator answered
2263
3281
  * 400.
2264
3282
  *
2265
- * It is also what keeps `metadataInfo` scoped: the predicates `propertyToTs` consults live
2266
- * behind `withVisibility`, and calling them outside one leaves a module-level singleton built
2267
- * for the wrong program.
3283
+ * The predicates `propertyToTs` consults read the visibility `withVisibility` sets, and outside
3284
+ * one they read `Read`.
2268
3285
  */
2269
- withVisibility(program, resolveRequestVisibility(program, operation.operation, operation.verb), () => {
3286
+ withVisibility(requestVisibilityOf(program, operation), () => withRequestReading(program, requestVisibilityOf(program, operation), () => {
2270
3287
  const body = requestBodyOf(operation);
2271
3288
  const placement = requestBodyPlacementOf(program, operation);
2272
3289
  /** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
@@ -2278,7 +3295,7 @@ function collectRequestTypes(program, registry, service) {
2278
3295
  * beside the payload, they are arguments to the verification. Leaving them out of the wire
2279
3296
  * type would describe an input the application cannot act on.
2280
3297
  */
2281
- const extraProperties = operation.parameters.parameters
3298
+ const extraProperties = publishedParametersOf(operation)
2282
3299
  .filter((parameter) => parameter.type === "path" ||
2283
3300
  parameter.type === "query" ||
2284
3301
  parameter.type === "header" ||
@@ -2290,9 +3307,16 @@ function collectRequestTypes(program, registry, service) {
2290
3307
  * `"x-thing"` here too: a consumer checking its handlers against these types otherwise
2291
3308
  * fails to typecheck against a server that is correct.
2292
3309
  */
3310
+ // One value per name and location, as the document publishes the first declaration.
3311
+ .filter((parameter, index, all) => all.findIndex((other) => sameWireName(other, parameter)) === index)
2293
3312
  .map((parameter) => ({
2294
3313
  key: parameter.name,
2295
- line: registry.expressionForProperty(parameter.param, parameter.name),
3314
+ // At the visibility its shared component is published at - see `parameterVisibilityOf`.
3315
+ // Refused as `never`, as its validator is - a header case collision, or a `@useRef`.
3316
+ line: headerCaseCollisionOf(program, operation, parameter) ||
3317
+ externalReferenceOf(parameter.param) !== undefined
3318
+ ? `${objectKey(parameter.name)}${parameter.param.optional ? "?" : ""}: never;`
3319
+ : withVisibility(parameterVisibilityOf(parameter.param) ?? currentVisibility(), () => registry.expressionForProperty(parameter.param, parameter.name)),
2296
3320
  }));
2297
3321
  const fileContentType = fileContentTypeOf(operation);
2298
3322
  if (fileContentType !== undefined) {
@@ -2326,15 +3350,16 @@ function collectRequestTypes(program, registry, service) {
2326
3350
  * it, though the input is the text or the bytes that arrived. 0.27.0 declared it, and a
2327
3351
  * consumer importing it lost the import when the body became raw.
2328
3352
  */
2329
- if (body !== undefined && declaresComponent(body))
2330
- registry.expressionFor(body);
3353
+ if (body !== undefined && declaresComponent(body)) {
3354
+ withMetadataAnnotationsIgnored(ignoresMetadata(parameterBody), () => registry.expressionFor(body));
3355
+ }
2331
3356
  }
2332
3357
  else {
2333
3358
  const expression = parameterBody?.bodyKind === "multipart"
2334
- ? multipartTsOf({ parts: parameterBody.parts }, registry)
3359
+ ? multipartTsOf(program, parameterBody, registry)
2335
3360
  : body === undefined
2336
3361
  ? undefined
2337
- : registry.expressionFor(body);
3362
+ : withMetadataAnnotationsIgnored(ignoresMetadata(parameterBody), () => registry.expressionFor(body, true));
2338
3363
  if (parameterBody?.bodyKind !== "multipart")
2339
3364
  bodyRef = expression;
2340
3365
  if (expression !== undefined) {
@@ -2351,10 +3376,12 @@ function collectRequestTypes(program, registry, service) {
2351
3376
  entries.push({
2352
3377
  operationId: operationIdOf(program, operation.operation),
2353
3378
  bodyRef,
2354
- input: parts.length === 0 ? "Record<string, never>" : `Simplify<${parts.join(" & ")}>`,
3379
+ input: parts.length === 0
3380
+ ? "globalThis.Record<string, never>"
3381
+ : `Simplify<${parts.join(" & ")}>`,
2355
3382
  streamsBody,
2356
3383
  });
2357
- });
3384
+ }));
2358
3385
  }
2359
3386
  return entries;
2360
3387
  }
@@ -2368,7 +3395,7 @@ function collectResponseTypes(program, registry, service) {
2368
3395
  * components under the wrong suffix. Saying it here rather than relying on the default is what
2369
3396
  * makes that impossible from either direction.
2370
3397
  */
2371
- withVisibility(program, Visibility.Read, () => {
3398
+ withVisibility(Visibility.Read, () => {
2372
3399
  for (const operation of service.operations) {
2373
3400
  /**
2374
3401
  * **The same responses, and the same members, the server emitters render.** This walk used
@@ -2394,7 +3421,8 @@ function collectResponseTypes(program, registry, service) {
2394
3421
  if (member.unvalidated &&
2395
3422
  member.response.body !== undefined &&
2396
3423
  declaresComponent(member.response.body)) {
2397
- registry.expressionFor(member.response.body);
3424
+ const declared = member.response.body;
3425
+ withMetadataAnnotationsIgnored(member.response.ignoresMetadata, () => registry.expressionFor(declared));
2398
3426
  }
2399
3427
  switch (member.serving) {
2400
3428
  case "none":
@@ -2408,9 +3436,10 @@ function collectResponseTypes(program, registry, service) {
2408
3436
  case "unvalidated":
2409
3437
  return "string";
2410
3438
  default: {
2411
- if (member.response.body === undefined)
3439
+ const declared = member.response.body;
3440
+ if (declared === undefined)
2412
3441
  return undefined;
2413
- const ref = registry.expressionFor(member.response.body);
3442
+ const ref = withMetadataAnnotationsIgnored(member.response.ignoresMetadata, () => registry.expressionFor(declared, true));
2414
3443
  bodyRefs.push(ref);
2415
3444
  return `Produced<${ref}>`;
2416
3445
  }
@@ -2434,8 +3463,8 @@ function collectResponseTypes(program, registry, service) {
2434
3463
  const every = headers.every(({ property }) => property.optional);
2435
3464
  const rendered = headers
2436
3465
  .map(({ name, property }) => property.optional
2437
- ? `readonly ${objectKey(name)}?: ${propertyTypeToTs(program, property)} | undefined`
2438
- : `readonly ${objectKey(name)}: ${propertyTypeToTs(program, property)}`)
3466
+ ? `readonly ${objectKey(name)}?: ${headerTypeOf(program, property)} | undefined`
3467
+ : `readonly ${objectKey(name)}: ${headerTypeOf(program, property)}`)
2439
3468
  .join("; ");
2440
3469
  fields.push(`readonly headers${every ? "?" : ""}: { ${rendered} }`);
2441
3470
  }
@@ -2493,9 +3522,9 @@ const BYTE_STREAM = `
2493
3522
  */
2494
3523
  export interface ByteStream {
2495
3524
  getReader(): {
2496
- read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array | undefined }>;
3525
+ read(): globalThis.Promise<{ readonly done: boolean; readonly value?: Uint8Array | undefined }>;
2497
3526
  releaseLock(): void;
2498
- cancel?(reason?: unknown): Promise<void>;
3527
+ cancel?(reason?: unknown): globalThis.Promise<void>;
2499
3528
  };
2500
3529
  }
2501
3530
  `;
@@ -2532,7 +3561,7 @@ function renderRequestTypes(entries, responses, registry) {
2532
3561
  : `\nexport type ${declaration.name} = ${declaration.source};\n`;
2533
3562
  });
2534
3563
  const responseLookup = responses
2535
- .map((entry) => `\t/** Every success body \`${entry.operationId}\` declares. */\n\t${entry.operationId}: ${entry.output};`)
3564
+ .map((entry) => `\t/** Every success body \`${commentSafe(entry.operationId)}\` declares. */\n\t${objectKey(entry.operationId)}: ${entry.output};`)
2536
3565
  .join("\n");
2537
3566
  /**
2538
3567
  * **One member per declared response and media type, each pairing a status with its own body.**
@@ -2540,7 +3569,7 @@ function renderRequestTypes(entries, responses, registry) {
2540
3569
  * over a service binding returns `WireResults[op]`, and a handler can hand it straight on.
2541
3570
  */
2542
3571
  const resultLookup = responses
2543
- .map((entry) => `\t/** Every response \`${entry.operationId}\` declares. */\n\t${entry.operationId}:${entry.result.length === 0 ? " never" : entry.result.map((member) => `\n\t\t| ${member}`).join("")};`)
3572
+ .map((entry) => `\t/** Every response \`${commentSafe(entry.operationId)}\` declares. */\n\t${objectKey(entry.operationId)}:${entry.result.length === 0 ? " never" : entry.result.map((member) => `\n\t\t| ${member}`).join("")};`)
2544
3573
  .join("\n");
2545
3574
  /**
2546
3575
  * **Keyed by operation id, and the only name an operation's input has.**
@@ -2553,7 +3582,7 @@ function renderRequestTypes(entries, responses, registry) {
2553
3582
  * `WireInputs["createUser"]` is the same type, and nothing a spec declares can collide with it.
2554
3583
  */
2555
3584
  const lookup = entries
2556
- .map((entry) => `\t/** The input for \`${entry.operationId}\`, as its handler receives it. */\n\t${entry.operationId}: ${entry.input.replaceAll("\n", "\n\t")};`)
3585
+ .map((entry) => `\t/** The input for \`${commentSafe(entry.operationId)}\`, as its handler receives it. */\n\t${objectKey(entry.operationId)}: ${entry.input.replaceAll("\n", "\n\t")};`)
2557
3586
  .join("\n");
2558
3587
  /**
2559
3588
  * **Emitted only where an operation has something to flatten.**
@@ -2598,8 +3627,9 @@ type Simplify<T> = { [K in keyof T]: T[K] } & {};
2598
3627
  * one service: 2 contract methods returning \`readonly T[]\` and 35 readonly array properties, none
2599
3628
  * of them expressible against the published shape.
2600
3629
  */
2601
- export type Produced<T> = T extends readonly (infer Element)[]
2602
- ? readonly Produced<Element>[]
3630
+ export type Produced<T> = T extends readonly unknown[]
3631
+ ? // Mapped over the list itself, so a tuple keeps its positions: \`[A?, ...unknown[]]\`.
3632
+ { readonly [K in keyof T]: Produced<T[K]> }
2603
3633
  : T extends object
2604
3634
  ? { readonly [K in keyof T]: Produced<T[K]> }
2605
3635
  : T;
@@ -2700,7 +3730,7 @@ function renderWireAssertions(schemas, types, entries, responses, contractsPacka
2700
3730
  }
2701
3731
  if (paired.length === 0)
2702
3732
  return undefined;
2703
- const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.input<typeof ${pair.identifier}>>, Contracts.${pair.name}>>;`);
3733
+ const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.input<typeof ${pair.identifier}>>, Flat<Contracts.${pair.name}>>>;`);
2704
3734
  return `${generatedBanner()}
2705
3735
  import type { z } from "zod";
2706
3736
  import type * as Contracts from "${contractsPackage}";
@@ -2762,10 +3792,9 @@ type DeclaredKeyOf<T> = keyof {
2762
3792
 
2763
3793
  type Declared<T> = T extends (...args: never[]) => unknown
2764
3794
  ? T
2765
- : T extends readonly (infer Element)[]
2766
- ? T extends Element[]
2767
- ? Declared<Element>[]
2768
- : readonly Declared<Element>[]
3795
+ : T extends readonly unknown[]
3796
+ ? // Mapped over the list itself: a tuple keeps its positions, and \`readonly\` is kept as it is.
3797
+ { [K in keyof T]: Declared<T[K]> }
2769
3798
  : T extends object
2770
3799
  ? [DeclaredKeyOf<T>] extends [never]
2771
3800
  ? [T[keyof T]] extends [never]
@@ -2792,6 +3821,24 @@ type Declared<T> = T extends (...args: never[]) => unknown
2792
3821
  }
2793
3822
  : T;
2794
3823
 
3824
+ /**
3825
+ * **The contract type with its intersections written out**, as \`Declared\` writes out the validator's:
3826
+ * \`(A) & (Inner)\` - a redeclaration merged with a \`@oneOf\` union it cannot be merged into key by key
3827
+ * - and the one object type a mapping makes of it are one shape, which the identity check below tells
3828
+ * apart by spelling. Mapped and nothing dropped, so an index signature the contract states is still
3829
+ * compared.
3830
+ */
3831
+ type Flat<T> = T extends (...args: never[]) => unknown
3832
+ ? T
3833
+ : T extends object
3834
+ ? [keyof T] extends [never]
3835
+ ? // **Every object with no key is the one \`{}\`**, as \`Declared\` writes an empty object: an
3836
+ // empty interface beside an empty literal, \`M1 | {}\`, is otherwise two members where
3837
+ // the validator's side is one.
3838
+ {}
3839
+ : { [K in keyof T]: Flat<T[K]> }
3840
+ : T;
3841
+
2795
3842
  /**
2796
3843
  * **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
2797
3844
  * type when the two disagree, and a type alias to an odd type compiles fine - 66 assertions that
@@ -2963,124 +4010,190 @@ export async function emitHttpZod(context) {
2963
4010
  for (const snapshot of snapshots) {
2964
4011
  beginOperationIds();
2965
4012
  const service = snapshot.service;
2966
- const serviceName = service.namespace.name;
2967
- const perService = options.services?.[serviceName];
2968
- const outputDir = perService?.["output-dir"] ??
2969
- (distinctServices.size > 1
2970
- ? resolvePath(context.emitterOutputDir, serviceName)
2971
- : context.emitterOutputDir);
2972
4013
  /**
2973
- * **Split where two services would write one file, and nowhere else.** A shared contracts
2974
- * directory was written once per service to one path, so the second service's
2975
- * `requests.gen.ts` replaced the first's and a whole surface lost its contracts with no
2976
- * diagnostic. Counted over the services that WRITE there: one service falling back to the
2977
- * shared directory, beside others that name their own, collides with nothing, and splitting
2978
- * it moved contracts a project already imported. A relative `contracts-package` is resolved
2979
- * from each service's own `wire-contract.gen.ts`, so it names that service's contracts; a
2980
- * package specifier has to be set per service.
4014
+ * **Rendered whole before anything is written.** This service's view, its operation ids and
4015
+ * its banner are module state, and an `await` while they are in force lets another compile in
4016
+ * the same process run in between: the contract types were walked after `schemas.gen.ts` was
4017
+ * written, and with three fixtures compiled at once in one suite, one walked them under
4018
+ * another program's view - its components unsuffixed, `DerivedK` declared twice, its wire
4019
+ * contract unable to pair a validator with its type. Written after the scope closes, nothing
4020
+ * awaits inside it.
2981
4021
  */
2982
- const sharedContractsDir = options["contracts-output-dir"];
2983
- const resolved = {
2984
- contractsOutputDir: perService?.["contracts-output-dir"] ??
2985
- (sharedContractsDir !== undefined && sharingContracts.length > 1
2986
- ? resolvePath(sharedContractsDir, serviceName)
2987
- : sharedContractsDir),
2988
- contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
2989
- sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
2990
- compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
2991
- keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
2992
- regenerateHint: perService?.["regenerate-hint"] ?? options["regenerate-hint"],
2993
- };
2994
- beginBanner(resolved.regenerateHint);
2995
- const registry = new SchemaRegistry(context.program);
2996
- const { result: { routes }, imports: externals, vocabularies, } = collectExternalImports(() => withContractsPackage(resolved.contractsPackage, () => withSealedObjects(resolved.sealObjectSchemas, () => {
2997
- // Declarations first: a vocabulary is a fact about the contract, so it is collected from
2998
- // what the service DECLARES rather than from what the Zod walk happens to touch. An enum
2999
- // reachable only through an error body was silently absent otherwise.
3000
- noteDeclaredVocabularies(service.namespace.enums.values());
4022
+ const writes = [];
4023
+ // Named and written as openapi3 names and writes this service's components - see `published.ts`.
4024
+ const restoreView = beginServiceView(context.program, service.namespace);
4025
+ try {
3001
4026
  /**
3002
- * A closed key SET is a vocabulary too, and it cannot be an enum.
3003
- *
3004
- * TypeSpec has no key-constrained `Record`, so a model naming its permitted properties is
3005
- * how the document states one - and a generated type cannot be iterated, which leaves a
3006
- * consumer needing the set at run time with a hand-maintained copy. Naming the model in
3007
- * `key-vocabularies` writes the keys beside the types. A name matching nothing is
3008
- * reported rather than quietly emitting no tuple.
4027
+ * **A union that reaches itself through unions alone is refused before anything is walked,
4028
+ * and the service emits nothing.** Every other refusal is reported and walked past, so one
4029
+ * compile names them all; this one cannot be walked past, because every reading of it - the
4030
+ * schema, the type, a parameter's forms - recurses without end. The rest of the service is
4031
+ * read once the cycle is broken. See `reachesItselfThroughUnions`.
3009
4032
  */
3010
- const emittedKeyVocabularies = noteKeyVocabularies(context.program, service.namespace.models.values(), resolved.keyVocabularies);
3011
- for (const name of resolved.keyVocabularies) {
3012
- if (emittedKeyVocabularies.includes(name))
3013
- continue;
3014
- reportDiagnostic(context.program, {
3015
- code: "unknown-key-vocabulary",
3016
- target: service.namespace,
3017
- format: { name, service: service.namespace.name },
3018
- });
3019
- }
4033
+ const selfReaching = [...publishedTypes()].filter((type) => type.kind === "Union" &&
4034
+ type.name !== undefined &&
4035
+ reachesItselfThroughUnions(context.program, type));
4036
+ for (const union of selfReaching)
4037
+ refuseSelfReachingUnion(context.program, union);
4038
+ if (selfReaching.length > 0)
4039
+ continue;
4040
+ const serviceName = service.namespace.name;
4041
+ const perService = options.services?.[serviceName];
4042
+ const outputDir = perService?.["output-dir"] ??
4043
+ (distinctServices.size > 1
4044
+ ? resolvePath(context.emitterOutputDir, serviceName)
4045
+ : context.emitterOutputDir);
3020
4046
  /**
3021
- * The error body is registered INSIDE this walk, so its validator lands in
3022
- * `schemas.gen.ts` alongside every other declaration. Nothing else reaches it: the Zod
3023
- * walk follows success bodies and parameters, so without this a runtime would have no
3024
- * generated schema to check the documents it sends against.
3025
- *
3026
- * **Its expression is registered and then deliberately discarded.** It used to be
3027
- * returned and used as a fallback for any response arm whose own body could not be
3028
- * resolved, which meant every catch-all answered with whichever shape `errorBodyOf` had
3029
- * settled on rather than the one its own response declares. Each arm resolves its own
3030
- * body now; registering this one only ensures it is declared.
4047
+ * **Split where two services would write one file, and nowhere else.** A shared contracts
4048
+ * directory was written once per service to one path, so the second service's
4049
+ * `requests.gen.ts` replaced the first's and a whole surface lost its contracts with no
4050
+ * diagnostic. Counted over the services that WRITE there: one service falling back to the
4051
+ * shared directory, beside others that name their own, collides with nothing, and splitting
4052
+ * it moved contracts a project already imported. A relative `contracts-package` is resolved
4053
+ * from each service's own `wire-contract.gen.ts`, so it names that service's contracts; a
4054
+ * package specifier has to be set per service.
3031
4055
  */
3032
- const errorBody = errorBodyOf(context.program, service);
3033
- if (errorBody !== undefined) {
3034
- withVisibility(context.program, Visibility.Read, () => registry.expressionFor(errorBody));
3035
- }
3036
- return {
3037
- routes: withStreamModule(streamModule, () => collectRoutes(context.program, registry, service)),
4056
+ const sharedContractsDir = options["contracts-output-dir"];
4057
+ const resolved = {
4058
+ contractsOutputDir: perService?.["contracts-output-dir"] ??
4059
+ (sharedContractsDir !== undefined && sharingContracts.length > 1
4060
+ ? resolvePath(sharedContractsDir, serviceName)
4061
+ : sharedContractsDir),
4062
+ contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
4063
+ sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
4064
+ compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
4065
+ keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
4066
+ regenerateHint: perService?.["regenerate-hint"] ?? options["regenerate-hint"],
3038
4067
  };
3039
- })));
3040
- /**
3041
- * **Named AFTER the walk and BEFORE the file is rendered.** Naming mints identifiers that
3042
- * `schemas.gen.ts` must declare, so it has to happen while the registry is complete and before
3043
- * anything is written - and it has to happen HERE rather than in a server emitter, or the
3044
- * validators it produces never reach this package's own output.
3045
- */
3046
- const { names, declarations } = nameRouteSchemas(routes, resolved.compileSchemas);
3047
- await emitFile(context.program, {
3048
- path: resolvePath(outputDir, "schemas.gen.ts"),
3049
- content: renderSchemas(registry, externals, declarations, resolved.compileSchemas),
3050
- });
3051
- if (resolved.contractsOutputDir !== undefined) {
3052
- const types = new TypeRegistry(context.program);
4068
+ beginBanner(resolved.regenerateHint);
4069
+ const registry = new SchemaRegistry(context.program);
4070
+ const { result: { routes }, imports: externals, vocabularies, } = collectExternalImports(() => withContractsPackage(resolved.contractsPackage, () => withSealedObjects(resolved.sealObjectSchemas, () => {
4071
+ // Declarations first: a vocabulary is a fact about the contract, so it is collected from
4072
+ // what the service DECLARES rather than from what the Zod walk happens to touch. An enum
4073
+ // reachable only through an error body was silently absent otherwise.
4074
+ noteDeclaredVocabularies(service.namespace.enums.values());
4075
+ /**
4076
+ * A closed key SET is a vocabulary too, and it cannot be an enum.
4077
+ *
4078
+ * TypeSpec has no key-constrained `Record`, so a model naming its permitted properties is
4079
+ * how the document states one - and a generated type cannot be iterated, which leaves a
4080
+ * consumer needing the set at run time with a hand-maintained copy. Naming the model in
4081
+ * `key-vocabularies` writes the keys beside the types. A name matching nothing is
4082
+ * reported rather than quietly emitting no tuple.
4083
+ */
4084
+ const emittedKeyVocabularies = noteKeyVocabularies(context.program, service.namespace.models.values(), resolved.keyVocabularies);
4085
+ for (const name of resolved.keyVocabularies) {
4086
+ if (emittedKeyVocabularies.includes(name))
4087
+ continue;
4088
+ reportDiagnostic(context.program, {
4089
+ code: "unknown-key-vocabulary",
4090
+ target: service.namespace,
4091
+ format: { name, service: service.namespace.name },
4092
+ });
4093
+ }
4094
+ /**
4095
+ * The error body is registered INSIDE this walk, so its validator lands in
4096
+ * `schemas.gen.ts` alongside every other declaration. Nothing else reaches it: the Zod
4097
+ * walk follows success bodies and parameters, so without this a runtime would have no
4098
+ * generated schema to check the documents it sends against.
4099
+ *
4100
+ * **Its expression is registered and then deliberately discarded.** It used to be
4101
+ * returned and used as a fallback for any response arm whose own body could not be
4102
+ * resolved, which meant every catch-all answered with whichever shape `errorBodyOf` had
4103
+ * settled on rather than the one its own response declares. Each arm resolves its own
4104
+ * body now; registering this one only ensures it is declared.
4105
+ */
4106
+ const errorBody = errorBodyOf(context.program, service);
4107
+ if (errorBody !== undefined) {
4108
+ withVisibility(Visibility.Read, () => registry.expressionFor(errorBody));
4109
+ }
4110
+ const routes = withStreamModule(streamModule, () => collectRoutes(context.program, registry, service));
4111
+ // And every type no operation reaches, as openapi3 publishes it - see `unreachedTypes`.
4112
+ withVisibility(Visibility.All, () => {
4113
+ for (const type of unreachedTypes())
4114
+ registry.expressionFor(type);
4115
+ });
4116
+ // And every component openapi3 writes that no route names - see `publishedComponents`.
4117
+ for (const { type, at, ignore, binary } of publishedComponents(context.program)) {
4118
+ withVisibility(at, () => withMetadataAnnotationsIgnored(ignore, () => withMultipartBinary(binary, () => registry.declare(type))));
4119
+ }
4120
+ return { routes };
4121
+ })));
3053
4122
  /**
3054
- * **Inside the same stream scope as the route walk**, because both read the same response
3055
- * records: outside it `streamedTypeOf` cannot see `@typespec/streams`, so an SSE body was
3056
- * stated as the string the document describes rather than the stream a server sends.
4123
+ * **Named AFTER the walk and BEFORE the file is rendered.** Naming mints identifiers that
4124
+ * `schemas.gen.ts` must declare, so it has to happen while the registry is complete and before
4125
+ * anything is written - and it has to happen HERE rather than in a server emitter, or the
4126
+ * validators it produces never reach this package's own output.
3057
4127
  */
3058
- const { entries, responses } = withStreamModule(streamModule, () => ({
3059
- entries: collectRequestTypes(context.program, types, service),
3060
- responses: collectResponseTypes(context.program, types, service),
3061
- }));
3062
- await emitFile(context.program, {
3063
- path: resolvePath(resolved.contractsOutputDir, "requests.gen.ts"),
3064
- content: renderRequestTypes(entries, responses, types),
3065
- });
3066
- await emitFile(context.program, {
3067
- path: resolvePath(resolved.contractsOutputDir, "vocabularies.gen.ts"),
3068
- content: renderVocabularies(vocabularies),
4128
+ const { names, declarations } = nameRouteSchemas(routes, resolved.compileSchemas);
4129
+ writes.push({
4130
+ path: resolvePath(outputDir, "schemas.gen.ts"),
4131
+ content: renderSchemas(registry, externals, declarations, resolved.compileSchemas),
3069
4132
  });
3070
- const assertions = renderWireAssertions(registry, types, entries, responses, resolved.contractsPackage);
3071
- if (assertions !== undefined) {
3072
- await emitFile(context.program, {
3073
- path: resolvePath(outputDir, "wire-contract.gen.ts"),
3074
- content: assertions,
4133
+ if (resolved.contractsOutputDir !== undefined) {
4134
+ const types = new TypeRegistry(context.program, registry.held);
4135
+ /**
4136
+ * **Inside the same stream scope as the route walk**, because both read the same response
4137
+ * records: outside it `streamedTypeOf` cannot see `@typespec/streams`, so an SSE body was
4138
+ * stated as the string the document describes rather than the stream a server sends.
4139
+ */
4140
+ /**
4141
+ * **And inside the same sealing**, because whether a model is published at all can depend
4142
+ * on it - a template instance extending a base openapi3 seals is refused on both sides,
4143
+ * `sealed-base-extended` - and the two walks have to agree about that.
4144
+ */
4145
+ const { requests, assertions } = withSealedObjects(resolved.sealObjectSchemas, () => {
4146
+ const { entries, responses } = withStreamModule(streamModule, () => ({
4147
+ entries: collectRequestTypes(context.program, types, service),
4148
+ responses: collectResponseTypes(context.program, types, service),
4149
+ }));
4150
+ withVisibility(Visibility.All, () => {
4151
+ for (const type of unreachedTypes())
4152
+ types.expressionFor(type);
4153
+ });
4154
+ for (const { type, at, ignore, binary } of publishedComponents(context.program)) {
4155
+ withVisibility(at, () => withMetadataAnnotationsIgnored(ignore, () => withMultipartBinary(binary, () => types.declare(type))));
4156
+ }
4157
+ types.refuse(registry.refusedNames());
4158
+ return {
4159
+ requests: renderRequestTypes(entries, responses, types),
4160
+ assertions: renderWireAssertions(registry, types, entries, responses, resolved.contractsPackage),
4161
+ };
3075
4162
  });
4163
+ writes.push({
4164
+ path: resolvePath(resolved.contractsOutputDir, "requests.gen.ts"),
4165
+ content: requests,
4166
+ });
4167
+ writes.push({
4168
+ path: resolvePath(resolved.contractsOutputDir, "vocabularies.gen.ts"),
4169
+ content: renderVocabularies(vocabularies),
4170
+ });
4171
+ if (assertions !== undefined) {
4172
+ writes.push({
4173
+ path: resolvePath(outputDir, "wire-contract.gen.ts"),
4174
+ content: assertions,
4175
+ });
4176
+ }
3076
4177
  }
4178
+ // Still inside this service's id scope, so each operation resolves to the id its route carries.
4179
+ const operations = new Map(service.operations.map((operation) => [
4180
+ operationIdOf(context.program, operation.operation),
4181
+ operation,
4182
+ ]));
4183
+ emitted.push({
4184
+ service,
4185
+ routes,
4186
+ schemaNames: names,
4187
+ operations,
4188
+ outputDir,
4189
+ options: resolved,
4190
+ });
4191
+ }
4192
+ finally {
4193
+ restoreView();
3077
4194
  }
3078
- // Still inside this service's id scope, so each operation resolves to the id its route carries.
3079
- const operations = new Map(service.operations.map((operation) => [
3080
- operationIdOf(context.program, operation.operation),
3081
- operation,
3082
- ]));
3083
- emitted.push({ service, routes, schemaNames: names, operations, outputDir, options: resolved });
4195
+ for (const write of writes)
4196
+ await emitFile(context.program, write);
3084
4197
  }
3085
4198
  return emitted;
3086
4199
  }