typespec-http-zod 0.25.1 → 0.26.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,77 @@ 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.
279
+ *
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.
305
290
  *
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.
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
+ optional: property.optional,
320
+ });
328
321
  }
329
322
  }
330
- }
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
- }
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));
323
+ if (streamedTypeOf(program, response.type) !== undefined)
324
+ entry.streamed = true;
325
+ byStatus.set(key, entry);
362
326
  }
363
327
  }
364
- return [...arms]
365
- .map(([status, schema]) => ({ status, schema }))
366
- .toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status));
328
+ return [...byStatus.values()]
329
+ .map((entry) => {
330
+ const contentTypes = [...entry.contentTypes];
331
+ const binary = entry.body !== undefined && isBinaryPart(entry.body) && isRawBinaryMediaType(contentTypes);
332
+ return {
333
+ status: entry.status,
334
+ schema: entry.body === undefined
335
+ ? undefined
336
+ : binary
337
+ ? "z.unknown()"
338
+ : registry.expressionFor(entry.body),
339
+ contentTypes,
340
+ headers: entry.headers,
341
+ binary,
342
+ streamed: entry.streamed,
343
+ textual: entry.body !== undefined && isStringType(entry.body),
344
+ };
345
+ })
346
+ .toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status) ||
347
+ statusOrderOf(a.status) - statusOrderOf(b.status));
367
348
  }
368
349
  /**
369
350
  * Where an arm sits in OpenAPI's own precedence order.
@@ -380,21 +361,40 @@ function statusPrecedenceOf(status) {
380
361
  return 2;
381
362
  return typeof status === "number" ? 0 : 1;
382
363
  }
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);
364
+ /**
365
+ * Whether a type's values are strings: `string` or a scalar built on it, a string literal or template,
366
+ * or a union of those.
367
+ *
368
+ * Read from the type rather than from the emitted schema, which would be a decision made by matching
369
+ * generated text - `z.iso.datetime()` is a string and does not begin with `z.string`.
370
+ */
371
+ function isStringType(type) {
372
+ switch (type.kind) {
373
+ case "String":
374
+ case "StringTemplate":
375
+ return true;
376
+ case "Scalar":
377
+ for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
378
+ if (scalar.name === "string")
379
+ return true;
380
+ }
381
+ return false;
382
+ case "Union":
383
+ return [...type.variants.values()].every((variant) => isStringType(variant.type));
384
+ default:
385
+ return false;
391
386
  }
392
- return undefined;
387
+ }
388
+ /** Ascending within one precedence class, so the order does not depend on declaration order. */
389
+ function statusOrderOf(status) {
390
+ if (typeof status === "number")
391
+ return status;
392
+ return status === "default" ? 0 : Number(status.charAt(0));
393
393
  }
394
394
  /**
395
395
  * Everything the document says about authenticating an operation, read ONCE.
396
396
  *
397
- * **`scopes` and `noAuth` were two lossy projections of this, each with its own read of the same
397
+ * **`scopes` and a boolean `noAuth` were two lossy projections of this, each with its own read of the same
398
398
  * fact.** A flat scope union cannot say which scheme demanded what, so an emitter that wanted the
399
399
  * gate the document actually publishes had to call `getAuthenticationForOperation` a third time and
400
400
  * rebuild the requirements itself -- which is what both server emitters were doing, in two
@@ -406,7 +406,6 @@ function bodySchemaOf(response, registry) {
406
406
  function authenticationFor(program, operation) {
407
407
  const options = getAuthenticationForOperation(program, operation.operation)?.options ?? [];
408
408
  const security = [];
409
- let noAuth = false;
410
409
  for (const option of options) {
411
410
  const requirement = {};
412
411
  let anonymous = false;
@@ -418,7 +417,6 @@ function authenticationFor(program, operation) {
418
417
  */
419
418
  if (scheme.type === "noAuth") {
420
419
  anonymous = true;
421
- noAuth = true;
422
420
  continue;
423
421
  }
424
422
  /** Scopes belong to the flows of an OAuth2 scheme; every other kind has none. */
