typespec-http-zod 0.27.0 → 0.28.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,16 @@
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
5
  import { SchemaRegistry, TypeRegistry } from "./registry.js";
6
- import { typeToTs } from "./types.js";
6
+ import { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
7
+ import { propertyTypeToTs } from "./types.js";
7
8
  import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
8
9
  import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
9
10
  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";
11
+ export { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
12
+ export { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
13
+ import { collectExternalImports, isPayloadProperty, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
11
14
  /**
12
15
  * `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
13
16
  * service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
@@ -154,10 +157,10 @@ let operationIds;
154
157
  /**
155
158
  * Establish a fresh id resolver for one service's emission.
156
159
  *
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.
160
+ * **Scoped per SERVICE rather than per walk, because every walk has to name an operation the same
161
+ * way.** `collectRoutes` and `collectRequestTypes` both visit every operation, but each is a separate
162
+ * walk; deduplicating independently would let a suffix depend on the order a walk happened to meet
163
+ * two operations in, and the artefacts would name one operation two ways.
161
164
  *
162
165
  * A reset rather than a wrapper, because the emission loop is `for (const snapshot of snapshots)`
163
166
  * and awaits each service to completion before the next begins. Nothing interleaves, so there is no
@@ -275,7 +278,7 @@ function statusKeysOf(program, response) {
275
278
  return keys;
276
279
  }
277
280
  /**
278
- * Every response the operation declares, one per status key, in OpenAPI's precedence order.
281
+ * Every response the operation declares, one per status key, in the order they were RESOLVED.
279
282
  *
280
283
  * **Each response's own body, headers and media types, and nothing borrowed from another.** The
281
284
  * failure arms used to be collected here separately from the success, and before that every failure
@@ -288,10 +291,23 @@ function statusKeysOf(program, response) {
288
291
  * Where two responses resolve to the same status the first body wins, which is what the document
289
292
  * does, and media types and headers are the union across them.
290
293
  *
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.
294
+ * Resolution order, not precedence order, because a renderer that declares components has to
295
+ * declare them in the order they were always declared; {@link inPrecedenceOrder} sorts.
296
+ *
297
+ * **Resolved once per operation, and every walk reads that record.** Resolving reports what the
298
+ * document cannot publish, such as a status range OpenAPI cannot key; the route walk and the contract
299
+ * walk each resolved the same operation, so with contracts emitted every such error was raised twice.
293
300
  */
294
- function responsesOf(program, operation, registry) {
301
+ function resolvedResponsesOf(program, operation) {
302
+ const resolved = resolvedResponses.get(operation);
303
+ if (resolved !== undefined)
304
+ return resolved;
305
+ const fresh = resolveResponses(program, operation);
306
+ resolvedResponses.set(operation, fresh);
307
+ return fresh;
308
+ }
309
+ const resolvedResponses = new WeakMap();
310
+ function resolveResponses(program, operation) {
295
311
  const byStatus = new Map();
296
312
  for (const response of operation.responses) {
297
313
  for (const status of statusKeysOf(program, response)) {
@@ -302,30 +318,28 @@ function responsesOf(program, operation, registry) {
302
318
  contentTypes: new Set(),
303
319
  headers: [],
304
320
  streamed: false,
321
+ file: false,
305
322
  };
306
323
  for (const content of response.responses) {
307
324
  if (entry.body === undefined && content.body?.bodyKind === "single") {
308
325
  entry.body = bodyTypeOf(content.body.type);
309
326
  }
327
+ /**
328
+ * **An `Http.File` response's body is its contents, served as bytes.** The same reading
329
+ * as a file request body: a media type and nothing to serialise. Read only for
330
+ * `"single"`, every download in `type/file` declared `image/png` and was served empty.
331
+ */
332
+ if (entry.body === undefined && content.body?.bodyKind === "file") {
333
+ entry.body = content.body.contents.type;
334
+ entry.file = true;
335
+ }
310
336
  for (const contentType of content.body?.contentTypes ?? []) {
311
337
  entry.contentTypes.add(contentType);
312
338
  }
313
339
  for (const [name, property] of Object.entries(content.headers ?? {})) {
314
340
  if (entry.headers.some((existing) => existing.name === name))
315
341
  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
- });
342
+ entry.headers.push({ name, property });
329
343
  }
330
344
  }
331
345
  if (streamedTypeOf(program, response.type) !== undefined)
@@ -333,27 +347,73 @@ function responsesOf(program, operation, registry) {
333
347
  byStatus.set(key, entry);
334
348
  }
335
349
  }
336
- return [...byStatus.values()]
337
- .map((entry) => {
350
+ return [...byStatus.values()].map((entry) => {
338
351
  const contentTypes = [...entry.contentTypes];
339
- const binary = entry.body !== undefined && isBinaryPart(entry.body) && isRawBinaryMediaType(contentTypes);
340
352
  return {
341
353
  status: entry.status,
342
- schema: entry.body === undefined
343
- ? undefined
344
- : binary
345
- ? "z.unknown()"
346
- : registry.expressionFor(entry.body),
354
+ body: entry.body,
347
355
  contentTypes,
348
356
  headers: entry.headers,
349
- binary,
357
+ binary: entry.file ||
358
+ (entry.body !== undefined &&
359
+ isBinaryPart(entry.body) &&
360
+ isRawBinaryMediaType(contentTypes)),
350
361
  streamed: entry.streamed,
351
- textual: entry.body !== undefined && isStringType(entry.body),
362
+ textual: !entry.file && entry.body !== undefined && isStringType(entry.body),
352
363
  };
353
- })
354
- .toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status) ||
364
+ });
365
+ }
366
+ /** Responses in OpenAPI's precedence order: exact codes, then ranges, then `default`. */
367
+ function inPrecedenceOrder(responses) {
368
+ return responses.toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status) ||
355
369
  statusOrderOf(a.status) - statusOrderOf(b.status));
356
370
  }
371
+ /**
372
+ * Every response the operation declares, rendered as Zod, in OpenAPI's precedence order.
373
+ *
374
+ * Every header is rendered before any body, and bodies in precedence order, which is the order this
375
+ * has always declared components in: the emitted files do not move because the resolution moved.
376
+ *
377
+ * Call inside `withVisibility(program, Visibility.Read, ...)`: a response is read at `Read`, and
378
+ * resolving it under a request visibility declares components under the wrong suffix.
379
+ */
380
+ function responsesOf(program, operation, registry) {
381
+ const withHeaders = resolvedResponsesOf(program, operation).map((response) => ({
382
+ response,
383
+ headers: response.headers.map(({ name, property }) => ({
384
+ name,
385
+ /**
386
+ * **Encode-aware, as the schema beside it is.** `@encode(seconds, int32)` makes a `duration`
387
+ * an integer on the wire, and the header schema checks an integer; reading the declared type
388
+ * alone published `string`, so a handler satisfying its own result type was refused by its
389
+ * own header check and the arm could not be returned without a cast.
390
+ */
391
+ type: propertyTypeToTs(program, property),
392
+ /**
393
+ * **The Zod expression, which used to be discarded here.** Every REQUEST parameter goes
394
+ * through `expressionForProperty` a few hundred lines below; a response header went
395
+ * through `typeToTs` and nothing else, so the emitted arm carried a name and an
396
+ * optionality and no way to check a value. A response body that breaks its schema throws
397
+ * `ResponseContractError`; a header that breaks its own was served.
398
+ */
399
+ schema: registry.expressionForProperty(property),
400
+ optional: property.optional,
401
+ })),
402
+ }));
403
+ return inPrecedenceOrder(withHeaders.map(({ response, headers }) => ({ status: response.status, response, headers }))).map(({ response, headers }) => ({
404
+ status: response.status,
405
+ schema: response.body === undefined
406
+ ? undefined
407
+ : response.binary
408
+ ? "z.unknown()"
409
+ : registry.expressionFor(response.body),
410
+ contentTypes: response.contentTypes,
411
+ headers,
412
+ binary: response.binary,
413
+ streamed: response.streamed,
414
+ textual: response.textual,
415
+ }));
416
+ }
357
417
  /**
358
418
  * Where an arm sits in OpenAPI's own precedence order.
359
419
  *
@@ -539,33 +599,6 @@ export function errorBodyOf(program, service) {
539
599
  function bodyTypeOf(type) {
540
600
  return isVoidType(type) || isNeverType(type) ? undefined : type;
541
601
  }
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
602
  /**
570
603
  * The media types this operation's success response can be served as.
571
604
  *
@@ -801,7 +834,18 @@ function isBinaryPart(type) {
801
834
  export function isRawBinaryMediaType(contentTypes) {
802
835
  if (contentTypes.length === 0)
803
836
  return false;
804
- return contentTypes.every((type) => !/^application\/(json|.*\+json)$/.test(type));
837
+ return contentTypes.every((type) => !isJsonMediaType(type));
838
+ }
839
+ /**
840
+ * A media type a server can parse a request body from into a value its schema can check: JSON and
841
+ * the `+json` suffix RFC 6839 defines, a URL-encoded form, and multipart. Anything else - XML, a
842
+ * vendor type - has no reading this emitter can derive from the document.
843
+ */
844
+ function isParseableMediaType(type) {
845
+ const essence = mediaTypeEssenceOf(type);
846
+ return (isJsonMediaType(essence) ||
847
+ essence === "application/x-www-form-urlencoded" ||
848
+ essence.startsWith("multipart/"));
805
849
  }
806
850
  /** The declared request body type, if the operation takes one. */
807
851
  function requestBodyOf(operation) {
@@ -1000,15 +1044,28 @@ function mediaTypeDecoded(declared) {
1000
1044
  */
1001
1045
  if (value === "*/*")
1002
1046
  return "z.string()";
1003
- return value.endsWith("/*")
1004
- ? `z.templateLiteral([${JSON.stringify(value.slice(0, -1))}, z.string()])`
1047
+ return isMediaRange(value)
1048
+ ? `z.templateLiteral([${JSON.stringify(mediaTypeEssenceOf(value).slice(0, -1))}, z.string()])`
1005
1049
  : `z.literal(${JSON.stringify(value)})`;
1006
1050
  });
1007
1051
  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})`;
1052
+ const ranged = values.some(isMediaRange) ? schema : declared;
1053
+ /**
1054
+ * **The request's media type is reduced to its essence, and so is every declared one.** A value
1055
+ * declared as its own essence - `application/json` - is the literal the essence is compared with.
1056
+ * One declared with a parameter or in capitals - `application/json; charset=utf-8` - is restored
1057
+ * from the essence, so the request is admitted and the handler is given the type as the document
1058
+ * declares it, which is what its input type says. Compared as written, such a value refused every
1059
+ * request: no request's essence carries a parameter.
1060
+ */
1061
+ const restored = values.filter((value) => !isMediaRange(value) && mediaTypeEssenceOf(value) !== value);
1062
+ const essence = `(raw.split(";")[0] ?? "").trim().toLowerCase()`;
1063
+ const decoded = restored.length === 0
1064
+ ? essence
1065
+ : `((essence) => ${restored
1066
+ .map((value) => `essence === ${JSON.stringify(mediaTypeEssenceOf(value))} ? ${JSON.stringify(value)} : `)
1067
+ .join("")}essence)(${essence})`;
1068
+ return `z.preprocess((raw) => (typeof raw === "string" ? ${decoded} : raw), ${ranged})`;
1012
1069
  }
1013
1070
  function parameterSchemasOf(program, operation, registry, segments,
1014
1071
  /** The names of a query string written into the route itself, which no parameter may gather. */
@@ -1149,6 +1206,13 @@ literalQueryNames) {
1149
1206
  (byTarget["header-without-accept"] ??= []).push(entry);
1150
1207
  }
1151
1208
  }
1209
+ const fileContentType = fileContentTypeOf(operation);
1210
+ if (fileContentType !== undefined) {
1211
+ // The same decoding a declared `Content-Type` header gets: parameters dropped, lowercased.
1212
+ const entry = `\t${objectKey("Content-Type")}: ${mediaTypeDecoded(registry.expressionFor(fileContentType.type))},`;
1213
+ (byTarget["header"] ??= []).push(entry);
1214
+ (byTarget["header-without-accept"] ??= []).push(entry);
1215
+ }
1152
1216
  const shape = (target) => {
1153
1217
  const entries = byTarget[target];
1154
1218
  return entries === undefined ? undefined : `z.object({\n${entries.join("\n")}\n})`;
@@ -1186,6 +1250,178 @@ function modelPropertiesOf(model) {
1186
1250
  }
1187
1251
  return chain.flatMap((current) => [...current.properties.values()]);
1188
1252
  }
1253
+ /**
1254
+ * **The media type an `Http.File` body declares, as the input it is.** A file's `contentType` is the
1255
+ * request's `Content-Type`: `Http.File<"image/png" | "image/jpeg">` accepts either and the handler has
1256
+ * to know which arrived, exactly as it would from a declared `@header contentType`. `@typespec/http`
1257
+ * keeps it on the file model rather than among the operation's parameters, so without this a
1258
+ * handler for a two-type upload had the bytes and no way to tell what they were.
1259
+ *
1260
+ * `undefined` where the body is not a file, or where the operation already declares the header.
1261
+ */
1262
+ function fileContentTypeOf(operation) {
1263
+ const body = operation.parameters.body;
1264
+ if (body?.bodyKind !== "file")
1265
+ return undefined;
1266
+ const declared = operation.parameters.parameters.some((parameter) => parameter.type === "header" && parameter.name.toLowerCase() === "content-type");
1267
+ return declared ? undefined : body.contentTypeProperty;
1268
+ }
1269
+ /**
1270
+ * Where a request body reaches a handler's input: spread into it, named under a property, or handed
1271
+ * over unread.
1272
+ *
1273
+ * **One decision, read by every artefact that states an operation's input.** The server emitters
1274
+ * render a handler's input from {@link EmittedRoute.bodyProperty}; the contract types in
1275
+ * `requests.gen.ts` re-derived the input for themselves and always merged, so for every body this
1276
+ * names - an indexer, an optional body, a scalar, enum or union - `WireInputs` described an input no
1277
+ * handler receives.
1278
+ */
1279
+ /**
1280
+ * The keys a merged body would contribute to the handler's input: each property of the body SENT,
1281
+ * at the operation's request visibility, under its wire name, or each named part of a multipart
1282
+ * body. A read-only property is not sent, so it contributes nothing.
1283
+ */
1284
+ function mergedBodyKeysOf(program, operation) {
1285
+ const body = operation.parameters.body;
1286
+ if (body?.bodyKind === "multipart") {
1287
+ return body.parts.flatMap((part) => (part.name === undefined ? [] : [part.name]));
1288
+ }
1289
+ const type = requestBodyOf(operation);
1290
+ if (type?.kind !== "Model")
1291
+ return [];
1292
+ return withVisibility(program, resolveRequestVisibility(program, operation.operation, operation.verb), () => [...walkPropertiesInherited(type)]
1293
+ .filter((property) => isPayloadProperty(program, property))
1294
+ .map((property) => resolveEncodedName(program, property, "application/json")));
1295
+ }
1296
+ function requestBodyPlacementOf(program, operation) {
1297
+ const requestType = requestBodyOf(operation);
1298
+ const bodyParameter = operation.parameters.body;
1299
+ const contentTypes = [...(bodyParameter?.contentTypes ?? [])];
1300
+ /**
1301
+ * A `bytes` body means the BYTES are the contract.
1302
+ *
1303
+ * A webhook's MAC covers exactly what arrived, so a parsed-and-re-serialised object would
1304
+ * verify a different string and every delivery would fail authentication.
1305
+ */
1306
+ const bytes = requestType?.kind === "Scalar" && requestType.name === "bytes";
1307
+ /**
1308
+ * **A body declared only under media types nothing can parse into it is handed over raw.** A
1309
+ * model as `application/xml` has no serialisation this emitter can derive, and the server has no
1310
+ * reader for it; it used to get a header check and then call the handler with the body never
1311
+ * read, while the warning beside it said such requests were refused. Handed over as it arrived,
1312
+ * the handler parses it - exactly what the response side already asks of a handler serving a model
1313
+ * as XML. It is the text under a textual media type and the bytes under any other
1314
+ * (`rawBodyBinary`). A string body is not this: it IS text, and its schema still checks it
1315
+ * (`requestTextual`).
1316
+ */
1317
+ const unparseable = bodyParameter?.bodyKind === "single" &&
1318
+ requestType !== undefined &&
1319
+ !bytes &&
1320
+ !isStringType(requestType) &&
1321
+ contentTypes.length > 0 &&
1322
+ contentTypes.every((type) => !isParseableMediaType(type));
1323
+ /**
1324
+ * **An `Http.File` body is raw too: the file's contents ARE the body.** `@typespec/openapi3`
1325
+ * publishes it as `content: { "image/png": { schema: { contentMediaType: "image/png" } } }` - a
1326
+ * media type and no type, nothing to parse and nothing to validate - which is exactly a `bytes`
1327
+ * body under that media type. `requestBodyOf` reads only `bodyKind === "single"`, so a file body
1328
+ * reached no placement at all: the handler was passed `{}` and the upload was dropped, from a
1329
+ * compile that reported success, on all four uploads in `type/file`.
1330
+ */
1331
+ const rawBodyProperty = bytes || bodyParameter?.bodyKind === "file" || unparseable
1332
+ ? (bodyParameter?.property?.name ?? "body")
1333
+ : undefined;
1334
+ /**
1335
+ * A body with an indexer is named rather than spread - see `bodyProperty`. Read from the
1336
+ * resolved model's own indexer rather than from the emitted schema text, which would be a
1337
+ * decision made by pattern-matching generated output.
1338
+ */
1339
+ /**
1340
+ * **An OPTIONAL body is named for the same reason an indexed one is**: the merge cannot
1341
+ * express "absent". See {@link EmittedRoute.optionalBody}.
1342
+ */
1343
+ const optionalBody = bodyParameter?.property?.optional === true;
1344
+ /**
1345
+ * **An optional MULTIPART body reaches the same rule by a second route, because
1346
+ * `requestType` cannot see it.** `requestBodyOf` resolves a type only for
1347
+ * `bodyKind === "single"`, so `requestType` is `undefined` for every multipart operation
1348
+ * and the `optionalBody` disjunct above was unreachable however the document declared it.
1349
+ *
1350
+ * The emitted server for such an operation did not compile. Measured, and the mechanism is
1351
+ * worth stating because it is not a union: spreading a value typed `Parts | undefined`
1352
+ * makes every part OPTIONAL, so `{ file?: ...; pages?: ... }` was passed where
1353
+ * `{ file: ...; pages: ... }` was required. `TS2345`, inside `app.gen.ts` itself.
1354
+ *
1355
+ * That is the failure {@link EmittedRoute.optionalBody} predicts in prose - a merge has no
1356
+ * way to say "these properties are here only sometimes" without making every one of them
1357
+ * optional, which is a weaker and different claim about the body that IS sent. So this is
1358
+ * the stated rule reaching the one body kind it had never been applied to, not a new rule.
1359
+ *
1360
+ * **The REQUIRED arm is deliberately excluded.** The indexed reason does not apply to a
1361
+ * parts model, so a required multipart body merges as safely as a required single one, and
1362
+ * `typespec-hono/test/filepart/` compiles a handler that reads `input.file` directly.
1363
+ * Widening this to "multipart is always named" would rewrite that directory for no defect.
1364
+ *
1365
+ * This is the third hole the `"single"` gate has produced, after `multipartSchemaOf` and
1366
+ * `multipartTsOf`, both recorded above.
1367
+ */
1368
+ const optionalMultipartBody = bodyParameter?.bodyKind === "multipart" && optionalBody;
1369
+ /**
1370
+ * **A body that is not a MODEL has no properties to merge, and merging it anyway lost it.**
1371
+ *
1372
+ * `@body body: string` emitted a handler input of `Record<string, never>` and a call site of
1373
+ * `{}`: the server validated the body and then never handed it to the handler, silently, from
1374
+ * a compile that reported success. `@body body: SomeEnum` was the same rule failing loudly,
1375
+ * `TS2698: Spread types may only be created from object types`, which is what
1376
+ * `payload/media-type`, `type/enum/*`, `type/scalar` and `versioning/returnTypeChangedFrom`
1377
+ * had been pinned on in the conformance corpus.
1378
+ *
1379
+ * A scalar, an enum, a union or a literal body is therefore NAMED, which is the treatment an
1380
+ * indexed or optional body already gets and for the same reason: the merge cannot express it.
1381
+ *
1382
+ * **Except a `bytes` body**, which {@link EmittedRoute.rawBodyProperty} already names, and
1383
+ * naming it twice would put the same value under two keys.
1384
+ */
1385
+ const unmergeableBody = requestType !== undefined && requestType.kind !== "Model" && rawBodyProperty === undefined;
1386
+ /**
1387
+ * **A body sharing a key with a parameter is named, because merged one value replaces the
1388
+ * other.** `PUT /renames/{name}` with a body `{ name }` handed the handler one `name`, the body's,
1389
+ * and the path's was lost with nothing reported - in `parameters/spread`'s composite request, and
1390
+ * in any spec whose body repeats a path or query parameter. The document states the two
1391
+ * separately; named, the handler is given both. A read-only property is not in the body sent, so
1392
+ * `@path id` beside a model whose `id` is read-only collides with nothing and still merges.
1393
+ */
1394
+ const parameterNames = new Set(operation.parameters.parameters.map((parameter) => parameter.name));
1395
+ const collides = rawBodyProperty === undefined &&
1396
+ mergedBodyKeysOf(program, operation).some((key) => parameterNames.has(key));
1397
+ const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
1398
+ optionalMultipartBody ||
1399
+ unmergeableBody ||
1400
+ collides
1401
+ ? (bodyParameter?.property?.name ?? "body")
1402
+ : undefined;
1403
+ /**
1404
+ * A file is handed over as a stream of bytes whatever its media type. `@typespec/http` lets an
1405
+ * emitter represent a textual file "as strings or streams of textual characters", and one
1406
+ * representation for every file is what lets a file declared `application/json` be told from a
1407
+ * JSON body - which the media type alone cannot do.
1408
+ *
1409
+ * **A body nothing parses is bytes unless every type it is declared under is text.** Decoded as
1410
+ * text, `application/octet-stream` lost every byte that is not valid UTF-8; see
1411
+ * {@link isTextualMediaType}.
1412
+ */
1413
+ const rawBodyBinary = rawBodyProperty !== undefined &&
1414
+ (unparseable
1415
+ ? !contentTypes.every(isTextualMediaType)
1416
+ : bodyParameter?.bodyKind === "file" || isRawBinaryMediaType(contentTypes));
1417
+ return {
1418
+ rawBodyProperty,
1419
+ rawBodyBinary,
1420
+ rawBodyUnvalidated: unparseable,
1421
+ bodyProperty,
1422
+ optionalBody,
1423
+ };
1424
+ }
1189
1425
  export function collectRoutes(program, registry, service) {
1190
1426
  const routes = [];
1191
1427
  {
@@ -1215,73 +1451,7 @@ export function collectRoutes(program, registry, service) {
1215
1451
  // `@requestVisibility` when the operation states one, otherwise the verb's default - the
1216
1452
  // same resolution `@typespec/openapi3` performs before emitting a request schema.
1217
1453
  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;
1454
+ const { rawBodyProperty, rawBodyBinary, rawBodyUnvalidated, bodyProperty, optionalBody } = requestBodyPlacementOf(program, operation);
1285
1455
  const authentication = authenticationFor(program, operation);
1286
1456
  const template = routeTemplateOf(operation.uriTemplate, operation.parameters.parameters
1287
1457
  .filter((parameter) => parameter.type === "path")
@@ -1370,6 +1540,8 @@ export function collectRoutes(program, registry, service) {
1370
1540
  */
1371
1541
  responses: withVisibility(program, Visibility.Read, () => responsesOf(program, operation, registry)),
1372
1542
  rawBodyProperty,
1543
+ rawBodyBinary,
1544
+ rawBodyUnvalidated,
1373
1545
  bodyProperty,
1374
1546
  optionalBody,
1375
1547
  scopes: authentication.scopes,
@@ -1562,10 +1734,9 @@ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
1562
1734
  * `p?: T` directly - at every depth, which a shallow mapped type never reached - so the narrowing
1563
1735
  * happens once, in the schema, and this helper has nothing left to do.
1564
1736
  *
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)`.
1737
+ * The contract types in `requests.gen.ts` say the same since `0.28.0`: `?: T`, and
1738
+ * `?: T | undefined` only where `.default()` accepts an explicit `undefined`. They had kept it
1739
+ * everywhere on reasons that stopped holding once this schema moved - see `propertyToTs`.
1569
1740
  */
1570
1741
  const usesZod = new Set([...declarations, ...routeDeclarations].join("").match(/[A-Za-z_$][\w$]*/g) ?? []).has("z");
1571
1742
  const parts = [generatedBanner()];
@@ -1747,6 +1918,7 @@ function nameRouteSchemas(routes, compileSchemas) {
1747
1918
  query,
1748
1919
  queryFields: gathers ? queryFields : query,
1749
1920
  header,
1921
+ selectedAccept: negotiating ? route.accept : undefined,
1750
1922
  cookie,
1751
1923
  body,
1752
1924
  arms,
@@ -1780,120 +1952,127 @@ function responseArmsOf(route, arms) {
1780
1952
  });
1781
1953
  return `[${rendered.join(", ")}]`;
1782
1954
  }
1783
- const upperFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
1784
1955
  /**
1785
- * The effective input of every operation, as TypeScript: its body, with the path, query and header
1786
- * parameters merged over it.
1956
+ * Every operation's input, as TypeScript, exactly as a handler receives it.
1957
+ *
1958
+ * **One placement rule, the one the server emitters render from.** A model body is merged with the
1959
+ * path, query, header and cookie parameters; a body the merge cannot express is named under its
1960
+ * parameter's name, optional where the document lets it be absent; a `bytes` body is handed over
1961
+ * unread. {@link requestBodyPlacementOf} decides all three, for this walk and for
1962
+ * {@link EmittedRoute} alike. This walk used to merge every body, so for a union, an array, an
1963
+ * indexed model, an optional body, a scalar or an enum, `WireInputs` described an input no handler is
1964
+ * given - and one consumer carried a shim re-flattening the handler's input to reach a backend typed
1965
+ * by it, after the divergence cost it 171 failing tests.
1966
+ *
1967
+ * **Every operation, whatever it takes and whatever it answers.** An operation with no input is
1968
+ * `Record<string, never>`, which is what a handler for it is passed; one whose only success is a
1969
+ * range is as much an operation as any other. Both used to be skipped, so `keyof WireInputs` could
1970
+ * not enumerate a document's operations, and a consumer named the missing ones by hand.
1787
1971
  *
1788
1972
  * **This used to drop any parameter literally named `companyId`.** One application carried its
1789
1973
  * tenant identifier in a context object rather than in the payload, and the emitter was taught to
1790
1974
  * match - so for every consumer, a path parameter with that name vanished from the generated input
1791
1975
  * type while the Zod validator went on requiring it. Two artefacts describing different shapes, from
1792
1976
  * 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
1977
  */
1978
+ /** A body type the contract declares by name, rather than spells inline: a model, union or enum. */
1979
+ function declaresComponent(type) {
1980
+ return type.kind === "Model" || type.kind === "Union" || type.kind === "Enum";
1981
+ }
1798
1982
  function collectRequestTypes(program, registry, service) {
1799
1983
  const entries = [];
1800
- {
1801
- for (const operation of service.operations) {
1802
- if (successStatusOf(operation) === undefined)
1803
- continue;
1984
+ for (const operation of service.operations) {
1985
+ /**
1986
+ * **Walked at the operation's REQUEST visibility, which is the whole point of this file.**
1987
+ *
1988
+ * `resolveRequestVisibility` is `@typespec/http`'s own answer - POST is Create, PATCH is
1989
+ * Update, PUT is CreateOrUpdate - and it is what the Zod walk already uses at the route level.
1990
+ * Without it this walk ran at the default `Read`, so a POST body's contract type carried the
1991
+ * canonical property set while the validator beside it accepted the create projection. A
1992
+ * caller satisfying the published type sent read-only fields and a strict validator answered
1993
+ * 400.
1994
+ *
1995
+ * It is also what keeps `metadataInfo` scoped: the predicates `propertyToTs` consults live
1996
+ * behind `withVisibility`, and calling them outside one leaves a module-level singleton built
1997
+ * for the wrong program.
1998
+ */
1999
+ withVisibility(program, resolveRequestVisibility(program, operation.operation, operation.verb), () => {
2000
+ const body = requestBodyOf(operation);
2001
+ const placement = requestBodyPlacementOf(program, operation);
2002
+ /** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
2003
+ const parameterBody = operation.parameters.body;
1804
2004
  /**
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.
2005
+ * Headers are part of the input too.
1813
2006
  *
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.
2007
+ * The webhook's three Standard-Webhooks headers are inside the MAC - they are not metadata
2008
+ * beside the payload, they are arguments to the verification. Leaving them out of the wire
2009
+ * type would describe an input the application cannot act on.
1817
2010
  */
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;
2011
+ const extraProperties = operation.parameters.parameters
2012
+ .filter((parameter) => parameter.type === "path" ||
2013
+ parameter.type === "query" ||
2014
+ parameter.type === "header" ||
2015
+ // The second half of the same omission: a validator with no matching type.
2016
+ parameter.type === "cookie")
1823
2017
  /**
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.
2018
+ * Named as the WIRE names it, which is what the emitted validator keys on.
2019
+ * `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
2020
+ * `"x-thing"` here too: a consumer checking its handlers against these types otherwise
2021
+ * fails to typecheck against a server that is correct.
1829
2022
  */
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;
2023
+ .map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
2024
+ const fileContentType = fileContentTypeOf(operation);
2025
+ if (fileContentType !== undefined) {
2026
+ extraProperties.push(`${objectKey("Content-Type")}: ${registry.expressionFor(fileContentType.type)};`);
2027
+ }
2028
+ const parts = [];
2029
+ let streamsBody = false;
2030
+ let bodyRef;
2031
+ if (placement.rawBodyProperty !== undefined) {
1847
2032
  /**
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.
2033
+ * **A binary body is the unread stream, and is spelled as one without naming
2034
+ * `ReadableStream`.** The server hands it over unread, so a backend it is forwarded to
2035
+ * receives a stream; a contract saying `ArrayBuffer` made a gateway read a 25 MiB upload
2036
+ * into an isolate to satisfy it. See `BYTE_STREAM`. A `bytes` body under a JSON type is
2037
+ * the text a signature covers, and arrives as a string.
1852
2038
  */
1853
- const rawBody = body?.kind === "Scalar" && body.name === "bytes"
1854
- ? (operation.parameters.body?.property?.name ?? "body")
1855
- : undefined;
2039
+ streamsBody = placement.rawBodyBinary;
2040
+ parts.push(`{\n\t${objectKey(placement.rawBodyProperty)}: ${streamsBody ? "ByteStream | null" : "string"};\n}`);
1856
2041
  /**
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.
2042
+ * **The model a body nothing parses names is still declared**, as the document declares
2043
+ * it, though the input is the text or the bytes that arrived. 0.27.0 declared it, and a
2044
+ * consumer importing it lost the import when the body became raw.
1866
2045
  */
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
- });
2046
+ if (body !== undefined && declaresComponent(body))
2047
+ registry.expressionFor(body);
2048
+ }
2049
+ else {
2050
+ const expression = parameterBody?.bodyKind === "multipart"
2051
+ ? multipartTsOf({ parts: parameterBody.parts }, registry)
2052
+ : body === undefined
2053
+ ? undefined
2054
+ : registry.expressionFor(body);
2055
+ if (parameterBody?.bodyKind !== "multipart")
2056
+ bodyRef = expression;
2057
+ if (expression !== undefined) {
2058
+ parts.push(placement.bodyProperty === undefined
2059
+ ? expression
2060
+ : `{\n\t${objectKey(placement.bodyProperty)}${placement.optionalBody ? "?" : ""}: ${expression.replaceAll("\n", "\n\t")};\n}`);
2061
+ }
2062
+ }
2063
+ if (extraProperties.length > 0) {
2064
+ parts.push(`{\n${extraProperties.map((line) => `\t${line}`).join("\n")}\n}`);
2065
+ }
2066
+ entries.push({
2067
+ operationId: operationIdOf(program, operation.operation),
2068
+ bodyRef,
2069
+ input: parts.length === 0 ? "Record<string, never>" : `Simplify<${parts.join(" & ")}>`,
2070
+ streamsBody,
1883
2071
  });
1884
- }
2072
+ });
1885
2073
  }
