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 +66 -4
- package/dist/src/api.js +105 -7
- package/dist/src/runtime.d.ts +22 -0
- package/package.json +1 -1
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
|
|
47
|
+
* The status a row registers: the first non-error status the operation declares.
|
|
48
48
|
*
|
|
49
|
-
*
|
|
50
|
-
* handler can return, so it is not something this emitter can
|
|
51
|
-
* operation carrying only those is skipped rather than guessed
|
|
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
|
|
67
|
+
* The status a row registers: the first non-error status the operation declares.
|
|
68
68
|
*
|
|
69
|
-
*
|
|
70
|
-
* handler can return, so it is not something this emitter can
|
|
71
|
-
* operation carrying only those is skipped rather than guessed
|
|
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
|
-
|
|
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.
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -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.
|
|
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",
|