@@ -427,13 +425,24 @@ function authenticationFor(program, operation) {
427
425
  ? [...new Set(scheme.flows.flatMap((flow) => flow.scopes.map((scope) => scope.value)))]
428
426
  : [];
429
427
  }
430
- if (anonymous && Object.keys(requirement).length === 0)
431
- continue;
432
- if (Object.keys(requirement).length > 0)
428
+ /**
429
+ * **An option that needs nothing is published as `{}`, and kept.** `NoAuth | X` is
430
+ * `security: [{}, { X: [] }]` in the document `@typespec/openapi3` writes from the same program,
431
+ * and `{}` is the requirement every caller satisfies. Dropping it published `[{ X: [] }]`, a
432
+ * contract that refuses the anonymous caller the document accepts; typespec-hono had carried a
433
+ * corrected copy of this rule of its own because this one was wrong.
434
+ */
435
+ if (anonymous || Object.keys(requirement).length > 0)
433
436
  security.push(requirement);
434
437
  }
435
438
  const scopes = [...new Set(security.flatMap((requirement) => Object.values(requirement).flat()))];
436
- return { security, scopes, noAuth };
439
+ const anonymous = security.some((requirement) => Object.keys(requirement).length === 0);
440
+ const real = security.some((requirement) => Object.keys(requirement).length > 0);
441
+ return {
442
+ security,
443
+ scopes,
444
+ authentication: !real ? "none" : anonymous ? "optional" : "required",
445
+ };
437
446
  }
438
447
  /**
439
448
  * The body every `@error` response in the service declares, when they all declare the same one.
@@ -591,70 +600,6 @@ function elementKindOf(program, type) {
591
600
  function isArrayType(type) {
592
601
  return type.kind === "Model" && type.indexer?.key.name === "integer";
593
602
  }
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
603
  /**
659
604
  * The Zod for a **multipart** request body, built from its parts.
660
605
  *
@@ -701,16 +646,26 @@ function multipartTsOf(body, registry) {
701
646
  });
702
647
  return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
703
648
  }
704
- function multipartSchemaOf(body, registry) {
649
+ function multipartSchemaOf(program, body, registry) {
705
650
  const entries = body.parts.map((part) => {
706
651
  const type = part.body?.type;
652
+ /**
653
+ * **A form carries text, so a part that means a number or a boolean is decoded first**, by the
654
+ * same rule {@link wireDecoded} applies to path, query and header values: `"0.5"` becomes
655
+ * `0.5`, and text that is not a well-formed number is passed on unchanged to fail against the
656
+ * schema the document publishes. `c.req.parseBody()` hands a text part over as a string, so
657
+ * `z.number()` alone refused every conformant request; measured on `@typespec/http-specs`
658
+ * `payload/multipart/non-string-float`, a 400 for the request the scenario documents.
659
+ */
707
660
  const inner = type === undefined
708
661
  ? "z.unknown()"
709
662
  : isFilePart(type)
710
663
  ? `z.unknown().refine(${MULTIPART_FILE_REFINEMENT})`
711
664
  : isBinaryPart(type)
712
665
  ? "z.unknown()"
713
- : registry.expressionFor(type);
666
+ : isJsonPart(part)
667
+ ? jsonTextDecoded(registry.expressionFor(type))
668
+ : wireDecoded(registry.expressionFor(type), wireKindOf(program, type), elementKindOf(program, type));
714
669
  // `multi` is `HttpPart<T>[]` - the same part repeated, which arrives as an array.
715
670
  const value = part.multi === true ? `z.array(${inner})` : inner;
716
671
  // `.exactOptional()` for the same reason as a model property: `parseBody` yields present keys
@@ -782,6 +737,27 @@ function isFilePart(type) {
782
737
  return false;
783
738
  }
784
739
  /** `bytes`, or a model built on `File` - the parts openapi3 publishes as an empty schema. */
