typespec-http-zod 0.20.0 → 0.22.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.d.ts CHANGED
@@ -263,6 +263,8 @@ export interface EmittedRoute {
263
263
  readonly headers: readonly {
264
264
  readonly name: string;
265
265
  readonly property: string;
266
+ /** The TypeScript type of the value, so a server emitter can put it in a signature. */
267
+ readonly type: string;
266
268
  }[];
267
269
  }[];
268
270
  /**
@@ -302,6 +304,18 @@ export interface EmittedRoute {
302
304
  value: boolean | string;
303
305
  status: number;
304
306
  } | undefined;
307
+ /**
308
+ * The property a handler sets to choose between several declared success statuses, and the
309
+ * statuses it may carry - read from a `@statusCode` typed as a union of literals.
310
+ *
311
+ * **Published because the handler has to be able to SAY it.** The property is HTTP metadata, so it
312
+ * is stripped from the body schema (correctly - it is not body), which means a server emitter has
313
+ * to add it back to the type a handler returns or the arms name something unsatisfiable.
314
+ */
315
+ readonly statusSelector: {
316
+ property: string;
317
+ statuses: readonly number[];
318
+ } | undefined;
305
319
  /** The schema for the SECOND success status, when the operation declares two distinct arms. */
306
320
  readonly alternateResponseSchema: string | undefined;
307
321
  }