1886
2074
  return entries;
1887
2075
  }
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
2076
  function collectResponseTypes(program, registry, service) {
1898
2077
  const entries = [];
1899
2078
  /**
@@ -1906,20 +2085,135 @@ function collectResponseTypes(program, registry, service) {
1906
2085
  */
1907
2086
  withVisibility(program, Visibility.Read, () => {
1908
2087
  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;
2088
+ /**
2089
+ * **The same responses, and the same members, the server emitters render.** This walk used
2090
+ * to ask for the lowest success status and its body, so an operation answering `200 Thing`
2091
+ * or `201 Other` published `Thing`, one answering `200 Thing` or `204` published `Thing`,
2092
+ * and one answering only `204` was absent - while the runtime beside it carried every arm.
2093
+ */
2094
+ const responses = inPrecedenceOrder(resolvedResponsesOf(program, operation));
2095
+ const hasBody = (response) => response.body !== undefined;
2096
+ let streams = false;
2097
+ const bodyRefs = [];
2098
+ /**
2099
+ * **What a producer SUPPLIES for a body, which is what the server emitters accept for it.** A
2100
+ * stream or a raw binary body is handed over as bytes and never parsed, so it is spelled as
2101
+ * bytes - `ByteStream` rather than the platform stream, for the same reason as a request body.
2102
+ * A model under a media type that is not JSON is what the producer serialised: text under a
2103
+ * textual media type, bytes under any other. Everything else is the producer's view of the
2104
+ * contract type.
2105
+ */
2106
+ const bodyOf = (member) => {
2107
+ // A model a body served unvalidated names is declared all the same, as the request side
2108
+ // declares one under a media type nothing parses: the document declares it either way.
2109
+ if (member.unvalidated &&
2110
+ member.response.body !== undefined &&
2111
+ declaresComponent(member.response.body)) {
2112
+ registry.expressionFor(member.response.body);
2113
+ }
2114
+ switch (member.serving) {
2115
+ case "none":
2116
+ return undefined;
2117
+ case "stream":
2118
+ streams = true;
2119
+ return "ByteStream";
2120
+ case "binary":
2121
+ streams = true;
2122
+ return "ByteStream | Uint8Array<ArrayBuffer> | ArrayBuffer";
2123
+ case "unvalidated":
2124
+ return "string";
2125
+ default: {
2126
+ if (member.response.body === undefined)
2127
+ return undefined;
2128
+ const ref = registry.expressionFor(member.response.body);
2129
+ bodyRefs.push(ref);
2130
+ return `Produced<${ref}>`;
2131
+ }
2132
+ }
2133
+ };
2134
+ const members = resultMembersOf(responses, hasBody).map((member) => {
2135
+ const body = bodyOf(member);
2136
+ const codes = statusCodesOf(member.response, responses, hasBody);
2137
+ const fields = [`readonly status: ${codes.length === 0 ? "never" : codes.join(" | ")}`];
2138
+ if (member.choosesMediaType && member.mediaType !== undefined) {
2139
+ fields.push(`readonly contentType: ${mediaTypeTypeOf(member.mediaType)}`);
2140
+ }
2141
+ fields.push(body === undefined ? "readonly body?: undefined" : `readonly body: ${body}`);
2142
+ const headers = member.response.headers;
2143
+ if (headers.length > 0) {
2144
+ /**
2145
+ * Keyed by the WIRE name, as the document publishes it. `headers` is optional only when
2146
+ * every header in it is, and an optional header admits an explicit `undefined`, because
2147
+ * a server drops one before it checks and sends the rest.
2148
+ */
2149
+ const every = headers.every(({ property }) => property.optional);
2150
+ const rendered = headers
2151
+ .map(({ name, property }) => property.optional
2152
+ ? `readonly ${objectKey(name)}?: ${propertyTypeToTs(program, property)} | undefined`
2153
+ : `readonly ${objectKey(name)}: ${propertyTypeToTs(program, property)}`)
2154
+ .join("; ");
2155
+ fields.push(`readonly headers${every ? "?" : ""}: { ${rendered} }`);
2156
+ }
2157
+ return { member, body, text: `{ ${fields.join("; ")} }` };
2158
+ });
2159
+ /**
2160
+ * A success is a `2xx` or `3xx` outcome, exact or ranged, as {@link successStatusesOf} holds.
2161
+ * `default` is not one: it is every status nothing else declares, which is mostly failures.
2162
+ */
2163
+ const outputs = [
2164
+ ...new Set(members
2165
+ .filter(({ member }) => isSuccessStatus(member.response.status))
2166
+ .map(({ body }) => body ?? "undefined")),
2167
+ ];
1915
2168
  entries.push({
1916
2169
  operationId: operationIdOf(program, operation.operation),
1917
- ref: registry.expressionFor(body),
2170
+ output: outputs.length === 0 ? "never" : outputs.join(" | "),
2171
+ result: members.map(({ text }) => text),
2172
+ streams,
2173
+ bodyRefs,
1918
2174
  });
1919
2175
  }
1920
2176
  });