740
+ /**
741
+ * Whether a multipart part travels as JSON: every media type `@typespec/http` resolves for it is a
742
+ * JSON one. That is `application/json` for a model or array part, and whatever an explicit
743
+ * `@header contentType` names for a part that declares one.
744
+ */
745
+ function isJsonPart(part) {
746
+ const contentTypes = part.body?.contentTypes ?? [];
747
+ return (contentTypes.length > 0 &&
748
+ contentTypes.every((contentType) => /^application\/(?:[\w.+-]+\+)?json$/i.test(contentType)));
749
+ }
750
+ /**
751
+ * **A JSON part is decoded from the JSON text a form carries** before the document's schema applies.
752
+ * A client writes the part as text with `Content-Type: application/json` and no filename, so
753
+ * `c.req.parseBody()` hands over a string; the schema alone refused every such request with
754
+ * `expected object, received string`. Text that is not JSON is an issue of its own rather than a
755
+ * string passed on, so a part whose schema admits a string still refuses malformed JSON. A value that
756
+ * is not text is left alone for the schema to judge.
757
+ */
758
+ function jsonTextDecoded(declared) {
759
+ 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})`;
760
+ }
785
761
  function isBinaryPart(type) {
786
762
  if (type.kind === "Scalar") {
787
763
  for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
@@ -996,9 +972,16 @@ function mediaTypeDecoded(declared) {
996
972
  const normalise = values.every((value) => value === value.toLowerCase()) ? ".toLowerCase()" : "";
997
973
  return `z.preprocess((raw) => (typeof raw === "string" ? (raw.split(";")[0] ?? "").trim()${normalise} : raw), ${declared})`;
998
974
  }
999
- function parameterSchemasOf(program, operation, registry) {
975
+ function parameterSchemasOf(program, operation, registry, segments,
976
+ /** The names of a query string written into the route itself, which no parameter may gather. */
977
+ literalQueryNames) {
1000
978
  const byTarget = {};
1001
979
  let accept;
980
+ /** Form-exploded records and models, gathered back under their names around the query object. */
981
+ const gathered = [];
982
+ const queryNames = operation.parameters.parameters
983
+ .filter((parameter) => parameter.type === "query")
984
+ .map((parameter) => parameter.name);
1002
985
  for (const parameter of operation.parameters.parameters) {
1003
986
  if (parameter.type !== "path" && parameter.type !== "query" && parameter.type !== "header") {
1004
987
  continue;
@@ -1046,11 +1029,54 @@ function parameterSchemasOf(program, operation, registry) {
1046
1029
  * occurrences arrive already an array and pass through untouched, exactly as the delimiter
1047
1030
  * arm leaves a pre-split value alone.
1048
1031
  */
1049
- const expression = isExplodedCollection(parameter)
1032
+ const boxedOrSplit = isExplodedCollection(parameter)
1050
1033
  ? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
1051
1034
  : delimiter === undefined
1052
1035
  ? decoded
1053
1036
  : `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${JSON.stringify(delimiter)}) : raw), ${decoded})`;
1037
+ /**
1038
+ * **An RFC 6570 expansion is undone OUTSIDE everything above**, because it is the first thing the
1039
+ * wire did: `array.a.b` becomes `["a", "b"]`, then the element decoder and the document's schema
1040
+ * run on that. See {@link uriExpansionCall}.
1041
+ */
1042
+ const shape = expansionShapeOf(parameter.param.type);
1043
+ const values = recordValuesOf(program, parameter.param.type);
1044
+ let expression = boxedOrSplit;
1045
+ if (parameter.type === "path") {
1046
+ const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
1047
+ if (segment !== undefined && needsExpansionDecoding(segment, shape)) {
1048
+ expression = `z.preprocess(${uriExpansionCall({ ...segment, name: parameter.name, shape, values })}, ${decoded})`;
1049
+ }
1050
+ }
1051
+ else if (parameter.type === "query" && shape === "record") {
1052
+ if (parameter.explode === true) {
1053
+ /**
1054
+ * `{?param*}` over an object expands to the object's own keys, so there is no `param` key for
1055
+ * this entry to read; the gatherer around the query object puts one back.
1056
+ */
1057
+ const model = parameter.param.type.kind === "Model" && parameter.param.type.indexer === undefined
1058
+ ? parameter.param.type
1059
+ : undefined;
1060
+ const properties = model === undefined ? [] : modelPropertiesOf(model);
1061
+ gathered.push(explodedQueryCall({
1062
+ name: parameter.name,
1063
+ others: [...queryNames.filter((name) => name !== parameter.name), ...literalQueryNames],
1064
+ keys: model === undefined
1065
+ ? undefined
1066
+ : properties.map((property) => resolveEncodedName(program, property, "application/json")),
1067
+ numbers: properties
1068
+ .filter((property) => wireKindOf(program, property.type) === "number")
1069
+ .map((property) => resolveEncodedName(program, property, "application/json")),
1070
+ booleans: properties
1071
+ .filter((property) => wireKindOf(program, property.type) === "boolean")
1072
+ .map((property) => resolveEncodedName(program, property, "application/json")),
1073
+ values,
1074
+ }));
1075
+ }
1076
+ else {
1077
+ expression = `z.preprocess(${uriExpansionCall({ prefix: "", suffix: "", operator: "", name: parameter.name, explode: false, shape, values })}, ${decoded})`;
1078
+ }
1079
+ }
1054
1080
  const entry = `\t${objectKey(parameter.name)}: ${expression},`;
1055
1081
  (byTarget[parameter.type] ??= []).push(entry);
1056
1082
  /**
@@ -1074,24 +1100,49 @@ function parameterSchemasOf(program, operation, registry) {
1074
1100
  const entries = byTarget[target];
1075
1101
  return entries === undefined ? undefined : `z.object({\n${entries.join("\n")}\n})`;
1076
1102
  };
1103
+ const query = shape("query");
1077
1104
  return {
1078
1105
  path: shape("path"),
1079
- query: shape("query"),
1106
+ query: query === undefined
1107
+ ? undefined
1108
+ : gathered.reduce((inner, gather) => `z.preprocess(${gather}, ${inner})`, query),
1109
+ queryFields: query,
1080
1110
  header: shape("header"),
1081
1111
  negotiatedHeader: shape("header-without-accept"),
1082
1112
  accept,
1083
1113
  };
1084
1114
  }
1115
+ /** What a parameter's value decodes into once its expansion is undone. */
1116
+ function expansionShapeOf(type) {
1117
+ if (isArrayType(type))
1118
+ return "list";
1119
+ return type.kind === "Model" ? "record" : "scalar";
1120
+ }
1121
+ /** How a record's values decode, read from its indexer the way a scalar's kind is read. */
1122
+ function recordValuesOf(program, type) {
1123
+ if (type.kind !== "Model" || isArrayType(type) || type.indexer === undefined)
1124
+ return "";
1125
+ return wireKindOf(program, type.indexer.value) ?? "";
1126
+ }
1127
+ /** A model's properties, its base model's first, as the document lists them. */
1128
+ function modelPropertiesOf(model) {
1129
+ const chain = [];
1130
+ for (let current = model; current !== undefined; current = current.baseModel) {
1131
+ chain.unshift(current);
1132
+ }
1133
+ return chain.flatMap((current) => [...current.properties.values()]);
1134
+ }
1085
1135
  export function collectRoutes(program, registry, service) {
1086
1136
  const routes = [];
1087
1137
  {
1088
1138
  for (const operation of service.operations) {
1139
+ /**
1140
+ * **Every operation is a route, whatever its responses are.** This used to require an exact
1141
+ * 2xx or 3xx, which dropped an operation whose only success was a `2XX` range without a word
1142
+ * - the same silent shape as the 282 bodyless operations an earlier rule dropped, because a
1143
+ * range is not a number. A bodyless success is ordinary HTTP, and so is a ranged one.
1144
+ */
1089
1145
  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
1146
  const requestType = requestBodyOf(operation);
1096
1147
  /**
1097
1148
  * **A streamed operation's EVENT payloads are not reachable from its body.**
@@ -1110,9 +1161,6 @@ export function collectRoutes(program, registry, service) {
1110
1161
  // `@requestVisibility` when the operation states one, otherwise the verb's default - the
1111
1162
  // same resolution `@typespec/openapi3` performs before emitting a request schema.
1112
1163
  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
1164
  /**
1117
1165
  * A `bytes` body means the BYTES are the contract.
1118
1166
  *
@@ -1158,11 +1206,36 @@ export function collectRoutes(program, registry, service) {
1158
1206
  * `multipartTsOf`, both recorded above.
1159
1207
  */
1160
1208
  const optionalMultipartBody = bodyParameter?.bodyKind === "multipart" && optionalBody;
1209
+ /**
1210
+ * **A body that is not a MODEL has no properties to merge, and merging it anyway lost it.**
1211
+ *
1212
+ * `@body body: string` emitted a handler input of `Record<string, never>` and a call site of
1213
+ * `{}`: the server validated the body and then never handed it to the handler, silently, from
1214
+ * a compile that reported success. `@body body: SomeEnum` was the same rule failing loudly,
1215
+ * `TS2698: Spread types may only be created from object types`, which is what
1216
+ * `payload/media-type`, `type/enum/*`, `type/scalar` and `versioning/returnTypeChangedFrom`
1217
+ * had been pinned on in the conformance corpus.
1218
+ *
1219
+ * A scalar, an enum, a union or a literal body is therefore NAMED, which is the treatment an
1220
+ * indexed or optional body already gets and for the same reason: the merge cannot express it.
1221
+ *
1222
+ * **Except a `bytes` body**, which {@link EmittedRoute.rawBodyProperty} already names, and
1223
+ * naming it twice would put the same value under two keys.
1224
+ */
1225
+ const unmergeableBody = requestType !== undefined && requestType.kind !== "Model" && rawBodyProperty === undefined;
1161
1226
  const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
