typespec-http-zod 0.25.1 → 0.27.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,11 +1,12 @@
1
- import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolvePath, } from "@typespec/compiler";
2
- import { getAuthenticationForOperation, isStatusCode, resolveRequestVisibility, Visibility, } from "@typespec/http";
1
+ import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolveEncodedName, resolvePath, } from "@typespec/compiler";
2
+ import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
3
3
  import { getOperationId, resolveOperationId } from "@typespec/openapi";
4
4
  import { reportDiagnostic } from "./lib.js";
5
5
  import { SchemaRegistry, TypeRegistry } from "./registry.js";
6
6
  import { typeToTs } from "./types.js";
7
7
  import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
8
8
  import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
9
+ import { EXPLODED_QUERY_SOURCE, explodedQueryCall, needsExpansionDecoding, routeTemplateOf, URI_EXPANSION_SOURCE, uriExpansionCall, } from "./uri-template.js";
9
10
  import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
10
11
  /**
11
12
  * `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
@@ -273,97 +274,85 @@ function statusKeysOf(program, response) {
273
274
  }
274
275
  return keys;
275
276
  }
276
- /** Whether a document status key describes a SUCCESS, so the success arms already carry it. */
277
- function isSuccessKey(status) {
278
- return status === "2XX" || (typeof status === "number" && status >= 200 && status < 300);
279
- }
280
- /**
281
- * Every FAILURE arm the operation declares, each with the schema for ITS OWN body.
282
- *
283
- * **None of these reached the generated output at all, and a census is the proof.** Measured on one
284
- * service, the document declared `200`x9, `201`x3, `202`x1, `401`x1 and `default`x2, and only the
285
- * first three were emitted. So the schema for a failure never arrived, and no application could have
286
- * checked what it sends when something goes wrong even if it wanted to.
287
- *
288
- * **Then every arm that did arrive carried the SERVICE-WIDE error schema.** The spike declares
289
- * `@error model WebhookUnauthorized { ...ProblemDetails; @statusCode 401 }` and openapi3 publishes
290
- * the 401 as `$ref: WebhookUnauthorized` while we answered with `problemDetailsSchema`. The shapes
291
- * were identical there, so nothing was mis-validated - but the component the document names for that
292
- * response had no declaration at all.
293
- *
294
- * **`"default"` used to be the one arm with no body of its own**, filled in downstream from the
295
- * service-wide schema. That was backwards: a catch-all response declares a body like any other
296
- * response, and `response/status-code-range` gives two operations whose `default` is a DIFFERENT
297
- * model from every other failure they declare. Reading each response's own body answers all three at
298
- * once, and is why the emitter no longer needs to believe a service has one error shape.
299
- *
300
- * Arms come out in the order OpenAPI resolves them - exact codes, then ranges, then the catch-all -
301
- * so a consumer scanning for the first match applies the precedence the specification states.
302
- */
303
277
  /**
304
- * The headers each response status declares, keyed by the status the arm answers with.
278
+ * Every response the operation declares, one per status key, in OpenAPI's precedence order.
305
279
  *
306
- * **Read from `@typespec/http`'s own resolution**, which separates a response's headers from its
307
- * body and gives each one under its WIRE name with the declaring property behind it. Deriving either
308
- * name any other way would be a second spelling of a fact the compiler already resolved.
280
+ * **Each response's own body, headers and media types, and nothing borrowed from another.** The
281
+ * failure arms used to be collected here separately from the success, and before that every failure
282
+ * carried the service-wide error schema: `@error model WebhookUnauthorized { ...ProblemDetails;
283
+ * @statusCode 401 }` is published as `$ref: WebhookUnauthorized`, and `response/status-code-range`
284
+ * gives two operations whose `default` is a different model from every other failure they declare.
285
+ * Reading each status's own response answers both, and is why nothing here believes a service has
286
+ * one error shape or one success shape.
287
+ *
288
+ * Where two responses resolve to the same status the first body wins, which is what the document
289
+ * does, and media types and headers are the union across them.
290
+ *
291
+ * Call inside `withVisibility(program, Visibility.Read, ...)`: a response is read at `Read`, and
292
+ * resolving it under a request visibility declares components under the wrong suffix.
309
293
  */
310
- function responseHeadersOf(program, operation) {
294
+ function responsesOf(program, operation, registry) {
311
295
  const byStatus = new Map();
312
296
  for (const response of operation.responses) {
313
- const status = response.statusCodes;
314
- if (typeof status !== "number")
315
- continue;
316
- const headers = [];
317
- for (const content of response.responses) {
318
- for (const [name, property] of Object.entries(content.headers ?? {})) {
319
- if (!headers.some((existing) => existing.name === name)) {
320
- /**
321
- * **The TYPE too, because a handler has to be able to SET this.** A `@header`
322
- * property is stripped from the body schema - correctly, it is not body - so a server
323
- * emitter has to put it back into the type a handler returns, and it cannot do that
324
- * from a name alone. Measured before this existed: an arm naming `contentType` and
325
- * `metadata` against a handler declared `Awaitable<Result<void>>`.
326
- */
327
- headers.push({ name, property: property.name, type: typeToTs(program, property.type) });
297
+ for (const status of statusKeysOf(program, response)) {
298
+ const key = String(status);
299
+ const entry = byStatus.get(key) ?? {
300
+ status,
301
+ body: undefined,
302
+ contentTypes: new Set(),
303
+ headers: [],
304
+ streamed: false,
305
+ };
306
+ for (const content of response.responses) {
307
+ if (entry.body === undefined && content.body?.bodyKind === "single") {
308
+ entry.body = bodyTypeOf(content.body.type);
309
+ }
310
+ for (const contentType of content.body?.contentTypes ?? []) {
311
+ entry.contentTypes.add(contentType);
312
+ }
313
+ for (const [name, property] of Object.entries(content.headers ?? {})) {
314
+ if (entry.headers.some((existing) => existing.name === name))
315
+ continue;
316
+ entry.headers.push({
317
+ name,
318
+ type: typeToTs(program, property.type),
319
+ /**
320
+ * **The Zod expression, which used to be discarded here.** Every REQUEST parameter
321
+ * goes through `expressionForProperty` a few hundred lines below; a response header
322
+ * went through `typeToTs` and nothing else, so the emitted arm carried a name and an
323
+ * optionality and no way to check a value. A response body that breaks its schema
324
+ * throws `ResponseContractError`; a header that breaks its own was served.
325
+ */
326
+ schema: registry.expressionForProperty(property),
327
+ optional: property.optional,
328
+ });
328
329
  }
329
330
  }
331
+ if (streamedTypeOf(program, response.type) !== undefined)
332
+ entry.streamed = true;
333
+ byStatus.set(key, entry);
330
334
  }
331
- if (headers.length === 0)
332
- continue;
333
- const existing = byStatus.get(status) ?? [];
334
- byStatus.set(status, [
335
- ...existing,
336
- ...headers.filter((h) => !existing.some((e) => e.name === h.name)),
337
- ]);
338
- }
339
- return [...byStatus].map(([status, headers]) => ({ status, headers }));
340
- }
341
- /** The media types each numeric response status offers, from the compiler's own resolution. */
342
- function responseMediaTypesOf(operation) {
343
- const byStatus = new Map();
344
- for (const response of operation.responses) {
345
- const status = response.statusCodes;
346
- if (typeof status !== "number")
347
- continue;
348
- const types = responseContentTypesOf(operation, status);
349
- if (types.length === 0)
350
- continue;
351
- byStatus.set(status, types);
352
335
  }
353
- return [...byStatus].map(([status, contentTypes]) => ({ status, contentTypes }));
354
- }
355
- function errorArmsOf(program, operation, registry) {
356
- const arms = new Map();
357
- for (const response of operation.responses) {
358
- for (const status of statusKeysOf(program, response)) {
359
- if (isSuccessKey(status) || arms.has(status))
360
- continue;
361
- arms.set(status, bodySchemaOf(response, registry));
362
- }
363
- }
364
- return [...arms]
365
- .map(([status, schema]) => ({ status, schema }))
366
- .toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status));
336
+ return [...byStatus.values()]
337
+ .map((entry) => {
338
+ const contentTypes = [...entry.contentTypes];
339
+ const binary = entry.body !== undefined && isBinaryPart(entry.body) && isRawBinaryMediaType(contentTypes);
340
+ return {
341
+ status: entry.status,
342
+ schema: entry.body === undefined
343
+ ? undefined
344
+ : binary
345
+ ? "z.unknown()"
346
+ : registry.expressionFor(entry.body),
347
+ contentTypes,
348
+ headers: entry.headers,
349
+ binary,
350
+ streamed: entry.streamed,
351
+ textual: entry.body !== undefined && isStringType(entry.body),
352
+ };
353
+ })
354
+ .toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status) ||
355
+ statusOrderOf(a.status) - statusOrderOf(b.status));
367
356
  }