1921
2177
  return entries;
1922
2178
  }
2179
+ /** A `2xx` or `3xx` status, exact or as a range: an outcome a handler returns rather than a failure. */
2180
+ function isSuccessStatus(status) {
2181
+ if (typeof status === "number")
2182
+ return status >= 200 && status < 400;
2183
+ return status === "2XX" || status === "3XX";
2184
+ }
2185
+ /**
2186
+ * **A stream of bytes, spelled as the reader every stream has rather than as `ReadableStream`.**
2187
+ *
2188
+ * The server hands a binary request body over unread, so the contract has to say "stream" - a
2189
+ * contract saying `ArrayBuffer` made a gateway read a 25 MiB upload into an isolate just to satisfy
2190
+ * a backend's type. But `requests.gen.ts` is framework-free, and a contracts package compiled under
2191
+ * `lib: ["ES2024"]` and no `types` cannot name `ReadableStream` at all (`TS2304`); only lib.dom,
2192
+ * lib.webworker, `@cloudflare/workers-types` and `@types/node` declare it.
2193
+ *
2194
+ * **This is the part of a stream every one of those declares, and nothing else.** Measured: a platform
2195
+ * `ReadableStream<Uint8Array> | null` is assignable to it under the default libs, lib.dom,
2196
+ * lib.webworker, workers-types and `@types/node`, on TypeScript 5.9.3 and 7.0.2, while an
2197
+ * `ArrayBuffer` is refused. `AsyncIterable<Uint8Array>`, the obvious alternative, is NOT satisfied
2198
+ * by lib.dom's stream on TypeScript 5.9 unless `dom.asynciterable` is also listed - which the usual
2199
+ * `["ES2020", "DOM", "DOM.Iterable"]` does not.
2200
+ *
2201
+ * The same precedent as a multipart file part: a type only an ambient library declares is spelled
2202
+ * structurally, so the emitted output compiles wherever it lands.
2203
+ */
2204
+ const BYTE_STREAM = `
2205
+ /**
2206
+ * A stream of bytes, as every platform's \`ReadableStream<Uint8Array>\` satisfies it - spelled
2207
+ * structurally so this file needs no DOM or runtime library to name it.
2208
+ */
2209
+ export interface ByteStream {
2210
+ getReader(): {
2211
+ read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array | undefined }>;
2212
+ releaseLock(): void;
2213
+ cancel?(reason?: unknown): Promise<void>;
2214
+ };
2215
+ }
2216
+ `;
1923
2217
  /**
1924
2218
  * `requests.gen.ts` - the shape both sides are checked against.
1925
2219
  *
@@ -1952,29 +2246,29 @@ function renderRequestTypes(entries, responses, registry) {
1952
2246
  ? `\nexport interface ${declaration.name} ${declaration.source}\n`
1953
2247
  : `\nexport type ${declaration.name} = ${declaration.source};\n`;
1954
2248
  });
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
- });
2249
+ const responseLookup = responses
2250
+ .map((entry) => `\t/** Every success body \`${entry.operationId}\` declares. */\n\t${entry.operationId}: ${entry.output};`)
2251
+ .join("\n");
1965
2252
  /**
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.
2253
+ * **One member per declared response and media type, each pairing a status with its own body.**
2254
+ * The union a server emitter's handlers return, stated with nothing to import: a backend reached
2255
+ * over a service binding returns `WireResults[op]`, and a handler can hand it straight on.
1972
2256
  */
1973
- const responseLookup = responses
1974
- .map((entry) => `\t${entry.operationId}: Produced<${entry.ref}>;`)
2257
+ const resultLookup = responses
2258
+ .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
2259
  .join("\n");
2260
+ /**
2261
+ * **Keyed by operation id, and the only name an operation's input has.**
2262
+ *
2263
+ * Each input used to be declared a second time as an alias, `CreateUserInput` for `createUser`,
2264
+ * and this lookup referenced the aliases. That is a name a spec may well give its own request
2265
+ * model, so the two declarations collided (`TS2300`) and `requests.gen.ts` did not
2266
+ * compile for an operation as ordinary as `createUser(@body body: CreateUserInput)`. A type
2267
+ * exported under a name this emitter invents is a name a spec author can already be using.
2268
+ * `WireInputs["createUser"]` is the same type, and nothing a spec declares can collide with it.
2269
+ */
1976
2270
  const lookup = entries
1977
- .map((entry) => `\t${entry.operationId}: ${upperFirst(entry.operationId)}Input;`)
2271
+ .map((entry) => `\t/** The input for \`${entry.operationId}\`, as its handler receives it. */\n\t${entry.operationId}: ${entry.input.replaceAll("\n", "\n\t")};`)
1978
2272
  .join("\n");
1979
2273
  /**
1980
2274
  * **Emitted only where an operation has something to flatten.**
@@ -1986,9 +2280,9 @@ function renderRequestTypes(entries, responses, registry) {
1986
2280
  * `TS6196: 'Simplify' is declared but never used.` Third of its kind, after the unconditional
1987
2281
  * `zValidator` and `z` imports.
1988
2282
  *
1989
- * Decided from the aliases actually rendered rather than by searching the output for the name.
2283
+ * Decided from the inputs actually rendered rather than by searching the output for the name.
1990
2284
  */
1991
- const simplifyHelper = inputs.length === 0
2285
+ const simplifyHelper = !entries.some((entry) => entry.input.startsWith("Simplify<"))
1992
2286
  ? ""
1993
2287
  : `/**
1994
2288
  * Flattens an intersection into a single object type.
@@ -2024,14 +2318,19 @@ export type Produced<T> = T extends readonly (infer Element)[]
2024
2318
  : T extends object
2025
2319
  ? { readonly [K in keyof T]: Produced<T[K]> }
2026
2320
  : T;
2027
- ${declarations.join("")}${inputs.join("")}
2028
- /** Every operation's effective input, keyed by operation id - what both sides assert against. */
2321
+ ${entries.some((entry) => entry.streamsBody) || responses.some((entry) => entry.streams) ? BYTE_STREAM : ""}${declarations.join("")}
2322
+ /**
2323
+ * Every operation's input, keyed by operation id: what its handler receives, and what a caller of a
2324
+ * backend typed by it hands over. An operation that takes nothing is \`Record<string, never>\`.
2325
+ */
2029
2326
  export interface WireInputs {
2030
2327
  ${lookup}
2031
2328
  }