1162
- optionalMultipartBody
1227
+ optionalMultipartBody ||
1228
+ unmergeableBody
1163
1229
  ? (bodyParameter?.property?.name ?? "body")
1164
1230
  : undefined;
1165
1231
  const authentication = authenticationFor(program, operation);
1232
+ const template = routeTemplateOf(operation.uriTemplate, operation.parameters.parameters
1233
+ .filter((parameter) => parameter.type === "path")
1234
+ .map((parameter) => ({
1235
+ name: parameter.name,
1236
+ optional: parameter.param.optional,
1237
+ reserved: parameter.type === "path" && parameter.allowReserved,
1238
+ })));
1166
1239
  routes.push({
1167
1240
  operationId: operationIdOf(program, operation.operation),
1168
1241
  verb: operation.verb.toUpperCase(),
@@ -1188,10 +1261,16 @@ export function collectRoutes(program, registry, service) {
1188
1261
  reservedPathParameters: operation.parameters.parameters
1189
1262
  .filter((parameter) => parameter.type === "path" && parameter.allowReserved)
1190
1263
  .map((parameter) => parameter.name),
1191
- statusCode,
1192
- statusCodes,
1193
- responseContentTypes: responseContentTypesOf(operation, statusCode),
1264
+ pathSegments: template.segments,
1265
+ literalQuery: template.literalQuery,
1266
+ responseContentTypes: statusCode === undefined ? [] : responseContentTypesOf(operation, statusCode),
1194
1267
  requestContentTypes: [...(operation.parameters.body?.contentTypes ?? [])],
1268
+ // `isRawBinaryMediaType` is "every declared type is non-JSON", which is the same question
1269
+ // here: a string body under `application/json` IS json, and its reader is the JSON one.
1270
+ requestTextual: requestType !== undefined &&
1271
+ rawBodyProperty === undefined &&
1272
+ isStringType(requestType) &&
1273
+ isRawBinaryMediaType([...(operation.parameters.body?.contentTypes ?? [])]),
1195
1274
  /**
1196
1275
  * `@summary` first, `@doc` as the fallback.
1197
1276
  *
@@ -1213,56 +1292,34 @@ export function collectRoutes(program, registry, service) {
1213
1292
  // such route mounted with nothing checking the request at all.
1214
1293
  const parameterBody = operation.parameters.body;
1215
1294
  if (parameterBody?.bodyKind === "multipart") {
1216
- return multipartSchemaOf(parameterBody, registry);
1295
+ return multipartSchemaOf(program, parameterBody, registry);
1217
1296
  }
1218
1297
  return requestType === undefined ? undefined : registry.expressionFor(requestType);
1219
1298
  }),
1220
1299
  ...(() => {
1221
- const split = withVisibility(program, requestVisibility, () => parameterSchemasOf(program, operation, registry));
1300
+ const split = withVisibility(program, requestVisibility, () => parameterSchemasOf(program, operation, registry, template.segments, template.literalQuery.map(([name]) => name)));
1222
1301
  return {
1223
1302
  pathSchema: split.path,
1224
1303
  querySchema: split.query,
1304
+ queryFieldsSchema: split.queryFields,
1225
1305
  headerSchema: split.header,
1226
1306
  negotiatedHeaderSchema: split.negotiatedHeader,
1227
1307
  accept: split.accept,
1228
1308
  };
1229
1309
  })(),
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)),
1310
+ /**
1311
+ * Read at `Visibility.Read`, as openapi3 reads a response, and after the request walks so
1312
+ * declarations keep the order they had. Resolving responses under the ambient request
1313
+ * visibility declared six components under the wrong suffix - measured:
1314
+ * type/model/visibility gained six divergences.
1315
+ */
1316
+ responses: withVisibility(program, Visibility.Read, () => responsesOf(program, operation, registry)),
1242
1317
  rawBodyProperty,
1243
1318
  bodyProperty,
1244
1319
  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
1320
  scopes: authentication.scopes,
1252
1321
  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),
1322
+ authentication: authentication.authentication,
1266
1323
  });
