typespec-http-zod 0.27.0 → 0.29.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,13 +1,17 @@
1
- import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolveEncodedName, resolvePath, } from "@typespec/compiler";
1
+ import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolveEncodedName, resolvePath, walkPropertiesInherited, } from "@typespec/compiler";
2
2
  import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
3
3
  import { getOperationId, resolveOperationId } from "@typespec/openapi";
4
4
  import { reportDiagnostic } from "./lib.js";
5
+ import { literal } from "./literal.js";
5
6
  import { SchemaRegistry, TypeRegistry } from "./registry.js";
6
- import { typeToTs } from "./types.js";
7
+ import { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
8
+ import { propertyTypeToTs } from "./types.js";
7
9
  import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
8
10
  import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
9
11
  import { EXPLODED_QUERY_SOURCE, explodedQueryCall, needsExpansionDecoding, routeTemplateOf, URI_EXPANSION_SOURCE, uriExpansionCall, } from "./uri-template.js";
10
- import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
12
+ export { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
13
+ export { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
14
+ import { collectExternalImports, isPayloadProperty, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
11
15
  /**
12
16
  * `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
13
17
  * service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
@@ -50,6 +54,12 @@ export { EmitterOptionsSchema } from "./lib.js";
50
54
  * independently.
51
55
  */
52
56
  export { jsDocComment, objectKey } from "./zod.js";
57
+ /**
58
+ * And the same for a VALUE written into emitted code: a string, enum value or media type as a literal
59
+ * whose source is ASCII. Spec text outside ASCII is the case, and a sibling emitter spelling it with a
60
+ * bare `JSON.stringify` writes the character itself into a file its consumer commits.
61
+ */
62
+ export { literal } from "./literal.js";
53
63
  /** Named once. The emitted banner and the internal-invariant messages both quote it. */
54
64
  const PACKAGE_NAME = "typespec-http-zod";
55
65
  /**
@@ -154,10 +164,10 @@ let operationIds;
154
164
  /**
155
165
  * Establish a fresh id resolver for one service's emission.
156
166
  *
157
- * **Scoped per SERVICE rather than per walk, because the walks see different subsets.**
158
- * `collectRoutes` visits every operation with a success status; `collectRequestTypes` skips any with
159
- * no body and no parameters. Deduplicating independently would let the two assign a suffix to
160
- * different operations, and the artefacts would name one operation two ways.
167
+ * **Scoped per SERVICE rather than per walk, because every walk has to name an operation the same
168
+ * way.** `collectRoutes` and `collectRequestTypes` both visit every operation, but each is a separate
169
+ * walk; deduplicating independently would let a suffix depend on the order a walk happened to meet
170
+ * two operations in, and the artefacts would name one operation two ways.
161
171
  *
162
172
  * A reset rather than a wrapper, because the emission loop is `for (const snapshot of snapshots)`
163
173
  * and awaits each service to completion before the next begins. Nothing interleaves, so there is no
@@ -275,7 +285,7 @@ function statusKeysOf(program, response) {
275
285
  return keys;
276
286
  }
277
287
  /**
278
- * Every response the operation declares, one per status key, in OpenAPI's precedence order.
288
+ * Every response the operation declares, one per status key, in the order they were RESOLVED.
279
289
  *
280
290
  * **Each response's own body, headers and media types, and nothing borrowed from another.** The
281
291
  * failure arms used to be collected here separately from the success, and before that every failure
@@ -288,10 +298,23 @@ function statusKeysOf(program, response) {
288
298
  * Where two responses resolve to the same status the first body wins, which is what the document
289
299
  * does, and media types and headers are the union across them.
290
300
  *
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.
301
+ * Resolution order, not precedence order, because a renderer that declares components has to
302
+ * declare them in the order they were always declared; {@link inPrecedenceOrder} sorts.
303
+ *
304
+ * **Resolved once per operation, and every walk reads that record.** Resolving reports what the
305
+ * document cannot publish, such as a status range OpenAPI cannot key; the route walk and the contract
306
+ * walk each resolved the same operation, so with contracts emitted every such error was raised twice.
293
307
  */
294
- function responsesOf(program, operation, registry) {
308
+ function resolvedResponsesOf(program, operation) {
309
+ const resolved = resolvedResponses.get(operation);
310
+ if (resolved !== undefined)
311
+ return resolved;
312
+ const fresh = resolveResponses(program, operation);
313
+ resolvedResponses.set(operation, fresh);
314
+ return fresh;
315
+ }
316
+ const resolvedResponses = new WeakMap();
317
+ function resolveResponses(program, operation) {
295
318
  const byStatus = new Map();
296
319
  for (const response of operation.responses) {
297
320
  for (const status of statusKeysOf(program, response)) {
@@ -302,30 +325,28 @@ function responsesOf(program, operation, registry) {
302
325
  contentTypes: new Set(),
303
326
  headers: [],
304
327
  streamed: false,
328
+ file: false,
305
329
  };
306
330
  for (const content of response.responses) {
307
331
  if (entry.body === undefined && content.body?.bodyKind === "single") {
308
332
  entry.body = bodyTypeOf(content.body.type);
309
333
  }
334
+ /**
335
+ * **An `Http.File` response's body is its contents, served as bytes.** The same reading
336
+ * as a file request body: a media type and nothing to serialise. Read only for
337
+ * `"single"`, every download in `type/file` declared `image/png` and was served empty.
338
+ */
339
+ if (entry.body === undefined && content.body?.bodyKind === "file") {
340
+ entry.body = content.body.contents.type;
341
+ entry.file = true;
342
+ }
310
343
  for (const contentType of content.body?.contentTypes ?? []) {
311
344
  entry.contentTypes.add(contentType);
312
345
  }
313
346
  for (const [name, property] of Object.entries(content.headers ?? {})) {
314
347
  if (entry.headers.some((existing) => existing.name === name))
315
348
  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
- });
349
+ entry.headers.push({ name, property });
329
350
  }
330
351
  }
331
352
  if (streamedTypeOf(program, response.type) !== undefined)
@@ -333,27 +354,73 @@ function responsesOf(program, operation, registry) {
333
354
  byStatus.set(key, entry);
334
355
  }
335
356
  }
336
- return [...byStatus.values()]
337
- .map((entry) => {
357
+ return [...byStatus.values()].map((entry) => {
338
358
  const contentTypes = [...entry.contentTypes];
339
- const binary = entry.body !== undefined && isBinaryPart(entry.body) && isRawBinaryMediaType(contentTypes);
340
359
  return {
341
360
  status: entry.status,
342
- schema: entry.body === undefined
343
- ? undefined
344
- : binary
345
- ? "z.unknown()"
346
- : registry.expressionFor(entry.body),
361
+ body: entry.body,
347
362
  contentTypes,
348
363
  headers: entry.headers,
349
- binary,
364
+ binary: entry.file ||
365
+ (entry.body !== undefined &&
366
+ isBinaryPart(entry.body) &&
367
+ isRawBinaryMediaType(contentTypes)),
350
368
  streamed: entry.streamed,
351
- textual: entry.body !== undefined && isStringType(entry.body),
369
+ textual: !entry.file && entry.body !== undefined && isStringType(entry.body),
352
370
  };
353
- })
354
- .toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status) ||
371
+ });
372
+ }
373
+ /** Responses in OpenAPI's precedence order: exact codes, then ranges, then `default`. */
374
+ function inPrecedenceOrder(responses) {
375
+ return responses.toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status) ||
355
376
  statusOrderOf(a.status) - statusOrderOf(b.status));
356
377
  }
378
+ /**
379
+ * Every response the operation declares, rendered as Zod, in OpenAPI's precedence order.
380
+ *
381
+ * Every header is rendered before any body, and bodies in precedence order, which is the order this
382
+ * has always declared components in: the emitted files do not move because the resolution moved.
383
+ *
384
+ * Call inside `withVisibility(program, Visibility.Read, ...)`: a response is read at `Read`, and
385
+ * resolving it under a request visibility declares components under the wrong suffix.
386
+ */
387
+ function responsesOf(program, operation, registry) {
388
+ const withHeaders = resolvedResponsesOf(program, operation).map((response) => ({
389
+ response,
390
+ headers: response.headers.map(({ name, property }) => ({
391
+ name,
392
+ /**
393
+ * **Encode-aware, as the schema beside it is.** `@encode(seconds, int32)` makes a `duration`
394
+ * an integer on the wire, and the header schema checks an integer; reading the declared type
395
+ * alone published `string`, so a handler satisfying its own result type was refused by its
396
+ * own header check and the arm could not be returned without a cast.
397
+ */
398
+ type: propertyTypeToTs(program, property),
399
+ /**
400
+ * **The Zod expression, which used to be discarded here.** Every REQUEST parameter goes
401
+ * through `expressionForProperty` a few hundred lines below; a response header went
402
+ * through `typeToTs` and nothing else, so the emitted arm carried a name and an
403
+ * optionality and no way to check a value. A response body that breaks its schema throws
404
+ * `ResponseContractError`; a header that breaks its own was served.
405
+ */
406
+ schema: registry.expressionForProperty(property),
407
+ optional: property.optional,
408
+ })),
409
+ }));
410
+ return inPrecedenceOrder(withHeaders.map(({ response, headers }) => ({ status: response.status, response, headers }))).map(({ response, headers }) => ({
411
+ status: response.status,
412
+ schema: response.body === undefined
413
+ ? undefined
414
+ : response.binary
415
+ ? "z.unknown()"
416
+ : registry.expressionFor(response.body),
417
+ contentTypes: response.contentTypes,
418
+ headers,
419
+ binary: response.binary,
420
+ streamed: response.streamed,
421
+ textual: response.textual,
422
+ }));
423
+ }
357
424
  /**
358
425
  * Where an arm sits in OpenAPI's own precedence order.
359
426
  *
@@ -539,33 +606,6 @@ export function errorBodyOf(program, service) {
539
606
  function bodyTypeOf(type) {
540
607
  return isVoidType(type) || isNeverType(type) ? undefined : type;
541
608
  }
542
- /**
543
- * The declared body type of the operation's success response, if it has one.
544
- *
545
- * **`void` and `never` ARE "no body", and reading them as types was a defect.** A response with
546
- * no content reaches this in two shapes: as no body at all, which `undefined` already covered, and
547
- * as a body whose type is `void` - which is what `tsp-openapi3` writes when it converts a `204`,
548
- * and what `op remove(): void` means. The second used to be returned as a type, walked, and refused
549
- * with `Cannot emit Zod for Intrinsic: 'void' has no runtime representation'`. That took out the
550
- * Swagger Petstore, the most widely implemented reference API there is, on the round-trip suite's
551
- * first run.
552
- *
553
- * One fact arriving by two roads. Answering it here rather than at each of the four call sites is
554
- * what keeps them from drifting - `collectRoutes`, the contract types, the dual-status
555
- * discriminator and the alternate-arm schema all want the same answer to the same question.
556
- */
557
- function successBodyOf(operation, status) {
558
- for (const response of operation.responses) {
559
- if (response.statusCodes !== status)
560
- continue;
561
- for (const content of response.responses) {
562
- if (content.body?.bodyKind !== "single")
563
- continue;
564
- return bodyTypeOf(content.body.type);
565
- }
566
- }
567
- return undefined;
568
- }
569
609
  /**
570
610
  * The media types this operation's success response can be served as.
571
611
  *
@@ -801,7 +841,18 @@ function isBinaryPart(type) {
801
841
  export function isRawBinaryMediaType(contentTypes) {
802
842
  if (contentTypes.length === 0)
803
843
  return false;
804
- return contentTypes.every((type) => !/^application\/(json|.*\+json)$/.test(type));
844
+ return contentTypes.every((type) => !isJsonMediaType(type));
845
+ }
846
+ /**
847
+ * A media type a server can parse a request body from into a value its schema can check: JSON and
848
+ * the `+json` suffix RFC 6839 defines, a URL-encoded form, and multipart. Anything else - XML, a
849
+ * vendor type - has no reading this emitter can derive from the document.
850
+ */
851
+ function isParseableMediaType(type) {
852
+ const essence = mediaTypeEssenceOf(type);
853
+ return (isJsonMediaType(essence) ||
854
+ essence === "application/x-www-form-urlencoded" ||
855
+ essence.startsWith("multipart/"));
805
856
  }
806
857
  /** The declared request body type, if the operation takes one. */
807
858
  function requestBodyOf(operation) {
@@ -1000,15 +1051,28 @@ function mediaTypeDecoded(declared) {
1000
1051
  */
1001
1052
  if (value === "*/*")
1002
1053
  return "z.string()";
1003
- return value.endsWith("/*")
1004
- ? `z.templateLiteral([${JSON.stringify(value.slice(0, -1))}, z.string()])`
1005
- : `z.literal(${JSON.stringify(value)})`;
1054
+ return isMediaRange(value)
1055
+ ? `z.templateLiteral([${literal(mediaTypeEssenceOf(value).slice(0, -1))}, z.string()])`
1056
+ : `z.literal(${literal(value)})`;
1006
1057
  });
1007
1058
  const schema = admitted.length === 1 ? (admitted[0] ?? declared) : `z.union([${admitted.join(", ")}])`;
1008
- const ranged = values.some((value) => value.endsWith("/*")) ? schema : declared;
1009
- // Lowercased only when every declared value already is, so an oddly-cased one keeps matching.
1010
- const normalise = values.every((value) => value === value.toLowerCase()) ? ".toLowerCase()" : "";
1011
- return `z.preprocess((raw) => (typeof raw === "string" ? (raw.split(";")[0] ?? "").trim()${normalise} : raw), ${ranged})`;
1059
+ const ranged = values.some(isMediaRange) ? schema : declared;
1060
+ /**
1061
+ * **The request's media type is reduced to its essence, and so is every declared one.** A value
1062
+ * declared as its own essence - `application/json` - is the literal the essence is compared with.
1063
+ * One declared with a parameter or in capitals - `application/json; charset=utf-8` - is restored
1064
+ * from the essence, so the request is admitted and the handler is given the type as the document
1065
+ * declares it, which is what its input type says. Compared as written, such a value refused every
1066
+ * request: no request's essence carries a parameter.
1067
+ */
1068
+ const restored = values.filter((value) => !isMediaRange(value) && mediaTypeEssenceOf(value) !== value);
1069
+ const essence = `(raw.split(";")[0] ?? "").trim().toLowerCase()`;
1070
+ const decoded = restored.length === 0
1071
+ ? essence
1072
+ : `((essence) => ${restored
1073
+ .map((value) => `essence === ${literal(mediaTypeEssenceOf(value))} ? ${literal(value)} : `)
1074
+ .join("")}essence)(${essence})`;
1075
+ return `z.preprocess((raw) => (typeof raw === "string" ? ${decoded} : raw), ${ranged})`;
1012
1076
  }
