typespec-http-zod 0.10.0 → 0.11.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
@@ -44,11 +44,12 @@ export { EmitterOptionsSchema, type EmitterOptions } from "./lib.js";
44
44
  */
45
45
  export { objectKey } from "./zod.js";
46
46
  /**
47
- * The success status a row registers.
47
+ * The status a row registers: the first non-error status the operation declares.
48
48
  *
49
- * An operation declares exactly one success status. A range (`2XX`) or a wildcard is not a status a
50
- * handler can return, so it is not something this emitter can turn into a `respond()` call - an
51
- * operation carrying only those is skipped rather than guessed at.
49
+ * **"Success" here means "an outcome a handler returns", which includes a redirect.** A range
50
+ * (`2XX`) or a wildcard is not a status a handler can return, so it is not something this emitter can
51
+ * turn into a `respond()` call, and an operation carrying only those is skipped rather than guessed
52
+ * at.
52
53
  */
53
54
  export declare function successStatusOf(operation: HttpOperation): number | undefined;
54
55
  /**
@@ -209,6 +210,41 @@ export interface EmittedRoute {
209
210
  readonly status: StatusKey;
210
211
  readonly schema: string | undefined;
211
212
  }[];
213
+ /**
214
+ * The headers each response status declares, as the document publishes them.
215
+ *
216
+ * **`deps.respond` could not set a header the contract promises.** A spec may declare `@header` on
217
+ * a response model - a `Location` on a 302, a `Link`, a correlation echo - and the document
218
+ * publishes it under `responses.<code>.headers`, but the arm carried a status and a body schema and
219
+ * nothing else. So the server could not answer faithfully, and a redirect could not be served at
220
+ * all. Reported as blocking by a consumer, and it is a fact the document already states.
221
+ *
222
+ * **Both names are carried, because two different things need them.** `name` is the WIRE name,
223
+ * which is what the response sets; `property` is the name on the returned value, which is where the
224
+ * value is read from. `@header("x-correlation-id") correlationId: string` is `x-correlation-id` on
225
+ * the wire and `correlationId` in the handler's result, and an emitter given only one of them would
226
+ * have to guess the other.
227
+ */
228
+ readonly responseHeaders: readonly {
229
+ readonly status: StatusKey;
230
+ readonly headers: readonly {
231
+ readonly name: string;
232
+ readonly property: string;
233
+ }[];
234
+ }[];
235
+ /**
236
+ * The media types each response status offers.
237
+ *
238
+ * **`responseContentTypes` above carries this for the PRIMARY status only, and an arm carried
239
+ * none at all.** An operation whose one status offers `application/json` and `text/event-stream`
240
+ * emitted a single arm holding the JSON schema, and the alternative was dropped from the list
241
+ * entirely, so `deps.respond` could not tell what it was answering with and a non-JSON operation
242
+ * could not be served faithfully. Reported by two consumers independently.
243
+ */
244
+ readonly responseMediaTypes: readonly {
245
+ readonly status: StatusKey;
246
+ readonly contentTypes: readonly string[];
247
+ }[];
212
248
  /**
213
249
  * No caller is established, so the operation receives no `ServiceContext`.
214
250
  *
package/dist/src/api.js CHANGED
@@ -64,11 +64,12 @@ const GENERATED_BANNER = `// GENERATED by ${PACKAGE_NAME} from the TypeSpec serv
64
64
  // Recompile the spec that produced it; edits here are overwritten on the next run.
65
65
  `;
66
66
  /**
67
- * The success status a row registers.
67
+ * The status a row registers: the first non-error status the operation declares.
68
68
  *
69
- * An operation declares exactly one success status. A range (`2XX`) or a wildcard is not a status a
70
- * handler can return, so it is not something this emitter can turn into a `respond()` call - an
71
- * operation carrying only those is skipped rather than guessed at.
69
+ * **"Success" here means "an outcome a handler returns", which includes a redirect.** A range
70
+ * (`2XX`) or a wildcard is not a status a handler can return, so it is not something this emitter can
71
+ * turn into a `respond()` call, and an operation carrying only those is skipped rather than guessed
72
+ * at.
72
73
  */