1267
1324
  }
1268
1325
  }
@@ -1344,7 +1401,7 @@ function renderExternalImports(externals, ...bodies) {
1344
1401
  function compiled(expression, on) {
1345
1402
  return on ? `z.compile(${expression})` : expression;
1346
1403
  }
1347
- function renderSchemas(registry, externals, routeDeclarations, runtimeModule, compileSchemas) {
1404
+ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
1348
1405
  const declarations = registry.declarations().map((d) => {
1349
1406
  /**
1350
1407
  * **A declaration on a CYCLE cannot take its type from `z.infer`, because that is the loop.**
@@ -1440,20 +1497,45 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule, co
1440
1497
  */
1441
1498
  const usesZod = new Set([...declarations, ...routeDeclarations].join("").match(/[A-Za-z_$][\w$]*/g) ?? []).has("z");
1442
1499
  const parts = [generatedBanner()];
1443
- if (usesZod)
1500
+ if (usesZod || routeDeclarations.length > 0)
1444
1501
  parts.push('\nimport { z } from "zod";\n');
1445
1502
  /**
1446
- * A TYPE import, so nothing of this package's survives into the emitted JavaScript.
1503
+ * **The arm shape is declared IN this file, not imported, and that is what lets two emitters share
1504
+ * it.** It used to be `import type { ResponseArm } from <runtime-module>`, the one line of this
1505
+ * file that depended on where a runtime lived. A server emitter and a tool emitter pointed at one
1506
+ * output directory both write `schemas.gen.ts`, and `emitFile` is a silent last-writer-wins, so the
1507
+ * two had to agree on that specifier or the order of `emit:` decided which import survived. With
1508
+ * the shape declared here the file depends on no runtime at all, and `runtime-module` had nothing
1509
+ * left to decide.
1447
1510
  *
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
1511
+ * The annotation is still what makes a misspelled or omitted `schema` a compile error rather than
1512
+ * an arm that silently validates nothing. Not exported: the type an application reads an arm
1513
+ * through is `typespec-http-zod/runtime`'s, which is structurally the same. Emitted only where
1514
+ * there are arms to annotate, because an unused declaration fails the lint a generated file has to
1451
1515
  * pass like any other.
1452
1516
  */
1517
+ parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
1518
+ /**
1519
+ * **The RFC 6570 decoders are declared here, once, and only where a validator calls them**, for the
1520
+ * reason `z` is imported only when used: an unused declaration fails the lint a generated file has
1521
+ * to pass. Declared rather than imported, so this file still depends on no runtime.
1522
+ */
1523
+ const referenced = identifiersIn(routeDeclarations.join(""));
1524
+ if (referenced.has("uriExpansion"))
1525
+ parts.push(URI_EXPANSION_SOURCE);
1526
+ if (referenced.has("explodedQuery"))
1527
+ parts.push(EXPLODED_QUERY_SOURCE);
1453
1528
  if (routeDeclarations.length > 0) {
1454
- parts.push(`import type { ResponseArm } from ${JSON.stringify(runtimeModule)};\n`);
1529
+ parts.push(`
1530
+ /** One declared response: its status key, its body's schema, its media types and its headers. */
1531
+ type ResponseArm = {
1532
+ readonly status: number | "default" | \`\${1 | 2 | 3 | 4 | 5}XX\`;
1533
+ readonly schema: z.ZodType | undefined;
1534
+ readonly contentTypes?: readonly string[];
1535
+ readonly headers?: readonly { readonly name: string; readonly optional: boolean }[];
1536
+ };
1537
+ `);
1455
1538
  }
1456
- parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
1457
1539
  parts.push(declarations.join(""));
1458
1540
  if (routeDeclarations.length > 0)
1459
1541
  parts.push(`\n${routeDeclarations.join("\n")}\n`);
@@ -1542,129 +1624,72 @@ function nameRouteSchemas(routes, compileSchemas) {
1542
1624
  };
1543
1625
  // Order follows the request: what identifies the resource, then what filters it, then its body.
1544
1626
  const path = declare("Path", route.pathSchema);
1545
- const query = declare("Query", route.querySchema);
1627
+ /**
1628
+ * Where a query group is gathered, its object is declared on its own and the wire validator wraps
1629
+ * that identifier, so both have a name and only the second pipes.
1630
+ */
1631
+ const gathers = route.queryFieldsSchema !== undefined && route.querySchema !== route.queryFieldsSchema;
1632
+ const queryFields = gathers ? declare("QueryFields", route.queryFieldsSchema) : undefined;
1633
+ const query = declare("Query", gathers && queryFields !== undefined && route.queryFieldsSchema !== undefined
1634
+ ? route.querySchema?.replace(route.queryFieldsSchema, queryFields)
1635
+ : route.querySchema);
1546
1636
  const negotiating = shared.has(`${route.verb} ${route.path}`);
1547
1637
  const header = declare("Header", negotiating ? route.negotiatedHeaderSchema : route.headerSchema);
1548
1638
  // A raw body is read as text, never parsed, so it has no validator - the bytes are the contract.
1549
1639
  const body = route.rawBodyProperty === undefined ? declare("Body", route.requestSchema) : undefined;
1550
- const response = declare("Response", route.responseSchema);
1551
- const alternateResponse = declare("AlternateResponse", route.alternateResponseSchema);
1640
+ /**
1641
+ * One identifier per distinct body, suffixed by the status that first carries it. Two statuses
1642
+ * sharing one inline body share its declaration rather than emitting it twice.
1643
+ */
1644
+ const declaredBodies = new Map();
1645
+ const arms = route.responses.map((response) => {
1646
+ if (response.schema === undefined)
1647
+ return { status: response.status, schema: undefined };
1648
+ if (!declaredBodies.has(response.schema)) {
1649
+ const suffix = response.status === "default" ? "Default" : String(response.status);
1650
+ declaredBodies.set(response.schema, declare(`Response${suffix}`, response.schema));
1651
+ }
1652
+ return { status: response.status, schema: declaredBodies.get(response.schema) };
1653
+ });
1552
1654
  const responses = schemaConst(route.operationId, "Responses");
1553
- declarations.push(`export const ${responses} = ${responseArmsOf(route, response, alternateResponse)} satisfies readonly ResponseArm[];`);
1655
+ declarations.push(`export const ${responses} = ${responseArmsOf(route, arms)} satisfies readonly ResponseArm[];`);
1554
1656
  names.set(route.operationId, {
1555
1657
  operationId: route.operationId,
1556
1658
  path,
1557
1659
  query,
1660
+ queryFields: gathers ? queryFields : query,
1558
1661
  header,
1559
1662
  body,
1560
- response,
1561
- alternateResponse,
1663
+ arms,
1562
1664
  responses,
1563
1665
  });
1564
1666
  }
1565
1667
  return { names, declarations };
1566
1668
  }
1567
1669
  /**
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.
1670
+ * What an operation may answer with, as a literal - each declared status with the body, media types
1671
+ * and headers the document gives it, in OpenAPI's own precedence order.
1672
+ *
1673
+ * **Rendered from one record per status, so no arm can shadow another.** The primary success arm and
1674
+ * the failure arms used to be rendered from separate projections and then deduplicated by matching
1675
+ * the emitted text for `status:`, which is how a redirect came to be declared twice and how a
1676
+ * failure arm came to carry no headers while a success arm with the same headers did.
1570
1677
  *
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.
1678
+ * `contentTypes` and `headers` are written only where the document names some, so "none declared"
1679
+ * and "none carried" are the same state rather than two a runtime has to tell apart.
1573
1680
  */
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(", ")}]`;
1681
+ function responseArmsOf(route, arms) {
1682
+ const rendered = route.responses.map((response, index) => {
1683
+ const schema = arms[index]?.schema ?? "undefined";
1684
+ const contentTypes = response.contentTypes.length === 0
1685
+ ? ""
1686
+ : `, contentTypes: [${response.contentTypes.map((type) => JSON.stringify(type)).join(", ")}]`;
1687
+ const headers = response.headers.length === 0
1688
+ ? ""
1689
+ : `, headers: [${response.headers.map((header) => `{ name: ${JSON.stringify(header.name)}, optional: ${header.optional} }`).join(", ")}]`;
1690
+ return `{ status: ${JSON.stringify(response.status)}, schema: ${schema}${contentTypes}${headers} }`;
1691
+ });
1692
+ return `[${rendered.join(", ")}]`;
1668
1693
  }
1669
1694
  const upperFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
1670
1695
  /**
@@ -2129,8 +2154,6 @@ ${entries.join("\n")}
2129
2154
  } as const;
2130
2155
  `;
