typespec-http-zod 0.9.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
  /**
@@ -109,6 +110,32 @@ export interface EmittedRoute {
109
110
  readonly operationId: string;
110
111
  readonly verb: string;
111
112
  readonly path: string;
113
+ /**
114
+ * The WIRE names of path parameters that use RFC 6570 reserved expansion, and so match across `/`.
115
+ *
116
+ * **`path` alone cannot carry this, and that is not an oversight in the spelling.** A note path is
117
+ * `areas/health.md`, so a vault route has to be `GET /vault/:path{.+}`; `@typespec/http` resolves
118
+ * `@route("/vault/{+path}")` to `path` = `/vault/{path}` with the operator already stripped, and
119
+ * `@typespec/openapi3` publishes the same. **Measured at 3.0.0, 3.1.0 and 3.2.0**: OpenAPI cannot
120
+ * express reserved expansion at any version, so the document says `/vault/{path}` and openapi3
121
+ * raises `path-reserved-expansion` as a warning. The fact exists in the program and reaches no
122
+ * artefact, so a server emitter mounts a route that answers 404 to every request it was written
123
+ * for. This is the one place the IR deliberately carries more than the document - see
124
+ * `docs/reference.md`.
125
+ *
126
+ * **Read from the parameter, never by parsing `path` or `uriTemplate` for `{+`.** Two forms reach
127
+ * `allowReserved`, and only the parameter entry answers for both: the operator in the template
128
+ * (`@route("/vault/{+path}")`), and `@path(#{allowReserved: true})` on a required parameter the
129
+ * template does not already name, which is what `@typespec/http-specs` itself uses and which
130
+ * `@typespec/http` resolves by APPENDING `{+param}` to the template. Deciding this by matching
131
+ * emitted text would also be the defect class that produced six others here.
132
+ *
133
+ * Wire names, so `@path("note-path") notePath` appears as `note-path` - the name a router has to
134
+ * match, and the same one `pathSchema` keys on. Empty when the operation has none, never
135
+ * `undefined`, so a consumer does not branch. Reserved-ness is per PARAMETER: `/repo/{owner}/{+ref}`
136
+ * carries one of each.
137
+ */
138
+ readonly reservedPathParameters: readonly string[];
112
139
  readonly statusCode: number;
113
140
  readonly statusCodes: readonly number[];
114
141
  /** Media types the success body can be served as - the basis for `Accept` negotiation. */
@@ -183,6 +210,41 @@ export interface EmittedRoute {
183
210
  readonly status: StatusKey;
184
211
  readonly schema: string | undefined;
185
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
+ }[];
186
248
  /**
187
249
  * No caller is established, so the operation receives no `ServiceContext`.
188
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) {
@@ -892,6 +954,14 @@ export function collectRoutes(program, registry, service) {
892
954
  * particular router can express the path.
893
955
  */
894
956
  path: operation.path,
957
+ /**
958
+ * Asked of the resolved parameter, which is the only place both marker forms agree -
959
+ * see {@link EmittedRoute.reservedPathParameters}. `allowReserved` is a required
960
+ * boolean on every path parameter entry, so this reads a fact rather than an absence.
961
+ */
962
+ reservedPathParameters: operation.parameters.parameters
963
+ .filter((parameter) => parameter.type === "path" && parameter.allowReserved)
964
+ .map((parameter) => parameter.name),
895
965
  statusCode,
896
966
  statusCodes,
897
967
  responseContentTypes: responseContentTypesOf(operation, statusCode),
@@ -948,6 +1018,8 @@ export function collectRoutes(program, registry, service) {
948
1018
  // them under the ambient request visibility declared six components under the wrong
949
1019
  // suffix - measured: type/model/visibility gained six divergences.
950
1020
  errorArms: withVisibility(program, Visibility.Read, () => errorArmsOf(program, operation, registry)),
1021
+ responseHeaders: responseHeadersOf(operation),
1022
+ responseMediaTypes: responseMediaTypesOf(operation),
951
1023
  scopes: [
952
1024
  ...new Set((getAuthenticationForOperation(program, operation.operation)?.options ?? []).flatMap((option) => option.schemes.flatMap((scheme) => scheme.type === "oauth2"
953
1025
  ? scheme.flows.flatMap((flow) => flow.scopes.map((scope) => scope.value))
@@ -1198,13 +1270,39 @@ function nameRouteSchemas(routes) {
1198
1270
  function responseArmsOf(route, response, alternate) {
1199
1271
  const arms = [];
1200
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
+ };
1201
1299
  if (route.statusBy === undefined) {
1202
- arms.push(`{ status: ${route.statusCode}, schema: ${primary} }`);
1300
+ arms.push(`{ status: ${route.statusCode}, schema: ${primary}${mediaTypesFor(route.statusCode)}${headersFor(route.statusCode)} }`);
1203
1301
  }
1204
1302
  else {
1205
1303
  const alternateSchema = alternate ?? "undefined";
1206
1304
  const { property, value, status } = route.statusBy;
1207
- 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)} }`);
1208
1306
  }
1209
1307
  /**
1210
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.9.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",