368
357
  /**
369
358
  * Where an arm sits in OpenAPI's own precedence order.
@@ -380,21 +369,40 @@ function statusPrecedenceOf(status) {
380
369
  return 2;
381
370
  return typeof status === "number" ? 0 : 1;
382
371
  }
383
- /** A response's own body, as an emitted Zod expression - `undefined` where it declares none. */
384
- function bodySchemaOf(response, registry) {
385
- for (const content of response.responses) {
386
- if (content.body?.bodyKind !== "single")
387
- continue;
388
- const type = bodyTypeOf(content.body.type);
389
- if (type !== undefined)
390
- return registry.expressionFor(type);
372
+ /**
373
+ * Whether a type's values are strings: `string` or a scalar built on it, a string literal or template,
374
+ * or a union of those.
375
+ *
376
+ * Read from the type rather than from the emitted schema, which would be a decision made by matching
377
+ * generated text - `z.iso.datetime()` is a string and does not begin with `z.string`.
378
+ */
379
+ function isStringType(type) {
380
+ switch (type.kind) {
381
+ case "String":
382
+ case "StringTemplate":
383
+ return true;
384
+ case "Scalar":
385
+ for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
386
+ if (scalar.name === "string")
387
+ return true;
388
+ }
389
+ return false;
390
+ case "Union":
391
+ return [...type.variants.values()].every((variant) => isStringType(variant.type));
392
+ default:
393
+ return false;
391
394
  }
392
- return undefined;
395
+ }
396
+ /** Ascending within one precedence class, so the order does not depend on declaration order. */
397
+ function statusOrderOf(status) {
398
+ if (typeof status === "number")
399
+ return status;
400
+ return status === "default" ? 0 : Number(status.charAt(0));
393
401
  }
394
402
  /**
395
403
  * Everything the document says about authenticating an operation, read ONCE.
396
404
  *
397
- * **`scopes` and `noAuth` were two lossy projections of this, each with its own read of the same
405
+ * **`scopes` and a boolean `noAuth` were two lossy projections of this, each with its own read of the same
398
406
  * fact.** A flat scope union cannot say which scheme demanded what, so an emitter that wanted the
399
407
  * gate the document actually publishes had to call `getAuthenticationForOperation` a third time and
400
408
  * rebuild the requirements itself -- which is what both server emitters were doing, in two
@@ -406,7 +414,6 @@ function bodySchemaOf(response, registry) {
406
414
  function authenticationFor(program, operation) {
407
415
  const options = getAuthenticationForOperation(program, operation.operation)?.options ?? [];
408
416
  const security = [];
409
- let noAuth = false;
410
417
  for (const option of options) {
411
418
  const requirement = {};
412
419
  let anonymous = false;
@@ -418,7 +425,6 @@ function authenticationFor(program, operation) {
418
425
  */
419
426
  if (scheme.type === "noAuth") {
420
427
  anonymous = true;
421
- noAuth = true;
422
428
  continue;
423
429
  }
424
430
  /** Scopes belong to the flows of an OAuth2 scheme; every other kind has none. */
@@ -427,13 +433,24 @@ function authenticationFor(program, operation) {
427
433
  ? [...new Set(scheme.flows.flatMap((flow) => flow.scopes.map((scope) => scope.value)))]
428
434
  : [];
429
435
  }
430
- if (anonymous && Object.keys(requirement).length === 0)
431
- continue;
432
- if (Object.keys(requirement).length > 0)
436
+ /**
437
+ * **An option that needs nothing is published as `{}`, and kept.** `NoAuth | X` is
438
+ * `security: [{}, { X: [] }]` in the document `@typespec/openapi3` writes from the same program,
439
+ * and `{}` is the requirement every caller satisfies. Dropping it published `[{ X: [] }]`, a
440
+ * contract that refuses the anonymous caller the document accepts; typespec-hono had carried a
441
+ * corrected copy of this rule of its own because this one was wrong.
442
+ */
443
+ if (anonymous || Object.keys(requirement).length > 0)
433
444
  security.push(requirement);
434
445
  }
435
446
  const scopes = [...new Set(security.flatMap((requirement) => Object.values(requirement).flat()))];
436
- return { security, scopes, noAuth };
447
+ const anonymous = security.some((requirement) => Object.keys(requirement).length === 0);
448
+ const real = security.some((requirement) => Object.keys(requirement).length > 0);
449
+ return {
450
+ security,
451
+ scopes,
452
+ authentication: !real ? "none" : anonymous ? "optional" : "required",
453
+ };
437
454
  }