2131
2156
  }
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
2157
  /**
2135
2158
  * Emit this library's artefacts for every `@service` in the program, and hand back what was emitted.
2136
2159
  *
@@ -2185,9 +2208,8 @@ function reportTruncatedDocComments(program) {
2185
2208
  scalar: check,
2186
2209
  });
2187
2210
  }
2188
- export async function emitHttpZod(context, wrapper = {}) {
2211
+ export async function emitHttpZod(context) {
2189
2212
  const options = context.options;
2190
- const defaultRuntimeModule = wrapper.defaultRuntimeModule ?? DEFAULT_RUNTIME_MODULE;
2191
2213
  /**
2192
2214
  * Each service already projected to the version it currently serves - see `versioning.ts`.
2193
2215
  *
@@ -2279,7 +2301,6 @@ export async function emitHttpZod(context, wrapper = {}) {
2279
2301
  sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
2280
2302
  compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
2281
2303
  keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
2282
- runtimeModule: perService?.["runtime-module"] ?? options["runtime-module"] ?? defaultRuntimeModule,
2283
2304
  regenerateHint: perService?.["regenerate-hint"] ?? options["regenerate-hint"],
2284
2305
  };
2285
2306
  beginBanner(resolved.regenerateHint);
@@ -2337,7 +2358,7 @@ export async function emitHttpZod(context, wrapper = {}) {
2337
2358
  const { names, declarations } = nameRouteSchemas(routes, resolved.compileSchemas);
2338
2359
  await emitFile(context.program, {
2339
2360
  path: resolvePath(outputDir, "schemas.gen.ts"),
2340
- content: renderSchemas(registry, externals, declarations, resolved.runtimeModule, resolved.compileSchemas),
2361
+ content: renderSchemas(registry, externals, declarations, resolved.compileSchemas),
2341
2362
  });
2342
2363
  if (resolved.contractsOutputDir !== undefined) {
2343
2364
  const types = new TypeRegistry(context.program);