package/dist/src/api.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, NoTarget, resolvePath, } from "@typespec/compiler";
2
- import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
2
+ import { getAuthenticationForOperation, isStatusCode, 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
7
  import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
7
8
  import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
8
9
  import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
@@ -306,7 +307,7 @@ function isSuccessKey(status) {
306
307
  * body and gives each one under its WIRE name with the declaring property behind it. Deriving either
307
308
  * name any other way would be a second spelling of a fact the compiler already resolved.
308
309
  */
309
- function responseHeadersOf(operation) {
310
+ function responseHeadersOf(program, operation) {
310
311
  const byStatus = new Map();
311
312
  for (const response of operation.responses) {
312
313
  const status = response.statusCodes;
@@ -316,7 +317,14 @@ function responseHeadersOf(operation) {
316
317
  for (const content of response.responses) {
317
318
  for (const [name, property] of Object.entries(content.headers ?? {})) {
318
319
  if (!headers.some((existing) => existing.name === name)) {
319
- headers.push({ name, property: property.name });
320
+ /**
321
+ * **The TYPE too, because a handler has to be able to SET this.** A `@header`
322
+ * property is stripped from the body schema - correctly, it is not body - so a server
323
+ * emitter has to put it back into the type a handler returns, and it cannot do that
324
+ * from a name alone. Measured before this existed: an arm naming `contentType` and
325
+ * `metadata` against a handler declared `Awaitable<Result<void>>`.
326
+ */
327
+ headers.push({ name, property: property.name, type: typeToTs(program, property.type) });
320
328
  }
321
329
  }
322
330
  }
@@ -579,6 +587,36 @@ function statusDiscriminatorOf(operation, statuses) {
579
587
  }
580
588
  return undefined;
581
589
  }
590
+ /**
591
+ * How a handler says WHICH success status it means, when the spec declares more than one.
592
+ *
593
+ * **The `@statusCode` property is the selector, and it is the one the spec already wrote.**
594
+ * `model Created { @statusCode statusCode: 200 | 201; @body body: Item }` declares both statuses and
595
+ * names the property that chooses between them, so nothing has to be inferred from the body's shape.
596
+ *
597
+ * That inference is what {@link statusDiscriminatorOf} does, and it needs a required literal property
598
+ * on one of the bodies. Measured across the whole conformance corpus: it fires ZERO times, so before
599
+ * this the second arm had never been emitted by any spec at all - `armFor` could not select a status
600
+ * that was never written. Both remain: a discriminator still serves two DIFFERENT response models,
601
+ * this serves one model declaring a union of statuses.
602
+ *
603
+ * All statuses of such a model share one body by construction - it is one model - so every arm
604
+ * carries the same schema and only the status and the selector value differ.
605
+ */
606
+ function statusSelectorOf(program, operation, statuses) {
607
+ if (statuses.length < 2)
608
+ return undefined;
609
+ for (const response of operation.responses) {
610
+ const type = response.type;
611
+ if (type === undefined || type.kind !== "Model")
612
+ continue;
613
+ for (const [name, property] of type.properties) {
614
+ if (isStatusCode(program, property))
615
+ return { property: name, statuses };
616
+ }
617
+ }
618
+ return undefined;
619
+ }
582
620
  /** The emitted schema for a specific success status, used for the second arm of a dual-status op. */
583
621
  function alternateSchemaFor(operation, status, registry) {
584
622
  const body = successBodyOf(operation, status);
@@ -1039,6 +1077,7 @@ export function collectRoutes(program, registry, service) {
1039
1077
  const requestVisibility = resolveRequestVisibility(program, operation.operation, operation.verb);
1040
1078
  const statusCodes = successStatusesOf(operation);
1041
1079
  const discriminator = statusDiscriminatorOf(operation, statusCodes);
1080
+ const statusSelector = statusSelectorOf(program, operation, statusCodes);
1042
1081
  /**
1043
1082
  * A `bytes` body means the BYTES are the contract.
1044
1083
  *
@@ -1145,7 +1184,7 @@ export function collectRoutes(program, registry, service) {
1145
1184
  // them under the ambient request visibility declared six components under the wrong
1146
1185
  // suffix - measured: type/model/visibility gained six divergences.
1147
1186
  errorArms: withVisibility(program, Visibility.Read, () => errorArmsOf(program, operation, registry)),
1148
- responseHeaders: responseHeadersOf(operation),
1187
+ responseHeaders: responseHeadersOf(program, operation),
1149
1188
  responseMediaTypes: responseMediaTypesOf(operation),
1150
1189
  scopes: [
1151
1190
  ...new Set((getAuthenticationForOperation(program, operation.operation)?.options ?? []).flatMap((option) => option.schemes.flatMap((scheme) => scheme.type === "oauth2"
@@ -1154,6 +1193,7 @@ export function collectRoutes(program, registry, service) {
1154
1193
  ],
1155
1194
  noAuth: noAuthFor(program, operation),
1156
1195
  statusBy: discriminator,
1196
+ statusSelector,
1157
1197
  /**
1158
1198
  * **Each arm is validated against its OWN shape.**
1159
1199
  *
@@ -1461,7 +1501,22 @@ function responseArmsOf(route, response, alternate) {
1461
1501
  .join(", ");
1462
1502
  return `, headers: [${rendered}]`;
1463
1503
  };
1464
- if (route.statusBy === undefined) {
1504
+ if (route.statusSelector !== undefined) {
1505
+ /**
1506
+ * **One arm per declared status, keyed on the property the spec already named.**
1507
+ *
1508
+ * The selector arms come first and the default last, the same order the discriminator below
1509
+ * uses: anything scanning for a match wants the specific ones before the fallback. Every arm
1510
+ * carries the same schema because a `@statusCode` union is ONE model - the statuses differ,
1511
+ * the body does not.
1512
+ */
1513
+ const { property, statuses } = route.statusSelector;
1514
+ for (const status of statuses.filter((candidate) => candidate !== route.statusCode)) {
1515
+ arms.push(`{ status: ${status}, schema: ${primary}${mediaTypesFor(status)}${headersFor(status)}, when: { property: ${JSON.stringify(property)}, value: ${status} } }`);
1516
+ }
1517
+ arms.push(`{ status: ${route.statusCode}, schema: ${primary}${mediaTypesFor(route.statusCode)}${headersFor(route.statusCode)} }`);
1518
+ }
1519
+ else if (route.statusBy === undefined) {
1465
1520
  arms.push(`{ status: ${route.statusCode}, schema: ${primary}${mediaTypesFor(route.statusCode)}${headersFor(route.statusCode)} }`);
1466
1521
  }
1467
1522
  else {
@@ -1659,8 +1714,21 @@ function renderRequestTypes(entries, responses, registry) {
1659
1714
  const lookup = entries
1660
1715
  .map((entry) => `\t${entry.operationId}: ${upperFirst(entry.operationId)}Input;`)
1661
1716
  .join("\n");
1662
- return `${generatedBanner()}
1663
- /**
1717
+ /**
1718
+ * **Emitted only where an operation has something to flatten.**
1719
+ *
1720
+ * A generated file has to pass the lint of whatever project it lands in, and `noUnusedLocals`
1721
+ * makes an unused type declaration an error. A service whose operations take no input at all -
1722
+ * two parameterless `GET`s, which is where a health check starts and therefore where every new
1723
+ * consumer starts - got this declaration and no use of it, so `requests.gen.ts` did not compile.
1724
+ * `TS6196: 'Simplify' is declared but never used.` Third of its kind, after the unconditional
1725
+ * `zValidator` and `z` imports.
1726
+ *
1727
+ * Decided from the aliases actually rendered rather than by searching the output for the name.
1728
+ */
1729
+ const simplifyHelper = inputs.length === 0
1730
+ ? ""
1731
+ : `/**
1664
1732
  * Flattens an intersection into a single object type.
1665
1733
  *
1666
1734
  * Without it, \`Body & { filingId: string }\` and the equivalent flat object are structurally the
@@ -1668,7 +1736,8 @@ function renderRequestTypes(entries, responses, registry) {
1668
1736
  * this file with the emitted Zod would fail on correct code.
1669
1737
  */
1670
1738
  type Simplify<T> = { [K in keyof T]: T[K] } & {};
1671
-
1739
+ `;
1740
+ return `${generatedBanner()}${simplifyHelper}
1672
1741
  /**
1673
1742
  * The same shape, tolerant of \`readonly\`.
1674
1743
  *
@@ -78,9 +78,17 @@ export interface ResponseArm {
78
78
  */
79
79
  readonly status: number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
80
80
  readonly schema: ZodType | undefined;
81
+ /**
82
+ * How a handler said WHICH arm it meant, where an operation declares more than one success.
83
+ *
84
+ * **`number` too, because a `@statusCode` union selects by the status itself.**
85
+ * `model Created { @statusCode statusCode: 200 | 201 }` names the property that chooses and its
86
+ * values are the statuses; the discriminator case carries a boolean or string literal off the body
87
+ * instead. Both answer the same question, so both use this one field.
88
+ */
81
89
  readonly when?: {
82
90
  readonly property: string;
83
- readonly value: boolean | string;
91
+ readonly value: boolean | number | string;
84
92
  };
85
93
  }
86
94
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "TypeSpec emitter and library: turn an HTTP service definition into Zod validators and framework-free contract types that agree, keyword for keyword, with the OpenAPI document @typespec/openapi3 publishes from the same source.",
5
5
  "keywords": [
6
6
  "codegen",