1013
1077
  function parameterSchemasOf(program, operation, registry, segments,
1014
1078
  /** The names of a query string written into the route itself, which no parameter may gather. */
@@ -1086,7 +1150,7 @@ literalQueryNames) {
1086
1150
  ? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
1087
1151
  : delimiter === undefined
1088
1152
  ? decoded
1089
- : `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${JSON.stringify(delimiter)}) : raw), ${decoded})`;
1153
+ : `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${literal(delimiter)}) : raw), ${decoded})`;
1090
1154
  /**
1091
1155
  * **An RFC 6570 expansion is undone OUTSIDE everything above**, because it is the first thing the
1092
1156
  * wire did: `array.a.b` becomes `["a", "b"]`, then the element decoder and the document's schema
@@ -1096,8 +1160,8 @@ literalQueryNames) {
1096
1160
  const values = recordValuesOf(program, parameter.param.type);
1097
1161
  let expression = boxedOrSplit;
1098
1162
  if (parameter.type === "path") {
1099
- const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
1100
- if (segment !== undefined && needsExpansionDecoding(segment, shape)) {
1163
+ const segment = decodedSegmentOf(parameter, segments);
1164
+ if (segment !== undefined) {
1101
1165
  expression = `z.preprocess(${uriExpansionCall({ ...segment, name: parameter.name, shape, values })}, ${decoded})`;
1102
1166
  }
1103
1167
  }
@@ -1141,14 +1205,21 @@ literalQueryNames) {
1141
1205
  * validates every OTHER header and leaves this one to the negotiation.
1142
1206
  */
1143
1207
  if (parameter.type === "header" && parameter.name.toLowerCase() === "accept") {
1144
- const literal = parameter.param.type;
1145
- if (literal.kind === "String")
1146
- accept = { name: parameter.name, value: literal.value };
1208
+ const declared = parameter.param.type;
1209
+ if (declared.kind === "String")
1210
+ accept = { name: parameter.name, value: declared.value };
1147
1211
  }
1148
1212
  else if (parameter.type === "header") {
1149
1213
  (byTarget["header-without-accept"] ??= []).push(entry);
1150
1214
  }
1151
1215
  }
1216
+ const fileContentType = fileContentTypeOf(operation);
1217
+ if (fileContentType !== undefined) {
1218
+ // The same decoding a declared `Content-Type` header gets: parameters dropped, lowercased.
1219
+ const entry = `\t${objectKey("Content-Type")}: ${mediaTypeDecoded(registry.expressionFor(fileContentType.type))},`;
1220
+ (byTarget["header"] ??= []).push(entry);
1221
+ (byTarget["header-without-accept"] ??= []).push(entry);
1222
+ }
1152
1223
  const shape = (target) => {
1153
1224
  const entries = byTarget[target];
1154
1225
  return entries === undefined ? undefined : `z.object({\n${entries.join("\n")}\n})`;
@@ -1167,6 +1238,19 @@ literalQueryNames) {
1167
1238
  };
1168
1239
  }
1169
1240
  /** What a parameter's value decodes into once its expansion is undone. */
1241
+ /**
1242
+ * The route segment a path parameter's validator decodes, or `undefined` where it validates the
1243
+ * segment's text as the value. The one rule for both the validator and
1244
+ * {@link EmittedRoute.decodedPathParameters}, so a consumer reading the route cannot disagree with
1245
+ * the validator it describes.
1246
+ */
1247
+ function decodedSegmentOf(parameter, segments) {
1248
+ const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
1249
+ return segment !== undefined &&
1250
+ needsExpansionDecoding(segment, expansionShapeOf(parameter.param.type))
1251
+ ? segment
1252
+ : undefined;
1253
+ }
1170
1254
  function expansionShapeOf(type) {
1171
1255
  if (isArrayType(type))
1172
1256
  return "list";
@@ -1186,6 +1270,178 @@ function modelPropertiesOf(model) {
1186
1270
  }
1187
1271
  return chain.flatMap((current) => [...current.properties.values()]);
1188
1272
  }