2032
2329
 
2033
2330
  /**
2034
- * Every operation's success response, keyed by operation id.
2331
+ * Every operation's success bodies, keyed by operation id: the body of each \`2xx\` and \`3xx\` response it
2332
+ * declares, \`undefined\` for one with no body, and \`never\` where it declares no success at all. Which
2333
+ * status goes with which body is \`WireResults\`.
2035
2334
  *
2036
2335
  * **A floor, not an equality.** These shapes are permissive: a caller pins the fields it depends
2037
2336
  * on and lets a richer view through, so a producer's result must CONTAIN what the document promises
@@ -2041,6 +2340,15 @@ ${lookup}
2041
2340
  export interface WireOutputs {
2042
2341
  ${responseLookup}
2043
2342
  }
2343
+
2344
+ /**
2345
+ * Every operation's result, keyed by operation id: one member per response it declares, each pairing
2346
+ * a status with that status's body and headers. A status no response declares does not compile, and
2347
+ * a \`4XX\` or \`default\` response is the codes it stands for, written out.
2348
+ */
2349
+ export interface WireResults {
2350
+ ${resultLookup}
2351
+ }
2044
2352
  `;
2045
2353
  }
2046
2354
  /**
@@ -2078,16 +2386,25 @@ ${responseLookup}
2078
2386
  * `Equals` here, not assignability: the two describe one shape, and a property present on one side
2079
2387
  * and absent from the other is exactly the drift this exists to catch.
2080
2388
  */
2081
- function renderWireAssertions(schemas, types, entries, contractsPackage) {
2389
+ function renderWireAssertions(schemas, types, entries, responses, contractsPackage) {
2082
2390
  // The assertions pair the emitted Zod against the request TYPES, which live in the contracts
2083
2391
  // package. With no package named there is nothing to import them from and nothing to pair - the
2084
2392
  // types are still written to `contracts-output-dir`, they simply have no shared specifier yet.
2085
2393
  if (contractsPackage === undefined)
2086
2394
  return undefined;
2087
- const requestModels = new Set(entries.map((entry) => entry.bodyRef).filter((ref) => ref !== undefined));
2395
+ /**
2396
+ * **Every model a body names, the responses' as well as the requests'.** This paired request
2397
+ * bodies only, so a model that is only ever SENT was compared with nothing; that was harmless
2398
+ * while the contract types described a single success body and nothing consumed it, and is not now
2399
+ * that `WireResults` states every response a backend returns.
2400
+ */
2401
+ const bodyModels = new Set([
2402
+ ...entries.map((entry) => entry.bodyRef).filter((ref) => ref !== undefined),
2403
+ ...responses.flatMap((entry) => entry.bodyRefs),
2404
+ ]);
2088
2405
  const paired = [];
2089
2406
  for (const declaration of types.declarations()) {
2090
- if (!requestModels.has(declaration.name))
2407
+ if (!bodyModels.has(declaration.name))
2091
2408
  continue;
2092
2409
  const schema = schemas
2093
2410
  .declarations()
@@ -2148,26 +2465,6 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
2148
2465
  * dropped from a shape that has other keys, and kept - with its value type still mapped - from one
2149
2466
  * that does not.
2150
2467
  */
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
2468
  type DeclaredKeyOf<T> = keyof {
2172
2469
  [K in keyof T as string extends K
2173
2470
  ? never
@@ -2196,9 +2493,8 @@ type Declared<T> = T extends (...args: never[]) => unknown
2196
2493
  // equate a real dictionary with an empty object: a dictionary whose values are
2197
2494
  // \`never\` accepts nothing either.
2198
2495
  {}
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.
2496
+ : // A shape with no declared keys is a dictionary: its index signature is kept and
2497
+ // its value type mapped.
2202
2498
  { [K in keyof T]: Declared<T[K]> }
2203
2499
  : {
2204
2500
  [K in keyof T as string extends K
@@ -2207,7 +2503,7 @@ type Declared<T> = T extends (...args: never[]) => unknown
2207
2503
  ? never
2208
2504
  : symbol extends K
2209
2505
  ? never
2210
- : K]: Supplied<T, K>;
2506
+ : K]: Declared<T[K]>;
2211
2507
  }
2212
2508
  : T;
2213
2509
 
@@ -2342,6 +2638,8 @@ export async function emitHttpZod(context) {
2342
2638
  * there means a server emitter follows without knowing this rule exists.
2343
2639
  */
2344
2640
  const distinctServices = new Set(snapshots.map((snapshot) => snapshot.service.namespace.name));
2641
+ /** The services writing to the top-level `contracts-output-dir`: every one not naming its own. */
2642
+ const sharingContracts = [...distinctServices].filter((name) => options.services?.[name]?.["contracts-output-dir"] === undefined);
2345
2643
  /**
2346
2644
  * **Every service must seal the same way, because `@typespec/openapi3` has no per-service
2347
2645
  * options.**
@@ -2386,8 +2684,22 @@ export async function emitHttpZod(context) {
2386
2684
  (distinctServices.size > 1
2387
2685
  ? resolvePath(context.emitterOutputDir, serviceName)
2388
2686
  : context.emitterOutputDir);
2687
+ /**
2688
+ * **Split where two services would write one file, and nowhere else.** A shared contracts
2689
+ * directory was written once per service to one path, so the second service's
2690
+ * `requests.gen.ts` replaced the first's and a whole surface lost its contracts with no
2691
+ * diagnostic. Counted over the services that WRITE there: one service falling back to the
2692
+ * shared directory, beside others that name their own, collides with nothing, and splitting
2693
+ * it moved contracts a project already imported. A relative `contracts-package` is resolved
2694
+ * from each service's own `wire-contract.gen.ts`, so it names that service's contracts; a
2695
+ * package specifier has to be set per service.
2696
+ */
2697
+ const sharedContractsDir = options["contracts-output-dir"];
2389
2698
  const resolved = {
2390
- contractsOutputDir: perService?.["contracts-output-dir"] ?? options["contracts-output-dir"],
2699
+ contractsOutputDir: perService?.["contracts-output-dir"] ??
2700
+ (sharedContractsDir !== undefined && sharingContracts.length > 1
2701
+ ? resolvePath(sharedContractsDir, serviceName)
2702
+ : sharedContractsDir),
2391
2703
  contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
2392
2704
  sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
2393
2705
  compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
@@ -2453,8 +2765,15 @@ export async function emitHttpZod(context) {
2453
2765
  });
2454
2766
  if (resolved.contractsOutputDir !== undefined) {
2455
2767
  const types = new TypeRegistry(context.program);
2456
- const entries = collectRequestTypes(context.program, types, service);
2457
- const responses = collectResponseTypes(context.program, types, service);
2768
+ /**
2769
+ * **Inside the same stream scope as the route walk**, because both read the same response
2770
+ * records: outside it `streamedTypeOf` cannot see `@typespec/streams`, so an SSE body was
2771
+ * stated as the string the document describes rather than the stream a server sends.
2772
+ */
2773
+ const { entries, responses } = withStreamModule(streamModule, () => ({
2774
+ entries: collectRequestTypes(context.program, types, service),
2775
+ responses: collectResponseTypes(context.program, types, service),
2776
+ }));
2458
2777
  await emitFile(context.program, {
2459
2778
  path: resolvePath(resolved.contractsOutputDir, "requests.gen.ts"),
2460
2779
  content: renderRequestTypes(entries, responses, types),
@@ -2463,7 +2782,7 @@ export async function emitHttpZod(context) {
2463
2782
  path: resolvePath(resolved.contractsOutputDir, "vocabularies.gen.ts"),
2464
2783
  content: renderVocabularies(vocabularies),
2465
2784
  });
2466
- const assertions = renderWireAssertions(registry, types, entries, resolved.contractsPackage);
2785
+ const assertions = renderWireAssertions(registry, types, entries, responses, resolved.contractsPackage);
2467
2786
  if (assertions !== undefined) {
2468
2787
  await emitFile(context.program, {
2469
2788
  path: resolvePath(outputDir, "wire-contract.gen.ts"),
@@ -2471,7 +2790,12 @@ export async function emitHttpZod(context) {
2471
2790
  });
2472
2791
  }
2473
2792
  }
2474
- emitted.push({ service, routes, schemaNames: names, outputDir, options: resolved });
2793
+ // Still inside this service's id scope, so each operation resolves to the id its route carries.
2794
+ const operations = new Map(service.operations.map((operation) => [
2795
+ operationIdOf(context.program, operation.operation),
2796
+ operation,
2797
+ ]));
2798
+ emitted.push({ service, routes, schemaNames: names, operations, outputDir, options: resolved });
2475
2799
  }
2476
2800
  return emitted;
2477
2801
  }