73
74
  export function successStatusOf(operation) {
74
75
  return successStatusesOf(operation)[0];
@@ -85,7 +86,23 @@ export function successStatusesOf(operation) {
85
86
  const codes = [];
86
87
  for (const response of operation.responses) {
87
88
  const code = response.statusCodes;
88
- if (typeof code === "number" && code >= 200 && code < 300)
89
+ /**
90
+ * **2xx AND 3xx, because a redirect is an outcome rather than an error.**
91
+ *
92
+ * This accepted 2xx only, so an operation whose one declared response was a `302` had no status,
93
+ * was skipped by the `continue` in `collectRoutes`, and vanished with no diagnostic. The document
94
+ * declared the route and the emitted output had no trace of it, so a client generated from that
95
+ * document would 404 while the compile reported success. A `Location` on a 302 and an OAuth
96
+ * redirect are the ordinary cases.
97
+ *
98
+ * 4xx and 5xx stay out: those are error arms, collected from `@error` models separately, and a
99
+ * handler does not return one by returning normally.
100
+ *
101
+ * **Additive by construction.** Measured across the whole conformance corpus before the change:
102
+ * ZERO operations declare a response set without a 2xx, so no existing route, count or baseline
103
+ * moves. Only operations that were being dropped are added.
104
+ */
105
+ if (typeof code === "number" && code >= 200 && code < 400)
89
106
  codes.push(code);
90
107
  }
91
108
  return [...new Set(codes)].toSorted((a, b) => a - b);
@@ -261,6 +278,51 @@ function isSuccessKey(status) {
261
278
  * Arms come out in the order OpenAPI resolves them - exact codes, then ranges, then the catch-all -
262
279
  * so a consumer scanning for the first match applies the precedence the specification states.
263
280
  */
281
+ /**
282
+ * The headers each response status declares, keyed by the status the arm answers with.
283
+ *
284
+ * **Read from `@typespec/http`'s own resolution**, which separates a response's headers from its
285
+ * body and gives each one under its WIRE name with the declaring property behind it. Deriving either
286
+ * name any other way would be a second spelling of a fact the compiler already resolved.
287
+ */
288
+ function responseHeadersOf(operation) {
289
+ const byStatus = new Map();
290
+ for (const response of operation.responses) {
291
+ const status = response.statusCodes;
292
+ if (typeof status !== "number")
293
+ continue;
294
+ const headers = [];
295
+ for (const content of response.responses) {
296
+ for (const [name, property] of Object.entries(content.headers ?? {})) {
297
+ if (!headers.some((existing) => existing.name === name)) {
298
+ headers.push({ name, property: property.name });
299
+ }
300
+ }
301
+ }
302
+ if (headers.length === 0)
303
+ continue;
304
+ const existing = byStatus.get(status) ?? [];
305
+ byStatus.set(status, [
306
+ ...existing,
307
+ ...headers.filter((h) => !existing.some((e) => e.name === h.name)),
308
+ ]);
309
+ }
310
+ return [...byStatus].map(([status, headers]) => ({ status, headers }));
311
+ }
312
+ /** The media types each numeric response status offers, from the compiler's own resolution. */
313
+ function responseMediaTypesOf(operation) {
314
+ const byStatus = new Map();
315
+ for (const response of operation.responses) {
316
+ const status = response.statusCodes;
317
+ if (typeof status !== "number")
318
+ continue;
319
+ const types = responseContentTypesOf(operation, status);
320
+ if (types.length === 0)
321
+ continue;
322
+ byStatus.set(status, types);
323
+ }
324
+ return [...byStatus].map(([status, contentTypes]) => ({ status, contentTypes }));
325
+ }
264
326
  function errorArmsOf(program, operation, registry) {
265
327
  const arms = new Map();
266
328
  for (const response of operation.responses) {
@@ -956,6 +1018,8 @@ export function collectRoutes(program, registry, service) {
956
1018
  // them under the ambient request visibility declared six components under the wrong
957
1019
  // suffix - measured: type/model/visibility gained six divergences.
958
1020
  errorArms: withVisibility(program, Visibility.Read, () => errorArmsOf(program, operation, registry)),
1021
+ responseHeaders: responseHeadersOf(operation),
1022
+ responseMediaTypes: responseMediaTypesOf(operation),
959
1023
  scopes: [
960
1024
  ...new Set((getAuthenticationForOperation(program, operation.operation)?.options ?? []).flatMap((option) => option.schemes.flatMap((scheme) => scheme.type === "oauth2"
961
1025
  ? scheme.flows.flatMap((flow) => flow.scopes.map((scope) => scope.value))
@@ -1206,13 +1270,39 @@ function nameRouteSchemas(routes) {
1206
1270
  function responseArmsOf(route, response, alternate) {
1207
1271
  const arms = [];
1208
1272
  const primary = response ?? "undefined";
1273
+ /**
1274
+ * The headers this status declares, rendered onto the arm that answers with it.
1275
+ *
1276
+ * Emitted only where there are some: an arm for a response declaring none must not acquire an
1277
+ * empty list, or every `respond` implementation has to tell "none declared" from "none carried".
1278
+ */
1279
+ /**
1280
+ * The media types this status offers. Emitted only where the document names more than one, because
1281
+ * a single type is what a `respond` already assumes and repeating it on every arm in every service
1282
+ * would be noise rather than a fact.
1283
+ */
1284
+ const mediaTypesFor = (status) => {
1285
+ const declared = route.responseMediaTypes.find((entry) => entry.status === status)?.contentTypes ?? [];
1286
+ if (declared.length < 2)
1287
+ return "";
1288
+ return `, contentTypes: [${declared.map((t) => JSON.stringify(t)).join(", ")}]`;
1289
+ };
1290
+ const headersFor = (status) => {
1291
+ const declared = route.responseHeaders.find((entry) => entry.status === status)?.headers ?? [];
1292
+ if (declared.length === 0)
1293
+ return "";
1294
+ const rendered = declared
1295
+ .map((h) => `{ name: ${JSON.stringify(h.name)}, property: ${JSON.stringify(h.property)} }`)
1296
+ .join(", ");
1297
+ return `, headers: [${rendered}]`;
1298
+ };
1209
1299
  if (route.statusBy === undefined) {
1210
- arms.push(`{ status: ${route.statusCode}, schema: ${primary} }`);
1300
+ arms.push(`{ status: ${route.statusCode}, schema: ${primary}${mediaTypesFor(route.statusCode)}${headersFor(route.statusCode)} }`);
1211
1301
  }
1212
1302
  else {
1213
1303
  const alternateSchema = alternate ?? "undefined";
1214
1304
  const { property, value, status } = route.statusBy;
1215
- arms.push(`{ status: ${status}, schema: ${alternateSchema}, when: { property: ${JSON.stringify(property)}, value: ${JSON.stringify(value)} } }`, `{ status: ${route.statusCode}, schema: ${primary} }`);
1305
+ arms.push(`{ status: ${status}, schema: ${alternateSchema}${mediaTypesFor(status)}${headersFor(status)}, when: { property: ${JSON.stringify(property)}, value: ${JSON.stringify(value)} } }`, `{ status: ${route.statusCode}, schema: ${primary}${mediaTypesFor(route.statusCode)}${headersFor(route.statusCode)} }`);
1216
1306
  }
1217
1307
  /**
1218
1308
  * The failure arms, which used to be dropped entirely.
@@ -39,6 +39,28 @@ import type { ZodType } from "zod";
39
39
  * arm is legitimate - so the typo produces a valid-looking arm that validates nothing.
40
40
  */
41
41
  export interface ResponseArm {
42
+ /**
43
+ * The media types this response offers, where the document names more than one.
44
+ *
45
+ * Absent where it names one, which is what an application already assumes. Present, it is the set
46
+ * to negotiate against: `selectContentType` takes the caller's `Accept` and these.
47
+ */
48
+ readonly contentTypes?: readonly string[];
49
+ /**
50
+ * The headers this response declares, as the document publishes them.
51
+ *
52
+ * **Two names, because two different things need them.** `name` is the WIRE name, which is what
53
+ * the response sets; `property` is the name on the value the handler returned, which is where the
54
+ * value is read from. `@header("x-correlation-id") correlationId: string` is `x-correlation-id`
55
+ * on the wire and `correlationId` in the result.
56
+ *
57
+ * Absent where the response declares none, so "none declared" and "none carried" are the same
58
+ * thing rather than two states an application has to tell apart.
59
+ */
60
+ readonly headers?: readonly {
61
+ readonly name: string;
62
+ readonly property: string;
63
+ }[];
42
64
  /**
43
65
  * The status this arm answers with: an exact code, a `4XX`-style range, or the catch-all.
44
66
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",