438
455
  /**
439
456
  * The body every `@error` response in the service declares, when they all declare the same one.
@@ -591,70 +608,6 @@ function elementKindOf(program, type) {
591
608
  function isArrayType(type) {
592
609
  return type.kind === "Model" && type.indexer?.key.name === "integer";
593
610
  }
594
- /**
595
- * How to choose between an operation's two success statuses, read from the declared arms.
596
- *
597
- * **A discriminator, not a trial-parse.** "Try the 200 schema, and if it fails use the 202" is the
598
- * banned fallback wearing a different hat: a genuine validation failure on the first arm would be
599
- * reported as the second arm's shape. Each arm pins a **required literal** property instead -
600
- * `awaiting: true` on the accepted one - and a consumer reads that one property.
601
- *
602
- * This replaces hand-written per-operation closures, which existed only because a spec declaring the
603
- * SAME body model for both statuses could not say which was which.
604
- */
605
- function statusDiscriminatorOf(operation, statuses) {
606
- if (statuses.length < 2)
607
- return undefined;
608
- for (const status of statuses) {
609
- const body = successBodyOf(operation, status);
610
- if (body?.kind !== "Model")
611
- continue;
612
- for (const [name, property] of body.properties) {
613
- // The OTHER arm carries the same property as optional; a required literal is the mark.
614
- if (property.optional)
615
- continue;
616
- if (property.type.kind === "Boolean" || property.type.kind === "String") {
617
- return { property: name, value: property.type.value, status };
618
- }
619
- }
620
- }
621
- return undefined;
622
- }
623
- /**
624
- * How a handler says WHICH success status it means, when the spec declares more than one.
625
- *
626
- * **The `@statusCode` property is the selector, and it is the one the spec already wrote.**
627
- * `model Created { @statusCode statusCode: 200 | 201; @body body: Item }` declares both statuses and
628
- * names the property that chooses between them, so nothing has to be inferred from the body's shape.
629
- *
630
- * That inference is what {@link statusDiscriminatorOf} does, and it needs a required literal property
631
- * on one of the bodies. Measured across the whole conformance corpus: it fires ZERO times, so before
632
- * this the second arm had never been emitted by any spec at all - `armFor` could not select a status
633
- * that was never written. Both remain: a discriminator still serves two DIFFERENT response models,
634
- * this serves one model declaring a union of statuses.
635
- *
636
- * All statuses of such a model share one body by construction - it is one model - so every arm
637
- * carries the same schema and only the status and the selector value differ.
638
- */
639
- function statusSelectorOf(program, operation, statuses) {
640
- if (statuses.length < 2)
641
- return undefined;
642
- for (const response of operation.responses) {
643
- const type = response.type;
644
- if (type === undefined || type.kind !== "Model")
645
- continue;
646
- for (const [name, property] of type.properties) {
647
- if (isStatusCode(program, property))
648
- return { property: name, statuses };
649
- }
650
- }
651
- return undefined;
652
- }
653
- /** The emitted schema for a specific success status, used for the second arm of a dual-status op. */
654
- function alternateSchemaFor(operation, status, registry) {
655
- const body = successBodyOf(operation, status);
656
- return body === undefined ? undefined : registry.expressionFor(body);
657
- }
658
611
  /**
659
612
  * The Zod for a **multipart** request body, built from its parts.
660
613
  *
@@ -701,16 +654,26 @@ function multipartTsOf(body, registry) {
701
654
  });
702
655
  return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
703
656
  }
704
- function multipartSchemaOf(body, registry) {
657
+ function multipartSchemaOf(program, body, registry) {
705
658
  const entries = body.parts.map((part) => {
706
659
  const type = part.body?.type;
660
+ /**
661
+ * **A form carries text, so a part that means a number or a boolean is decoded first**, by the
662
+ * same rule {@link wireDecoded} applies to path, query and header values: `"0.5"` becomes
663
+ * `0.5`, and text that is not a well-formed number is passed on unchanged to fail against the
664
+ * schema the document publishes. `c.req.parseBody()` hands a text part over as a string, so
665
+ * `z.number()` alone refused every conformant request; measured on `@typespec/http-specs`
666
+ * `payload/multipart/non-string-float`, a 400 for the request the scenario documents.
667
+ */
707
668
  const inner = type === undefined
708
669
  ? "z.unknown()"
709
670
  : isFilePart(type)
710
671
  ? `z.unknown().refine(${MULTIPART_FILE_REFINEMENT})`
711
672
  : isBinaryPart(type)
712
673
  ? "z.unknown()"
713
- : registry.expressionFor(type);
674
+ : isJsonPart(part)
675
+ ? jsonTextDecoded(registry.expressionFor(type))
676
+ : wireDecoded(registry.expressionFor(type), wireKindOf(program, type), elementKindOf(program, type));
714
677
  // `multi` is `HttpPart<T>[]` - the same part repeated, which arrives as an array.
715
678
  const value = part.multi === true ? `z.array(${inner})` : inner;
716
679
  // `.exactOptional()` for the same reason as a model property: `parseBody` yields present keys
@@ -782,6 +745,27 @@ function isFilePart(type) {
782
745
  return false;
783
746
  }
784
747
  /** `bytes`, or a model built on `File` - the parts openapi3 publishes as an empty schema. */
748
+ /**
749
+ * Whether a multipart part travels as JSON: every media type `@typespec/http` resolves for it is a
750
+ * JSON one. That is `application/json` for a model or array part, and whatever an explicit
751
+ * `@header contentType` names for a part that declares one.
752
+ */
753
+ function isJsonPart(part) {
754
+ const contentTypes = part.body?.contentTypes ?? [];
755
+ return (contentTypes.length > 0 &&
756
+ contentTypes.every((contentType) => /^application\/(?:[\w.+-]+\+)?json$/i.test(contentType)));
757
+ }
758
+ /**
759
+ * **A JSON part is decoded from the JSON text a form carries** before the document's schema applies.
760
+ * A client writes the part as text with `Content-Type: application/json` and no filename, so
761
+ * `c.req.parseBody()` hands over a string; the schema alone refused every such request with
762
+ * `expected object, received string`. Text that is not JSON is an issue of its own rather than a
763
+ * string passed on, so a part whose schema admits a string still refuses malformed JSON. A value that
764
+ * is not text is left alone for the schema to judge.
765
+ */
766
+ function jsonTextDecoded(declared) {
767
+ 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})`;
768
+ }
785
769
  function isBinaryPart(type) {
786
770
  if (type.kind === "Scalar") {
787
771
  for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
@@ -992,15 +976,67 @@ function mediaTypeDecoded(declared) {
992
976
  .map((entry) => entry.trim().replace(/^"|"$/g, ""));
993
977
  if (values === undefined || values.length === 0)
994
978
  return declared;
979
+ /**
980
+ * **A declared media RANGE admits the types inside it, and a literal admits only itself.**
981
+ *
982
+ * \`@header contentType: "text/*"\` published \`z.literal("text/*")\`, so the only request that
983
+ * satisfied it was one sending the range itself as its \`Content-Type\`, which no client does.
984
+ * Measured before this existed: \`text/plain\` and \`text/html\` both answered 400 on a route whose
985
+ * document declares the whole \`text\` group.
986
+ *
987
+ * A template literal is the Zod counterpart of the TypeScript type the contract already uses for
988
+ * a range, so the validator and the handler's own type say the same thing.
989
+ */
990
+ const admitted = values.map((value) => {
991
+ /**
992
+ * **The full wildcard admits every media type, so there is nothing left to match on.**
993
+ *
994
+ * Spelled as a template literal it becomes the prefix `*` + `/`, which matches only a
995
+ * Content-Type whose text begins with the range's own first two characters. Measured on the
996
+ * emitted schema: `application/json`, `text/plain` and `image/png` were all REFUSED and the
997
+ * literal `*` + `/*` was the one value accepted, which is the defect the range branch exists
998
+ * to remove rather than to respell. A consumer declares this on a file upload that takes
999
+ * whatever the caller sends.
1000
+ */
1001
+ if (value === "*/*")
1002
+ return "z.string()";
1003
+ return value.endsWith("/*")
1004
+ ? `z.templateLiteral([${JSON.stringify(value.slice(0, -1))}, z.string()])`
1005
+ : `z.literal(${JSON.stringify(value)})`;
1006
+ });
1007
+ const schema = admitted.length === 1 ? (admitted[0] ?? declared) : `z.union([${admitted.join(", ")}])`;
1008
+ const ranged = values.some((value) => value.endsWith("/*")) ? schema : declared;
995
1009
  // Lowercased only when every declared value already is, so an oddly-cased one keeps matching.
996
1010
  const normalise = values.every((value) => value === value.toLowerCase()) ? ".toLowerCase()" : "";
997
- return `z.preprocess((raw) => (typeof raw === "string" ? (raw.split(";")[0] ?? "").trim()${normalise} : raw), ${declared})`;
1011
+ return `z.preprocess((raw) => (typeof raw === "string" ? (raw.split(";")[0] ?? "").trim()${normalise} : raw), ${ranged})`;
998
1012
  }