1273
+ /**
1274
+ * **The media type an `Http.File` body declares, as the input it is.** A file's `contentType` is the
1275
+ * request's `Content-Type`: `Http.File<"image/png" | "image/jpeg">` accepts either and the handler has
1276
+ * to know which arrived, exactly as it would from a declared `@header contentType`. `@typespec/http`
1277
+ * keeps it on the file model rather than among the operation's parameters, so without this a
1278
+ * handler for a two-type upload had the bytes and no way to tell what they were.
1279
+ *
1280
+ * `undefined` where the body is not a file, or where the operation already declares the header.
1281
+ */
1282
+ function fileContentTypeOf(operation) {
1283
+ const body = operation.parameters.body;
1284
+ if (body?.bodyKind !== "file")
1285
+ return undefined;
1286
+ const declared = operation.parameters.parameters.some((parameter) => parameter.type === "header" && parameter.name.toLowerCase() === "content-type");
1287
+ return declared ? undefined : body.contentTypeProperty;
1288
+ }
1289
+ /**
1290
+ * Where a request body reaches a handler's input: spread into it, named under a property, or handed
1291
+ * over unread.
1292
+ *
1293
+ * **One decision, read by every artefact that states an operation's input.** The server emitters
1294
+ * render a handler's input from {@link EmittedRoute.bodyProperty}; the contract types in
1295
+ * `requests.gen.ts` re-derived the input for themselves and always merged, so for every body this
1296
+ * names - an indexer, an optional body, a scalar, enum or union - `WireInputs` described an input no
1297
+ * handler receives.
1298
+ */
1299
+ /**
1300
+ * The keys a merged body would contribute to the handler's input: each property of the body SENT,
1301
+ * at the operation's request visibility, under its wire name, or each named part of a multipart
1302
+ * body. A read-only property is not sent, so it contributes nothing.
1303
+ */
1304
+ function mergedBodyKeysOf(program, operation) {
1305
+ const body = operation.parameters.body;
1306
+ if (body?.bodyKind === "multipart") {
1307
+ return body.parts.flatMap((part) => (part.name === undefined ? [] : [part.name]));
1308
+ }
1309
+ const type = requestBodyOf(operation);
1310
+ if (type?.kind !== "Model")
1311
+ return [];
1312
+ return withVisibility(program, resolveRequestVisibility(program, operation.operation, operation.verb), () => [...walkPropertiesInherited(type)]
1313
+ .filter((property) => isPayloadProperty(program, property))
1314
+ .map((property) => resolveEncodedName(program, property, "application/json")));
1315
+ }
1316
+ function requestBodyPlacementOf(program, operation) {
1317
+ const requestType = requestBodyOf(operation);
1318
+ const bodyParameter = operation.parameters.body;
1319
+ const contentTypes = [...(bodyParameter?.contentTypes ?? [])];
1320
+ /**
1321
+ * A `bytes` body means the BYTES are the contract.
1322
+ *
1323
+ * A webhook's MAC covers exactly what arrived, so a parsed-and-re-serialised object would
1324
+ * verify a different string and every delivery would fail authentication.
1325
+ */
1326
+ const bytes = requestType?.kind === "Scalar" && requestType.name === "bytes";
1327
+ /**
1328
+ * **A body declared only under media types nothing can parse into it is handed over raw.** A
1329
+ * model as `application/xml` has no serialisation this emitter can derive, and the server has no
1330
+ * reader for it; it used to get a header check and then call the handler with the body never
1331
+ * read, while the warning beside it said such requests were refused. Handed over as it arrived,
1332
+ * the handler parses it - exactly what the response side already asks of a handler serving a model
1333
+ * as XML. It is the text under a textual media type and the bytes under any other
1334
+ * (`rawBodyBinary`). A string body is not this: it IS text, and its schema still checks it
1335
+ * (`requestTextual`).
1336
+ */
1337
+ const unparseable = bodyParameter?.bodyKind === "single" &&
1338
+ requestType !== undefined &&
1339
+ !bytes &&
1340
+ !isStringType(requestType) &&
1341
+ contentTypes.length > 0 &&
1342
+ contentTypes.every((type) => !isParseableMediaType(type));
1343
+ /**
1344
+ * **An `Http.File` body is raw too: the file's contents ARE the body.** `@typespec/openapi3`
1345
+ * publishes it as `content: { "image/png": { schema: { contentMediaType: "image/png" } } }` - a
1346
+ * media type and no type, nothing to parse and nothing to validate - which is exactly a `bytes`
1347
+ * body under that media type. `requestBodyOf` reads only `bodyKind === "single"`, so a file body
1348
+ * reached no placement at all: the handler was passed `{}` and the upload was dropped, from a
1349
+ * compile that reported success, on all four uploads in `type/file`.
1350
+ */
1351
+ const rawBodyProperty = bytes || bodyParameter?.bodyKind === "file" || unparseable
1352
+ ? (bodyParameter?.property?.name ?? "body")
1353
+ : undefined;
1354
+ /**
1355
+ * A body with an indexer is named rather than spread - see `bodyProperty`. Read from the
1356
+ * resolved model's own indexer rather than from the emitted schema text, which would be a
1357
+ * decision made by pattern-matching generated output.
1358
+ */
1359
+ /**
1360
+ * **An OPTIONAL body is named for the same reason an indexed one is**: the merge cannot
1361
+ * express "absent". See {@link EmittedRoute.optionalBody}.
1362
+ */
1363
+ const optionalBody = bodyParameter?.property?.optional === true;
1364
+ /**
1365
+ * **An optional MULTIPART body reaches the same rule by a second route, because
1366
+ * `requestType` cannot see it.** `requestBodyOf` resolves a type only for
1367
+ * `bodyKind === "single"`, so `requestType` is `undefined` for every multipart operation
1368
+ * and the `optionalBody` disjunct above was unreachable however the document declared it.
1369
+ *
1370
+ * The emitted server for such an operation did not compile. Measured, and the mechanism is
1371
+ * worth stating because it is not a union: spreading a value typed `Parts | undefined`
1372
+ * makes every part OPTIONAL, so `{ file?: ...; pages?: ... }` was passed where
1373
+ * `{ file: ...; pages: ... }` was required. `TS2345`, inside `app.gen.ts` itself.
1374
+ *
1375
+ * That is the failure {@link EmittedRoute.optionalBody} predicts in prose - a merge has no
1376
+ * way to say "these properties are here only sometimes" without making every one of them
1377
+ * optional, which is a weaker and different claim about the body that IS sent. So this is
1378
+ * the stated rule reaching the one body kind it had never been applied to, not a new rule.
1379
+ *
1380
+ * **The REQUIRED arm is deliberately excluded.** The indexed reason does not apply to a
1381
+ * parts model, so a required multipart body merges as safely as a required single one, and
1382
+ * `typespec-hono/test/filepart/` compiles a handler that reads `input.file` directly.
1383
+ * Widening this to "multipart is always named" would rewrite that directory for no defect.
1384
+ *
1385
+ * This is the third hole the `"single"` gate has produced, after `multipartSchemaOf` and
1386
+ * `multipartTsOf`, both recorded above.
1387
+ */
1388
+ const optionalMultipartBody = bodyParameter?.bodyKind === "multipart" && optionalBody;
1389
+ /**
1390
+ * **A body that is not a MODEL has no properties to merge, and merging it anyway lost it.**
1391
+ *
1392
+ * `@body body: string` emitted a handler input of `Record<string, never>` and a call site of
1393
+ * `{}`: the server validated the body and then never handed it to the handler, silently, from
1394
+ * a compile that reported success. `@body body: SomeEnum` was the same rule failing loudly,
1395
+ * `TS2698: Spread types may only be created from object types`, which is what
1396
+ * `payload/media-type`, `type/enum/*`, `type/scalar` and `versioning/returnTypeChangedFrom`
1397
+ * had been pinned on in the conformance corpus.
1398
+ *
1399
+ * A scalar, an enum, a union or a literal body is therefore NAMED, which is the treatment an
1400
+ * indexed or optional body already gets and for the same reason: the merge cannot express it.
1401
+ *
1402
+ * **Except a `bytes` body**, which {@link EmittedRoute.rawBodyProperty} already names, and
1403
+ * naming it twice would put the same value under two keys.
1404
+ */
1405
+ const unmergeableBody = requestType !== undefined && requestType.kind !== "Model" && rawBodyProperty === undefined;
1406
+ /**
1407
+ * **A body sharing a key with a parameter is named, because merged one value replaces the
1408
+ * other.** `PUT /renames/{name}` with a body `{ name }` handed the handler one `name`, the body's,
1409
+ * and the path's was lost with nothing reported - in `parameters/spread`'s composite request, and
1410
+ * in any spec whose body repeats a path or query parameter. The document states the two
1411
+ * separately; named, the handler is given both. A read-only property is not in the body sent, so
1412
+ * `@path id` beside a model whose `id` is read-only collides with nothing and still merges.
1413
+ */
1414
+ const parameterNames = new Set(operation.parameters.parameters.map((parameter) => parameter.name));
1415
+ const collides = rawBodyProperty === undefined &&
1416
+ mergedBodyKeysOf(program, operation).some((key) => parameterNames.has(key));
1417
+ const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
1418
+ optionalMultipartBody ||
1419
+ unmergeableBody ||
1420
+ collides
1421
+ ? (bodyParameter?.property?.name ?? "body")
1422
+ : undefined;
1423
+ /**
1424
+ * A file is handed over as a stream of bytes whatever its media type. `@typespec/http` lets an
1425
+ * emitter represent a textual file "as strings or streams of textual characters", and one
1426
+ * representation for every file is what lets a file declared `application/json` be told from a
1427
+ * JSON body - which the media type alone cannot do.
1428
+ *
1429
+ * **A body nothing parses is bytes unless every type it is declared under is text.** Decoded as
1430
+ * text, `application/octet-stream` lost every byte that is not valid UTF-8; see
1431
+ * {@link isTextualMediaType}.
1432
+ */
1433
+ const rawBodyBinary = rawBodyProperty !== undefined &&
1434
+ (unparseable
1435
+ ? !contentTypes.every(isTextualMediaType)
1436
+ : bodyParameter?.bodyKind === "file" || isRawBinaryMediaType(contentTypes));
1437
+ return {
1438
+ rawBodyProperty,
1439
+ rawBodyBinary,
1440
+ rawBodyUnvalidated: unparseable,
1441
+ bodyProperty,
1442
+ optionalBody,
1443
+ };
1444
+ }
1189
1445
  export function collectRoutes(program, registry, service) {
1190
1446
  const routes = [];
1191
1447
  {
@@ -1215,73 +1471,7 @@ export function collectRoutes(program, registry, service) {
1215
1471
  // `@requestVisibility` when the operation states one, otherwise the verb's default - the
1216
1472
  // same resolution `@typespec/openapi3` performs before emitting a request schema.
1217
1473
  const requestVisibility = resolveRequestVisibility(program, operation.operation, operation.verb);
1218
- /**
1219
- * A `bytes` body means the BYTES are the contract.
1220
- *
1221
- * A webhook's MAC covers exactly what arrived, so a parsed-and-re-serialised object would
1222
- * verify a different string and every delivery would fail authentication.
1223
- */
1224
- const bodyParameter = operation.parameters.body;
1225
- const rawBodyProperty = requestType?.kind === "Scalar" && requestType.name === "bytes"
1226
- ? (bodyParameter?.property?.name ?? "body")
1227
- : undefined;
1228
- /**
1229
- * A body with an indexer is named rather than spread - see `bodyProperty`. Read from the
1230
- * resolved model's own indexer rather than from the emitted schema text, which would be a
1231
- * decision made by pattern-matching generated output.
1232
- */
1233
- /**
1234
- * **An OPTIONAL body is named for the same reason an indexed one is**: the merge cannot
1235
- * express "absent". See {@link EmittedRoute.optionalBody}.
1236
- */
1237
- const optionalBody = bodyParameter?.property?.optional === true;
1238
- /**
1239
- * **An optional MULTIPART body reaches the same rule by a second route, because
1240
- * `requestType` cannot see it.** `requestBodyOf` resolves a type only for
1241
- * `bodyKind === "single"`, so `requestType` is `undefined` for every multipart operation
1242
- * and the `optionalBody` disjunct above was unreachable however the document declared it.
1243
- *
1244
- * The emitted server for such an operation did not compile. Measured, and the mechanism is
1245
- * worth stating because it is not a union: spreading a value typed `Parts | undefined`
1246
- * makes every part OPTIONAL, so `{ file?: ...; pages?: ... }` was passed where
1247
- * `{ file: ...; pages: ... }` was required. `TS2345`, inside `app.gen.ts` itself.
1248
- *
1249
- * That is the failure {@link EmittedRoute.optionalBody} predicts in prose - a merge has no
1250
- * way to say "these properties are here only sometimes" without making every one of them
1251
- * optional, which is a weaker and different claim about the body that IS sent. So this is
1252
- * the stated rule reaching the one body kind it had never been applied to, not a new rule.
1253
- *
1254
- * **The REQUIRED arm is deliberately excluded.** The indexed reason does not apply to a
1255
- * parts model, so a required multipart body merges as safely as a required single one, and
1256
- * `typespec-hono/test/filepart/` compiles a handler that reads `input.file` directly.
1257
- * Widening this to "multipart is always named" would rewrite that directory for no defect.
1258
- *
1259
- * This is the third hole the `"single"` gate has produced, after `multipartSchemaOf` and
1260
- * `multipartTsOf`, both recorded above.
1261
- */
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;
1280
- const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
1281
- optionalMultipartBody ||
1282
- unmergeableBody
1283
- ? (bodyParameter?.property?.name ?? "body")
1284
- : undefined;
1474
+ const { rawBodyProperty, rawBodyBinary, rawBodyUnvalidated, bodyProperty, optionalBody } = requestBodyPlacementOf(program, operation);
1285
1475
  const authentication = authenticationFor(program, operation);
1286
1476
  const template = routeTemplateOf(operation.uriTemplate, operation.parameters.parameters
1287
1477
  .filter((parameter) => parameter.type === "path")
@@ -1316,6 +1506,9 @@ export function collectRoutes(program, registry, service) {
1316
1506
  .filter((parameter) => parameter.type === "path" && parameter.allowReserved)
1317
1507
  .map((parameter) => parameter.name),
1318
1508
  pathSegments: template.segments,
1509
+ decodedPathParameters: operation.parameters.parameters
1510
+ .filter((parameter) => decodedSegmentOf(parameter, template.segments) !== undefined)
1511
+ .map((parameter) => parameter.name),
1319
1512
  literalQuery: template.literalQuery,
1320
1513
  responseContentTypes: statusCode === undefined ? [] : responseContentTypesOf(operation, statusCode),
1321
1514
  requestContentTypes: [...(operation.parameters.body?.contentTypes ?? [])],
@@ -1370,6 +1563,8 @@ export function collectRoutes(program, registry, service) {
1370
1563
  */
1371
1564
  responses: withVisibility(program, Visibility.Read, () => responsesOf(program, operation, registry)),
1372
1565
  rawBodyProperty,
1566
+ rawBodyBinary,
1567
+ rawBodyUnvalidated,
1373
1568
  bodyProperty,
1374
1569
  optionalBody,
1375
1570
  scopes: authentication.scopes,
@@ -1409,9 +1604,121 @@ export function collectRoutes(program, registry, service) {
1409
1604
  * Tokenising has neither, needs no escaping to stay correct, and is one pass rather than one pattern
1410
1605
  * per name. `typespec-hono` arrived at it after the same defect went live there: a spec whose every
1411
1606
  * identifier carried a `$` emitted a file that imported nothing and did not compile.
1607
+ *
1608
+ * **Only CODE is tokenised: string, template and regular-expression literals and comments are
1609
+ * skipped.** Spec-authored text reaches emitted source inside them - a `.describe("...")`, a
1610
+ * `.regex(/.../)` - and a word in that text is not a reference. Measured: a parameter documented as
1611
+ * "names uriExpansion and explodedQuery" declared both RFC 6570 decoders in a service that decodes
1612
+ * nothing, and the file failed `noUnusedLocals` - the lint a generated file has to pass like any
1613
+ * other. A literal is told from code by its opening character; a `/` opens a regular expression only
1614
+ * where an expression may start, which is the rule a JavaScript scanner uses, and this emitter
1615
+ * writes no division.
1412
1616
  */
1413
1617
  function identifiersIn(source) {
1414
- return new Set(source.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []);
1618
+ const found = new Set();
1619
+ scanCode(source, 0, found, false);
1620
+ return found;
1621
+ }
1622
+ /**
1623
+ * Collect the identifiers of the code from `start`, returning where it stopped: at the end, or -
1624
+ * `nested`, for a template's `${...}` - just past the brace that closes it.
1625
+ */
1626
+ function scanCode(source, start, found, nested) {
1627
+ const identifier = /[A-Za-z_$][A-Za-z0-9_$]*/y;
1628
+ let previous = "";
1629
+ let depth = 0;
1630
+ let index = start;
1631
+ while (index < source.length) {
1632
+ const char = source.charAt(index);
1633
+ const next = source.charAt(index + 1);
1634
+ if (char === "`") {
1635
+ index = scanTemplate(source, index, found);
1636
+ previous = char;
1637
+ }
1638
+ else if (char === '"' || char === "'") {
1639
+ index = quotedEnd(source, index, char);
1640
+ previous = char;
1641
+ }
1642
+ else if (char === "/" && next === "/") {
1643
+ const end = source.indexOf("\n", index);
1644
+ index = end === -1 ? source.length : end;
1645
+ }
1646
+ else if (char === "/" && next === "*") {
1647
+ const end = source.indexOf("*/", index + 2);
1648
+ index = end === -1 ? source.length : end + 2;
1649
+ }
1650
+ else if (char === "/" && (previous === "" || "(,=:[!&|?{};".includes(previous))) {
1651
+ index = regexEnd(source, index);
1652
+ previous = char;
1653
+ }
1654
+ else if (/[A-Za-z_$]/.test(char)) {
1655
+ identifier.lastIndex = index;
1656
+ const [name = char] = identifier.exec(source) ?? [];
1657
+ found.add(name);
1658
+ index += name.length;
1659
+ previous = "a";
1660
+ }
1661
+ else {
1662
+ if (char === "}" && nested && depth === 0)
1663
+ return index + 1;
1664
+ if (char === "{")
1665
+ depth += 1;
1666
+ if (char === "}")
1667
+ depth -= 1;
1668
+ if (!/\s/.test(char))
1669
+ previous = char;
1670
+ index += 1;
1671
+ }
1672
+ }
1673
+ return index;
1674
+ }
1675
+ /** Past a template literal opened at `start`, collecting the code in each `${...}` on the way. */
1676
+ function scanTemplate(source, start, found) {
1677
+ let index = start + 1;
1678
+ while (index < source.length) {
1679
+ const char = source.charAt(index);
1680
+ if (char === "\\")
1681
+ index += 2;
1682
+ else if (char === "`")
1683
+ return index + 1;
1684
+ else if (char === "$" && source.charAt(index + 1) === "{") {
1685
+ index = scanCode(source, index + 2, found, true);
1686
+ }
1687
+ else
1688
+ index += 1;
1689
+ }
1690
+ return index;
1691
+ }
1692
+ /** Past a quoted string opened at `start`, escapes honoured. */
1693
+ function quotedEnd(source, start, quote) {
1694
+ for (let index = start + 1; index < source.length; index += 1) {
1695
+ const char = source.charAt(index);
1696
+ if (char === "\\")
1697
+ index += 1;
1698
+ else if (char === quote)
1699
+ return index + 1;
1700
+ }
1701
+ return source.length;
1702
+ }
1703
+ /** Past a regular-expression literal opened at `start`, flags included. */
1704
+ function regexEnd(source, start) {
1705
+ let inClass = false;
1706
+ for (let index = start + 1; index < source.length; index += 1) {
1707
+ const char = source.charAt(index);
1708
+ if (char === "\\")
1709
+ index += 1;
1710
+ else if (char === "[")
1711
+ inClass = true;
1712
+ else if (char === "]")
1713
+ inClass = false;
1714
+ else if (char === "/" && !inClass) {
1715
+ let end = index + 1;
1716
+ while (/[a-z]/.test(source.charAt(end)))
1717
+ end += 1;
1718
+ return end;
1719
+ }
1720
+ }
1721
+ return source.length;
1415
1722
  }
1416
1723
  /** Whether emitted source references `name` as an identifier rather than as part of a longer one. */
1417
1724
  export function referencedIn(source, name) {
@@ -1423,7 +1730,7 @@ function renderExternalImports(externals, ...bodies) {
1423
1730
  for (const [module, names] of externals) {
1424
1731
  const used = [...names].filter((name) => referencedIn(haystack, name));
1425
1732
  if (used.length > 0) {
1426
- lines.push(`import { ${used.toSorted().join(", ")} } from ${JSON.stringify(module)};\n`);
1733
+ lines.push(`import { ${used.toSorted().join(", ")} } from ${literal(module)};\n`);
1427
1734
  }
1428
1735
  }
1429
1736
  return lines.join("");
@@ -1562,10 +1869,9 @@ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
1562
1869
  * `p?: T` directly - at every depth, which a shallow mapped type never reached - so the narrowing
1563
1870
  * happens once, in the schema, and this helper has nothing left to do.
1564
1871
  *
1565
- * The contract types in `requests.gen.ts` still keep `?: T | undefined` on purpose: they are the
1566
- * floor a PRODUCER supplies, and `{ p: undefined }` serialises identically to omitting `p`, so
1567
- * refusing it there would cost every producer a conditional spread and buy the wire nothing.
1568
- * Narrowing THAT surface was tried in `0.19.0` and reverted - it broke `(ctx, input) => ok(input)`.
1872
+ * The contract types in `requests.gen.ts` say the same since `0.28.0`: `?: T`, and
1873
+ * `?: T | undefined` only where `.default()` accepts an explicit `undefined`. They had kept it
1874
+ * everywhere on reasons that stopped holding once this schema moved - see `propertyToTs`.
1569
1875
  */
1570
1876
  const usesZod = new Set([...declarations, ...routeDeclarations].join("").match(/[A-Za-z_$][\w$]*/g) ?? []).has("z");
1571
1877
  const parts = [generatedBanner()];
@@ -1747,6 +2053,7 @@ function nameRouteSchemas(routes, compileSchemas) {
1747
2053
  query,
1748
2054
  queryFields: gathers ? queryFields : query,
1749
2055
  header,
2056
+ selectedAccept: negotiating ? route.accept : undefined,
1750
2057
  cookie,
1751
2058
  body,
1752
2059
  arms,
@@ -1772,128 +2079,135 @@ function responseArmsOf(route, arms) {
1772
2079
  const schema = arms[index]?.schema ?? "undefined";
1773
2080
  const contentTypes = response.contentTypes.length === 0
1774
2081
  ? ""
1775
- : `, contentTypes: [${response.contentTypes.map((type) => JSON.stringify(type)).join(", ")}]`;
2082
+ : `, contentTypes: [${response.contentTypes.map((type) => literal(type)).join(", ")}]`;
1776
2083
  const headers = response.headers.length === 0
1777
2084
  ? ""
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} }`;
2085
+ : `, headers: [${response.headers.map((header) => `{ name: ${literal(header.name)}, optional: ${header.optional} }`).join(", ")}]`;
2086
+ return `{ status: ${literal(response.status)}, schema: ${schema}${contentTypes}${headers} }`;
1780
2087
  });
1781
2088
  return `[${rendered.join(", ")}]`;
1782
2089
  }
1783
- const upperFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
1784
2090
  /**
1785
- * The effective input of every operation, as TypeScript: its body, with the path, query and header
1786
- * parameters merged over it.
2091
+ * Every operation's input, as TypeScript, exactly as a handler receives it.
2092
+ *
2093
+ * **One placement rule, the one the server emitters render from.** A model body is merged with the
2094
+ * path, query, header and cookie parameters; a body the merge cannot express is named under its
2095
+ * parameter's name, optional where the document lets it be absent; a `bytes` body is handed over
2096
+ * unread. {@link requestBodyPlacementOf} decides all three, for this walk and for
2097
+ * {@link EmittedRoute} alike. This walk used to merge every body, so for a union, an array, an
2098
+ * indexed model, an optional body, a scalar or an enum, `WireInputs` described an input no handler is
2099
+ * given - and one consumer carried a shim re-flattening the handler's input to reach a backend typed
2100
+ * by it, after the divergence cost it 171 failing tests.
2101
+ *
2102
+ * **Every operation, whatever it takes and whatever it answers.** An operation with no input is
2103
+ * `Record<string, never>`, which is what a handler for it is passed; one whose only success is a
2104
+ * range is as much an operation as any other. Both used to be skipped, so `keyof WireInputs` could
2105
+ * not enumerate a document's operations, and a consumer named the missing ones by hand.
1787
2106
  *
1788
2107
  * **This used to drop any parameter literally named `companyId`.** One application carried its
1789
2108
  * tenant identifier in a context object rather than in the payload, and the emitter was taught to
1790
2109
  * match - so for every consumer, a path parameter with that name vanished from the generated input
1791
2110
  * type while the Zod validator went on requiring it. Two artefacts describing different shapes, from
1792
2111
  * a rule no document states.
1793
- *
1794
- * It is the same defect class this emitter exists to delete, and it hid better than most: the
1795
- * emitted assertions pair the validator against the contract types for MODELS, so a disagreement
1796
- * about a merged input type is not among the things they compare.
1797
2112
  */
2113
+ /** A body type the contract declares by name, rather than spells inline: a model, union or enum. */
2114
+ function declaresComponent(type) {
2115
+ return type.kind === "Model" || type.kind === "Union" || type.kind === "Enum";
2116
+ }
1798
2117
  function collectRequestTypes(program, registry, service) {
1799
2118
  const entries = [];
1800
- {
1801
- for (const operation of service.operations) {
1802
- if (successStatusOf(operation) === undefined)
1803
- continue;
2119
+ for (const operation of service.operations) {
2120
+ /**
2121
+ * **Walked at the operation's REQUEST visibility, which is the whole point of this file.**
2122
+ *
2123
+ * `resolveRequestVisibility` is `@typespec/http`'s own answer - POST is Create, PATCH is
2124
+ * Update, PUT is CreateOrUpdate - and it is what the Zod walk already uses at the route level.
2125
+ * Without it this walk ran at the default `Read`, so a POST body's contract type carried the
2126
+ * canonical property set while the validator beside it accepted the create projection. A
2127
+ * caller satisfying the published type sent read-only fields and a strict validator answered
2128
+ * 400.
2129
+ *
2130
+ * It is also what keeps `metadataInfo` scoped: the predicates `propertyToTs` consults live
2131
+ * behind `withVisibility`, and calling them outside one leaves a module-level singleton built
2132
+ * for the wrong program.
2133
+ */
2134
+ withVisibility(program, resolveRequestVisibility(program, operation.operation, operation.verb), () => {
2135
+ const body = requestBodyOf(operation);
2136
+ const placement = requestBodyPlacementOf(program, operation);
2137
+ /** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
2138
+ const parameterBody = operation.parameters.body;
1804
2139
  /**
1805
- * **Walked at the operation's REQUEST visibility, which is the whole point of this file.**
1806
- *
1807
- * `resolveRequestVisibility` is `@typespec/http`'s own answer - POST is Create, PATCH is
1808
- * Update, PUT is CreateOrUpdate - and it is what the Zod walk already uses at the route
1809
- * level. Without it this walk ran at the default `Read`, so a POST body's contract type
1810
- * carried the canonical property set while the validator beside it accepted the create
1811
- * projection. A caller satisfying the published type sent read-only fields and a strict
1812
- * validator answered 400.
2140
+ * Headers are part of the input too.
1813
2141
  *
1814
- * It is also what keeps `metadataInfo` scoped: the predicates `propertyToTs` consults live
1815
- * behind `withVisibility`, and calling them outside one leaves a module-level singleton
1816
- * built for the wrong program.
2142
+ * The webhook's three Standard-Webhooks headers are inside the MAC - they are not metadata
2143
+ * beside the payload, they are arguments to the verification. Leaving them out of the wire
2144
+ * type would describe an input the application cannot act on.
1817
2145
  */
1818
- withVisibility(program, resolveRequestVisibility(program, operation.operation, operation.verb), () => {
1819
- const body = requestBodyOf(operation);
1820
- /** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
1821
- const multipartBody = operation.parameters.body;
1822
- const multipart = multipartBody?.bodyKind === "multipart" ? { parts: multipartBody.parts } : undefined;
2146
+ const extraProperties = operation.parameters.parameters
2147
+ .filter((parameter) => parameter.type === "path" ||
2148
+ parameter.type === "query" ||
2149
+ parameter.type === "header" ||
2150
+ // The second half of the same omission: a validator with no matching type.
2151
+ parameter.type === "cookie")
1823
2152
  /**
1824
- * Headers are part of the input too.
1825
- *
1826
- * The webhook's three Standard-Webhooks headers are inside the MAC - they are not metadata
1827
- * beside the payload, they are arguments to the verification. Leaving them out of the wire
1828
- * type would describe an input the application cannot act on.
2153
+ * Named as the WIRE names it, which is what the emitted validator keys on.
2154
+ * `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
2155
+ * `"x-thing"` here too: a consumer checking its handlers against these types otherwise
2156
+ * fails to typecheck against a server that is correct.
1829
2157
  */
1830
- const extraProperties = operation.parameters.parameters
1831
- .filter((parameter) => parameter.type === "path" ||
1832
- parameter.type === "query" ||
1833
- parameter.type === "header" ||
1834
- // The second half of the same omission: a validator with no matching type.
1835
- parameter.type === "cookie")
1836
- /**
1837
- * Named as the WIRE names it, which is what the emitted validator keys on.
1838
- * `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
1839
- * `"x-thing"` here too: a consumer checking its handlers against these types otherwise
1840
- * fails to typecheck against a server that is correct.
1841
- */
1842
- .map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
1843
- // `return` rather than `continue`: this body is a callback now, and returning from it
1844
- // skips the operation exactly as the `continue` did.
1845
- if (body === undefined && extraProperties.length === 0)
1846
- return;
2158
+ .map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
2159
+ const fileContentType = fileContentTypeOf(operation);
2160
+ if (fileContentType !== undefined) {
2161
+ extraProperties.push(`${objectKey("Content-Type")}: ${registry.expressionFor(fileContentType.type)};`);
2162
+ }
2163
+ const parts = [];
2164
+ let streamsBody = false;
2165
+ let bodyRef;
2166
+ if (placement.rawBodyProperty !== undefined) {
1847
2167
  /**
1848
- * A `bytes` body reaches the use-case as a NAMED property holding the unparsed body, not as
1849
- * a bare value, so the wire type is `{ rawBody: ... }`, matching what a server actually
1850
- * builds. Referencing the scalar directly would type the whole input as that value and
1851
- * quietly drop the headers beside it.
2168
+ * **A binary body is the unread stream, and is spelled as one without naming
2169
+ * `ReadableStream`.** The server hands it over unread, so a backend it is forwarded to
2170
+ * receives a stream; a contract saying `ArrayBuffer` made a gateway read a 25 MiB upload
2171
+ * into an isolate to satisfy it. See `BYTE_STREAM`. A `bytes` body under a JSON type is
2172
+ * the text a signature covers, and arrives as a string.
1852
2173
  */
1853
- const rawBody = body?.kind === "Scalar" && body.name === "bytes"
1854
- ? (operation.parameters.body?.property?.name ?? "body")
1855
- : undefined;
2174
+ streamsBody = placement.rawBodyBinary;
2175
+ parts.push(`{\n\t${objectKey(placement.rawBodyProperty)}: ${streamsBody ? "ByteStream | null" : "string"};\n}`);
1856
2176
  /**
1857
- * **Raw binary is `ArrayBuffer`; base64 inside a JSON payload is `string`.** The same
1858
- * `isRawBinaryMediaType` rule the response side and the multipart parts already follow,
1859
- * applied here for the first time.
1860
- *
1861
- * This type said `string` for every `bytes` body whatever it was served as. A server reading
1862
- * an `application/octet-stream` body hands the handler bytes, so a consumer checking its
1863
- * handlers against these types could not typecheck against a correct server. It is the
1864
- * request half of the corruption already fixed on the reader: decoding raw bytes as text
1865
- * replaces every byte outside ASCII, and typing them as text is what invites it.
2177
+ * **The model a body nothing parses names is still declared**, as the document declares
2178
+ * it, though the input is the text or the bytes that arrived. 0.27.0 declared it, and a
2179
+ * consumer importing it lost the import when the body became raw.
1866
2180
  */
1867
- const rawBodyType = isRawBinaryMediaType([
1868
- ...(operation.parameters.body?.contentTypes ?? []),
1869
- ])
1870
- ? "ArrayBuffer"
1871
- : "string";
1872
- entries.push({
1873
- operationId: operationIdOf(program, operation.operation),
1874
- bodyRef: rawBody !== undefined
1875
- ? `{\n\t${rawBody}: ${rawBodyType};\n}`
1876
- : multipart !== undefined
1877
- ? multipartTsOf(multipart, registry)
1878
- : body === undefined
1879
- ? undefined
1880
- : registry.expressionFor(body),
1881
- extraProperties,
1882
- });
2181
+ if (body !== undefined && declaresComponent(body))
2182
+ registry.expressionFor(body);
2183
+ }
2184
+ else {
2185
+ const expression = parameterBody?.bodyKind === "multipart"
2186
+ ? multipartTsOf({ parts: parameterBody.parts }, registry)
2187
+ : body === undefined
2188
+ ? undefined
2189
+ : registry.expressionFor(body);
2190
+ if (parameterBody?.bodyKind !== "multipart")
2191
+ bodyRef = expression;
2192
+ if (expression !== undefined) {
2193
+ parts.push(placement.bodyProperty === undefined
2194
+ ? expression
2195
+ : `{\n\t${objectKey(placement.bodyProperty)}${placement.optionalBody ? "?" : ""}: ${expression.replaceAll("\n", "\n\t")};\n}`);
2196
+ }
2197
+ }
2198
+ if (extraProperties.length > 0) {
2199
+ parts.push(`{\n${extraProperties.map((line) => `\t${line}`).join("\n")}\n}`);
2200
+ }
2201
+ entries.push({
2202
+ operationId: operationIdOf(program, operation.operation),
2203
+ bodyRef,
2204
+ input: parts.length === 0 ? "Record<string, never>" : `Simplify<${parts.join(" & ")}>`,
2205
+ streamsBody,
1883
2206
  });
1884
- }
2207
+ });
1885
2208
  }
1886
2209
  return entries;
1887
2210
  }
1888
- /**
1889
- * The success response every operation declares, so a producer can be held to producing it.
1890
- *
1891
- * **Pinned fields only - these are PERMISSIVE shapes.** The gateway names the fields it depends on
1892
- * and lets a richer view through untouched, so this is a floor and not an equality: the
1893
- * domain's result must CONTAIN what the document promises, and may contain more. Asserting equality
1894
- * would turn every field a producer legitimately adds into a build failure, which is the same mistake
1895
- * as making a response schema strict.
1896
- */
1897
2211
  function collectResponseTypes(program, registry, service) {
1898
2212
  const entries = [];
1899
2213
  /**
@@ -1906,20 +2220,135 @@ function collectResponseTypes(program, registry, service) {
1906
2220
  */
1907
2221
  withVisibility(program, Visibility.Read, () => {
1908
2222
  for (const operation of service.operations) {
1909
- const status = successStatusOf(operation);
1910
- if (status === undefined)
1911
- continue;
1912
- const body = successBodyOf(operation, status);
1913
- if (body === undefined)
1914
- continue;
2223
+ /**
2224
+ * **The same responses, and the same members, the server emitters render.** This walk used
2225
+ * to ask for the lowest success status and its body, so an operation answering `200 Thing`
2226
+ * or `201 Other` published `Thing`, one answering `200 Thing` or `204` published `Thing`,
2227
+ * and one answering only `204` was absent - while the runtime beside it carried every arm.
2228
+ */
2229
+ const responses = inPrecedenceOrder(resolvedResponsesOf(program, operation));
2230
+ const hasBody = (response) => response.body !== undefined;
2231
+ let streams = false;
2232
+ const bodyRefs = [];
2233
+ /**
2234
+ * **What a producer SUPPLIES for a body, which is what the server emitters accept for it.** A
2235
+ * stream or a raw binary body is handed over as bytes and never parsed, so it is spelled as
2236
+ * bytes - `ByteStream` rather than the platform stream, for the same reason as a request body.
2237
+ * A model under a media type that is not JSON is what the producer serialised: text under a
2238
+ * textual media type, bytes under any other. Everything else is the producer's view of the
2239
+ * contract type.
2240
+ */
2241
+ const bodyOf = (member) => {
2242
+ // A model a body served unvalidated names is declared all the same, as the request side
2243
+ // declares one under a media type nothing parses: the document declares it either way.
2244
+ if (member.unvalidated &&
2245
+ member.response.body !== undefined &&
2246
+ declaresComponent(member.response.body)) {
2247
+ registry.expressionFor(member.response.body);
2248
+ }
2249
+ switch (member.serving) {
2250
+ case "none":
2251
+ return undefined;
2252
+ case "stream":
2253
+ streams = true;
2254
+ return "ByteStream";
2255
+ case "binary":
2256
+ streams = true;
2257
+ return "ByteStream | Uint8Array<ArrayBuffer> | ArrayBuffer";
2258
+ case "unvalidated":
2259
+ return "string";
2260
+ default: {
2261
+ if (member.response.body === undefined)
2262
+ return undefined;
2263
+ const ref = registry.expressionFor(member.response.body);
2264
+ bodyRefs.push(ref);
2265
+ return `Produced<${ref}>`;
2266
+ }
2267
+ }
2268
+ };
2269
+ const members = resultMembersOf(responses, hasBody).map((member) => {
2270
+ const body = bodyOf(member);
2271
+ const codes = statusCodesOf(member.response, responses, hasBody);
2272
+ const fields = [`readonly status: ${codes.length === 0 ? "never" : codes.join(" | ")}`];
2273
+ if (member.choosesMediaType && member.mediaType !== undefined) {
2274
+ fields.push(`readonly contentType: ${mediaTypeTypeOf(member.mediaType)}`);
2275
+ }
2276
+ fields.push(body === undefined ? "readonly body?: undefined" : `readonly body: ${body}`);
2277
+ const headers = member.response.headers;
2278
+ if (headers.length > 0) {
2279
+ /**
2280
+ * Keyed by the WIRE name, as the document publishes it. `headers` is optional only when
2281
+ * every header in it is, and an optional header admits an explicit `undefined`, because
2282
+ * a server drops one before it checks and sends the rest.
2283
+ */
2284
+ const every = headers.every(({ property }) => property.optional);
2285
+ const rendered = headers
2286
+ .map(({ name, property }) => property.optional
2287
+ ? `readonly ${objectKey(name)}?: ${propertyTypeToTs(program, property)} | undefined`
2288
+ : `readonly ${objectKey(name)}: ${propertyTypeToTs(program, property)}`)
2289
+ .join("; ");
2290
+ fields.push(`readonly headers${every ? "?" : ""}: { ${rendered} }`);
2291
+ }
2292
+ return { member, body, text: `{ ${fields.join("; ")} }` };
2293
+ });
2294
+ /**
2295
+ * A success is a `2xx` or `3xx` outcome, exact or ranged, as {@link successStatusesOf} holds.
2296
+ * `default` is not one: it is every status nothing else declares, which is mostly failures.
2297
+ */
2298
+ const outputs = [
2299
+ ...new Set(members
2300
+ .filter(({ member }) => isSuccessStatus(member.response.status))
2301
+ .map(({ body }) => body ?? "undefined")),
2302
+ ];
1915
2303
  entries.push({
1916
2304
  operationId: operationIdOf(program, operation.operation),
1917
- ref: registry.expressionFor(body),
2305
+ output: outputs.length === 0 ? "never" : outputs.join(" | "),
2306
+ result: members.map(({ text }) => text),
2307
+ streams,
2308
+ bodyRefs,
1918
2309
  });
1919
2310
  }
1920
2311
  });
1921
2312
  return entries;
1922
2313
  }
2314
+ /** A `2xx` or `3xx` status, exact or as a range: an outcome a handler returns rather than a failure. */
2315
+ function isSuccessStatus(status) {
2316
+ if (typeof status === "number")
2317
+ return status >= 200 && status < 400;
2318
+ return status === "2XX" || status === "3XX";
2319
+ }
2320
+ /**
2321
+ * **A stream of bytes, spelled as the reader every stream has rather than as `ReadableStream`.**
2322
+ *
2323
+ * The server hands a binary request body over unread, so the contract has to say "stream" - a
2324
+ * contract saying `ArrayBuffer` made a gateway read a 25 MiB upload into an isolate just to satisfy
2325
+ * a backend's type. But `requests.gen.ts` is framework-free, and a contracts package compiled under
2326
+ * `lib: ["ES2024"]` and no `types` cannot name `ReadableStream` at all (`TS2304`); only lib.dom,
2327
+ * lib.webworker, `@cloudflare/workers-types` and `@types/node` declare it.
2328
+ *
2329
+ * **This is the part of a stream every one of those declares, and nothing else.** Measured: a platform
2330
+ * `ReadableStream<Uint8Array> | null` is assignable to it under the default libs, lib.dom,
2331
+ * lib.webworker, workers-types and `@types/node`, on TypeScript 5.9.3 and 7.0.2, while an
2332
+ * `ArrayBuffer` is refused. `AsyncIterable<Uint8Array>`, the obvious alternative, is NOT satisfied
2333
+ * by lib.dom's stream on TypeScript 5.9 unless `dom.asynciterable` is also listed - which the usual
2334
+ * `["ES2020", "DOM", "DOM.Iterable"]` does not.
2335
+ *
2336
+ * The same precedent as a multipart file part: a type only an ambient library declares is spelled
2337
+ * structurally, so the emitted output compiles wherever it lands.
2338
+ */
2339
+ const BYTE_STREAM = `
2340
+ /**
2341
+ * A stream of bytes, as every platform's \`ReadableStream<Uint8Array>\` satisfies it - spelled
2342
+ * structurally so this file needs no DOM or runtime library to name it.
2343
+ */
2344
+ export interface ByteStream {
2345
+ getReader(): {
2346
+ read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array | undefined }>;
2347
+ releaseLock(): void;
2348
+ cancel?(reason?: unknown): Promise<void>;
2349
+ };
2350
+ }
2351
+ `;
1923
2352
  /**
1924
2353
  * `requests.gen.ts` - the shape both sides are checked against.
1925
2354
  *
@@ -1952,29 +2381,29 @@ function renderRequestTypes(entries, responses, registry) {
1952
2381
  ? `\nexport interface ${declaration.name} ${declaration.source}\n`
1953
2382
  : `\nexport type ${declaration.name} = ${declaration.source};\n`;
1954
2383
  });
1955
- const inputs = entries.map((entry) => {
1956
- const parts = [];
1957
- if (entry.bodyRef !== undefined)
1958
- parts.push(entry.bodyRef);
1959
- if (entry.extraProperties.length > 0) {
1960
- parts.push(`{\n${entry.extraProperties.map((line) => `\t${line}`).join("\n")}\n}`);
1961
- }
1962
- return (`\n/** The effective input for \`${entry.operationId}\` - its body, with path and query parameters merged over it. */\n` +
1963
- `export type ${upperFirst(entry.operationId)}Input = Simplify<${parts.join(" & ")}>;\n`);
1964
- });
2384
+ const responseLookup = responses
2385
+ .map((entry) => `\t/** Every success body \`${entry.operationId}\` declares. */\n\t${entry.operationId}: ${entry.output};`)
2386
+ .join("\n");
1965
2387
  /**
1966
- * The same set again, keyed by operation id.
1967
- *
1968
- * TypeScript cannot index a module's *type* exports by a computed name - `typeof Contracts` carries
1969
- * only the value side, so `Contracts["AppointOfficerInput"]` does not resolve. Both sides'
1970
- * assertions are written as mapped types over every operation, and this is what makes that
1971
- * possible. Generated with the aliases above, so it cannot fall behind them.
2388
+ * **One member per declared response and media type, each pairing a status with its own body.**
2389
+ * The union a server emitter's handlers return, stated with nothing to import: a backend reached
2390
+ * over a service binding returns `WireResults[op]`, and a handler can hand it straight on.
1972
2391
  */
1973
- const responseLookup = responses
1974
- .map((entry) => `\t${entry.operationId}: Produced<${entry.ref}>;`)
2392
+ const resultLookup = responses
2393
+ .map((entry) => `\t/** Every response \`${entry.operationId}\` declares. */\n\t${entry.operationId}:${entry.result.length === 0 ? " never" : entry.result.map((member) => `\n\t\t| ${member}`).join("")};`)
1975
2394
  .join("\n");
2395
+ /**
2396
+ * **Keyed by operation id, and the only name an operation's input has.**
2397
+ *
2398
+ * Each input used to be declared a second time as an alias, `CreateUserInput` for `createUser`,
2399
+ * and this lookup referenced the aliases. That is a name a spec may well give its own request
2400
+ * model, so the two declarations collided (`TS2300`) and `requests.gen.ts` did not
2401
+ * compile for an operation as ordinary as `createUser(@body body: CreateUserInput)`. A type
2402
+ * exported under a name this emitter invents is a name a spec author can already be using.
2403
+ * `WireInputs["createUser"]` is the same type, and nothing a spec declares can collide with it.
2404
+ */
1976
2405
  const lookup = entries
1977
- .map((entry) => `\t${entry.operationId}: ${upperFirst(entry.operationId)}Input;`)
2406
+ .map((entry) => `\t/** The input for \`${entry.operationId}\`, as its handler receives it. */\n\t${entry.operationId}: ${entry.input.replaceAll("\n", "\n\t")};`)
1978
2407
  .join("\n");
1979
2408
  /**
1980
2409
  * **Emitted only where an operation has something to flatten.**
@@ -1986,9 +2415,9 @@ function renderRequestTypes(entries, responses, registry) {
1986
2415
  * `TS6196: 'Simplify' is declared but never used.` Third of its kind, after the unconditional
1987
2416
  * `zValidator` and `z` imports.
1988
2417
  *
1989
- * Decided from the aliases actually rendered rather than by searching the output for the name.
2418
+ * Decided from the inputs actually rendered rather than by searching the output for the name.
1990
2419
  */
1991
- const simplifyHelper = inputs.length === 0
2420
+ const simplifyHelper = !entries.some((entry) => entry.input.startsWith("Simplify<"))
1992
2421
  ? ""
1993
2422
  : `/**
1994
2423
  * Flattens an intersection into a single object type.
@@ -2024,14 +2453,19 @@ export type Produced<T> = T extends readonly (infer Element)[]
2024
2453
  : T extends object
2025
2454
  ? { readonly [K in keyof T]: Produced<T[K]> }
2026
2455
  : T;
2027
- ${declarations.join("")}${inputs.join("")}
2028
- /** Every operation's effective input, keyed by operation id - what both sides assert against. */
2456
+ ${entries.some((entry) => entry.streamsBody) || responses.some((entry) => entry.streams) ? BYTE_STREAM : ""}${declarations.join("")}
2457
+ /**
2458
+ * Every operation's input, keyed by operation id: what its handler receives, and what a caller of a
2459
+ * backend typed by it hands over. An operation that takes nothing is \`Record<string, never>\`.
2460
+ */
2029
2461
  export interface WireInputs {
2030
2462
  ${lookup}
2031
2463
  }
2032
2464
 
2033
2465
  /**
2034
- * Every operation's success response, keyed by operation id.
2466
+ * Every operation's success bodies, keyed by operation id: the body of each \`2xx\` and \`3xx\` response it
2467
+ * declares, \`undefined\` for one with no body, and \`never\` where it declares no success at all. Which
2468
+ * status goes with which body is \`WireResults\`.
2035
2469
  *
2036
2470
  * **A floor, not an equality.** These shapes are permissive: a caller pins the fields it depends
2037
2471
  * on and lets a richer view through, so a producer's result must CONTAIN what the document promises
@@ -2041,6 +2475,15 @@ ${lookup}
2041
2475
  export interface WireOutputs {
2042
2476
  ${responseLookup}
2043
2477
  }
2478
+
2479
+ /**
2480
+ * Every operation's result, keyed by operation id: one member per response it declares, each pairing
2481
+ * a status with that status's body and headers. A status no response declares does not compile, and
2482
+ * a \`4XX\` or \`default\` response is the codes it stands for, written out.
2483
+ */
2484
+ export interface WireResults {
2485
+ ${resultLookup}
2486
+ }
2044
2487
  `;
2045
2488
  }
2046
2489
  /**
@@ -2078,16 +2521,25 @@ ${responseLookup}
2078
2521
  * `Equals` here, not assignability: the two describe one shape, and a property present on one side
2079
2522
  * and absent from the other is exactly the drift this exists to catch.
2080
2523
  */
2081
- function renderWireAssertions(schemas, types, entries, contractsPackage) {
2524
+ function renderWireAssertions(schemas, types, entries, responses, contractsPackage) {
2082
2525
  // The assertions pair the emitted Zod against the request TYPES, which live in the contracts
2083
2526
  // package. With no package named there is nothing to import them from and nothing to pair - the
2084
2527
  // types are still written to `contracts-output-dir`, they simply have no shared specifier yet.
2085
2528
  if (contractsPackage === undefined)
2086
2529
  return undefined;
2087
- const requestModels = new Set(entries.map((entry) => entry.bodyRef).filter((ref) => ref !== undefined));
2530
+ /**
2531
+ * **Every model a body names, the responses' as well as the requests'.** This paired request
2532
+ * bodies only, so a model that is only ever SENT was compared with nothing; that was harmless
2533
+ * while the contract types described a single success body and nothing consumed it, and is not now
2534
+ * that `WireResults` states every response a backend returns.
2535
+ */
2536
+ const bodyModels = new Set([
2537
+ ...entries.map((entry) => entry.bodyRef).filter((ref) => ref !== undefined),
2538
+ ...responses.flatMap((entry) => entry.bodyRefs),
2539
+ ]);
2088
2540
  const paired = [];
2089
2541
  for (const declaration of types.declarations()) {
2090
- if (!requestModels.has(declaration.name))
2542
+ if (!bodyModels.has(declaration.name))
2091
2543
  continue;
2092
2544
  const schema = schemas
2093
2545
  .declarations()
@@ -2148,26 +2600,6 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
2148
2600
  * dropped from a shape that has other keys, and kept - with its value type still mapped - from one
2149
2601
  * that does not.
2150
2602
  */
2151
- /**
2152
- * **An optional property is \`?: T\` here and \`?: T | undefined\` in the contract type, and that is
2153
- * deliberate on both sides.**
2154
- *
2155
- * The validator emits \`.exactOptional()\`, so what ARRIVES is absent or a value - JSON cannot carry an
2156
- * \`undefined\` and the document says exactly that by leaving the property out of \`required\`. The
2157
- * contract type is the floor a PRODUCER supplies, and \`{ p: undefined }\` serialises identically to
2158
- * omitting \`p\`, so refusing it would cost every producer a conditional spread and buy the wire
2159
- * nothing. Narrowing THAT surface was tried in \`0.19.0\` and reverted: it broke
2160
- * \`(ctx, input) => ok(input)\`.
2161
- *
2162
- * So the two disagree on exactly one axis, on purpose, and this widens the inferred side to the
2163
- * producer floor before comparing - the same job \`Declared\` does for openness, in the same pass
2164
- * rather than a second deep one. Every other kind of drift still fails: a property that changes type,
2165
- * appears, disappears or changes optionality is untouched by this.
2166
- */
2167
- type Supplied<T, K extends keyof T> = {} extends Pick<T, K>
2168
- ? Declared<T[K]> | undefined
2169
- : Declared<T[K]>;
2170
-
2171
2603
  type DeclaredKeyOf<T> = keyof {
2172
2604
  [K in keyof T as string extends K
2173
2605
  ? never
@@ -2196,9 +2628,8 @@ type Declared<T> = T extends (...args: never[]) => unknown
2196
2628
  // equate a real dictionary with an empty object: a dictionary whose values are
2197
2629
  // \`never\` accepts nothing either.
2198
2630
  {}
2199
- : // A shape with no declared keys is a dictionary: it has no optional property to
2200
- // widen, and an index signature satisfies \`{} extends Pick<T, K>\` for every
2201
- // value type.
2631
+ : // A shape with no declared keys is a dictionary: its index signature is kept and
2632
+ // its value type mapped.
2202
2633
  { [K in keyof T]: Declared<T[K]> }
2203
2634
  : {
2204
2635
  [K in keyof T as string extends K
@@ -2207,7 +2638,7 @@ type Declared<T> = T extends (...args: never[]) => unknown
2207
2638
  ? never
2208
2639
  : symbol extends K
2209
2640
  ? never
2210
- : K]: Supplied<T, K>;
2641
+ : K]: Declared<T[K]>;
2211
2642
  }
2212
2643
  : T;
2213
2644
 
@@ -2232,7 +2663,7 @@ ${assertions.join("\n")}
2232
2663
  function renderVocabularies(vocabularies) {
2233
2664
  const entries = [...vocabularies.entries()]
2234
2665
  .toSorted(([a], [b]) => a.localeCompare(b))
2235
- .map(([name, values]) => `\t${name}: [${values.map((v) => JSON.stringify(v)).join(", ")}],`);
2666
+ .map(([name, values]) => `\t${name}: [${values.map((v) => literal(v)).join(", ")}],`);
2236
2667
  return `${generatedBanner()}
2237
2668
  /**
2238
2669
  * The spec's vocabularies - every enum the surface reaches, and what each one contains.
@@ -2342,6 +2773,8 @@ export async function emitHttpZod(context) {
2342
2773
  * there means a server emitter follows without knowing this rule exists.
2343
2774
  */
2344
2775
  const distinctServices = new Set(snapshots.map((snapshot) => snapshot.service.namespace.name));
2776
+ /** The services writing to the top-level `contracts-output-dir`: every one not naming its own. */
2777
+ const sharingContracts = [...distinctServices].filter((name) => options.services?.[name]?.["contracts-output-dir"] === undefined);
2345
2778
  /**
2346
2779
  * **Every service must seal the same way, because `@typespec/openapi3` has no per-service
2347
2780
  * options.**
@@ -2386,8 +2819,22 @@ export async function emitHttpZod(context) {
2386
2819
  (distinctServices.size > 1
2387
2820
  ? resolvePath(context.emitterOutputDir, serviceName)
2388
2821
  : context.emitterOutputDir);
2822
+ /**
2823
+ * **Split where two services would write one file, and nowhere else.** A shared contracts
2824
+ * directory was written once per service to one path, so the second service's
2825
+ * `requests.gen.ts` replaced the first's and a whole surface lost its contracts with no
2826
+ * diagnostic. Counted over the services that WRITE there: one service falling back to the
2827
+ * shared directory, beside others that name their own, collides with nothing, and splitting
2828
+ * it moved contracts a project already imported. A relative `contracts-package` is resolved
2829
+ * from each service's own `wire-contract.gen.ts`, so it names that service's contracts; a
2830
+ * package specifier has to be set per service.
2831
+ */
2832
+ const sharedContractsDir = options["contracts-output-dir"];
2389
2833
  const resolved = {
2390
- contractsOutputDir: perService?.["contracts-output-dir"] ?? options["contracts-output-dir"],
2834
+ contractsOutputDir: perService?.["contracts-output-dir"] ??
2835
+ (sharedContractsDir !== undefined && sharingContracts.length > 1
2836
+ ? resolvePath(sharedContractsDir, serviceName)
2837
+ : sharedContractsDir),
2391
2838
  contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
2392
2839
  sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
2393
2840
  compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
@@ -2453,8 +2900,15 @@ export async function emitHttpZod(context) {
2453
2900
  });
2454
2901
  if (resolved.contractsOutputDir !== undefined) {
2455
2902
  const types = new TypeRegistry(context.program);
2456
- const entries = collectRequestTypes(context.program, types, service);
2457
- const responses = collectResponseTypes(context.program, types, service);
2903
+ /**
2904
+ * **Inside the same stream scope as the route walk**, because both read the same response
2905
+ * records: outside it `streamedTypeOf` cannot see `@typespec/streams`, so an SSE body was
2906
+ * stated as the string the document describes rather than the stream a server sends.
2907
+ */
2908
+ const { entries, responses } = withStreamModule(streamModule, () => ({
2909
+ entries: collectRequestTypes(context.program, types, service),
2910
+ responses: collectResponseTypes(context.program, types, service),
2911
+ }));
2458
2912
  await emitFile(context.program, {
2459
2913
  path: resolvePath(resolved.contractsOutputDir, "requests.gen.ts"),
2460
2914
  content: renderRequestTypes(entries, responses, types),
@@ -2463,7 +2917,7 @@ export async function emitHttpZod(context) {
2463
2917
  path: resolvePath(resolved.contractsOutputDir, "vocabularies.gen.ts"),
2464
2918
  content: renderVocabularies(vocabularies),
2465
2919
  });
2466
- const assertions = renderWireAssertions(registry, types, entries, resolved.contractsPackage);
2920
+ const assertions = renderWireAssertions(registry, types, entries, responses, resolved.contractsPackage);
2467
2921
  if (assertions !== undefined) {
2468
2922
  await emitFile(context.program, {
2469
2923
  path: resolvePath(outputDir, "wire-contract.gen.ts"),
@@ -2471,7 +2925,12 @@ export async function emitHttpZod(context) {
2471
2925
  });
2472
2926
  }
2473
2927
  }
2474
- emitted.push({ service, routes, schemaNames: names, outputDir, options: resolved });
2928
+ // Still inside this service's id scope, so each operation resolves to the id its route carries.
2929
+ const operations = new Map(service.operations.map((operation) => [
2930
+ operationIdOf(context.program, operation.operation),
2931
+ operation,
2932
+ ]));
2933
+ emitted.push({ service, routes, schemaNames: names, operations, outputDir, options: resolved });
2475
2934
  }
2476
2935
  return emitted;
2477
2936
  }