typespec-hono 0.22.0 → 0.24.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/README.md +102 -62
- package/dist/src/app.d.ts +95 -43
- package/dist/src/app.js +845 -251
- package/dist/src/emitter.d.ts +3 -3
- package/dist/src/emitter.js +52 -45
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +2 -0
- package/dist/src/lib.d.ts +13 -4
- package/dist/src/lib.js +41 -1
- package/dist/src/linter.d.ts +20 -0
- package/dist/src/linter.js +32 -0
- package/dist/src/rules/regexp-router-unsupported.rule.d.ts +32 -0
- package/dist/src/rules/regexp-router-unsupported.rule.js +65 -0
- package/dist/src/runtime.d.ts +160 -80
- package/dist/src/runtime.js +152 -1
- package/package.json +16 -16
- package/src/runtime.ts +217 -85
- package/dist/src/security.d.ts +0 -29
- package/dist/src/security.js +0 -48
package/dist/src/app.js
CHANGED
|
@@ -1,5 +1,281 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { RegExpRouter } from "hono/router/reg-exp-router";
|
|
2
2
|
import { isRawBinaryMediaType, jsDocComment, objectKey, } from "typespec-http-zod";
|
|
3
|
+
/**
|
|
4
|
+
* Hono's status codes, group by group, exactly as `hono/utils/http-status` declares them.
|
|
5
|
+
*
|
|
6
|
+
* **The generated route needs the LITERALS, not only the type.** A range arm is served from a
|
|
7
|
+
* `switch` on the result's status, and TypeScript narrows the result to one member only through a
|
|
8
|
+
* `case` label per literal: a type guard narrows the status and leaves the result a union, and
|
|
9
|
+
* merging the range into `default` pairs every body with every status. Both measured on hono 4.13.1.
|
|
10
|
+
*
|
|
11
|
+
* `test/status-codes.test.ts` holds these equal to Hono's own unions, so a status Hono adds fails a
|
|
12
|
+
* test rather than silently falling out of a range.
|
|
13
|
+
*/
|
|
14
|
+
export const STATUS_GROUPS = {
|
|
15
|
+
1: { type: "InfoStatusCode", codes: [100, 101, 102, 103] },
|
|
16
|
+
2: { type: "SuccessStatusCode", codes: [200, 201, 202, 203, 204, 205, 206, 207, 208, 226] },
|
|
17
|
+
3: { type: "RedirectStatusCode", codes: [300, 301, 302, 303, 304, 305, 306, 307, 308] },
|
|
18
|
+
4: {
|
|
19
|
+
type: "ClientErrorStatusCode",
|
|
20
|
+
codes: [
|
|
21
|
+
400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418,
|
|
22
|
+
421, 422, 423, 424, 425, 426, 428, 429, 431, 451,
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
5: {
|
|
26
|
+
type: "ServerErrorStatusCode",
|
|
27
|
+
codes: [500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511],
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Where every generated file imports its runtime from: the copy this package writes beside them.
|
|
32
|
+
*
|
|
33
|
+
* Declared here, where the import is rendered, and re-exported by the emitter that writes the file.
|
|
34
|
+
*/
|
|
35
|
+
export const DEFAULT_RUNTIME_MODULE = "./runtime.gen.js";
|
|
36
|
+
/** Hono's `ContentlessStatusCode`: a status that cannot carry a body, so `c.json` refuses it. */
|
|
37
|
+
export const CONTENTLESS_STATUS_CODES = [101, 204, 205, 304];
|
|
38
|
+
/** A media type whose body is a JSON value, served with `c.json`. The library's own rule. */
|
|
39
|
+
function isJsonMediaType(type) {
|
|
40
|
+
return /^application\/(json|.*\+json)$/.test(type);
|
|
41
|
+
}
|
|
42
|
+
function bodyServingOf(member) {
|
|
43
|
+
const { response, mediaType } = member;
|
|
44
|
+
if (response.schema === undefined || mediaType === undefined)
|
|
45
|
+
return "none";
|
|
46
|
+
if (response.streamed)
|
|
47
|
+
return "stream";
|
|
48
|
+
if (response.binary)
|
|
49
|
+
return "binary";
|
|
50
|
+
if (isJsonMediaType(mediaType))
|
|
51
|
+
return "json";
|
|
52
|
+
/**
|
|
53
|
+
* **A string body under a non-JSON media type IS the text**, so it is validated and served as is.
|
|
54
|
+
* Any other body under one - a model as `application/xml` - has no serialisation this emitter can
|
|
55
|
+
* derive, so the handler supplies the text and it is served unvalidated, which the
|
|
56
|
+
* `unvalidated-response-media-type` warning says out loud.
|
|
57
|
+
*/
|
|
58
|
+
return response.textual ? "text" : "unvalidated";
|
|
59
|
+
}
|
|
60
|
+
function membersOf(entry) {
|
|
61
|
+
return entry.route.responses.flatMap((response, index) => {
|
|
62
|
+
const schemaName = entry.names.arms[index]?.schema;
|
|
63
|
+
const headersSchemaName = entry.names.arms[index]?.headers;
|
|
64
|
+
if (response.contentTypes.length === 0) {
|
|
65
|
+
return [
|
|
66
|
+
{ response, schemaName, headersSchemaName, mediaType: undefined, choosesMediaType: false },
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The handler names the media type where the status offers several, and where the document names
|
|
71
|
+
* only a wildcard range, which is not a type a response can be sent as.
|
|
72
|
+
*/
|
|
73
|
+
const choosesMediaType = response.contentTypes.length > 1 || response.contentTypes.some(isMediaRange);
|
|
74
|
+
return response.contentTypes.map((mediaType) => ({
|
|
75
|
+
response,
|
|
76
|
+
schemaName,
|
|
77
|
+
headersSchemaName,
|
|
78
|
+
mediaType,
|
|
79
|
+
choosesMediaType,
|
|
80
|
+
}));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/** A media range such as `image/*`, rather than a type a response can be sent as. */
|
|
84
|
+
function isMediaRange(mediaType) {
|
|
85
|
+
return mediaType.endsWith("/*");
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The type of the `contentType` a handler names for a member.
|
|
89
|
+
*
|
|
90
|
+
* **A range is typed as the types inside it**: `image/*` is `` `image/${string}` ``, so `text/html`
|
|
91
|
+
* under it does not compile, and TypeScript still narrows a literal member away from it. Only the
|
|
92
|
+
* full range is `string`.
|
|
93
|
+
*/
|
|
94
|
+
function contentTypeTypeOf(mediaType) {
|
|
95
|
+
if (!isMediaRange(mediaType))
|
|
96
|
+
return JSON.stringify(mediaType);
|
|
97
|
+
if (mediaType === "*/*")
|
|
98
|
+
return "string";
|
|
99
|
+
return `\`${mediaType.slice(0, -1)}\${string}\``;
|
|
100
|
+
}
|
|
101
|
+
/** The Hono group a status key's codes belong to. */
|
|
102
|
+
function groupOf(status) {
|
|
103
|
+
const digit = typeof status === "number" ? Math.floor(status / 100) : Number(String(status)[0]);
|
|
104
|
+
return digit >= 1 && digit <= 5 ? STATUS_GROUPS[digit] : undefined;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The codes a status key answers with, as `case` labels, or `"default"` for the catch-all.
|
|
108
|
+
*
|
|
109
|
+
* **A range excludes what is declared more precisely**, because OpenAPI resolves an exact code before
|
|
110
|
+
* its range and `armFor` does the same: a `404` beside a `4XX` is the `404`'s. A bodied range also
|
|
111
|
+
* excludes the statuses that cannot carry a body.
|
|
112
|
+
*/
|
|
113
|
+
function caseLabelsOf(response, route) {
|
|
114
|
+
const { status } = response;
|
|
115
|
+
if (status === "default")
|
|
116
|
+
return "default";
|
|
117
|
+
if (typeof status === "number")
|
|
118
|
+
return [status];
|
|
119
|
+
const exact = new Set(route.responses.flatMap((candidate) => typeof candidate.status === "number" ? [candidate.status] : []));
|
|
120
|
+
const contentless = new Set(response.schema === undefined ? [] : CONTENTLESS_STATUS_CODES);
|
|
121
|
+
return (groupOf(status)?.codes ?? []).filter((code) => !exact.has(code) && !contentless.has(code));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Serve a handler's `result` for every response the operation declares, as statements at `depth`.
|
|
125
|
+
*
|
|
126
|
+
* **One `case` per status literal, and one Hono call per declared response.** `c.json(body, 404)` is
|
|
127
|
+
* what gives `hc` a typed body for a 404, so the route serves each status through its own call rather
|
|
128
|
+
* than through one call over the union, which measured as a single endpoint pairing every body with
|
|
129
|
+
* every status.
|
|
130
|
+
*
|
|
131
|
+
* **Every served body is the PARSED body.** `servedBody` checks it against the schema the document
|
|
132
|
+
* publishes for that status and throws `ResponseContractError` when it does not match, failures
|
|
133
|
+
* included, and what is sent is the parse result - so a field the schema does not declare does not
|
|
134
|
+
* reach the wire.
|
|
135
|
+
*/
|
|
136
|
+
function servingLines(entry, depth, uses) {
|
|
137
|
+
const indent = "\t".repeat(depth);
|
|
138
|
+
const operationId = JSON.stringify(entry.route.operationId);
|
|
139
|
+
const members = membersOf(entry);
|
|
140
|
+
const lines = [`${indent}switch (result.status) {`];
|
|
141
|
+
let servesDefault = false;
|
|
142
|
+
for (const response of entry.route.responses) {
|
|
143
|
+
const labels = caseLabelsOf(response, entry.route);
|
|
144
|
+
if (labels !== "default" && labels.length === 0)
|
|
145
|
+
continue;
|
|
146
|
+
if (labels === "default") {
|
|
147
|
+
servesDefault = true;
|
|
148
|
+
lines.push(`${indent}\tdefault: {`);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
lines.push(...labels.map((code) => `${indent}\tcase ${code}:`));
|
|
152
|
+
lines[lines.length - 1] = `${lines.at(-1) ?? ""} {`;
|
|
153
|
+
}
|
|
154
|
+
const status = labels !== "default" && labels.length === 1 ? String(labels[0]) : "result.status";
|
|
155
|
+
const own = members.filter((member) => member.response === response);
|
|
156
|
+
if (!own.some((member) => member.choosesMediaType)) {
|
|
157
|
+
for (const member of own) {
|
|
158
|
+
lines.push(`${indent}\t\t${serveCall(member, status, operationId, uses)}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
/**
|
|
163
|
+
* **Exact types by `case`, then ranges by `mediaTypeWithin`, then a throw.** A `case "image/*"`
|
|
164
|
+
* would compare the range's own spelling and never match the `image/png` the handler is typed
|
|
165
|
+
* to name. Every `case` returns, so after the switch TypeScript has already narrowed the
|
|
166
|
+
* result to the range members, whose bodies are served the same way.
|
|
167
|
+
*/
|
|
168
|
+
const exact = own.filter((member) => member.mediaType !== undefined && !isMediaRange(member.mediaType));
|
|
169
|
+
if (exact.length > 0) {
|
|
170
|
+
lines.push(`${indent}\t\tswitch (result.contentType) {`);
|
|
171
|
+
for (const member of exact) {
|
|
172
|
+
lines.push(`${indent}\t\t\tcase ${JSON.stringify(member.mediaType)}:`);
|
|
173
|
+
lines.push(`${indent}\t\t\t\t${serveCall(member, status, operationId, uses)}`);
|
|
174
|
+
}
|
|
175
|
+
lines.push(`${indent}\t\t}`);
|
|
176
|
+
}
|
|
177
|
+
for (const member of own) {
|
|
178
|
+
if (member.mediaType === undefined || !isMediaRange(member.mediaType))
|
|
179
|
+
continue;
|
|
180
|
+
uses.runtime.add("mediaTypeWithin");
|
|
181
|
+
lines.push(`${indent}\t\tif (mediaTypeWithin(result.contentType, ${JSON.stringify(member.mediaType)})) ${serveCall(member, status, operationId, uses)}`);
|
|
182
|
+
}
|
|
183
|
+
uses.runtime.add("UndeclaredStatusError");
|
|
184
|
+
lines.push(`${indent}\t\tthrow new UndeclaredStatusError(${operationId}, result);`);
|
|
185
|
+
}
|
|
186
|
+
lines.push(`${indent}\t}`);
|
|
187
|
+
}
|
|
188
|
+
if (!servesDefault) {
|
|
189
|
+
/**
|
|
190
|
+
* **Unreachable from a typed handler**: every declared status has a `case`, so `result` is
|
|
191
|
+
* `never` here. A cast, or untyped data from a service binding, is what arrives - and serving
|
|
192
|
+
* it would publish a status the document does not declare.
|
|
193
|
+
*/
|
|
194
|
+
uses.runtime.add("UndeclaredStatusError");
|
|
195
|
+
lines.push(`${indent}\tdefault:`);
|
|
196
|
+
lines.push(`${indent}\t\tthrow new UndeclaredStatusError(${operationId}, result);`);
|
|
197
|
+
}
|
|
198
|
+
lines.push(`${indent}}`);
|
|
199
|
+
return lines;
|
|
200
|
+
}
|
|
201
|
+
/** The one Hono call that serves one member, as a `return` statement. */
|
|
202
|
+
function serveCall(member, status, operationId, uses) {
|
|
203
|
+
const { response, mediaType } = member;
|
|
204
|
+
const serving = bodyServingOf(member);
|
|
205
|
+
const headers = [];
|
|
206
|
+
/**
|
|
207
|
+
* **`Content-Type` wherever `c.json` would not already say it.** `c.json` writes
|
|
208
|
+
* `application/json`; a `+json` type such as `application/problem+json` has to be stated, and
|
|
209
|
+
* `c.body` states nothing. The key is spelled `Content-Type` because Hono merges its own default
|
|
210
|
+
* under exactly that key, so a lowercase spelling would send both.
|
|
211
|
+
*/
|
|
212
|
+
if (mediaType !== undefined && serving !== "none" && mediaType !== "application/json") {
|
|
213
|
+
const value = member.choosesMediaType || mediaType.includes("*")
|
|
214
|
+
? "result.contentType"
|
|
215
|
+
: JSON.stringify(mediaType);
|
|
216
|
+
headers.push(`"Content-Type": ${value}`);
|
|
217
|
+
}
|
|
218
|
+
const allOptional = response.headers.every((header) => header.optional);
|
|
219
|
+
for (const header of response.headers) {
|
|
220
|
+
const key = JSON.stringify(header.name);
|
|
221
|
+
headers.push(`${key}: result.headers${allOptional ? "?." : ""}[${key}]`);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* **Declared headers are CHECKED, not only stringified**, which the body beside them always was.
|
|
225
|
+
* `headersOf` still serves a response whose only headers this emitter chose itself, such as the
|
|
226
|
+
* `Content-Type` a `c.body` call has to state.
|
|
227
|
+
*/
|
|
228
|
+
const checksHeaders = response.headers.length > 0 && member.headersSchemaName !== undefined;
|
|
229
|
+
if (headers.length > 0)
|
|
230
|
+
uses.runtime.add(checksHeaders ? "servedHeaders" : "headersOf");
|
|
231
|
+
const headersArgument = headers.length === 0
|
|
232
|
+
? ""
|
|
233
|
+
: checksHeaders
|
|
234
|
+
? `, servedHeaders(${member.headersSchemaName ?? "undefined"}, { ${headers.join(", ")} }, ${operationId}, ${status})`
|
|
235
|
+
: `, headersOf({ ${headers.join(", ")} })`;
|
|
236
|
+
const validated = () => {
|
|
237
|
+
uses.runtime.add("servedBody");
|
|
238
|
+
return `servedBody(${member.schemaName ?? "undefined"}, result.body, ${operationId}, ${status})`;
|
|
239
|
+
};
|
|
240
|
+
switch (serving) {
|
|
241
|
+
case "none":
|
|
242
|
+
return `return c.body(null, ${status}${headersArgument});`;
|
|
243
|
+
case "json":
|
|
244
|
+
return `return c.json(${validated()}, ${status}${headersArgument});`;
|
|
245
|
+
case "text":
|
|
246
|
+
return `return c.body(${validated()}, ${status}${headersArgument});`;
|
|
247
|
+
default:
|
|
248
|
+
return `return c.body(result.body, ${status}${headersArgument});`;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* A member's status as a TypeScript type: disjoint from every other member's, by construction.
|
|
253
|
+
*
|
|
254
|
+
* **Disjoint is the property that makes a wrong body a compile error.** Were `default` allowed to
|
|
255
|
+
* overlap a declared status, `{ status: 404, body: <the default arm's body> }` would type-check
|
|
256
|
+
* through the `default` member; measured, and the generated switch stopped compiling as well.
|
|
257
|
+
*/
|
|
258
|
+
function statusTypeOf(response, route) {
|
|
259
|
+
const { status } = response;
|
|
260
|
+
if (typeof status === "number")
|
|
261
|
+
return String(status);
|
|
262
|
+
const exact = route.responses.flatMap((candidate) => typeof candidate.status === "number" ? [String(candidate.status)] : []);
|
|
263
|
+
if (status !== "default") {
|
|
264
|
+
const group = groupOf(status);
|
|
265
|
+
if (group === undefined)
|
|
266
|
+
return "never";
|
|
267
|
+
const excluded = [
|
|
268
|
+
...exact.filter((code) => groupOf(Number(code)) === group),
|
|
269
|
+
...(response.schema === undefined ? [] : ["ContentlessStatusCode"]),
|
|
270
|
+
];
|
|
271
|
+
return excluded.length === 0 ? group.type : `Exclude<${group.type}, ${excluded.join(" | ")}>`;
|
|
272
|
+
}
|
|
273
|
+
const ranges = route.responses.flatMap((candidate) => typeof candidate.status === "string" && candidate.status !== "default"
|
|
274
|
+
? [groupOf(candidate.status)?.type ?? "never"]
|
|
275
|
+
: []);
|
|
276
|
+
const base = response.schema === undefined ? "StatusCode" : "ContentfulStatusCode";
|
|
277
|
+
return `Exclude<${base}, ${["InfoStatusCode", "UnofficialStatusCode", ...exact, ...ranges].join(" | ")}>`;
|
|
278
|
+
}
|
|
3
279
|
/**
|
|
4
280
|
* The header every emitted file carries.
|
|
5
281
|
*
|
|
@@ -16,56 +292,80 @@ export function generatedBanner(hint) {
|
|
|
16
292
|
}
|
|
17
293
|
/** A parameter name Hono can carry verbatim. Measured against Hono, not assumed. */
|
|
18
294
|
const PLAIN_PATH_PARAMETER = /^[A-Za-z0-9_.~-]+$/;
|
|
295
|
+
/** Text matched literally inside a Hono `{...}` parameter pattern. */
|
|
296
|
+
function escapeRegExp(text) {
|
|
297
|
+
return text.replace(/[.*+?^$()|[\]\\]/g, "\\$&");
|
|
298
|
+
}
|
|
19
299
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
300
|
+
* The Hono route for a route template, from the segments `typespec-http-zod` reads out of the
|
|
301
|
+
* operation's RFC 6570 `uriTemplate`.
|
|
302
|
+
*
|
|
303
|
+
* **This used to convert `route.path`, which has every operator stripped**, so `array{.param*}`,
|
|
304
|
+
* `array{;param}` and `optional{/name}` were mounted as `array:param` and `optional:name`, which Hono
|
|
305
|
+
* does not match at all. Measured by request: 31 of the URIs `@typespec/http-specs` `routes` and
|
|
306
|
+
* `parameters/path` declare answered 404 from a server generated from them.
|
|
307
|
+
*
|
|
308
|
+
* - A whole-segment expression is `:name`; a reserved or exploding `/` one crosses `/`, so it is
|
|
309
|
+
* `:name{.+}` (Hono's spelling of greedy); an optional last segment is `:name?`.
|
|
310
|
+
* - An expression written beside literal text, or with a label or matrix operator, is a pattern
|
|
311
|
+
* parameter matching the whole segment: `:param{array\.[^\x2F]*}`. The captured text is the
|
|
312
|
+
* segment, and the path validator undoes the expansion. `\x2F` rather than `/`, so the mounted
|
|
313
|
+
* path still splits into its segments on `/` for the ordering and sub-app rules below.
|
|
314
|
+
*
|
|
315
|
+
* **A name Hono cannot carry is REFUSED rather than approximated**, and so is a segment holding two
|
|
316
|
+
* expressions, which no segment router can split. The segment stays literal, so it matches nothing
|
|
317
|
+
* rather than the wrong thing. A space, `+` and `!` are the characters that fail; a hyphen, a dot and
|
|
318
|
+
* a tilde do not.
|
|
319
|
+
*
|
|
320
|
+
* **This runs at RENDER time, not during collection**, so what a request body must look like never
|
|
321
|
+
* depends on whether one framework's router can express the path.
|
|
41
322
|
*/
|
|
42
|
-
export function toHonoPath(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return template.replace(/\{([^}]+)\}/g, (match, name) => {
|
|
57
|
-
/**
|
|
58
|
-
* **The name check comes FIRST and is unconditional.** A name Hono cannot carry is refused
|
|
59
|
-
* whether or not it is reserved: greedy matching does not make a space or a `+` in a parameter
|
|
60
|
-
* name expressible, and letting one through because another flag was set would mount a route
|
|
61
|
-
* matching the wrong requests rather than one that fails.
|
|
62
|
-
*/
|
|
323
|
+
export function toHonoPath(segments, refuse) {
|
|
324
|
+
const template = `/${segments
|
|
325
|
+
.map((segment) => segment.kind === "expression"
|
|
326
|
+
? `${segment.prefix}{${segment.operator}${segment.parameter}${segment.explode ? "*" : ""}}${segment.suffix}`
|
|
327
|
+
: segment.text)
|
|
328
|
+
.join("/")}`;
|
|
329
|
+
const parts = segments.map((segment, index) => {
|
|
330
|
+
if (segment.kind === "literal")
|
|
331
|
+
return segment.text;
|
|
332
|
+
if (segment.kind === "unsupported") {
|
|
333
|
+
refuse(template, segment.text);
|
|
334
|
+
return segment.text;
|
|
335
|
+
}
|
|
336
|
+
const name = segment.parameter;
|
|
63
337
|
if (!PLAIN_PATH_PARAMETER.test(name)) {
|
|
64
338
|
refuse(template, name);
|
|
65
|
-
return
|
|
339
|
+
return `{${name}}`;
|
|
340
|
+
}
|
|
341
|
+
const whole = segment.prefix === "" &&
|
|
342
|
+
segment.suffix === "" &&
|
|
343
|
+
segment.operator !== "." &&
|
|
344
|
+
segment.operator !== ";";
|
|
345
|
+
if (whole) {
|
|
346
|
+
if (segment.reserved || (segment.operator === "/" && segment.explode))
|
|
347
|
+
return `:${name}{.+}`;
|
|
348
|
+
return segment.optional && index === segments.length - 1 ? `:${name}?` : `:${name}`;
|
|
66
349
|
}
|
|
67
|
-
|
|
350
|
+
const leader = segment.operator === "." || segment.operator === ";" ? escapeRegExp(segment.operator) : "";
|
|
351
|
+
const body = segment.reserved ? ".*" : "[^\\x2F]*";
|
|
352
|
+
return `:${name}{${escapeRegExp(segment.prefix)}${leader}${body}${escapeRegExp(segment.suffix)}}`;
|
|
68
353
|
});
|
|
354
|
+
return `/${parts.join("/")}`;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The document's `security` as a TypeScript literal, for the generated `authorize` call. `{}` is the
|
|
358
|
+
* anonymous alternative, written out so an `authorize` applying the documented rule (any one
|
|
359
|
+
* requirement, every scheme within it) admits a caller who presents nothing.
|
|
360
|
+
*/
|
|
361
|
+
function renderSecurity(requirements) {
|
|
362
|
+
return `[${requirements
|
|
363
|
+
.map((requirement) => Object.keys(requirement).length === 0
|
|
364
|
+
? "{}"
|
|
365
|
+
: `{ ${Object.entries(requirement)
|
|
366
|
+
.map(([scheme, scopes]) => `${JSON.stringify(scheme)}: [${scopes.map((scope) => JSON.stringify(scope)).join(", ")}]`)
|
|
367
|
+
.join(", ")} }`)
|
|
368
|
+
.join(", ")}]`;
|
|
69
369
|
}
|
|
70
370
|
/**
|
|
71
371
|
* The verb Hono actually dispatches under.
|
|
@@ -94,14 +394,18 @@ const HONO_METHOD = {
|
|
|
94
394
|
* A request location, as the DOCUMENT names it, mapped to the target `@hono/zod-validator` reads.
|
|
95
395
|
*
|
|
96
396
|
* **The two vocabularies are not the same, and only one of them is a contract fact.** OpenAPI says
|
|
97
|
-
* `path`, `query`, `header` and a request body; zValidator says `param`, `query`,
|
|
98
|
-
* `json`. The library publishes the first because that is what the document
|
|
99
|
-
* this package's job, and it is a map rather than a coincidence.
|
|
397
|
+
* `path`, `query`, `header`, `cookie` and a request body; zValidator says `param`, `query`,
|
|
398
|
+
* `header`, `cookie` and `json`. The library publishes the first because that is what the document
|
|
399
|
+
* states; translating is this package's job, and it is a map rather than a coincidence.
|
|
400
|
+
*
|
|
401
|
+
* `cookie` is the one location whose two spellings agree, and it is a first-class Hono target:
|
|
402
|
+
* `ValidationTargets` declares it and Hono's own validator reads it with `getCookie`.
|
|
100
403
|
*/
|
|
101
404
|
const VALIDATOR_TARGET = {
|
|
102
405
|
path: "param",
|
|
103
406
|
query: "query",
|
|
104
407
|
header: "header",
|
|
408
|
+
cookie: "cookie",
|
|
105
409
|
body: "json",
|
|
106
410
|
};
|
|
107
411
|
/**
|
|
@@ -120,12 +424,23 @@ const VALIDATOR_TARGET = {
|
|
|
120
424
|
* justifies, so it keeps the existing behaviour rather than acquiring a new one on the way past. That
|
|
121
425
|
* gap is real and is stated in the README rather than papered over here.
|
|
122
426
|
*/
|
|
123
|
-
function targetForMediaType(type) {
|
|
427
|
+
function targetForMediaType(type, textual) {
|
|
124
428
|
if (type === "application/x-www-form-urlencoded" || type.startsWith("multipart/"))
|
|
125
429
|
return "form";
|
|
126
430
|
// `application/json`, and the `+json` structured suffix RFC 6839 defines.
|
|
127
431
|
if (type === "application/json" || type.endsWith("+json"))
|
|
128
432
|
return VALIDATOR_TARGET.body;
|
|
433
|
+
/**
|
|
434
|
+
* **A body the document says IS text is read as text**, `@body body: string` under `text/plain`
|
|
435
|
+
* being the ordinary case. `requestTextual` is the library's answer about the TYPE, so the same
|
|
436
|
+
* media type carrying a model stays unparseable and keeps its warning: `text/plain` says how the
|
|
437
|
+
* value is framed, not what it is.
|
|
438
|
+
*
|
|
439
|
+
* Measured before this existed: such a route emitted no body middleware at all, so the body was
|
|
440
|
+
* never read and the handler was called with an empty input.
|
|
441
|
+
*/
|
|
442
|
+
if (textual)
|
|
443
|
+
return "text";
|
|
129
444
|
return undefined;
|
|
130
445
|
}
|
|
131
446
|
/**
|
|
@@ -146,13 +461,13 @@ function targetForMediaType(type) {
|
|
|
146
461
|
* package should make. Naming them is what stops a consumer believing a route is validated when it
|
|
147
462
|
* is not -- the previous behaviour said nothing and rejected them.
|
|
148
463
|
*/
|
|
149
|
-
function bodyValidationFor(contentTypes) {
|
|
464
|
+
function bodyValidationFor(contentTypes, textual) {
|
|
150
465
|
if (contentTypes.length === 0)
|
|
151
466
|
return { byType: [["", VALIDATOR_TARGET.body]], unparseable: [] };
|
|
152
467
|
const byType = [];
|
|
153
468
|
const unparseable = [];
|
|
154
469
|
for (const type of contentTypes) {
|
|
155
|
-
const target = targetForMediaType(type);
|
|
470
|
+
const target = targetForMediaType(type, textual);
|
|
156
471
|
if (target === undefined)
|
|
157
472
|
unparseable.push(type);
|
|
158
473
|
else
|
|
@@ -164,9 +479,9 @@ function bodyValidationFor(contentTypes) {
|
|
|
164
479
|
* The request-body middleware, emitted INTO the generated file rather than imported from the runtime
|
|
165
480
|
* module.
|
|
166
481
|
*
|
|
167
|
-
* **Because the runtime module
|
|
168
|
-
* application
|
|
169
|
-
*
|
|
482
|
+
* **Because the runtime module was a contract too, and it was the one that broke quietly.** An
|
|
483
|
+
* application could point `runtime-module` at a module of its own, and everything the generated file
|
|
484
|
+
* imported from there was something that application had to supply. A required, single-media-type body
|
|
170
485
|
* used to be mounted by `zValidator`, which throws `HTTPException` on a body it cannot read - a
|
|
171
486
|
* `text/plain` 400 raised before `deps.invalid` is called, so an API whose document declares a JSON
|
|
172
487
|
* error envelope answered a shape its own contract forbids. Routing those through the runtime's
|
|
@@ -198,17 +513,22 @@ function bodyValidationFor(contentTypes) {
|
|
|
198
513
|
* the single permitted `.refine()` is a synchronous predicate on a multipart file part. So the async
|
|
199
514
|
* path was buying nothing and costing on every request.
|
|
200
515
|
*
|
|
201
|
-
* Measured on an emitted five-property model, zod 4.5
|
|
516
|
+
* Measured on an emitted five-property model, zod 4.6.5, 200k iterations after a 20k warm-up:
|
|
202
517
|
*
|
|
203
518
|
* | call | ns/parse |
|
|
204
519
|
* | --- | --- |
|
|
205
|
-
* | `safeParseAsync` |
|
|
206
|
-
* | `safeParse` |
|
|
520
|
+
* | `safeParseAsync` | 444 |
|
|
521
|
+
* | `safeParse` | 130 |
|
|
207
522
|
*
|
|
208
|
-
* **
|
|
523
|
+
* **3.4x, on every parameter group of every request**, for a promise nothing awaited a result from.
|
|
209
524
|
* It also unblocks `z.compile()`, whose fast path is bypassed for any async parse - measured from
|
|
210
525
|
* `zod/compile`'s own shim, which returns the uncompiled run for `ctx.async`, and confirmed on the
|
|
211
|
-
* same fixture: compiled `safeParse` is
|
|
526
|
+
* same fixture: compiled `safeParse` is 64 ns and compiled `safeParseAsync` is 489 ns.
|
|
527
|
+
*
|
|
528
|
+
* **Zod 4.6's `.validate()` does not apply here**, though it looks as though it should. It is 121 ns
|
|
529
|
+
* against 130 ns on the success path, and its advantage is on the FAILURE path, where it skips
|
|
530
|
+
* building a `ZodError`. This runtime needs that error: it is what `deps.invalid` receives and what
|
|
531
|
+
* `ResponseContractError` carries. The numbers on zod 4.5.2 were 958 and 371.
|
|
212
532
|
*
|
|
213
533
|
* `validationFunction` is `@hono/zod-validator`'s published option and its return type admits a
|
|
214
534
|
* synchronous result, so this is the package's own seam rather than a way around it.
|
|
@@ -217,12 +537,44 @@ function bodyValidationFor(contentTypes) {
|
|
|
217
537
|
* synchronously, because `safeParse` THROWS on a schema that cannot - a claim about output this file
|
|
218
538
|
* does not produce is exactly the kind that needs an arm rather than a comment.
|
|
219
539
|
*/
|
|
220
|
-
const
|
|
221
|
-
|
|
540
|
+
const GUARDED_PARSE = `/**
|
|
541
|
+
* Parse, and treat input nested deeper than this runtime's stack as a refusal rather than a crash.
|
|
542
|
+
*
|
|
543
|
+
* **\`safeParse\` not throwing is the contract everything here rests on, and a deep enough value
|
|
544
|
+
* breaks it.** Zod recurses once per level, so a recursive model meets a \`RangeError\` that is not a
|
|
545
|
+
* validation failure and escapes to \`app.onError\` indistinguishable from a bug in the application's
|
|
546
|
+
* own code. Measured on \`workerd\`: a 40.5 KB body, about 1,925 levels, which Ajv on the published
|
|
547
|
+
* document calls VALID.
|
|
548
|
+
*
|
|
549
|
+
* It cannot be served, so it is refused - through \`deps.invalid\` like every other bad request, with
|
|
550
|
+
* an issue that says which limit was met rather than a generic failure. Anything that is not a
|
|
551
|
+
* \`RangeError\` is rethrown: a bug here must still look like one.
|
|
552
|
+
*/
|
|
553
|
+
function parsed(schema: z.ZodType, value: unknown): z.ZodSafeParseResult<unknown> {
|
|
554
|
+
try {
|
|
555
|
+
return schema.safeParse(value);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
if (!(error instanceof RangeError)) throw error;
|
|
558
|
+
return {
|
|
559
|
+
success: false,
|
|
560
|
+
error: new z.ZodError([
|
|
561
|
+
{
|
|
562
|
+
code: "custom",
|
|
563
|
+
path: [],
|
|
564
|
+
message: "input is nested too deeply for this runtime to validate",
|
|
565
|
+
},
|
|
566
|
+
]),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
`;
|
|
572
|
+
const SYNC_PARSE = `/** Parse synchronously: nothing emitted here is async, and the async path costs 3.4x. */
|
|
573
|
+
const SYNC = { validationFunction: (schema: z.ZodType, value: unknown) => parsed(schema, value) };
|
|
222
574
|
|
|
223
575
|
`;
|
|
224
576
|
const BODY_READER = `/** The two ways Hono can read a request body: \`c.req.json()\` and \`c.req.parseBody()\`. */
|
|
225
|
-
type BodyTarget = "json" | "form";
|
|
577
|
+
type BodyTarget = "json" | "form" | "text";
|
|
226
578
|
|
|
227
579
|
/**
|
|
228
580
|
* A body that is not what its content type claims.
|
|
@@ -236,7 +588,10 @@ const UNREADABLE = Symbol("a body that is not what its content type claims");
|
|
|
236
588
|
|
|
237
589
|
async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
|
|
238
590
|
try {
|
|
239
|
-
|
|
591
|
+
if (target === "form") return await c.req.parseBody({ all: true });
|
|
592
|
+
// A text body IS the text: it is validated as the string the caller sent, not parsed first.
|
|
593
|
+
if (target === "text") return await c.req.text();
|
|
594
|
+
return await c.req.json();
|
|
240
595
|
} catch {
|
|
241
596
|
return UNREADABLE;
|
|
242
597
|
}
|
|
@@ -251,8 +606,8 @@ async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
|
|
|
251
606
|
* is not a body that validated.
|
|
252
607
|
*/
|
|
253
608
|
function unreadableResult(schema: z.ZodType): { readonly success: boolean } {
|
|
254
|
-
const
|
|
255
|
-
return
|
|
609
|
+
const result = parsed(schema, undefined);
|
|
610
|
+
return result.success ? { success: false } : result;
|
|
256
611
|
}
|
|
257
612
|
|
|
258
613
|
/**
|
|
@@ -263,13 +618,63 @@ function unreadableResult(schema: z.ZodType): { readonly success: boolean } {
|
|
|
263
618
|
* generated. Parameters (\`; charset=utf-8\`, \`; boundary=...\`) are not part of the match, which
|
|
264
619
|
* matters because a multipart request always carries a boundary.
|
|
265
620
|
*
|
|
266
|
-
* A \`Content-Type\` matching nothing declared
|
|
267
|
-
*
|
|
621
|
+
* **A \`Content-Type\` matching nothing declared is REFUSED, and used to be parsed anyway.** It fell
|
|
622
|
+
* through to the first branch, and the comment here claimed the body would then "simply fail to
|
|
623
|
+
* parse" - which is not what happened: \`c.req.json()\` reads the bytes whatever the header says, so a
|
|
624
|
+
* route publishing \`application/json\` alone answered 200 to the same body sent as \`text/plain\`, as
|
|
625
|
+
* \`multipart/form-data\`, and with no \`Content-Type\` at all. The document says which media types an
|
|
626
|
+
* operation accepts, and a server that accepts others is not serving that document.
|
|
627
|
+
*
|
|
628
|
+
* \`undefined\` means "nothing declared covers this", and the caller refuses it through
|
|
629
|
+
* \`deps.invalid\` like any other bad request. No status is invented that the document does not
|
|
630
|
+
* describe, which is also what \`@typespec/http-server-js\` does with the identical case.
|
|
268
631
|
*/
|
|
269
|
-
function bodyTarget(
|
|
632
|
+
function bodyTarget(
|
|
633
|
+
c: Context,
|
|
634
|
+
branches: readonly (readonly [string, BodyTarget])[],
|
|
635
|
+
): BodyTarget | undefined {
|
|
270
636
|
const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
271
|
-
|
|
272
|
-
|
|
637
|
+
if (declared === "") return undefined;
|
|
638
|
+
/**
|
|
639
|
+
* A declared media RANGE matches the types inside it, which a string comparison cannot do.
|
|
640
|
+
* \`@typespec/http\` reports a bare \`string\`-typed \`contentType\` as the wildcard range, and an author
|
|
641
|
+
* may write a subtype wildcard such as text-slash-star outright. Comparing the range TEXT refuses
|
|
642
|
+
* every real request, because no caller sends the range itself as its \`Content-Type\`. This is the
|
|
643
|
+
* request-side counterpart of \`mediaTypeWithin\` in the runtime module, inlined because the branch
|
|
644
|
+
* list is emitted here.
|
|
645
|
+
*/
|
|
646
|
+
const covers = (range: string): boolean => {
|
|
647
|
+
if (range === "*/*") return true;
|
|
648
|
+
if (!range.endsWith("/*")) return false;
|
|
649
|
+
const slash = declared.indexOf("/");
|
|
650
|
+
return slash > 0 && declared.slice(0, slash) === range.slice(0, -2);
|
|
651
|
+
};
|
|
652
|
+
return branches.find(([mediaType]) => {
|
|
653
|
+
const lowered = mediaType.toLowerCase();
|
|
654
|
+
return lowered === declared || covers(lowered);
|
|
655
|
+
})?.[1];
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* The failure an undeclared media type produces, in the shape every other rejection has.
|
|
660
|
+
*
|
|
661
|
+
* Reported as an issue on the body rather than a bare \`{ success: false }\`, so an application's
|
|
662
|
+
* \`invalid\` hook can tell a caller WHICH thing was wrong - the same reason \`unreadableResult\` parses
|
|
663
|
+
* \`undefined\` rather than inventing a result.
|
|
664
|
+
*/
|
|
665
|
+
function unsupportedMediaTypeResult(
|
|
666
|
+
branches: readonly (readonly [string, BodyTarget])[],
|
|
667
|
+
): z.ZodSafeParseResult<unknown> {
|
|
668
|
+
return {
|
|
669
|
+
success: false,
|
|
670
|
+
error: new z.ZodError([
|
|
671
|
+
{
|
|
672
|
+
code: "custom",
|
|
673
|
+
path: ["content-type"],
|
|
674
|
+
message: \`this operation accepts \${branches.map(([type]) => type).join(", ")}\`,
|
|
675
|
+
},
|
|
676
|
+
]),
|
|
677
|
+
};
|
|
273
678
|
}
|
|
274
679
|
|
|
275
680
|
/** The app's rejection hook, as this file needs to name it. */
|
|
@@ -311,9 +716,14 @@ function validateBody<E extends Env, S extends z.ZodType>(
|
|
|
311
716
|
string,
|
|
312
717
|
{ in: { json: z.input<S> }; out: { json: z.output<S> } }
|
|
313
718
|
> = async (ctx, proceed) => {
|
|
314
|
-
const
|
|
719
|
+
const target = bodyTarget(ctx, branches);
|
|
720
|
+
const raw = target === undefined ? UNREADABLE : await readBody(ctx, target);
|
|
315
721
|
const result =
|
|
316
|
-
|
|
722
|
+
target === undefined
|
|
723
|
+
? unsupportedMediaTypeResult(branches)
|
|
724
|
+
: raw === UNREADABLE
|
|
725
|
+
? unreadableResult(schema)
|
|
726
|
+
: parsed(schema, raw);
|
|
317
727
|
const response = invalid(result, ctx);
|
|
318
728
|
if (response !== undefined) return response;
|
|
319
729
|
if (!result.success) return ctx.json(result, 400);
|
|
@@ -361,9 +771,14 @@ function validateOptionalBody<E extends Env, S extends z.ZodType>(
|
|
|
361
771
|
await proceed();
|
|
362
772
|
return undefined;
|
|
363
773
|
}
|
|
364
|
-
const
|
|
774
|
+
const target = bodyTarget(ctx, branches);
|
|
775
|
+
const raw = target === undefined ? UNREADABLE : await readBody(ctx, target);
|
|
365
776
|
const result =
|
|
366
|
-
|
|
777
|
+
target === undefined
|
|
778
|
+
? unsupportedMediaTypeResult(branches)
|
|
779
|
+
: raw === UNREADABLE
|
|
780
|
+
? unreadableResult(schema)
|
|
781
|
+
: parsed(schema, raw);
|
|
367
782
|
const response = invalid(result, ctx);
|
|
368
783
|
if (response !== undefined) return response;
|
|
369
784
|
if (!result.success) return ctx.json(result, 400);
|
|
@@ -375,6 +790,47 @@ function validateOptionalBody<E extends Env, S extends z.ZodType>(
|
|
|
375
790
|
};
|
|
376
791
|
}
|
|
377
792
|
|
|
793
|
+
`;
|
|
794
|
+
/**
|
|
795
|
+
* The caller context, established in middleware and handed to the handler.
|
|
796
|
+
*
|
|
797
|
+
* **Middleware, because a refusal returned inside the final handler erases the route's typed
|
|
798
|
+
* responses** - see `registrations`. **A per-request `WeakMap`, because `c.set` cannot carry it
|
|
799
|
+
* without leaking into the application's types.** Setting a variable needs the route's environment to
|
|
800
|
+
* declare it, and Hono's `Context` is invariant in its environment through `set`: a generated
|
|
801
|
+
* extension made every \`deps\` hook the application wrote against its own environment unassignable,
|
|
802
|
+
* measured as \`TS2345\` at the application's call site. The map keys on the request's own
|
|
803
|
+
* \`Context\`, which Hono passes unchanged from middleware to handler, including through a mounted
|
|
804
|
+
* sub-app.
|
|
805
|
+
*/
|
|
806
|
+
const CONTEXT_MIDDLEWARE = `\tconst contexts = new WeakMap<object, { readonly value: C }>();
|
|
807
|
+
const contextFor =
|
|
808
|
+
(authentication: "none" | "optional" | "required"): MiddlewareHandler<AppEnv> =>
|
|
809
|
+
async (c, next) => {
|
|
810
|
+
const ctx = deps.context(c, authentication);
|
|
811
|
+
if (ctx === null) return deps.noContext(c);
|
|
812
|
+
contexts.set(c, { value: ctx });
|
|
813
|
+
await next();
|
|
814
|
+
return undefined;
|
|
815
|
+
};
|
|
816
|
+
const contextOf = (c: object): C => {
|
|
817
|
+
const held = contexts.get(c);
|
|
818
|
+
if (held === undefined) throw new Error("typespec-hono: no caller context was established for this request");
|
|
819
|
+
return held.value;
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
`;
|
|
823
|
+
/** A caller accepting none of the media types a negotiated route offers is refused here. */
|
|
824
|
+
const ACCEPTABLE_MIDDLEWARE = `\tconst acceptable =
|
|
825
|
+
(offered: readonly string[]): MiddlewareHandler<AppEnv> =>
|
|
826
|
+
async (c, next) => {
|
|
827
|
+
if (selectContentType(c.req.header("accept"), offered) === undefined) {
|
|
828
|
+
return deps.notAcceptable(c, offered);
|
|
829
|
+
}
|
|
830
|
+
await next();
|
|
831
|
+
return undefined;
|
|
832
|
+
};
|
|
833
|
+
|
|
378
834
|
`;
|
|
379
835
|
/**
|
|
380
836
|
* How an unparsed body reaches the handler, and as what.
|
|
@@ -507,6 +963,64 @@ function inputTypeOf(entry) {
|
|
|
507
963
|
function capitaliseId(operationId) {
|
|
508
964
|
return `${operationId.charAt(0).toUpperCase()}${operationId.slice(1)}`;
|
|
509
965
|
}
|
|
966
|
+
/**
|
|
967
|
+
* Whether Hono's `RegExpRouter` can compile this route set, and the route it names if not.
|
|
968
|
+
*
|
|
969
|
+
* **Hono's own router, imported statically.** `hono` is a required peer - `runtime.ts` imports it and
|
|
970
|
+
* the generated server cannot run without it - so there is nothing to degrade gracefully around, and
|
|
971
|
+
* a guarded import would claim a resilience this package does not need. Running the real router is
|
|
972
|
+
* what stops the check drifting from a rule Hono never documented.
|
|
973
|
+
*
|
|
974
|
+
* **`match` as well as `add`.** `RegExpRouter` builds its expressions lazily, so a conflict added
|
|
975
|
+
* through `add` alone can go unreported until the first request. One `match` forces the build.
|
|
976
|
+
*/
|
|
977
|
+
export function regExpRouterRefusal(routes) {
|
|
978
|
+
const first = routes[0];
|
|
979
|
+
if (first === undefined)
|
|
980
|
+
return undefined;
|
|
981
|
+
const router = new RegExpRouter();
|
|
982
|
+
const noop = () => undefined;
|
|
983
|
+
try {
|
|
984
|
+
for (const [method, path] of routes)
|
|
985
|
+
router.add(method, path, noop);
|
|
986
|
+
router.match("GET", first[1]);
|
|
987
|
+
return undefined;
|
|
988
|
+
}
|
|
989
|
+
catch (error) {
|
|
990
|
+
/**
|
|
991
|
+
* **The CONSTRUCTOR name, not `.name`.** Hono's `UnsupportedPathError` extends `Error` without
|
|
992
|
+
* setting `this.name`, so the inherited `.name` reads `"Error"` and says nothing. Its `message`
|
|
993
|
+
* is the offending path and is the whole of what it reports.
|
|
994
|
+
*/
|
|
995
|
+
const thrown = error;
|
|
996
|
+
return {
|
|
997
|
+
path: thrown.message ?? "(unnamed)",
|
|
998
|
+
reason: thrown.constructor?.name ?? "the router refused it",
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* **The `[method, path]` pairs a router is given for one service**, exactly as the emitted server
|
|
1004
|
+
* registers them.
|
|
1005
|
+
*
|
|
1006
|
+
* One definition, used by the `regexp-router-unsupported` linter rule, so the rule judges the route
|
|
1007
|
+
* set the generated server really mounts. Built from the same helpers the render uses - the verb a
|
|
1008
|
+
* route registers under, `app.on` falling back to `GET` as Hono itself does, and `toHonoPath` on the
|
|
1009
|
+
* route's segments - and prefixed with each base path the service is served under.
|
|
1010
|
+
*
|
|
1011
|
+
* **The full path, never the one relative to a sub-app.** The relative form is what the emitted
|
|
1012
|
+
* text says and not what any router is given. Judging it reports conflicts between routes that are
|
|
1013
|
+
* not siblings at all: measured on five fixtures, two were called unmountable that mount perfectly.
|
|
1014
|
+
*/
|
|
1015
|
+
export function routerTableFor(routes, basePaths) {
|
|
1016
|
+
const noRefusal = () => undefined;
|
|
1017
|
+
return routes.flatMap((route) => {
|
|
1018
|
+
const verb = registrationVerbOf(route.verb);
|
|
1019
|
+
const method = HONO_METHOD[verb] === undefined ? "GET" : verb;
|
|
1020
|
+
const path = toHonoPath(route.pathSegments, noRefusal);
|
|
1021
|
+
return (basePaths.length === 0 ? [""] : basePaths).map((prefix) => [method, `${prefix}${path}` || "/"]);
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
510
1024
|
/**
|
|
511
1025
|
* The resource a route belongs to (its first path segment) or `undefined` when it has none.
|
|
512
1026
|
*
|
|
@@ -558,16 +1072,7 @@ export function renderApp(emitted, refuse,
|
|
|
558
1072
|
* the document publishes `/api/v1/accounts`. Mounting at the root made every client generated from
|
|
559
1073
|
* the document, and every "try it" in a rendered document, 404.
|
|
560
1074
|
*/
|
|
561
|
-
basePaths = []
|
|
562
|
-
/**
|
|
563
|
-
* What the DOCUMENT says a caller must satisfy, per operation id.
|
|
564
|
-
*
|
|
565
|
-
* **Resolved by the caller rather than read off `EmittedRoute`**, because which schemes an
|
|
566
|
-
* operation accepts is a fact about the HTTP program and not part of the validator IR the library
|
|
567
|
-
* publishes. Keeping it out of that IR is what stops a Hono concern leaking into a package whose
|
|
568
|
-
* audience is wider.
|
|
569
|
-
*/
|
|
570
|
-
securityFor) {
|
|
1075
|
+
basePaths = []) {
|
|
571
1076
|
const mounted = emitted.routes.flatMap((route) => {
|
|
572
1077
|
const names = emitted.schemaNames.get(route.operationId);
|
|
573
1078
|
// The library declares a `Responses` const for every operation, so a missing entry is a bug in
|
|
@@ -576,7 +1081,7 @@ securityFor) {
|
|
|
576
1081
|
return [];
|
|
577
1082
|
const validators = [];
|
|
578
1083
|
let body;
|
|
579
|
-
for (const location of ["path", "query", "header", "body"]) {
|
|
1084
|
+
for (const location of ["path", "query", "header", "cookie", "body"]) {
|
|
580
1085
|
const identifier = names[location];
|
|
581
1086
|
if (identifier === undefined)
|
|
582
1087
|
continue;
|
|
@@ -590,7 +1095,7 @@ securityFor) {
|
|
|
590
1095
|
* got. All three are now one emitted middleware; what the document decides is only the
|
|
591
1096
|
* branches it is given and whether an absent body is permitted.
|
|
592
1097
|
*/
|
|
593
|
-
const validation = bodyValidationFor(route.requestContentTypes);
|
|
1098
|
+
const validation = bodyValidationFor(route.requestContentTypes, route.requestTextual);
|
|
594
1099
|
if (validation.unparseable.length > 0) {
|
|
595
1100
|
refuse.unvalidatableMediaType(route, validation.unparseable);
|
|
596
1101
|
}
|
|
@@ -685,8 +1190,88 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
|
|
|
685
1190
|
*/
|
|
686
1191
|
const validates = entries.some((entry) => entry.validators.filter(([target]) => entry.body === undefined || target !== VALIDATOR_TARGET.body).length > 0);
|
|
687
1192
|
const syncHelper = validates ? SYNC_PARSE : "";
|
|
1193
|
+
/**
|
|
1194
|
+
* `parsed` is used by `SYNC` and by both body middlewares, so it is emitted where either is, and
|
|
1195
|
+
* BEFORE them: a generated file is read top to bottom and a helper used above its declaration
|
|
1196
|
+
* reads as a mistake even where hoisting makes it legal.
|
|
1197
|
+
*/
|
|
1198
|
+
const guardedParse = validates || mountsBody ? GUARDED_PARSE : "";
|
|
1199
|
+
/**
|
|
1200
|
+
* **One result type per operation: the union of every response its document declares.**
|
|
1201
|
+
*
|
|
1202
|
+
* A handler returns `{ status, body, headers }` for whichever response it means, success or
|
|
1203
|
+
* failure. That is `c.json(body, status, headers)` as data - what `@hono/zod-openapi` requires of a
|
|
1204
|
+
* handler as a union of typed responses - and the document's own Responses Object: keyed by status,
|
|
1205
|
+
* each with its body and its headers.
|
|
1206
|
+
*
|
|
1207
|
+
* **The return type used to be the SUCCESS body alone.** The arms named every failure the document
|
|
1208
|
+
* declares and no handler could return one, so failures were thrown past the generated code into
|
|
1209
|
+
* `onError`, where no declared status or body was checked. Nine of a real operation's ten arms were
|
|
1210
|
+
* unreachable that way.
|
|
1211
|
+
*
|
|
1212
|
+
* Data rather than a `Response`, so a handler stays transport-neutral: the same object can cross a
|
|
1213
|
+
* Workers service binding and serve `typespec-http-mcp`'s tools.
|
|
1214
|
+
*/
|
|
1215
|
+
const resultTypes = entries.map((entry) => {
|
|
1216
|
+
for (const response of entry.route.responses) {
|
|
1217
|
+
const unvalidated = membersOf(entry)
|
|
1218
|
+
.filter((member) => member.response === response && bodyServingOf(member) === "unvalidated")
|
|
1219
|
+
.flatMap((member) => (member.mediaType === undefined ? [] : [member.mediaType]));
|
|
1220
|
+
if (unvalidated.length > 0) {
|
|
1221
|
+
refuse.unvalidatedResponseMediaType(entry.route, response.status, unvalidated);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
const members = membersOf(entry).map((member) => {
|
|
1225
|
+
const { response } = member;
|
|
1226
|
+
const fields = [`readonly status: ${statusTypeOf(response, entry.route)}`];
|
|
1227
|
+
if (member.choosesMediaType && member.mediaType !== undefined) {
|
|
1228
|
+
fields.push(`readonly contentType: ${contentTypeTypeOf(member.mediaType)}`);
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* **What the handler SUPPLIES for this body, which is not always what the schema infers.** A
|
|
1232
|
+
* stream or raw binary body is handed to Hono unread, a model under a non-JSON media type is text
|
|
1233
|
+
* the handler serialised, and everything else is the producer's view of the schema.
|
|
1234
|
+
*/
|
|
1235
|
+
switch (bodyServingOf(member)) {
|
|
1236
|
+
case "none":
|
|
1237
|
+
fields.push("readonly body?: undefined");
|
|
1238
|
+
break;
|
|
1239
|
+
case "stream":
|
|
1240
|
+
fields.push("readonly body: ReadableStream");
|
|
1241
|
+
break;
|
|
1242
|
+
case "binary":
|
|
1243
|
+
fields.push("readonly body: ReadableStream | Uint8Array<ArrayBuffer> | ArrayBuffer");
|
|
1244
|
+
break;
|
|
1245
|
+
case "unvalidated":
|
|
1246
|
+
fields.push("readonly body: string");
|
|
1247
|
+
break;
|
|
1248
|
+
default:
|
|
1249
|
+
fields.push(`readonly body: Produced<z.infer<typeof ${member.schemaName}>>`);
|
|
1250
|
+
}
|
|
1251
|
+
if (response.headers.length > 0) {
|
|
1252
|
+
/**
|
|
1253
|
+
* Keyed by the WIRE name, as the document publishes it. `headers` itself is optional only
|
|
1254
|
+
* when every header in it is, so a response declaring a required header cannot be returned
|
|
1255
|
+
* without one.
|
|
1256
|
+
*/
|
|
1257
|
+
const every = response.headers.every((header) => header.optional);
|
|
1258
|
+
const rendered = response.headers
|
|
1259
|
+
.map((header) => header.optional
|
|
1260
|
+
? `readonly ${objectKey(header.name)}?: ${header.type} | undefined`
|
|
1261
|
+
: `readonly ${objectKey(header.name)}: ${header.type}`)
|
|
1262
|
+
.join("; ");
|
|
1263
|
+
fields.push(`readonly headers${every ? "?" : ""}: { ${rendered} }`);
|
|
1264
|
+
}
|
|
1265
|
+
return `{ ${fields.join("; ")} }`;
|
|
1266
|
+
});
|
|
1267
|
+
const name = `${capitaliseId(entry.route.operationId)}Result`;
|
|
1268
|
+
const body = members.length <= 1
|
|
1269
|
+
? ` ${members[0] ?? "never"}`
|
|
1270
|
+
: members.map((member) => `\n\t| ${member}`).join("");
|
|
1271
|
+
return `export type ${name} =${body};`;
|
|
1272
|
+
});
|
|
688
1273
|
const methods = entries.map((entry) => {
|
|
689
|
-
const { route
|
|
1274
|
+
const { route } = entry;
|
|
690
1275
|
/**
|
|
691
1276
|
* A negotiated member's `accept` is not in its validator (the negotiation supplies it) but it
|
|
692
1277
|
* IS in the operation's declared input, so the interface has to keep it. The literal is known
|
|
@@ -702,75 +1287,18 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
|
|
|
702
1287
|
? negotiated
|
|
703
1288
|
: `${validated} & ${negotiated}`;
|
|
704
1289
|
/**
|
|
705
|
-
* **
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
* description of the value in hand. Returning is the opposite direction: the handler supplies
|
|
710
|
-
* something the application already holds, and each of those becomes an obligation rather than
|
|
711
|
-
* a description. See `Produced` below for what that cost, measured.
|
|
712
|
-
*
|
|
713
|
-
* `typespec-http-zod` fixes the same three things on its contract types. None of it reaches
|
|
714
|
-
* here on its own, because this signature is derived from `z.infer` rather than from those
|
|
715
|
-
* types - which is exactly the half-fix `test/openmodel/` exists to catch.
|
|
1290
|
+
* **A PROPERTY signature, not a method signature, and the difference is a check.** TypeScript
|
|
1291
|
+
* compares a method signature's parameters bivariantly, so a handler declaring a richer caller
|
|
1292
|
+
* context than `deps.context` produces compiled against the method form - measured - and failed
|
|
1293
|
+
* against this one.
|
|
716
1294
|
*/
|
|
717
|
-
/**
|
|
718
|
-
* **The ENVELOPE a handler has to be able to say, beside the body it returns.**
|
|
719
|
-
*
|
|
720
|
-
* `@statusCode` and `@header` properties are stripped from the body schema - correctly, they
|
|
721
|
-
* are not body - so a return type derived from that schema alone could not carry them. The arms
|
|
722
|
-
* name them anyway: `{ headers: [{ property: "correlationId" }] }` tells `respond` to read a
|
|
723
|
-
* property off the returned value, and `when: { property: "statusCode" }` tells it which arm
|
|
724
|
-
* the handler meant. Measured before this existed, on `payload__head`:
|
|
725
|
-
* `Awaitable<Result<void>>` against an arm naming two header properties, so the emitter
|
|
726
|
-
* published an envelope contract nothing could satisfy.
|
|
727
|
-
*
|
|
728
|
-
* Only the SUCCESS statuses count. `responseHeaders` covers error responses too, and a header
|
|
729
|
-
* declared on a 404 is the error body's business rather than something a handler returns.
|
|
730
|
-
*/
|
|
731
|
-
const successStatuses = route.statusSelector?.statuses ?? [route.statusCode];
|
|
732
|
-
const envelope = [];
|
|
733
|
-
if (route.statusSelector !== undefined) {
|
|
734
|
-
envelope.push(`${objectKey(route.statusSelector.property)}: ${route.statusSelector.statuses.join(" | ")}`);
|
|
735
|
-
}
|
|
736
|
-
const headerEntries = route.responseHeaders.filter((entry) => successStatuses.includes(entry.status));
|
|
737
|
-
const declaredOn = new Map();
|
|
738
|
-
for (const entry of headerEntries) {
|
|
739
|
-
for (const header of entry.headers) {
|
|
740
|
-
const seen = declaredOn.get(header.property);
|
|
741
|
-
declaredOn.set(header.property, {
|
|
742
|
-
count: (seen?.count ?? 0) + 1,
|
|
743
|
-
type: header.type,
|
|
744
|
-
});
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
for (const [property, { count, type }] of declaredOn) {
|
|
748
|
-
// Required only where EVERY success status declares it; otherwise the handler cannot know
|
|
749
|
-
// which status it is answering with until it has chosen one.
|
|
750
|
-
const optional = count === headerEntries.length && headerEntries.length === successStatuses.length;
|
|
751
|
-
envelope.push(`${objectKey(property)}${optional ? "" : "?"}: ${type}`);
|
|
752
|
-
}
|
|
753
|
-
const envelopeType = envelope.length === 0 ? undefined : `{ ${envelope.join("; ")} }`;
|
|
754
|
-
const body = names.response === undefined ? undefined : `Produced<z.infer<typeof ${names.response}>>`;
|
|
755
|
-
const output = body === undefined
|
|
756
|
-
? (envelopeType ?? "void")
|
|
757
|
-
: envelopeType === undefined
|
|
758
|
-
? body
|
|
759
|
-
: `${body} & ${envelopeType}`;
|
|
760
|
-
const signature = `ctx: Ctx, input: ${input ?? EMPTY_INPUT}`;
|
|
761
1295
|
const doc = jsDocComment(route.summary, "\t");
|
|
762
|
-
return `${doc}\
|
|
1296
|
+
return `${doc}\treadonly ${objectKey(route.operationId)}: (ctx: C, input: ${input ?? EMPTY_INPUT}) => Awaitable<${capitaliseId(route.operationId)}Result>;`;
|
|
763
1297
|
});
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
*
|
|
769
|
-
* **Decided from the routes, not by searching the rendered text.** Asking whether the output
|
|
770
|
-
* mentions a name is how this package lost the `byContentType` import: the call gained an argument
|
|
771
|
-
* and the substring stopped matching, so a module referenced a function it no longer imported.
|
|
772
|
-
*/
|
|
773
|
-
const returnsAnything = entries.some((entry) => entry.names.response !== undefined);
|
|
1298
|
+
const returnsAnything = entries.some((entry) => membersOf(entry).some((member) => {
|
|
1299
|
+
const serving = bodyServingOf(member);
|
|
1300
|
+
return serving === "json" || serving === "text";
|
|
1301
|
+
}));
|
|
774
1302
|
const declaredHelper = returnsAnything
|
|
775
1303
|
? `/**
|
|
776
1304
|
* What a handler must SUPPLY, as opposed to what it receives.
|
|
@@ -828,7 +1356,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
828
1356
|
|
|
829
1357
|
`
|
|
830
1358
|
: "";
|
|
831
|
-
const aliases = entries.map((entry) => `export type ${capitaliseId(entry.route.operationId)}Handler = Operations[${JSON.stringify(entry.route.operationId)}];`);
|
|
1359
|
+
const aliases = entries.map((entry) => `export type ${capitaliseId(entry.route.operationId)}Handler<C = unknown> = Operations<C>[${JSON.stringify(entry.route.operationId)}];`);
|
|
832
1360
|
/**
|
|
833
1361
|
* **Which resources get a sub-app, and which routes stay on the root.**
|
|
834
1362
|
*
|
|
@@ -871,6 +1399,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
871
1399
|
const slot = `${registrationVerbOf(route.verb)} ${route.path}`;
|
|
872
1400
|
slots.set(slot, [...(slots.get(slot) ?? []), group]);
|
|
873
1401
|
}
|
|
1402
|
+
const uses = { runtime: new Set() };
|
|
874
1403
|
const registrations = [...slots.values()].map((groupsInSlot) => {
|
|
875
1404
|
const headGroups = groupsInSlot.filter((g) => g[0].route.verb === "HEAD");
|
|
876
1405
|
const plainGroups = groupsInSlot.filter((g) => g[0].route.verb !== "HEAD");
|
|
@@ -888,7 +1417,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
888
1417
|
/** A HEAD with no GET beside it: registered under GET, and guarded so only a HEAD reaches it. */
|
|
889
1418
|
const headOnly = plainGroups.length === 0 && headGroups.length > 0;
|
|
890
1419
|
const method = HONO_METHOD[registrationVerbOf(route.verb)] ?? "on";
|
|
891
|
-
const path = toHonoPath(route.
|
|
1420
|
+
const path = toHonoPath(route.pathSegments, (template, name) => refuse.unsupportedPathTemplate(route, template, name));
|
|
892
1421
|
/**
|
|
893
1422
|
* A route inside a sub-app is registered RELATIVE to the prefix it is mounted at.
|
|
894
1423
|
*
|
|
@@ -914,8 +1443,21 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
914
1443
|
* all and rested entirely on `deps.context` returning null, which answers "is somebody here"
|
|
915
1444
|
* rather than "did they satisfy the scheme the contract names".
|
|
916
1445
|
*/
|
|
917
|
-
|
|
918
|
-
|
|
1446
|
+
/**
|
|
1447
|
+
* Read from the route record, which carries the document's own `security` (`{}` for an
|
|
1448
|
+
* anonymous alternative) and whether a caller is needed at all. **This package kept a second
|
|
1449
|
+
* copy of that rule** in `security.ts`, because the library's once dropped `{}`; one copy now.
|
|
1450
|
+
*/
|
|
1451
|
+
const gate = route.authentication === "none"
|
|
1452
|
+
? []
|
|
1453
|
+
: [`\t\tdeps.authorize(${renderSecurity(route.security)}),`];
|
|
1454
|
+
/**
|
|
1455
|
+
* A query string written into the route itself identifies it as much as the path does, so a
|
|
1456
|
+
* request without it is not a request for this operation: 404, as any unrouted request gets.
|
|
1457
|
+
*/
|
|
1458
|
+
const literalQueryGuard = route.literalQuery.length === 0
|
|
1459
|
+
? []
|
|
1460
|
+
: [`\t\tliteralQuery(${JSON.stringify(route.literalQuery)}),`];
|
|
919
1461
|
/**
|
|
920
1462
|
* A HEAD operation with no GET beside it is registered under GET, because that is the only verb
|
|
921
1463
|
* Hono dispatches. The guard keeps the registration honest: a real GET is not in the document,
|
|
@@ -948,8 +1490,33 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
948
1490
|
...entry.body.branches.map(([type, target]) => `\t\t\t[${JSON.stringify(type)}, ${JSON.stringify(target)}],`),
|
|
949
1491
|
"\t\t]),",
|
|
950
1492
|
];
|
|
1493
|
+
/**
|
|
1494
|
+
* Whether the operation requires a caller, and NOTHING else about the caller.
|
|
1495
|
+
*
|
|
1496
|
+
* **This used to pass `"account"` or `"resource"`, chosen by how many path parameters the
|
|
1497
|
+
* route had, and that was a rule no document states.** `@useAuth(NoAuth)` reaches OpenAPI as
|
|
1498
|
+
* `security: []`, so "does this need a caller" is a contract fact and is generated. "Is this
|
|
1499
|
+
* account-scoped or resource-scoped" is not: no OpenAPI keyword expresses it, and the
|
|
1500
|
+
* path-parameter heuristic happened to fit the first consumer.
|
|
1501
|
+
*
|
|
1502
|
+
* **Middleware, after the validators, and never inline in the handler.** Hono reads a route's
|
|
1503
|
+
* response types from its final handler, and one plain `Response` returned there collapses
|
|
1504
|
+
* every typed response `hc` would otherwise see - measured, `res.json()` fell back to
|
|
1505
|
+
* `unknown` for every status. A plain `Response` from middleware contributes nothing to those
|
|
1506
|
+
* types, so the refusal moves there and the handler returns typed responses only.
|
|
1507
|
+
*/
|
|
1508
|
+
const authentication = route.authentication;
|
|
1509
|
+
/**
|
|
1510
|
+
* Several operations, one route: the caller's `Accept` chooses which one answers, and a caller
|
|
1511
|
+
* accepting none of them is refused in middleware for the same reason the context is.
|
|
1512
|
+
*/
|
|
1513
|
+
const offers = group.length > 1
|
|
1514
|
+
? group.flatMap((member) => member.route.responseContentTypes.map((contentType) => ({ contentType, member })))
|
|
1515
|
+
: [];
|
|
1516
|
+
const offered = `[${offers.map((offer) => JSON.stringify(offer.contentType)).join(", ")}]`;
|
|
951
1517
|
const middleware = [
|
|
952
1518
|
...(headOnly ? ["\t\theadOnly,"] : []),
|
|
1519
|
+
...literalQueryGuard,
|
|
953
1520
|
...gate,
|
|
954
1521
|
...validators
|
|
955
1522
|
// The body's validator IS the middleware above; emitting a `zValidator` beside it would
|
|
@@ -957,43 +1524,12 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
957
1524
|
.filter(([target]) => entry.body === undefined || target !== VALIDATOR_TARGET.body)
|
|
958
1525
|
.map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid, SYNC),`),
|
|
959
1526
|
...bodyMiddleware,
|
|
1527
|
+
`\t\tcontextFor(${JSON.stringify(authentication)}),`,
|
|
1528
|
+
...(offers.length > 0 ? [`\t\tacceptable(${offered}),`] : []),
|
|
960
1529
|
];
|
|
961
|
-
/**
|
|
962
|
-
* Whether the operation requires a caller, and NOTHING else about the caller.
|
|
963
|
-
*
|
|
964
|
-
* **This used to pass `"account"` or `"resource"`, chosen by how many path parameters the
|
|
965
|
-
* route had, and that was a rule no document states.** `@useAuth(NoAuth)` reaches OpenAPI as
|
|
966
|
-
* `security: []`, so "does this need a caller" is a contract fact and is generated. "Is this
|
|
967
|
-
* account-scoped or resource-scoped" is not: no OpenAPI keyword expresses it, and the
|
|
968
|
-
* path-parameter heuristic happened to fit the first consumer.
|
|
969
|
-
*/
|
|
970
1530
|
const body = [];
|
|
971
|
-
if (route.noAuth !== true) {
|
|
972
|
-
body.push(`\t\t\tconst ctx = deps.context(c, "required");`);
|
|
973
|
-
body.push("\t\t\tif (ctx === null) return deps.noContext(c);");
|
|
974
|
-
}
|
|
975
|
-
else {
|
|
976
|
-
/**
|
|
977
|
-
* **The same null check as an authenticated route, and it is what removes a CAST from
|
|
978
|
-
* generated output.** This used to emit `deps.context(c, "none") as Ctx`, because one
|
|
979
|
-
* signature returning `C | null` cannot express "this argument makes null impossible". A cast
|
|
980
|
-
* in generated code is worse than one in hand-written code: nobody reviews it, and it
|
|
981
|
-
* reappears on every compile.
|
|
982
|
-
*
|
|
983
|
-
* **Overloading `context` was tried and is worse.** It removes the cast from here and puts
|
|
984
|
-
* one in every consumer's `deps`, because an overloaded property type stops contextually
|
|
985
|
-
* typing a single implementation. Measured, the wiring consumer lost inference on every
|
|
986
|
-
* hook. Trading a cast in generated code for a cast in hand-written code is the wrong
|
|
987
|
-
* direction.
|
|
988
|
-
*
|
|
989
|
-
* Checking is also more honest than asserting: an app that returns null here has said it
|
|
990
|
-
* could not build a context, and the old cast handed the handler that null typed as `Ctx`.
|
|
991
|
-
*/
|
|
992
|
-
body.push(`\t\t\tconst ctx = deps.context(c, "none");`);
|
|
993
|
-
body.push("\t\t\tif (ctx === null) return deps.noContext(c);");
|
|
994
|
-
}
|
|
995
1531
|
// A dispatched body is in `validators` under the body target like any other, so there is
|
|
996
|
-
// nothing extra to spread:
|
|
1532
|
+
// nothing extra to spread: the body middleware published it there whichever parser ran.
|
|
997
1533
|
// Same rule as `inputTypeOf`: the body is identified by its schema, not by its target.
|
|
998
1534
|
const bodyTarget = validators.find(([, name]) => name === entry.names.body)?.[0];
|
|
999
1535
|
const pieces = validators.map(([target]) =>
|
|
@@ -1011,7 +1547,16 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1011
1547
|
const reader = rawBodyReaderFor(route.requestContentTypes);
|
|
1012
1548
|
pieces.push(`${objectKey(route.rawBodyProperty)}: ${reader.call}`);
|
|
1013
1549
|
}
|
|
1014
|
-
|
|
1550
|
+
/**
|
|
1551
|
+
* Call one operation's handler and serve what it returns, as statements at `depth` tabs.
|
|
1552
|
+
*
|
|
1553
|
+
* **Broken across lines rather than emitted as one.** Generated code is read far more often
|
|
1554
|
+
* than it is written (in review, in a stack trace, in a diff) and a single call reached 219
|
|
1555
|
+
* characters on a real service, against the 60-to-80 of every example in Hono's own
|
|
1556
|
+
* documentation. A call with no input stays on one line.
|
|
1557
|
+
*/
|
|
1558
|
+
const serve = (member, depth) => {
|
|
1559
|
+
const indent = "\t".repeat(depth);
|
|
1015
1560
|
// The member's own `accept` literal, which its input type requires and which the shared
|
|
1016
1561
|
// validator no longer supplies. We know it exactly: it is the branch we are in.
|
|
1017
1562
|
const own = group.length > 1 && member.route.accept !== undefined
|
|
@@ -1020,42 +1565,20 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1020
1565
|
]
|
|
1021
1566
|
: [];
|
|
1022
1567
|
const input = [...pieces, ...own];
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
* than it is written (in review, in a stack trace, in a diff) and a single call reached 219
|
|
1026
|
-
* characters on a real service, against the 60-to-80 of every example in Hono's own
|
|
1027
|
-
* documentation. Nothing about the behaviour changes; a reader's ability to see it does.
|
|
1028
|
-
*
|
|
1029
|
-
* A call with no input stays on one line, because wrapping it would add ceremony to something
|
|
1030
|
-
* already short.
|
|
1031
|
-
*/
|
|
1032
|
-
/**
|
|
1033
|
-
* **Indented literally, because the surrounding `+1 tab` only reaches the FIRST physical
|
|
1034
|
-
* line.** A multi-line fragment keeps whatever tabs it was written with, so the depths here
|
|
1035
|
-
* are absolute: the `return` sits at four, its arguments at five, and the handler's input
|
|
1036
|
-
* properties at six.
|
|
1037
|
-
*/
|
|
1038
|
-
const call = input.length === 0
|
|
1568
|
+
const call = `handlersFor(c).${member.route.operationId}(contextOf(c), {`;
|
|
1569
|
+
const invocation = input.length === 0
|
|
1039
1570
|
? // An empty object, because every operation takes `(ctx, input)`. See `EMPTY_INPUT`.
|
|
1040
|
-
|
|
1571
|
+
[`${indent}const result = await ${call}});`]
|
|
1041
1572
|
: [
|
|
1042
|
-
|
|
1043
|
-
...input.map((piece) =>
|
|
1044
|
-
|
|
1045
|
-
]
|
|
1046
|
-
return
|
|
1047
|
-
? `deps.respond(c, ${member.names.responses}, await ${call})`
|
|
1048
|
-
: [
|
|
1049
|
-
`deps.respond(`,
|
|
1050
|
-
`\t\t\t\t\tc,`,
|
|
1051
|
-
`\t\t\t\t\t${member.names.responses},`,
|
|
1052
|
-
`\t\t\t\t\tawait ${call},`,
|
|
1053
|
-
"\t\t\t\t)",
|
|
1054
|
-
].join("\n");
|
|
1573
|
+
`${indent}const result = await ${call}`,
|
|
1574
|
+
...input.map((piece) => `${indent}\t${piece},`),
|
|
1575
|
+
`${indent}});`,
|
|
1576
|
+
];
|
|
1577
|
+
return [...invocation, ...servingLines(member, depth, uses)];
|
|
1055
1578
|
};
|
|
1056
1579
|
/**
|
|
1057
1580
|
* Both verbs on one path: `c.req.method` still reads `HEAD` after Hono's rewrite, so one
|
|
1058
|
-
* registration serves both and each operation keeps its own handler and its own
|
|
1581
|
+
* registration serves both and each operation keeps its own handler and its own responses.
|
|
1059
1582
|
* Hono strips the body on the HEAD branch itself.
|
|
1060
1583
|
*
|
|
1061
1584
|
* The HEAD branch is emitted FIRST because it is the narrower condition, and it returns, so the
|
|
@@ -1064,31 +1587,31 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1064
1587
|
if (headBranch !== undefined) {
|
|
1065
1588
|
const headEntry = headBranch[0];
|
|
1066
1589
|
body.push(`\t\t\tif (c.req.method === "HEAD") {`);
|
|
1067
|
-
body.push(
|
|
1590
|
+
body.push(...serve(headEntry, 4));
|
|
1068
1591
|
body.push("\t\t\t}");
|
|
1069
1592
|
}
|
|
1070
1593
|
if (group.length === 1) {
|
|
1071
|
-
body.push(
|
|
1594
|
+
body.push(...serve(entry, 3));
|
|
1072
1595
|
}
|
|
1073
1596
|
else {
|
|
1074
1597
|
/**
|
|
1075
|
-
* Several operations, one route: the caller's `Accept` chooses which one answers.
|
|
1076
|
-
*
|
|
1077
1598
|
* The offered list and which operation serves each type are both read from the document.
|
|
1078
|
-
* `selectContentType` applies RFC 9110 section 12.5.1 to them. It lives in the runtime rather
|
|
1079
|
-
* in `deps` because both halves are derivable, and an app forced to supply it would be
|
|
1599
|
+
* `selectContentType` applies RFC 9110 section 12.5.1 to them. It lives in the runtime rather
|
|
1600
|
+
* than in `deps` because both halves are derivable, and an app forced to supply it would be
|
|
1080
1601
|
* re-implementing the standard.
|
|
1081
1602
|
*/
|
|
1082
|
-
const offers = group.flatMap((member) => member.route.responseContentTypes.map((contentType) => ({ contentType, member })));
|
|
1083
|
-
const offered = `[${offers.map((offer) => JSON.stringify(offer.contentType)).join(", ")}]`;
|
|
1084
1603
|
body.push(`\t\t\tconst served = selectContentType(c.req.header("accept"), ${offered});`);
|
|
1085
|
-
body.push(`\t\t\tif (served === undefined) return deps.notAcceptable(c, ${offered});`);
|
|
1086
1604
|
for (const offer of offers) {
|
|
1087
|
-
body.push(`\t\t\tif (served === ${JSON.stringify(offer.contentType)})
|
|
1605
|
+
body.push(`\t\t\tif (served === ${JSON.stringify(offer.contentType)}) {`);
|
|
1606
|
+
body.push(...serve(offer.member, 4));
|
|
1607
|
+
body.push("\t\t\t}");
|
|
1088
1608
|
}
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1609
|
+
/**
|
|
1610
|
+
* Unreachable: `acceptable` refused every request whose `Accept` matches none of these, and
|
|
1611
|
+
* `selectContentType` only returns a member of the list it was given. Thrown rather than
|
|
1612
|
+
* answered, because a plain `Response` here would erase every typed response on the route.
|
|
1613
|
+
*/
|
|
1614
|
+
body.push(`\t\t\tthrow new Error(${JSON.stringify(`typespec-hono: no operation on ${route.verb} ${route.path} serves the negotiated media type`)});`);
|
|
1092
1615
|
}
|
|
1093
1616
|
/**
|
|
1094
1617
|
* **`app.on` takes the METHOD first, and we were not passing one.** Only five verbs have a
|
|
@@ -1115,7 +1638,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1115
1638
|
"\t\t\t},",
|
|
1116
1639
|
"\t\t)",
|
|
1117
1640
|
].join("\n");
|
|
1118
|
-
return { target, text, headOnly };
|
|
1641
|
+
return { target, path: registeredPath, mountedPath: path, method, text, headOnly };
|
|
1119
1642
|
});
|
|
1120
1643
|
/**
|
|
1121
1644
|
* Every identifier this file names, imported from the module that declares it.
|
|
@@ -1136,16 +1659,22 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1136
1659
|
* Splitting on the language's own identifier rule and testing membership has neither failure: no
|
|
1137
1660
|
* metacharacter can be misread, and `Foo` cannot match `FooExtra`.
|
|
1138
1661
|
*/
|
|
1139
|
-
const rendered = [
|
|
1662
|
+
const rendered = [
|
|
1663
|
+
...registrations.map((r) => r.text),
|
|
1664
|
+
...resultTypes,
|
|
1665
|
+
...methods,
|
|
1666
|
+
...aliases,
|
|
1667
|
+
].join("\n");
|
|
1140
1668
|
const mentioned = new Set(rendered.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []);
|
|
1141
1669
|
const referenced = [
|
|
1142
1670
|
...new Set(entries.flatMap((entry) => [
|
|
1143
1671
|
entry.names.path,
|
|
1144
1672
|
entry.names.query,
|
|
1145
1673
|
entry.names.header,
|
|
1674
|
+
entry.names.cookie,
|
|
1146
1675
|
entry.names.body,
|
|
1147
|
-
entry.names.
|
|
1148
|
-
entry.names.
|
|
1676
|
+
...entry.names.arms.map((arm) => arm.schema),
|
|
1677
|
+
...entry.names.arms.map((arm) => arm.headers),
|
|
1149
1678
|
].filter((name) => name !== undefined))),
|
|
1150
1679
|
]
|
|
1151
1680
|
.filter((identifier) => mentioned.has(identifier))
|
|
@@ -1173,6 +1702,27 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1173
1702
|
* bottom". A rule a reordering could break. A single expression cannot be reordered wrongly: the
|
|
1174
1703
|
* sub-app is complete at the point it is mounted because it is its own initialiser.
|
|
1175
1704
|
*/
|
|
1705
|
+
/**
|
|
1706
|
+
* **A concrete path is registered before a templated one it would otherwise lose to.**
|
|
1707
|
+
*
|
|
1708
|
+
* Hono runs the first registered handler that matches, so `GET /items/:id` registered before
|
|
1709
|
+
* `GET /items/plain` answered every request for `/items/plain` - measured, with the wrong handler's
|
|
1710
|
+
* body. OpenAPI's Paths Object states the opposite rule: "concrete (non-templated) paths would be
|
|
1711
|
+
* matched before their templated counterparts". Ordering the registrations is how a router that
|
|
1712
|
+
* matches in order applies it. Otherwise the document's own order is kept, because the sort is
|
|
1713
|
+
* stable.
|
|
1714
|
+
*/
|
|
1715
|
+
registrations.sort((a, b) => {
|
|
1716
|
+
const left = a.path.split("/");
|
|
1717
|
+
const right = b.path.split("/");
|
|
1718
|
+
for (let index = 0; index < Math.min(left.length, right.length); index++) {
|
|
1719
|
+
const leftTemplated = (left[index] ?? "").startsWith(":");
|
|
1720
|
+
const rightTemplated = (right[index] ?? "").startsWith(":");
|
|
1721
|
+
if (leftTemplated !== rightTemplated)
|
|
1722
|
+
return leftTemplated ? 1 : -1;
|
|
1723
|
+
}
|
|
1724
|
+
return 0;
|
|
1725
|
+
});
|
|
1176
1726
|
const byTarget = new Map();
|
|
1177
1727
|
for (const registration of registrations) {
|
|
1178
1728
|
byTarget.set(registration.target, [
|
|
@@ -1199,6 +1749,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1199
1749
|
const negotiates = [...grouped.values()].some((group) => group.length > 1);
|
|
1200
1750
|
// Same rule: imported only where a HEAD operation stands alone on its path.
|
|
1201
1751
|
const guardsHead = registrations.some((registration) => registration.headOnly);
|
|
1752
|
+
const guardsQuery = emitted.routes.some((route) => route.literalQuery.length > 0);
|
|
1202
1753
|
/**
|
|
1203
1754
|
* **Which runtime imports to write is read from the DATA, never from the rendered text.**
|
|
1204
1755
|
*
|
|
@@ -1242,8 +1793,9 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1242
1793
|
const usesZod = mountsBody ||
|
|
1243
1794
|
// `SYNC` annotates its schema parameter `z.ZodType`, so the value import is load-bearing.
|
|
1244
1795
|
validates ||
|
|
1245
|
-
entries.some((entry) => inputTypeOf(entry) !== undefined ||
|
|
1246
|
-
|
|
1796
|
+
entries.some((entry) => inputTypeOf(entry) !== undefined) ||
|
|
1797
|
+
returnsAnything;
|
|
1798
|
+
const runtimeModule = JSON.stringify(DEFAULT_RUNTIME_MODULE);
|
|
1247
1799
|
/**
|
|
1248
1800
|
* **One base sub-app, mounted with `app.route()`. Hono's own nesting, not a rewritten path on
|
|
1249
1801
|
* every registration.** Prefixing each path individually would fight the resource grouping, and
|
|
@@ -1253,17 +1805,59 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1253
1805
|
*/
|
|
1254
1806
|
const usesBasePath = basePaths.length > 0;
|
|
1255
1807
|
const needsHonoValue = subApps.size > 0 || usesBasePath;
|
|
1808
|
+
/**
|
|
1809
|
+
* Hono's status-code types the result unions name, read from the rendered unions by TOKEN, the
|
|
1810
|
+
* same way the validator identifiers above are: an identifier absent from the text is genuinely
|
|
1811
|
+
* not needed, so the check cannot be wrong in the direction that breaks a build.
|
|
1812
|
+
*/
|
|
1813
|
+
const renderedResults = new Set(resultTypes.join("\n").match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []);
|
|
1814
|
+
const statusTypes = [
|
|
1815
|
+
"ClientErrorStatusCode",
|
|
1816
|
+
"ContentfulStatusCode",
|
|
1817
|
+
"ContentlessStatusCode",
|
|
1818
|
+
"InfoStatusCode",
|
|
1819
|
+
"RedirectStatusCode",
|
|
1820
|
+
"ServerErrorStatusCode",
|
|
1821
|
+
"StatusCode",
|
|
1822
|
+
"SuccessStatusCode",
|
|
1823
|
+
"UnofficialStatusCode",
|
|
1824
|
+
].filter((name) => renderedResults.has(name));
|
|
1825
|
+
const operates = entries.length > 0;
|
|
1826
|
+
const honoTypes = [
|
|
1827
|
+
"Context",
|
|
1828
|
+
...(mountsBody ? ["Env"] : []),
|
|
1829
|
+
...(needsHonoValue ? [] : ["Hono"]),
|
|
1830
|
+
"Input",
|
|
1831
|
+
...(operates || mountsBody ? ["MiddlewareHandler"] : []),
|
|
1832
|
+
];
|
|
1833
|
+
const runtimeValues = [
|
|
1834
|
+
...uses.runtime,
|
|
1835
|
+
...(negotiates ? ["selectContentType"] : []),
|
|
1836
|
+
...(guardsHead ? ["headOnly"] : []),
|
|
1837
|
+
...(guardsQuery ? ["literalQuery"] : []),
|
|
1838
|
+
].toSorted();
|
|
1839
|
+
const runtimeTypes = ["AppEnv", ...(operates ? ["Awaitable"] : []), "RouteDeps"];
|
|
1256
1840
|
return `${generatedBanner(emitted.options.regenerateHint)}
|
|
1257
|
-
${validates ? 'import { zValidator } from "@hono/zod-validator";\n' : ""}${needsHonoValue ? 'import { Hono } from "hono";\
|
|
1258
|
-
${usesZod ? 'import { z } from "zod";\n' : ""}import type {
|
|
1841
|
+
${validates ? 'import { zValidator } from "@hono/zod-validator";\n' : ""}${needsHonoValue ? 'import { Hono } from "hono";\n' : ""}import type { ${honoTypes.join(", ")} } from "hono";
|
|
1842
|
+
${statusTypes.length > 0 ? `import type { ${statusTypes.join(", ")} } from "hono/utils/http-status";\n` : ""}${usesZod ? 'import { z } from "zod";\n' : ""}import type { ${runtimeTypes.join(", ")} } from ${runtimeModule};${runtimeValues.length > 0 ? `\nimport { ${runtimeValues.join(", ")} } from ${runtimeModule};` : ""}
|
|
1259
1843
|
${imports}
|
|
1844
|
+
${guardedParse}${syncHelper}${bodyHelper}${fieldsHelper}${declaredHelper}/**
|
|
1845
|
+
* What each operation may answer with: one member per response the document declares.
|
|
1846
|
+
*
|
|
1847
|
+
* A handler returns \`{ status, body, headers }\` for whichever response it means. The generated route
|
|
1848
|
+
* serves it with the Hono call for that status, so \`hc\` sees a typed body per status, and a status
|
|
1849
|
+
* or body the document does not declare does not compile.
|
|
1850
|
+
*/
|
|
1851
|
+
${resultTypes.join("\n\n")}
|
|
1852
|
+
|
|
1260
1853
|
/**
|
|
1261
|
-
* One
|
|
1854
|
+
* One handler per operation, each concretely typed from the schemas it validates against.
|
|
1262
1855
|
*
|
|
1263
|
-
*
|
|
1264
|
-
*
|
|
1856
|
+
* \`C\` is the caller context, inferred by \`registerRoutes\` from \`deps.context\`. There is no cast
|
|
1857
|
+
* anywhere in this file, and no dynamic lookup: the generated call sites name the handler, so an
|
|
1858
|
+
* implementation whose input or result does not match the contract fails to compile.
|
|
1265
1859
|
*/
|
|
1266
|
-
|
|
1860
|
+
export interface Operations<C = unknown> {
|
|
1267
1861
|
${methods.join("\n")}
|
|
1268
1862
|
}
|
|
1269
1863
|
|
|
@@ -1304,12 +1898,12 @@ ${aliases.join("\n")}
|
|
|
1304
1898
|
export type Exhaustive<T> = T & Record<Exclude<keyof T, keyof Operations>, never>;
|
|
1305
1899
|
|
|
1306
1900
|
/** Mount every operation the service declares. */
|
|
1307
|
-
export function registerRoutes<T extends Operations
|
|
1901
|
+
export function registerRoutes<C, T extends Operations<C>>(
|
|
1308
1902
|
app: Hono<AppEnv>,
|
|
1309
1903
|
handlersFor: <P extends string, I extends Input>(c: Context<AppEnv, P, I>) => Exhaustive<T>,
|
|
1310
|
-
deps: RouteDeps,
|
|
1904
|
+
deps: RouteDeps<AppEnv, C>,
|
|
1311
1905
|
) {
|
|
1312
|
-
${subAppDeclarations}${usesBasePath
|
|
1906
|
+
${operates ? CONTEXT_MIDDLEWARE : ""}${negotiates ? ACCEPTABLE_MIDDLEWARE : ""}${subAppDeclarations}${usesBasePath
|
|
1313
1907
|
? `\tconst basePathRoutes = new Hono<AppEnv>()\n${rootChain.join("\n")};\n\n\treturn app${basePaths.map((prefix) => `\n\t\t.route(${JSON.stringify(prefix)}, basePathRoutes)`).join("")};`
|
|
1314
1908
|
: `\treturn app\n${rootChain.join("\n")};`}
|
|
1315
1909
|
}
|