999
- function parameterSchemasOf(program, operation, registry) {
1013
+ function parameterSchemasOf(program, operation, registry, segments,
1014
+ /** The names of a query string written into the route itself, which no parameter may gather. */
1015
+ literalQueryNames) {
1000
1016
  const byTarget = {};
1001
1017
  let accept;
1018
+ /** Form-exploded records and models, gathered back under their names around the query object. */
1019
+ const gathered = [];
1020
+ const queryNames = operation.parameters.parameters
1021
+ .filter((parameter) => parameter.type === "query")
1022
+ .map((parameter) => parameter.name);
1002
1023
  for (const parameter of operation.parameters.parameters) {
1003
- if (parameter.type !== "path" && parameter.type !== "query" && parameter.type !== "header") {
1024
+ /**
1025
+ * **`cookie` is here because `@typespec/http` puts it here**, and excluding it was silent.
1026
+ * `HttpOperationParameter` discriminates on `"header" | "cookie" | "query" | "path"`, and this
1027
+ * filter named three of the four, so a `@cookie` reached no validator, no handler input and no
1028
+ * diagnostic while `@typespec/openapi3` published it as `in: cookie, required: true`. A
1029
+ * requirement the document states and the server cannot see is the defect class this package
1030
+ * exists to remove, and a cookie is where a session and a CSRF token normally live.
1031
+ *
1032
+ * The exclusion was by omission rather than by a `never` check, which is why TypeScript never
1033
+ * objected; every other place that reads `parameter.type` handles an encoding question a cookie
1034
+ * does not have, since `CookieOptions` carries only a name.
1035
+ */
1036
+ if (parameter.type !== "path" &&
1037
+ parameter.type !== "query" &&
1038
+ parameter.type !== "header" &&
1039
+ parameter.type !== "cookie") {
1004
1040
  continue;
1005
1041
  }
1006
1042
  /**
@@ -1046,11 +1082,54 @@ function parameterSchemasOf(program, operation, registry) {
1046
1082
  * occurrences arrive already an array and pass through untouched, exactly as the delimiter
1047
1083
  * arm leaves a pre-split value alone.
1048
1084
  */
1049
- const expression = isExplodedCollection(parameter)
1085
+ const boxedOrSplit = isExplodedCollection(parameter)
1050
1086
  ? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
1051
1087
  : delimiter === undefined
1052
1088
  ? decoded
1053
1089
  : `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${JSON.stringify(delimiter)}) : raw), ${decoded})`;
1090
+ /**
1091
+ * **An RFC 6570 expansion is undone OUTSIDE everything above**, because it is the first thing the
1092
+ * wire did: `array.a.b` becomes `["a", "b"]`, then the element decoder and the document's schema
1093
+ * run on that. See {@link uriExpansionCall}.
1094
+ */
1095
+ const shape = expansionShapeOf(parameter.param.type);
1096
+ const values = recordValuesOf(program, parameter.param.type);
1097
+ let expression = boxedOrSplit;
1098
+ if (parameter.type === "path") {
1099
+ const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
1100
+ if (segment !== undefined && needsExpansionDecoding(segment, shape)) {
1101
+ expression = `z.preprocess(${uriExpansionCall({ ...segment, name: parameter.name, shape, values })}, ${decoded})`;
1102
+ }
1103
+ }
1104
+ else if (parameter.type === "query" && shape === "record") {
1105
+ if (parameter.explode === true) {
1106
+ /**
1107
+ * `{?param*}` over an object expands to the object's own keys, so there is no `param` key for
1108
+ * this entry to read; the gatherer around the query object puts one back.
1109
+ */
1110
+ const model = parameter.param.type.kind === "Model" && parameter.param.type.indexer === undefined
1111
+ ? parameter.param.type
1112
+ : undefined;
1113
+ const properties = model === undefined ? [] : modelPropertiesOf(model);
1114
+ gathered.push(explodedQueryCall({
1115
+ name: parameter.name,
1116
+ others: [...queryNames.filter((name) => name !== parameter.name), ...literalQueryNames],
1117
+ keys: model === undefined
1118
+ ? undefined
1119
+ : properties.map((property) => resolveEncodedName(program, property, "application/json")),
1120
+ numbers: properties
1121
+ .filter((property) => wireKindOf(program, property.type) === "number")
1122
+ .map((property) => resolveEncodedName(program, property, "application/json")),
1123
+ booleans: properties
1124
+ .filter((property) => wireKindOf(program, property.type) === "boolean")
1125
+ .map((property) => resolveEncodedName(program, property, "application/json")),
1126
+ values,
1127
+ }));
1128
+ }
1129
+ else {
1130
+ expression = `z.preprocess(${uriExpansionCall({ prefix: "", suffix: "", operator: "", name: parameter.name, explode: false, shape, values })}, ${decoded})`;
1131
+ }
1132
+ }
1054
1133
  const entry = `\t${objectKey(parameter.name)}: ${expression},`;
1055
1134
  (byTarget[parameter.type] ??= []).push(entry);
1056
1135
  /**
@@ -1074,24 +1153,50 @@ function parameterSchemasOf(program, operation, registry) {
1074
1153
  const entries = byTarget[target];
1075
1154
  return entries === undefined ? undefined : `z.object({\n${entries.join("\n")}\n})`;
1076
1155
  };
1156
+ const query = shape("query");
1077
1157
  return {
1158
+ cookie: shape("cookie"),
1078
1159
  path: shape("path"),
1079
- query: shape("query"),
1160
+ query: query === undefined
1161
+ ? undefined
1162
+ : gathered.reduce((inner, gather) => `z.preprocess(${gather}, ${inner})`, query),
1163
+ queryFields: query,
1080
1164
  header: shape("header"),
1081
1165
  negotiatedHeader: shape("header-without-accept"),
1082
1166
  accept,
1083
1167
  };
1084
1168
  }
1169
+ /** What a parameter's value decodes into once its expansion is undone. */
1170
+ function expansionShapeOf(type) {
1171
+ if (isArrayType(type))
1172
+ return "list";
1173
+ return type.kind === "Model" ? "record" : "scalar";
1174
+ }
1175
+ /** How a record's values decode, read from its indexer the way a scalar's kind is read. */
1176
+ function recordValuesOf(program, type) {
1177
+ if (type.kind !== "Model" || isArrayType(type) || type.indexer === undefined)
1178
+ return "";
1179
+ return wireKindOf(program, type.indexer.value) ?? "";
1180
+ }
1181
+ /** A model's properties, its base model's first, as the document lists them. */
1182
+ function modelPropertiesOf(model) {
1183
+ const chain = [];
1184
+ for (let current = model; current !== undefined; current = current.baseModel) {
1185
+ chain.unshift(current);
1186
+ }
1187
+ return chain.flatMap((current) => [...current.properties.values()]);
1188
+ }
1085
1189
  export function collectRoutes(program, registry, service) {
1086
1190
  const routes = [];
1087
1191
  {
1088
1192
  for (const operation of service.operations) {
1193
+ /**
1194
+ * **Every operation is a route, whatever its responses are.** This used to require an exact
1195
+ * 2xx or 3xx, which dropped an operation whose only success was a `2XX` range without a word
1196
+ * - the same silent shape as the 282 bodyless operations an earlier rule dropped, because a
1197
+ * range is not a number. A bodyless success is ordinary HTTP, and so is a ranged one.
1198
+ */
1089
1199
  const statusCode = successStatusOf(operation);
1090
- if (statusCode === undefined)
1091
- continue;
1092
- // No body is a legitimate success - see `EmittedRoute.responseSchema`. Skipping here is what
1093
- // dropped 282 operations across the conformance corpus without a word.
1094
- const responseType = successBodyOf(operation, statusCode);
1095
1200
  const requestType = requestBodyOf(operation);
1096
1201
  /**
1097
1202
  * **A streamed operation's EVENT payloads are not reachable from its body.**
@@ -1110,9 +1215,6 @@ export function collectRoutes(program, registry, service) {
1110
1215
  // `@requestVisibility` when the operation states one, otherwise the verb's default - the
1111
1216
  // same resolution `@typespec/openapi3` performs before emitting a request schema.
1112
1217
  const requestVisibility = resolveRequestVisibility(program, operation.operation, operation.verb);
1113
- const statusCodes = successStatusesOf(operation);
1114
- const discriminator = statusDiscriminatorOf(operation, statusCodes);
1115
- const statusSelector = statusSelectorOf(program, operation, statusCodes);
1116
1218
  /**
1117
1219
  * A `bytes` body means the BYTES are the contract.
1118
1220
  *
@@ -1158,11 +1260,36 @@ export function collectRoutes(program, registry, service) {
1158
1260
  * `multipartTsOf`, both recorded above.
1159
1261
  */
1160
1262
  const optionalMultipartBody = bodyParameter?.bodyKind === "multipart" && optionalBody;
1263
+ /**
1264
+ * **A body that is not a MODEL has no properties to merge, and merging it anyway lost it.**
1265
+ *
1266
+ * `@body body: string` emitted a handler input of `Record<string, never>` and a call site of
1267
+ * `{}`: the server validated the body and then never handed it to the handler, silently, from
1268
+ * a compile that reported success. `@body body: SomeEnum` was the same rule failing loudly,
1269
+ * `TS2698: Spread types may only be created from object types`, which is what
1270
+ * `payload/media-type`, `type/enum/*`, `type/scalar` and `versioning/returnTypeChangedFrom`
1271
+ * had been pinned on in the conformance corpus.
1272
+ *
1273
+ * A scalar, an enum, a union or a literal body is therefore NAMED, which is the treatment an
1274
+ * indexed or optional body already gets and for the same reason: the merge cannot express it.
1275
+ *
1276
+ * **Except a `bytes` body**, which {@link EmittedRoute.rawBodyProperty} already names, and
1277
+ * naming it twice would put the same value under two keys.
1278
+ */
1279
+ const unmergeableBody = requestType !== undefined && requestType.kind !== "Model" && rawBodyProperty === undefined;
1161
1280
  const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
1162
- optionalMultipartBody
1281
+ optionalMultipartBody ||
1282
+ unmergeableBody
1163
1283
  ? (bodyParameter?.property?.name ?? "body")
1164
1284
  : undefined;
1165
1285
  const authentication = authenticationFor(program, operation);
1286
+ const template = routeTemplateOf(operation.uriTemplate, operation.parameters.parameters
1287
+ .filter((parameter) => parameter.type === "path")
1288
+ .map((parameter) => ({
1289
+ name: parameter.name,
1290
+ optional: parameter.param.optional,
1291
+ reserved: parameter.type === "path" && parameter.allowReserved,
1292
+ })));
1166
1293
  routes.push({
1167
1294
  operationId: operationIdOf(program, operation.operation),
1168
1295
  verb: operation.verb.toUpperCase(),
@@ -1188,10 +1315,16 @@ export function collectRoutes(program, registry, service) {
1188
1315
  reservedPathParameters: operation.parameters.parameters
1189
1316
  .filter((parameter) => parameter.type === "path" && parameter.allowReserved)
1190
1317
  .map((parameter) => parameter.name),
1191
- statusCode,
1192
- statusCodes,
1193
- responseContentTypes: responseContentTypesOf(operation, statusCode),
1318
+ pathSegments: template.segments,
1319
+ literalQuery: template.literalQuery,
1320
+ responseContentTypes: statusCode === undefined ? [] : responseContentTypesOf(operation, statusCode),
1194
1321
  requestContentTypes: [...(operation.parameters.body?.contentTypes ?? [])],
1322
+ // `isRawBinaryMediaType` is "every declared type is non-JSON", which is the same question
1323
+ // here: a string body under `application/json` IS json, and its reader is the JSON one.
1324
+ requestTextual: requestType !== undefined &&
1325
+ rawBodyProperty === undefined &&
1326
+ isStringType(requestType) &&
1327
+ isRawBinaryMediaType([...(operation.parameters.body?.contentTypes ?? [])]),
1195
1328
  /**
1196
1329
  * `@summary` first, `@doc` as the fallback.
1197
1330
  *
@@ -1213,56 +1346,35 @@ export function collectRoutes(program, registry, service) {
1213
1346
  // such route mounted with nothing checking the request at all.
1214
1347
  const parameterBody = operation.parameters.body;
1215
1348
  if (parameterBody?.bodyKind === "multipart") {
1216
- return multipartSchemaOf(parameterBody, registry);
1349
+ return multipartSchemaOf(program, parameterBody, registry);
1217
1350
  }
1218
1351
  return requestType === undefined ? undefined : registry.expressionFor(requestType);
1219
1352
  }),
1220
1353
  ...(() => {
1221
- const split = withVisibility(program, requestVisibility, () => parameterSchemasOf(program, operation, registry));
1354
+ const split = withVisibility(program, requestVisibility, () => parameterSchemasOf(program, operation, registry, template.segments, template.literalQuery.map(([name]) => name)));
1222
1355
  return {
1356
+ cookieSchema: split.cookie,
1223
1357
  pathSchema: split.path,
1224
1358
  querySchema: split.query,
1359
+ queryFieldsSchema: split.queryFields,
1225
1360
  headerSchema: split.header,
1226
1361
  negotiatedHeaderSchema: split.negotiatedHeader,
1227
1362
  accept: split.accept,
1228
1363
  };
1229
1364
  })(),
1230
- responseSchema: responseType === undefined
1231
- ? undefined
1232
- : withVisibility(program, Visibility.Read, () =>
1233
- /**
1234
- * A raw binary body is `z.unknown()`, matching what a binary multipart part
1235
- * already emits and what the document actually asserts - see
1236
- * {@link isRawBinaryMediaType}.
1237
- */
1238
- isBinaryPart(responseType) &&
1239
- isRawBinaryMediaType(responseContentTypesOf(operation, statusCode))
1240
- ? "z.unknown()"
1241
- : registry.expressionFor(responseType)),
1365
+ /**
1366
+ * Read at `Visibility.Read`, as openapi3 reads a response, and after the request walks so
1367
+ * declarations keep the order they had. Resolving responses under the ambient request
1368
+ * visibility declared six components under the wrong suffix - measured:
1369
+ * type/model/visibility gained six divergences.
1370
+ */
1371
+ responses: withVisibility(program, Visibility.Read, () => responsesOf(program, operation, registry)),
1242
1372
  rawBodyProperty,
1243
1373
  bodyProperty,
1244
1374
  optionalBody,
1245
- // Responses are read at Visibility.Read, exactly as the success body above is. Resolving
1246
- // them under the ambient request visibility declared six components under the wrong
1247
- // suffix - measured: type/model/visibility gained six divergences.
1248
- errorArms: withVisibility(program, Visibility.Read, () => errorArmsOf(program, operation, registry)),
1249
- responseHeaders: responseHeadersOf(program, operation),
1250
- responseMediaTypes: responseMediaTypesOf(operation),
1251
1375
  scopes: authentication.scopes,
1252
1376
  security: authentication.security,
1253
- noAuth: authentication.noAuth,
1254
- statusBy: discriminator,
1255
- statusSelector,
1256
- /**
1257
- * **Each arm is validated against its OWN shape.**
1258
- *
1259
- * A consumer checks its value against the arm's schema, and with one schema
1260
- * for two statuses the acknowledged response was checked against the resolved arm's
1261
- * shape - a 502 on a perfectly good 202.
1262
- */
1263
- alternateResponseSchema: discriminator === undefined
1264
- ? undefined
1265
- : alternateSchemaFor(operation, discriminator.status, registry),
1377
+ authentication: authentication.authentication,
1266
1378
  });
1267
1379
  }
1268
1380
  }
@@ -1344,7 +1456,7 @@ function renderExternalImports(externals, ...bodies) {
1344
1456
  function compiled(expression, on) {
1345
1457
  return on ? `z.compile(${expression})` : expression;
1346
1458
  }
1347
- function renderSchemas(registry, externals, routeDeclarations, runtimeModule, compileSchemas) {
1459
+ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
1348
1460
  const declarations = registry.declarations().map((d) => {
1349
1461
  /**
1350
1462
  * **A declaration on a CYCLE cannot take its type from `z.infer`, because that is the loop.**
@@ -1395,13 +1507,30 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule, co
1395
1507
  * parse - the walk captures the reference while it is still in the temporal dead zone, so the
1396
1508
  * failure surfaces at request time rather than at import.
1397
1509
  *
1398
- * **Keyed on `annotated`, the fact the registry already holds** - it is set exactly when the
1399
- * declaration is deferred, which is exactly when `z.lazy()` is written - rather than on the
1400
- * shape of the emitted text. A model on a cycle whose back edge sits on a GETTER is a different
1401
- * case and is compiled like any other: measured, it parses, because a getter is not resolved
1402
- * until something reads it.
1510
+ * **CYCLE MEMBERSHIP decides this, not deferral, and that took a zod upgrade to learn.**
1511
+ *
1512
+ * It was keyed on `annotated` - set exactly when the declaration is deferred, which is exactly
1513
+ * when `z.lazy()` is written - on the measurement that a model whose back edge sits on a GETTER
1514
+ * compiled and parsed fine. That was true on zod 4.5.2 and is false on 4.6.5, which made the
1515
+ * recursive parse state weak. The failure moved from the deferred declaration to the one that
1516
+ * REFERENCES it, which was still being compiled:
1517
+ *
1518
+ * ```
1519
+ * const branchSchema = z.lazy(() => z.union([nodeSchema, z.string()]));
1520
+ * const nodeSchema = z.compile(z.strictObject({ ..., branch: branchSchema.exactOptional() }));
1521
+ * nodeSchema.safeParse({ label: "a", attributes: {} });
1522
+ * zod 4.5.2: true
1523
+ * zod 4.6.5: TypeError: Cannot read properties of undefined (reading '_zod')
1524
+ * ```
1525
+ *
1526
+ * Bisected: naming a compiled schema from inside a `z.lazy()` body is the whole trigger, and
1527
+ * the self-referencing getter is not needed for it. So the same rule the registry already
1528
+ * applies to structural types applies here - every member of the cycle, not only the deferred
1529
+ * one - and `structural` is the fact it already holds for exactly that set.
1530
+ *
1531
+ * The peer range admits 4.6, so this reached a running server on the first parse.
1403
1532
  */
1404
- return `\n${declared}\nexport const ${d.identifier}${annotation} = ${compiled(d.source, compileSchemas && d.annotated !== true)};\n`;
1533
+ return `\n${declared}\nexport const ${d.identifier}${annotation} = ${compiled(d.source, compileSchemas && d.structural === undefined)};\n`;
1405
1534
  });
1406
1535
  /**
1407
1536
  * **Written only when something in the file names it**, like the `ResponseArm` import below and for
@@ -1440,20 +1569,45 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule, co
1440
1569
  */
1441
1570
  const usesZod = new Set([...declarations, ...routeDeclarations].join("").match(/[A-Za-z_$][\w$]*/g) ?? []).has("z");
1442
1571
  const parts = [generatedBanner()];
1443
- if (usesZod)
1572
+ if (usesZod || routeDeclarations.length > 0)
1444
1573
  parts.push('\nimport { z } from "zod";\n');
1445
1574
  /**
1446
- * A TYPE import, so nothing of this package's survives into the emitted JavaScript.
1575
+ * **The arm shape is declared IN this file, not imported, and that is what lets two emitters share
1576
+ * it.** It used to be `import type { ResponseArm } from <runtime-module>`, the one line of this
1577
+ * file that depended on where a runtime lived. A server emitter and a tool emitter pointed at one
1578
+ * output directory both write `schemas.gen.ts`, and `emitFile` is a silent last-writer-wins, so the
1579
+ * two had to agree on that specifier or the order of `emit:` decided which import survived. With
1580
+ * the shape declared here the file depends on no runtime at all, and `runtime-module` had nothing
1581
+ * left to decide.
1447
1582
  *
1448
- * The annotation is what makes a misspelled or omitted `schema` a compile error rather than an arm
1449
- * that silently validates nothing - see the option's own docblock for the measurement. Emitted only
1450
- * when there are arms to annotate, because an unused import fails the lint a generated file has to
1583
+ * The annotation is still what makes a misspelled or omitted `schema` a compile error rather than
1584
+ * an arm that silently validates nothing. Not exported: the type an application reads an arm
1585
+ * through is `typespec-http-zod/runtime`'s, which is structurally the same. Emitted only where
1586
+ * there are arms to annotate, because an unused declaration fails the lint a generated file has to
1451
1587
  * pass like any other.
1452
1588
  */
1589
+ parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
1590
+ /**
1591
+ * **The RFC 6570 decoders are declared here, once, and only where a validator calls them**, for the
1592
+ * reason `z` is imported only when used: an unused declaration fails the lint a generated file has
1593
+ * to pass. Declared rather than imported, so this file still depends on no runtime.
1594
+ */
1595
+ const referenced = identifiersIn(routeDeclarations.join(""));
1596
+ if (referenced.has("uriExpansion"))
1597
+ parts.push(URI_EXPANSION_SOURCE);
1598
+ if (referenced.has("explodedQuery"))
1599
+ parts.push(EXPLODED_QUERY_SOURCE);
1453
1600
  if (routeDeclarations.length > 0) {
1454
- parts.push(`import type { ResponseArm } from ${JSON.stringify(runtimeModule)};\n`);
1601
+ parts.push(`
1602
+ /** One declared response: its status key, its body's schema, its media types and its headers. */
1603
+ type ResponseArm = {
1604
+ readonly status: number | "default" | \`\${1 | 2 | 3 | 4 | 5}XX\`;
1605
+ readonly schema: z.ZodType | undefined;
1606
+ readonly contentTypes?: readonly string[];
1607
+ readonly headers?: readonly { readonly name: string; readonly optional: boolean }[];
1608
+ };
1609
+ `);
1455
1610
  }
1456
- parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
1457
1611
  parts.push(declarations.join(""));
1458
1612
  if (routeDeclarations.length > 0)
1459
1613
  parts.push(`\n${routeDeclarations.join("\n")}\n`);
@@ -1542,129 +1696,89 @@ function nameRouteSchemas(routes, compileSchemas) {
1542
1696
  };
1543
1697
  // Order follows the request: what identifies the resource, then what filters it, then its body.
1544
1698
  const path = declare("Path", route.pathSchema);
1545
- const query = declare("Query", route.querySchema);
1699
+ /**
1700
+ * Where a query group is gathered, its object is declared on its own and the wire validator wraps
1701
+ * that identifier, so both have a name and only the second pipes.
1702
+ */
1703
+ const gathers = route.queryFieldsSchema !== undefined && route.querySchema !== route.queryFieldsSchema;
1704
+ const queryFields = gathers ? declare("QueryFields", route.queryFieldsSchema) : undefined;
1705
+ const query = declare("Query", gathers && queryFields !== undefined && route.queryFieldsSchema !== undefined
1706
+ ? route.querySchema?.replace(route.queryFieldsSchema, queryFields)
1707
+ : route.querySchema);
1708
+ const cookie = declare("Cookie", route.cookieSchema);
1546
1709
  const negotiating = shared.has(`${route.verb} ${route.path}`);
1547
1710
  const header = declare("Header", negotiating ? route.negotiatedHeaderSchema : route.headerSchema);
1548
1711
  // A raw body is read as text, never parsed, so it has no validator - the bytes are the contract.
1549
1712
  const body = route.rawBodyProperty === undefined ? declare("Body", route.requestSchema) : undefined;
1550
- const response = declare("Response", route.responseSchema);
1551
- const alternateResponse = declare("AlternateResponse", route.alternateResponseSchema);
1713
+ /**
1714
+ * One identifier per distinct body, suffixed by the status that first carries it. Two statuses
1715
+ * sharing one inline body share its declaration rather than emitting it twice.
1716
+ */
1717
+ const declaredBodies = new Map();
1718
+ const arms = route.responses.map((response) => {
1719
+ const suffix = response.status === "default" ? "Default" : String(response.status);
1720
+ /**
1721
+ * The headers this status declares, as one object schema, so a server can check what it is
1722
+ * about to send the way it already checks the body.
1723
+ *
1724
+ * **A plain `z.object`, which strips.** A response carries headers the document does not
1725
+ * declare - `Content-Type` among them, which the server sets itself - and those are not a
1726
+ * contract violation. What is checked is that every header the document DOES declare carries
1727
+ * a value the document permits.
1728
+ */
1729
+ const headers = response.headers.length === 0
1730
+ ? undefined
1731
+ : declare(`Response${suffix}Headers`, `z.object({\n${response.headers
1732
+ .map((header) => `\t${objectKey(header.name)}: ${header.schema},`)
1733
+ .join("\n")}\n})`);
1734
+ if (response.schema === undefined) {
1735
+ return { status: response.status, schema: undefined, headers };
1736
+ }
1737
+ if (!declaredBodies.has(response.schema)) {
1738
+ declaredBodies.set(response.schema, declare(`Response${suffix}`, response.schema));
1739
+ }
1740
+ return { status: response.status, schema: declaredBodies.get(response.schema), headers };
1741
+ });
1552
1742
  const responses = schemaConst(route.operationId, "Responses");
1553
- declarations.push(`export const ${responses} = ${responseArmsOf(route, response, alternateResponse)} satisfies readonly ResponseArm[];`);
1743
+ declarations.push(`export const ${responses} = ${responseArmsOf(route, arms)} satisfies readonly ResponseArm[];`);
1554
1744
  names.set(route.operationId, {
1555
1745
  operationId: route.operationId,
1556
1746
  path,
1557
1747
  query,
1748
+ queryFields: gathers ? queryFields : query,
1558
1749
  header,
1750
+ cookie,
1559
1751
  body,
1560
- response,
1561
- alternateResponse,
1752
+ arms,
1562
1753
  responses,
1563
1754
  });
1564
1755
  }
1565
1756
  return { names, declarations };
1566
1757
  }
1567
1758
  /**
1568
- * What an operation may answer with, as a literal - each declared status paired with the body the
1569
- * document gives it, in OpenAPI's own precedence order.
1759
+ * What an operation may answer with, as a literal - each declared status with the body, media types
1760
+ * and headers the document gives it, in OpenAPI's own precedence order.
1761
+ *
1762
+ * **Rendered from one record per status, so no arm can shadow another.** The primary success arm and
1763
+ * the failure arms used to be rendered from separate projections and then deduplicated by matching
1764
+ * the emitted text for `status:`, which is how a redirect came to be declared twice and how a
1765
+ * failure arm came to carry no headers while a success arm with the same headers did.
1570
1766
  *
1571
- * **This docblock was orphaned in the source it came from**, sitting above an unrelated function
1572
- * while the one it describes had none. Reattached.
1767
+ * `contentTypes` and `headers` are written only where the document names some, so "none declared"
1768
+ * and "none carried" are the same state rather than two a runtime has to tell apart.
1573
1769
  */
1574
- function responseArmsOf(route, response, alternate) {
1575
- const arms = [];
1576
- const primary = response ?? "undefined";
1577
- /**
1578
- * The headers this status declares, rendered onto the arm that answers with it.
1579
- *
1580
- * Emitted only where there are some: an arm for a response declaring none must not acquire an
1581
- * empty list, or every `respond` implementation has to tell "none declared" from "none carried".
1582
- */
1583
- /**
1584
- * The media types this status offers.
1585
- *
1586
- * **Emitted whenever the document names any, including a single one.** It was emitted only for
1587
- * several, on the reasoning that one type is what a `respond` already assumes - and that reasoning
1588
- * was wrong, because it assumed the one type is JSON. A `text/plain` arm and a JSON arm were then
1589
- * indistinguishable to a generic `respond`: the document knew and the runtime did not, so consumers
1590
- * restated the media type in their own result envelope to get it back.
1591
- */
1592
- const mediaTypesFor = (status) => {
1593
- const declared = route.responseMediaTypes.find((entry) => entry.status === status)?.contentTypes ?? [];
1594
- if (declared.length === 0)
1595
- return "";
1596
- return `, contentTypes: [${declared.map((t) => JSON.stringify(t)).join(", ")}]`;
1597
- };
1598
- const headersFor = (status) => {
1599
- const declared = route.responseHeaders.find((entry) => entry.status === status)?.headers ?? [];
1600
- if (declared.length === 0)
1601
- return "";
1602
- const rendered = declared
1603
- .map((h) => `{ name: ${JSON.stringify(h.name)}, property: ${JSON.stringify(h.property)} }`)
1604
- .join(", ");
1605
- return `, headers: [${rendered}]`;
1606
- };
1607
- if (route.statusSelector !== undefined) {
1608
- /**
1609
- * **One arm per declared status, keyed on the property the spec already named.**
1610
- *
1611
- * The selector arms come first and the default last, the same order the discriminator below
1612
- * uses: anything scanning for a match wants the specific ones before the fallback. Every arm
1613
- * carries the same schema because a `@statusCode` union is ONE model - the statuses differ,
1614
- * the body does not.
1615
- */
1616
- const { property, statuses } = route.statusSelector;
1617
- for (const status of statuses.filter((candidate) => candidate !== route.statusCode)) {
1618
- arms.push(`{ status: ${status}, schema: ${primary}${mediaTypesFor(status)}${headersFor(status)}, when: { property: ${JSON.stringify(property)}, value: ${status} } }`);
1619
- }
1620
- arms.push(`{ status: ${route.statusCode}, schema: ${primary}${mediaTypesFor(route.statusCode)}${headersFor(route.statusCode)} }`);
1621
- }
1622
- else if (route.statusBy === undefined) {
1623
- arms.push(`{ status: ${route.statusCode}, schema: ${primary}${mediaTypesFor(route.statusCode)}${headersFor(route.statusCode)} }`);
1624
- }
1625
- else {
1626
- const alternateSchema = alternate ?? "undefined";
1627
- const { property, value, status } = route.statusBy;
1628
- arms.push(`{ status: ${status}, schema: ${alternateSchema}${mediaTypesFor(status)}${headersFor(status)}, when: { property: ${JSON.stringify(property)}, value: ${JSON.stringify(value)} } }`, `{ status: ${route.statusCode}, schema: ${primary}${mediaTypesFor(route.statusCode)}${headersFor(route.statusCode)} }`);
1629
- }
1630
- /**
1631
- * The failure arms, which used to be dropped entirely.
1632
- *
1633
- * **The document declares what an error body looks like and the server never passed it on**, so
1634
- * `respond` could not check what it sends on failure even in principle.
1635
- *
1636
- * **This used to fall back to the service-wide error schema, and the fallback was the defect.**
1637
- * `arm.schema ?? errorSchema` was reached by every arm the emitter could not resolve a body for -
1638
- * which, while `"default"` was deliberately given none, meant every catch-all in every service
1639
- * answered with whichever shape `errorBodyOf` had settled on rather than the one its own response
1640
- * declares. Each arm resolves its own body now, so an arm with no schema is a response the document
1641
- * says carries no body, and saying `undefined` is the truthful answer rather than a guess.
1642
- */
1643
- /**
1644
- * **An error arm whose status the primary arm already answers is dropped, primary winning.**
1645
- *
1646
- * `errorArmsOf` excludes 2xx from the failure arms, so widening the primary to include 3xx made a
1647
- * redirect BOTH: `[{ status: 302, schema: undefined, headers: [...] }, { status: 302, schema:
1648
- * undefined }]`, two arms for one status where the second shadows the first's headers for anything
1649
- * scanning past the first match.
1650
- *
1651
- * Widening `isSuccessKey` to match would be wrong in the other direction: an operation declaring a
1652
- * 200 AND a 302 has 200 as its primary, so the 302 must stay a failure arm to be emitted at all.
1653
- * Deduplicating here keeps both cases, because it asks what was actually emitted rather than what
1654
- * might have been.
1655
- */
1656
- const emitted = new Set();
1657
- for (const arm of arms) {
1658
- const status = /status: ([^,}]+)/.exec(arm)?.[1]?.trim();
1659
- if (status !== undefined)
1660
- emitted.add(status);
1661
- }
1662
- for (const arm of route.errorArms) {
1663
- if (emitted.has(String(arm.status)) || emitted.has(JSON.stringify(arm.status)))
1664
- continue;
1665
- arms.push(`{ status: ${JSON.stringify(arm.status)}, schema: ${arm.schema ?? "undefined"} }`);
1666
- }
1667
- return `[${arms.join(", ")}]`;
1770
+ function responseArmsOf(route, arms) {
1771
+ const rendered = route.responses.map((response, index) => {
1772
+ const schema = arms[index]?.schema ?? "undefined";
1773
+ const contentTypes = response.contentTypes.length === 0
1774
+ ? ""
1775
+ : `, contentTypes: [${response.contentTypes.map((type) => JSON.stringify(type)).join(", ")}]`;
1776
+ const headers = response.headers.length === 0
1777
+ ? ""
1778
+ : `, headers: [${response.headers.map((header) => `{ name: ${JSON.stringify(header.name)}, optional: ${header.optional} }`).join(", ")}]`;
1779
+ return `{ status: ${JSON.stringify(response.status)}, schema: ${schema}${contentTypes}${headers} }`;
1780
+ });
1781
+ return `[${rendered.join(", ")}]`;
1668
1782
  }
1669
1783
  const upperFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
1670
1784
  /**
@@ -1716,7 +1830,9 @@ function collectRequestTypes(program, registry, service) {
1716
1830
  const extraProperties = operation.parameters.parameters
1717
1831
  .filter((parameter) => parameter.type === "path" ||
1718
1832
  parameter.type === "query" ||
1719
- parameter.type === "header")
1833
+ parameter.type === "header" ||
1834
+ // The second half of the same omission: a validator with no matching type.
1835
+ parameter.type === "cookie")
1720
1836
  /**
1721
1837
  * Named as the WIRE names it, which is what the emitted validator keys on.
1722
1838
  * `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
@@ -2033,7 +2149,7 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
2033
2149
  * that does not.
2034
2150
  */
2035
2151
  /**
2036
- * \u26a0\ufe0f **An optional property is \`?: T\` here and \`?: T | undefined\` in the contract type, and that is
2152
+ * **An optional property is \`?: T\` here and \`?: T | undefined\` in the contract type, and that is
2037
2153
  * deliberate on both sides.**
2038
2154
  *
2039
2155
  * The validator emits \`.exactOptional()\`, so what ARRIVES is absent or a value - JSON cannot carry an
@@ -2096,8 +2212,8 @@ type Declared<T> = T extends (...args: never[]) => unknown
2096
2212
  : T;
2097
2213
 
2098
2214
  /**
2099
- * \u26a0\ufe0f **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
2100
- * type when the two disagree, and a type alias to an odd type compiles fine \u2014 66 assertions that
2215
+ * **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
2216
+ * type when the two disagree, and a type alias to an odd type compiles fine - 66 assertions that
2101
2217
  * assert nothing. Only a constraint violation is an error.
2102
2218
  */
2103
2219
  type MustHold<T extends true> = T;
@@ -2129,8 +2245,6 @@ ${entries.join("\n")}
2129
2245
  } as const;
2130
2246
  `;
2131
2247
  }
2132
- /** What the generated files import their runtime contract from when nothing says otherwise. */
2133
- export const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
2134
2248
  /**
2135
2249
  * Emit this library's artefacts for every `@service` in the program, and hand back what was emitted.
2136
2250
  *
@@ -2185,9 +2299,8 @@ function reportTruncatedDocComments(program) {
2185
2299
  scalar: check,
2186
2300
  });
2187
2301
  }
2188
- export async function emitHttpZod(context, wrapper = {}) {
2302
+ export async function emitHttpZod(context) {
2189
2303
  const options = context.options;
2190
- const defaultRuntimeModule = wrapper.defaultRuntimeModule ?? DEFAULT_RUNTIME_MODULE;
2191
2304
  /**
2192
2305
  * Each service already projected to the version it currently serves - see `versioning.ts`.
2193
2306
  *
@@ -2279,7 +2392,6 @@ export async function emitHttpZod(context, wrapper = {}) {
2279
2392
  sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
2280
2393
  compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
2281
2394
  keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
2282
- runtimeModule: perService?.["runtime-module"] ?? options["runtime-module"] ?? defaultRuntimeModule,
2283
2395
  regenerateHint: perService?.["regenerate-hint"] ?? options["regenerate-hint"],
2284
2396
  };
2285
2397
  beginBanner(resolved.regenerateHint);
@@ -2337,7 +2449,7 @@ export async function emitHttpZod(context, wrapper = {}) {
2337
2449
  const { names, declarations } = nameRouteSchemas(routes, resolved.compileSchemas);
2338
2450
  await emitFile(context.program, {
2339
2451
  path: resolvePath(outputDir, "schemas.gen.ts"),
2340
- content: renderSchemas(registry, externals, declarations, resolved.runtimeModule, resolved.compileSchemas),
2452
+ content: renderSchemas(registry, externals, declarations, resolved.compileSchemas),
2341
2453
  });
2342
2454
  if (resolved.contractsOutputDir !== undefined) {
2343
2455
  const types = new TypeRegistry(context.program);