typespec-hono 0.21.0 → 0.23.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 +85 -62
- package/dist/src/app.d.ts +63 -43
- package/dist/src/app.js +643 -231
- package/dist/src/emitter.d.ts +3 -3
- package/dist/src/emitter.js +52 -45
- package/dist/src/lib.d.ts +13 -4
- package/dist/src/lib.js +41 -1
- package/dist/src/runtime.d.ts +123 -79
- package/dist/src/runtime.js +107 -0
- package/package.json +12 -12
- package/src/runtime.ts +167 -84
- package/dist/src/security.d.ts +0 -29
- package/dist/src/security.js +0 -48
package/dist/src/app.js
CHANGED
|
@@ -1,5 +1,266 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { isRawBinaryMediaType, jsDocComment, objectKey, } from "typespec-http-zod";
|
|
2
|
+
/**
|
|
3
|
+
* Hono's status codes, group by group, exactly as `hono/utils/http-status` declares them.
|
|
4
|
+
*
|
|
5
|
+
* **The generated route needs the LITERALS, not only the type.** A range arm is served from a
|
|
6
|
+
* `switch` on the result's status, and TypeScript narrows the result to one member only through a
|
|
7
|
+
* `case` label per literal: a type guard narrows the status and leaves the result a union, and
|
|
8
|
+
* merging the range into `default` pairs every body with every status. Both measured on hono 4.13.1.
|
|
9
|
+
*
|
|
10
|
+
* `test/status-codes.test.ts` holds these equal to Hono's own unions, so a status Hono adds fails a
|
|
11
|
+
* test rather than silently falling out of a range.
|
|
12
|
+
*/
|
|
13
|
+
export const STATUS_GROUPS = {
|
|
14
|
+
1: { type: "InfoStatusCode", codes: [100, 101, 102, 103] },
|
|
15
|
+
2: { type: "SuccessStatusCode", codes: [200, 201, 202, 203, 204, 205, 206, 207, 208, 226] },
|
|
16
|
+
3: { type: "RedirectStatusCode", codes: [300, 301, 302, 303, 304, 305, 306, 307, 308] },
|
|
17
|
+
4: {
|
|
18
|
+
type: "ClientErrorStatusCode",
|
|
19
|
+
codes: [
|
|
20
|
+
400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418,
|
|
21
|
+
421, 422, 423, 424, 425, 426, 428, 429, 431, 451,
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
5: {
|
|
25
|
+
type: "ServerErrorStatusCode",
|
|
26
|
+
codes: [500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511],
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Where every generated file imports its runtime from: the copy this package writes beside them.
|
|
31
|
+
*
|
|
32
|
+
* Declared here, where the import is rendered, and re-exported by the emitter that writes the file.
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_RUNTIME_MODULE = "./runtime.gen.js";
|
|
35
|
+
/** Hono's `ContentlessStatusCode`: a status that cannot carry a body, so `c.json` refuses it. */
|
|
36
|
+
export const CONTENTLESS_STATUS_CODES = [101, 204, 205, 304];
|
|
37
|
+
/** A media type whose body is a JSON value, served with `c.json`. The library's own rule. */
|
|
38
|
+
function isJsonMediaType(type) {
|
|
39
|
+
return /^application\/(json|.*\+json)$/.test(type);
|
|
40
|
+
}
|
|
41
|
+
function bodyServingOf(member) {
|
|
42
|
+
const { response, mediaType } = member;
|
|
43
|
+
if (response.schema === undefined || mediaType === undefined)
|
|
44
|
+
return "none";
|
|
45
|
+
if (response.streamed)
|
|
46
|
+
return "stream";
|
|
47
|
+
if (response.binary)
|
|
48
|
+
return "binary";
|
|
49
|
+
if (isJsonMediaType(mediaType))
|
|
50
|
+
return "json";
|
|
51
|
+
/**
|
|
52
|
+
* **A string body under a non-JSON media type IS the text**, so it is validated and served as is.
|
|
53
|
+
* Any other body under one - a model as `application/xml` - has no serialisation this emitter can
|
|
54
|
+
* derive, so the handler supplies the text and it is served unvalidated, which the
|
|
55
|
+
* `unvalidated-response-media-type` warning says out loud.
|
|
56
|
+
*/
|
|
57
|
+
return response.textual ? "text" : "unvalidated";
|
|
58
|
+
}
|
|
59
|
+
function membersOf(entry) {
|
|
60
|
+
return entry.route.responses.flatMap((response, index) => {
|
|
61
|
+
const schemaName = entry.names.arms[index]?.schema;
|
|
62
|
+
if (response.contentTypes.length === 0) {
|
|
63
|
+
return [{ response, schemaName, mediaType: undefined, choosesMediaType: false }];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The handler names the media type where the status offers several, and where the document names
|
|
67
|
+
* only a wildcard range, which is not a type a response can be sent as.
|
|
68
|
+
*/
|
|
69
|
+
const choosesMediaType = response.contentTypes.length > 1 || response.contentTypes.some(isMediaRange);
|
|
70
|
+
return response.contentTypes.map((mediaType) => ({
|
|
71
|
+
response,
|
|
72
|
+
schemaName,
|
|
73
|
+
mediaType,
|
|
74
|
+
choosesMediaType,
|
|
75
|
+
}));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** A media range such as `image/*`, rather than a type a response can be sent as. */
|
|
79
|
+
function isMediaRange(mediaType) {
|
|
80
|
+
return mediaType.endsWith("/*");
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The type of the `contentType` a handler names for a member.
|
|
84
|
+
*
|
|
85
|
+
* **A range is typed as the types inside it**: `image/*` is `` `image/${string}` ``, so `text/html`
|
|
86
|
+
* under it does not compile, and TypeScript still narrows a literal member away from it. Only the
|
|
87
|
+
* full range is `string`.
|
|
88
|
+
*/
|
|
89
|
+
function contentTypeTypeOf(mediaType) {
|
|
90
|
+
if (!isMediaRange(mediaType))
|
|
91
|
+
return JSON.stringify(mediaType);
|
|
92
|
+
if (mediaType === "*/*")
|
|
93
|
+
return "string";
|
|
94
|
+
return `\`${mediaType.slice(0, -1)}\${string}\``;
|
|
95
|
+
}
|
|
96
|
+
/** The Hono group a status key's codes belong to. */
|
|
97
|
+
function groupOf(status) {
|
|
98
|
+
const digit = typeof status === "number" ? Math.floor(status / 100) : Number(String(status)[0]);
|
|
99
|
+
return digit >= 1 && digit <= 5 ? STATUS_GROUPS[digit] : undefined;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The codes a status key answers with, as `case` labels, or `"default"` for the catch-all.
|
|
103
|
+
*
|
|
104
|
+
* **A range excludes what is declared more precisely**, because OpenAPI resolves an exact code before
|
|
105
|
+
* its range and `armFor` does the same: a `404` beside a `4XX` is the `404`'s. A bodied range also
|
|
106
|
+
* excludes the statuses that cannot carry a body.
|
|
107
|
+
*/
|
|
108
|
+
function caseLabelsOf(response, route) {
|
|
109
|
+
const { status } = response;
|
|
110
|
+
if (status === "default")
|
|
111
|
+
return "default";
|
|
112
|
+
if (typeof status === "number")
|
|
113
|
+
return [status];
|
|
114
|
+
const exact = new Set(route.responses.flatMap((candidate) => typeof candidate.status === "number" ? [candidate.status] : []));
|
|
115
|
+
const contentless = new Set(response.schema === undefined ? [] : CONTENTLESS_STATUS_CODES);
|
|
116
|
+
return (groupOf(status)?.codes ?? []).filter((code) => !exact.has(code) && !contentless.has(code));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Serve a handler's `result` for every response the operation declares, as statements at `depth`.
|
|
120
|
+
*
|
|
121
|
+
* **One `case` per status literal, and one Hono call per declared response.** `c.json(body, 404)` is
|
|
122
|
+
* what gives `hc` a typed body for a 404, so the route serves each status through its own call rather
|
|
123
|
+
* than through one call over the union, which measured as a single endpoint pairing every body with
|
|
124
|
+
* every status.
|
|
125
|
+
*
|
|
126
|
+
* **Every served body is the PARSED body.** `servedBody` checks it against the schema the document
|
|
127
|
+
* publishes for that status and throws `ResponseContractError` when it does not match, failures
|
|
128
|
+
* included, and what is sent is the parse result - so a field the schema does not declare does not
|
|
129
|
+
* reach the wire.
|
|
130
|
+
*/
|
|
131
|
+
function servingLines(entry, depth, uses) {
|
|
132
|
+
const indent = "\t".repeat(depth);
|
|
133
|
+
const operationId = JSON.stringify(entry.route.operationId);
|
|
134
|
+
const members = membersOf(entry);
|
|
135
|
+
const lines = [`${indent}switch (result.status) {`];
|
|
136
|
+
let servesDefault = false;
|
|
137
|
+
for (const response of entry.route.responses) {
|
|
138
|
+
const labels = caseLabelsOf(response, entry.route);
|
|
139
|
+
if (labels !== "default" && labels.length === 0)
|
|
140
|
+
continue;
|
|
141
|
+
if (labels === "default") {
|
|
142
|
+
servesDefault = true;
|
|
143
|
+
lines.push(`${indent}\tdefault: {`);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
lines.push(...labels.map((code) => `${indent}\tcase ${code}:`));
|
|
147
|
+
lines[lines.length - 1] = `${lines.at(-1) ?? ""} {`;
|
|
148
|
+
}
|
|
149
|
+
const status = labels !== "default" && labels.length === 1 ? String(labels[0]) : "result.status";
|
|
150
|
+
const own = members.filter((member) => member.response === response);
|
|
151
|
+
if (!own.some((member) => member.choosesMediaType)) {
|
|
152
|
+
for (const member of own) {
|
|
153
|
+
lines.push(`${indent}\t\t${serveCall(member, status, operationId, uses)}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
/**
|
|
158
|
+
* **Exact types by `case`, then ranges by `mediaTypeWithin`, then a throw.** A `case "image/*"`
|
|
159
|
+
* would compare the range's own spelling and never match the `image/png` the handler is typed
|
|
160
|
+
* to name. Every `case` returns, so after the switch TypeScript has already narrowed the
|
|
161
|
+
* result to the range members, whose bodies are served the same way.
|
|
162
|
+
*/
|
|
163
|
+
const exact = own.filter((member) => member.mediaType !== undefined && !isMediaRange(member.mediaType));
|
|
164
|
+
if (exact.length > 0) {
|
|
165
|
+
lines.push(`${indent}\t\tswitch (result.contentType) {`);
|
|
166
|
+
for (const member of exact) {
|
|
167
|
+
lines.push(`${indent}\t\t\tcase ${JSON.stringify(member.mediaType)}:`);
|
|
168
|
+
lines.push(`${indent}\t\t\t\t${serveCall(member, status, operationId, uses)}`);
|
|
169
|
+
}
|
|
170
|
+
lines.push(`${indent}\t\t}`);
|
|
171
|
+
}
|
|
172
|
+
for (const member of own) {
|
|
173
|
+
if (member.mediaType === undefined || !isMediaRange(member.mediaType))
|
|
174
|
+
continue;
|
|
175
|
+
uses.runtime.add("mediaTypeWithin");
|
|
176
|
+
lines.push(`${indent}\t\tif (mediaTypeWithin(result.contentType, ${JSON.stringify(member.mediaType)})) ${serveCall(member, status, operationId, uses)}`);
|
|
177
|
+
}
|
|
178
|
+
uses.runtime.add("UndeclaredStatusError");
|
|
179
|
+
lines.push(`${indent}\t\tthrow new UndeclaredStatusError(${operationId}, result);`);
|
|
180
|
+
}
|
|
181
|
+
lines.push(`${indent}\t}`);
|
|
182
|
+
}
|
|
183
|
+
if (!servesDefault) {
|
|
184
|
+
/**
|
|
185
|
+
* **Unreachable from a typed handler**: every declared status has a `case`, so `result` is
|
|
186
|
+
* `never` here. A cast, or untyped data from a service binding, is what arrives - and serving
|
|
187
|
+
* it would publish a status the document does not declare.
|
|
188
|
+
*/
|
|
189
|
+
uses.runtime.add("UndeclaredStatusError");
|
|
190
|
+
lines.push(`${indent}\tdefault:`);
|
|
191
|
+
lines.push(`${indent}\t\tthrow new UndeclaredStatusError(${operationId}, result);`);
|
|
192
|
+
}
|
|
193
|
+
lines.push(`${indent}}`);
|
|
194
|
+
return lines;
|
|
195
|
+
}
|
|
196
|
+
/** The one Hono call that serves one member, as a `return` statement. */
|
|
197
|
+
function serveCall(member, status, operationId, uses) {
|
|
198
|
+
const { response, mediaType } = member;
|
|
199
|
+
const serving = bodyServingOf(member);
|
|
200
|
+
const headers = [];
|
|
201
|
+
/**
|
|
202
|
+
* **`Content-Type` wherever `c.json` would not already say it.** `c.json` writes
|
|
203
|
+
* `application/json`; a `+json` type such as `application/problem+json` has to be stated, and
|
|
204
|
+
* `c.body` states nothing. The key is spelled `Content-Type` because Hono merges its own default
|
|
205
|
+
* under exactly that key, so a lowercase spelling would send both.
|
|
206
|
+
*/
|
|
207
|
+
if (mediaType !== undefined && serving !== "none" && mediaType !== "application/json") {
|
|
208
|
+
const value = member.choosesMediaType || mediaType.includes("*")
|
|
209
|
+
? "result.contentType"
|
|
210
|
+
: JSON.stringify(mediaType);
|
|
211
|
+
headers.push(`"Content-Type": ${value}`);
|
|
212
|
+
}
|
|
213
|
+
const allOptional = response.headers.every((header) => header.optional);
|
|
214
|
+
for (const header of response.headers) {
|
|
215
|
+
const key = JSON.stringify(header.name);
|
|
216
|
+
headers.push(`${key}: result.headers${allOptional ? "?." : ""}[${key}]`);
|
|
217
|
+
}
|
|
218
|
+
if (headers.length > 0)
|
|
219
|
+
uses.runtime.add("headersOf");
|
|
220
|
+
const headersArgument = headers.length === 0 ? "" : `, headersOf({ ${headers.join(", ")} })`;
|
|
221
|
+
const validated = () => {
|
|
222
|
+
uses.runtime.add("servedBody");
|
|
223
|
+
return `servedBody(${member.schemaName ?? "undefined"}, result.body, ${operationId}, ${status})`;
|
|
224
|
+
};
|
|
225
|
+
switch (serving) {
|
|
226
|
+
case "none":
|
|
227
|
+
return `return c.body(null, ${status}${headersArgument});`;
|
|
228
|
+
case "json":
|
|
229
|
+
return `return c.json(${validated()}, ${status}${headersArgument});`;
|
|
230
|
+
case "text":
|
|
231
|
+
return `return c.body(${validated()}, ${status}${headersArgument});`;
|
|
232
|
+
default:
|
|
233
|
+
return `return c.body(result.body, ${status}${headersArgument});`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* A member's status as a TypeScript type: disjoint from every other member's, by construction.
|
|
238
|
+
*
|
|
239
|
+
* **Disjoint is the property that makes a wrong body a compile error.** Were `default` allowed to
|
|
240
|
+
* overlap a declared status, `{ status: 404, body: <the default arm's body> }` would type-check
|
|
241
|
+
* through the `default` member; measured, and the generated switch stopped compiling as well.
|
|
242
|
+
*/
|
|
243
|
+
function statusTypeOf(response, route) {
|
|
244
|
+
const { status } = response;
|
|
245
|
+
if (typeof status === "number")
|
|
246
|
+
return String(status);
|
|
247
|
+
const exact = route.responses.flatMap((candidate) => typeof candidate.status === "number" ? [String(candidate.status)] : []);
|
|
248
|
+
if (status !== "default") {
|
|
249
|
+
const group = groupOf(status);
|
|
250
|
+
if (group === undefined)
|
|
251
|
+
return "never";
|
|
252
|
+
const excluded = [
|
|
253
|
+
...exact.filter((code) => groupOf(Number(code)) === group),
|
|
254
|
+
...(response.schema === undefined ? [] : ["ContentlessStatusCode"]),
|
|
255
|
+
];
|
|
256
|
+
return excluded.length === 0 ? group.type : `Exclude<${group.type}, ${excluded.join(" | ")}>`;
|
|
257
|
+
}
|
|
258
|
+
const ranges = route.responses.flatMap((candidate) => typeof candidate.status === "string" && candidate.status !== "default"
|
|
259
|
+
? [groupOf(candidate.status)?.type ?? "never"]
|
|
260
|
+
: []);
|
|
261
|
+
const base = response.schema === undefined ? "StatusCode" : "ContentfulStatusCode";
|
|
262
|
+
return `Exclude<${base}, ${["InfoStatusCode", "UnofficialStatusCode", ...exact, ...ranges].join(" | ")}>`;
|
|
263
|
+
}
|
|
3
264
|
/**
|
|
4
265
|
* The header every emitted file carries.
|
|
5
266
|
*
|
|
@@ -16,56 +277,80 @@ export function generatedBanner(hint) {
|
|
|
16
277
|
}
|
|
17
278
|
/** A parameter name Hono can carry verbatim. Measured against Hono, not assumed. */
|
|
18
279
|
const PLAIN_PATH_PARAMETER = /^[A-Za-z0-9_.~-]+$/;
|
|
280
|
+
/** Text matched literally inside a Hono `{...}` parameter pattern. */
|
|
281
|
+
function escapeRegExp(text) {
|
|
282
|
+
return text.replace(/[.*+?^$()|[\]\\]/g, "\\$&");
|
|
283
|
+
}
|
|
19
284
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
export function toHonoPath(template, refuse,
|
|
43
|
-
/**
|
|
44
|
-
* Wire names the document says carry RFC 6570 reserved expansion, so their value may contain `/`.
|
|
45
|
-
*
|
|
46
|
-
* **A hierarchical identifier is ONE value, not several segments.** An Obsidian note is
|
|
47
|
-
* `areas/health.md`; an S3 key and a GitHub file path are the same shape. A router that stops at
|
|
48
|
-
* the first `/` binds `areas` and 404s the rest. Hono spells the greedy form `:name{.+}`.
|
|
49
|
-
*
|
|
50
|
-
* Read from `EmittedRoute.reservedPathParameters`, which the library resolves from `allowReserved`
|
|
51
|
-
* on the parameter. **Never from the template**: the operator does not survive to `route.path`,
|
|
52
|
-
* `@typespec/http` strips it, and it can also be set with no operator in the template at all, so
|
|
53
|
-
* the template is a derived artefact rather than the source of truth.
|
|
285
|
+
* The Hono route for a route template, from the segments `typespec-http-zod` reads out of the
|
|
286
|
+
* operation's RFC 6570 `uriTemplate`.
|
|
287
|
+
*
|
|
288
|
+
* **This used to convert `route.path`, which has every operator stripped**, so `array{.param*}`,
|
|
289
|
+
* `array{;param}` and `optional{/name}` were mounted as `array:param` and `optional:name`, which Hono
|
|
290
|
+
* does not match at all. Measured by request: 31 of the URIs `@typespec/http-specs` `routes` and
|
|
291
|
+
* `parameters/path` declare answered 404 from a server generated from them.
|
|
292
|
+
*
|
|
293
|
+
* - A whole-segment expression is `:name`; a reserved or exploding `/` one crosses `/`, so it is
|
|
294
|
+
* `:name{.+}` (Hono's spelling of greedy); an optional last segment is `:name?`.
|
|
295
|
+
* - An expression written beside literal text, or with a label or matrix operator, is a pattern
|
|
296
|
+
* parameter matching the whole segment: `:param{array\.[^\x2F]*}`. The captured text is the
|
|
297
|
+
* segment, and the path validator undoes the expansion. `\x2F` rather than `/`, so the mounted
|
|
298
|
+
* path still splits into its segments on `/` for the ordering and sub-app rules below.
|
|
299
|
+
*
|
|
300
|
+
* **A name Hono cannot carry is REFUSED rather than approximated**, and so is a segment holding two
|
|
301
|
+
* expressions, which no segment router can split. The segment stays literal, so it matches nothing
|
|
302
|
+
* rather than the wrong thing. A space, `+` and `!` are the characters that fail; a hyphen, a dot and
|
|
303
|
+
* a tilde do not.
|
|
304
|
+
*
|
|
305
|
+
* **This runs at RENDER time, not during collection**, so what a request body must look like never
|
|
306
|
+
* depends on whether one framework's router can express the path.
|
|
54
307
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
308
|
+
export function toHonoPath(segments, refuse) {
|
|
309
|
+
const template = `/${segments
|
|
310
|
+
.map((segment) => segment.kind === "expression"
|
|
311
|
+
? `${segment.prefix}{${segment.operator}${segment.parameter}${segment.explode ? "*" : ""}}${segment.suffix}`
|
|
312
|
+
: segment.text)
|
|
313
|
+
.join("/")}`;
|
|
314
|
+
const parts = segments.map((segment, index) => {
|
|
315
|
+
if (segment.kind === "literal")
|
|
316
|
+
return segment.text;
|
|
317
|
+
if (segment.kind === "unsupported") {
|
|
318
|
+
refuse(template, segment.text);
|
|
319
|
+
return segment.text;
|
|
320
|
+
}
|
|
321
|
+
const name = segment.parameter;
|
|
63
322
|
if (!PLAIN_PATH_PARAMETER.test(name)) {
|
|
64
323
|
refuse(template, name);
|
|
65
|
-
return
|
|
324
|
+
return `{${name}}`;
|
|
325
|
+
}
|
|
326
|
+
const whole = segment.prefix === "" &&
|
|
327
|
+
segment.suffix === "" &&
|
|
328
|
+
segment.operator !== "." &&
|
|
329
|
+
segment.operator !== ";";
|
|
330
|
+
if (whole) {
|
|
331
|
+
if (segment.reserved || (segment.operator === "/" && segment.explode))
|
|
332
|
+
return `:${name}{.+}`;
|
|
333
|
+
return segment.optional && index === segments.length - 1 ? `:${name}?` : `:${name}`;
|
|
66
334
|
}
|
|
67
|
-
|
|
335
|
+
const leader = segment.operator === "." || segment.operator === ";" ? escapeRegExp(segment.operator) : "";
|
|
336
|
+
const body = segment.reserved ? ".*" : "[^\\x2F]*";
|
|
337
|
+
return `:${name}{${escapeRegExp(segment.prefix)}${leader}${body}${escapeRegExp(segment.suffix)}}`;
|
|
68
338
|
});
|
|
339
|
+
return `/${parts.join("/")}`;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* The document's `security` as a TypeScript literal, for the generated `authorize` call. `{}` is the
|
|
343
|
+
* anonymous alternative, written out so an `authorize` applying the documented rule (any one
|
|
344
|
+
* requirement, every scheme within it) admits a caller who presents nothing.
|
|
345
|
+
*/
|
|
346
|
+
function renderSecurity(requirements) {
|
|
347
|
+
return `[${requirements
|
|
348
|
+
.map((requirement) => Object.keys(requirement).length === 0
|
|
349
|
+
? "{}"
|
|
350
|
+
: `{ ${Object.entries(requirement)
|
|
351
|
+
.map(([scheme, scopes]) => `${JSON.stringify(scheme)}: [${scopes.map((scope) => JSON.stringify(scope)).join(", ")}]`)
|
|
352
|
+
.join(", ")} }`)
|
|
353
|
+
.join(", ")}]`;
|
|
69
354
|
}
|
|
70
355
|
/**
|
|
71
356
|
* The verb Hono actually dispatches under.
|
|
@@ -120,12 +405,23 @@ const VALIDATOR_TARGET = {
|
|
|
120
405
|
* justifies, so it keeps the existing behaviour rather than acquiring a new one on the way past. That
|
|
121
406
|
* gap is real and is stated in the README rather than papered over here.
|
|
122
407
|
*/
|
|
123
|
-
function targetForMediaType(type) {
|
|
408
|
+
function targetForMediaType(type, textual) {
|
|
124
409
|
if (type === "application/x-www-form-urlencoded" || type.startsWith("multipart/"))
|
|
125
410
|
return "form";
|
|
126
411
|
// `application/json`, and the `+json` structured suffix RFC 6839 defines.
|
|
127
412
|
if (type === "application/json" || type.endsWith("+json"))
|
|
128
413
|
return VALIDATOR_TARGET.body;
|
|
414
|
+
/**
|
|
415
|
+
* **A body the document says IS text is read as text**, `@body body: string` under `text/plain`
|
|
416
|
+
* being the ordinary case. `requestTextual` is the library's answer about the TYPE, so the same
|
|
417
|
+
* media type carrying a model stays unparseable and keeps its warning: `text/plain` says how the
|
|
418
|
+
* value is framed, not what it is.
|
|
419
|
+
*
|
|
420
|
+
* Measured before this existed: such a route emitted no body middleware at all, so the body was
|
|
421
|
+
* never read and the handler was called with an empty input.
|
|
422
|
+
*/
|
|
423
|
+
if (textual)
|
|
424
|
+
return "text";
|
|
129
425
|
return undefined;
|
|
130
426
|
}
|
|
131
427
|
/**
|
|
@@ -146,13 +442,13 @@ function targetForMediaType(type) {
|
|
|
146
442
|
* package should make. Naming them is what stops a consumer believing a route is validated when it
|
|
147
443
|
* is not -- the previous behaviour said nothing and rejected them.
|
|
148
444
|
*/
|
|
149
|
-
function bodyValidationFor(contentTypes) {
|
|
445
|
+
function bodyValidationFor(contentTypes, textual) {
|
|
150
446
|
if (contentTypes.length === 0)
|
|
151
447
|
return { byType: [["", VALIDATOR_TARGET.body]], unparseable: [] };
|
|
152
448
|
const byType = [];
|
|
153
449
|
const unparseable = [];
|
|
154
450
|
for (const type of contentTypes) {
|
|
155
|
-
const target = targetForMediaType(type);
|
|
451
|
+
const target = targetForMediaType(type, textual);
|
|
156
452
|
if (target === undefined)
|
|
157
453
|
unparseable.push(type);
|
|
158
454
|
else
|
|
@@ -164,9 +460,9 @@ function bodyValidationFor(contentTypes) {
|
|
|
164
460
|
* The request-body middleware, emitted INTO the generated file rather than imported from the runtime
|
|
165
461
|
* module.
|
|
166
462
|
*
|
|
167
|
-
* **Because the runtime module
|
|
168
|
-
* application
|
|
169
|
-
*
|
|
463
|
+
* **Because the runtime module was a contract too, and it was the one that broke quietly.** An
|
|
464
|
+
* application could point `runtime-module` at a module of its own, and everything the generated file
|
|
465
|
+
* imported from there was something that application had to supply. A required, single-media-type body
|
|
170
466
|
* used to be mounted by `zValidator`, which throws `HTTPException` on a body it cannot read - a
|
|
171
467
|
* `text/plain` 400 raised before `deps.invalid` is called, so an API whose document declares a JSON
|
|
172
468
|
* error envelope answered a shape its own contract forbids. Routing those through the runtime's
|
|
@@ -222,7 +518,7 @@ const SYNC = { validationFunction: (schema: z.ZodType, value: unknown) => schema
|
|
|
222
518
|
|
|
223
519
|
`;
|
|
224
520
|
const BODY_READER = `/** The two ways Hono can read a request body: \`c.req.json()\` and \`c.req.parseBody()\`. */
|
|
225
|
-
type BodyTarget = "json" | "form";
|
|
521
|
+
type BodyTarget = "json" | "form" | "text";
|
|
226
522
|
|
|
227
523
|
/**
|
|
228
524
|
* A body that is not what its content type claims.
|
|
@@ -236,7 +532,10 @@ const UNREADABLE = Symbol("a body that is not what its content type claims");
|
|
|
236
532
|
|
|
237
533
|
async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
|
|
238
534
|
try {
|
|
239
|
-
|
|
535
|
+
if (target === "form") return await c.req.parseBody({ all: true });
|
|
536
|
+
// A text body IS the text: it is validated as the string the caller sent, not parsed first.
|
|
537
|
+
if (target === "text") return await c.req.text();
|
|
538
|
+
return await c.req.json();
|
|
240
539
|
} catch {
|
|
241
540
|
return UNREADABLE;
|
|
242
541
|
}
|
|
@@ -375,6 +674,47 @@ function validateOptionalBody<E extends Env, S extends z.ZodType>(
|
|
|
375
674
|
};
|
|
376
675
|
}
|
|
377
676
|
|
|
677
|
+
`;
|
|
678
|
+
/**
|
|
679
|
+
* The caller context, established in middleware and handed to the handler.
|
|
680
|
+
*
|
|
681
|
+
* **Middleware, because a refusal returned inside the final handler erases the route's typed
|
|
682
|
+
* responses** - see `registrations`. **A per-request `WeakMap`, because `c.set` cannot carry it
|
|
683
|
+
* without leaking into the application's types.** Setting a variable needs the route's environment to
|
|
684
|
+
* declare it, and Hono's `Context` is invariant in its environment through `set`: a generated
|
|
685
|
+
* extension made every \`deps\` hook the application wrote against its own environment unassignable,
|
|
686
|
+
* measured as \`TS2345\` at the application's call site. The map keys on the request's own
|
|
687
|
+
* \`Context\`, which Hono passes unchanged from middleware to handler, including through a mounted
|
|
688
|
+
* sub-app.
|
|
689
|
+
*/
|
|
690
|
+
const CONTEXT_MIDDLEWARE = `\tconst contexts = new WeakMap<object, { readonly value: C }>();
|
|
691
|
+
const contextFor =
|
|
692
|
+
(authentication: "none" | "optional" | "required"): MiddlewareHandler<AppEnv> =>
|
|
693
|
+
async (c, next) => {
|
|
694
|
+
const ctx = deps.context(c, authentication);
|
|
695
|
+
if (ctx === null) return deps.noContext(c);
|
|
696
|
+
contexts.set(c, { value: ctx });
|
|
697
|
+
await next();
|
|
698
|
+
return undefined;
|
|
699
|
+
};
|
|
700
|
+
const contextOf = (c: object): C => {
|
|
701
|
+
const held = contexts.get(c);
|
|
702
|
+
if (held === undefined) throw new Error("typespec-hono: no caller context was established for this request");
|
|
703
|
+
return held.value;
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
`;
|
|
707
|
+
/** A caller accepting none of the media types a negotiated route offers is refused here. */
|
|
708
|
+
const ACCEPTABLE_MIDDLEWARE = `\tconst acceptable =
|
|
709
|
+
(offered: readonly string[]): MiddlewareHandler<AppEnv> =>
|
|
710
|
+
async (c, next) => {
|
|
711
|
+
if (selectContentType(c.req.header("accept"), offered) === undefined) {
|
|
712
|
+
return deps.notAcceptable(c, offered);
|
|
713
|
+
}
|
|
714
|
+
await next();
|
|
715
|
+
return undefined;
|
|
716
|
+
};
|
|
717
|
+
|
|
378
718
|
`;
|
|
379
719
|
/**
|
|
380
720
|
* How an unparsed body reaches the handler, and as what.
|
|
@@ -558,16 +898,7 @@ export function renderApp(emitted, refuse,
|
|
|
558
898
|
* the document publishes `/api/v1/accounts`. Mounting at the root made every client generated from
|
|
559
899
|
* the document, and every "try it" in a rendered document, 404.
|
|
560
900
|
*/
|
|
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) {
|
|
901
|
+
basePaths = []) {
|
|
571
902
|
const mounted = emitted.routes.flatMap((route) => {
|
|
572
903
|
const names = emitted.schemaNames.get(route.operationId);
|
|
573
904
|
// The library declares a `Responses` const for every operation, so a missing entry is a bug in
|
|
@@ -590,7 +921,7 @@ securityFor) {
|
|
|
590
921
|
* got. All three are now one emitted middleware; what the document decides is only the
|
|
591
922
|
* branches it is given and whether an absent body is permitted.
|
|
592
923
|
*/
|
|
593
|
-
const validation = bodyValidationFor(route.requestContentTypes);
|
|
924
|
+
const validation = bodyValidationFor(route.requestContentTypes, route.requestTextual);
|
|
594
925
|
if (validation.unparseable.length > 0) {
|
|
595
926
|
refuse.unvalidatableMediaType(route, validation.unparseable);
|
|
596
927
|
}
|
|
@@ -685,8 +1016,82 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
|
|
|
685
1016
|
*/
|
|
686
1017
|
const validates = entries.some((entry) => entry.validators.filter(([target]) => entry.body === undefined || target !== VALIDATOR_TARGET.body).length > 0);
|
|
687
1018
|
const syncHelper = validates ? SYNC_PARSE : "";
|
|
1019
|
+
/**
|
|
1020
|
+
* **One result type per operation: the union of every response its document declares.**
|
|
1021
|
+
*
|
|
1022
|
+
* A handler returns `{ status, body, headers }` for whichever response it means, success or
|
|
1023
|
+
* failure. That is `c.json(body, status, headers)` as data - what `@hono/zod-openapi` requires of a
|
|
1024
|
+
* handler as a union of typed responses - and the document's own Responses Object: keyed by status,
|
|
1025
|
+
* each with its body and its headers.
|
|
1026
|
+
*
|
|
1027
|
+
* **The return type used to be the SUCCESS body alone.** The arms named every failure the document
|
|
1028
|
+
* declares and no handler could return one, so failures were thrown past the generated code into
|
|
1029
|
+
* `onError`, where no declared status or body was checked. Nine of a real operation's ten arms were
|
|
1030
|
+
* unreachable that way.
|
|
1031
|
+
*
|
|
1032
|
+
* Data rather than a `Response`, so a handler stays transport-neutral: the same object can cross a
|
|
1033
|
+
* Workers service binding and serve `typespec-http-mcp`'s tools.
|
|
1034
|
+
*/
|
|
1035
|
+
const resultTypes = entries.map((entry) => {
|
|
1036
|
+
for (const response of entry.route.responses) {
|
|
1037
|
+
const unvalidated = membersOf(entry)
|
|
1038
|
+
.filter((member) => member.response === response && bodyServingOf(member) === "unvalidated")
|
|
1039
|
+
.flatMap((member) => (member.mediaType === undefined ? [] : [member.mediaType]));
|
|
1040
|
+
if (unvalidated.length > 0) {
|
|
1041
|
+
refuse.unvalidatedResponseMediaType(entry.route, response.status, unvalidated);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
const members = membersOf(entry).map((member) => {
|
|
1045
|
+
const { response } = member;
|
|
1046
|
+
const fields = [`readonly status: ${statusTypeOf(response, entry.route)}`];
|
|
1047
|
+
if (member.choosesMediaType && member.mediaType !== undefined) {
|
|
1048
|
+
fields.push(`readonly contentType: ${contentTypeTypeOf(member.mediaType)}`);
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* **What the handler SUPPLIES for this body, which is not always what the schema infers.** A
|
|
1052
|
+
* stream or raw binary body is handed to Hono unread, a model under a non-JSON media type is text
|
|
1053
|
+
* the handler serialised, and everything else is the producer's view of the schema.
|
|
1054
|
+
*/
|
|
1055
|
+
switch (bodyServingOf(member)) {
|
|
1056
|
+
case "none":
|
|
1057
|
+
fields.push("readonly body?: undefined");
|
|
1058
|
+
break;
|
|
1059
|
+
case "stream":
|
|
1060
|
+
fields.push("readonly body: ReadableStream");
|
|
1061
|
+
break;
|
|
1062
|
+
case "binary":
|
|
1063
|
+
fields.push("readonly body: ReadableStream | Uint8Array<ArrayBuffer> | ArrayBuffer");
|
|
1064
|
+
break;
|
|
1065
|
+
case "unvalidated":
|
|
1066
|
+
fields.push("readonly body: string");
|
|
1067
|
+
break;
|
|
1068
|
+
default:
|
|
1069
|
+
fields.push(`readonly body: Produced<z.infer<typeof ${member.schemaName}>>`);
|
|
1070
|
+
}
|
|
1071
|
+
if (response.headers.length > 0) {
|
|
1072
|
+
/**
|
|
1073
|
+
* Keyed by the WIRE name, as the document publishes it. `headers` itself is optional only
|
|
1074
|
+
* when every header in it is, so a response declaring a required header cannot be returned
|
|
1075
|
+
* without one.
|
|
1076
|
+
*/
|
|
1077
|
+
const every = response.headers.every((header) => header.optional);
|
|
1078
|
+
const rendered = response.headers
|
|
1079
|
+
.map((header) => header.optional
|
|
1080
|
+
? `readonly ${objectKey(header.name)}?: ${header.type} | undefined`
|
|
1081
|
+
: `readonly ${objectKey(header.name)}: ${header.type}`)
|
|
1082
|
+
.join("; ");
|
|
1083
|
+
fields.push(`readonly headers${every ? "?" : ""}: { ${rendered} }`);
|
|
1084
|
+
}
|
|
1085
|
+
return `{ ${fields.join("; ")} }`;
|
|
1086
|
+
});
|
|
1087
|
+
const name = `${capitaliseId(entry.route.operationId)}Result`;
|
|
1088
|
+
const body = members.length <= 1
|
|
1089
|
+
? ` ${members[0] ?? "never"}`
|
|
1090
|
+
: members.map((member) => `\n\t| ${member}`).join("");
|
|
1091
|
+
return `export type ${name} =${body};`;
|
|
1092
|
+
});
|
|
688
1093
|
const methods = entries.map((entry) => {
|
|
689
|
-
const { route
|
|
1094
|
+
const { route } = entry;
|
|
690
1095
|
/**
|
|
691
1096
|
* A negotiated member's `accept` is not in its validator (the negotiation supplies it) but it
|
|
692
1097
|
* IS in the operation's declared input, so the interface has to keep it. The literal is known
|
|
@@ -702,75 +1107,18 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
|
|
|
702
1107
|
? negotiated
|
|
703
1108
|
: `${validated} & ${negotiated}`;
|
|
704
1109
|
/**
|
|
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.
|
|
716
|
-
*/
|
|
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.
|
|
1110
|
+
* **A PROPERTY signature, not a method signature, and the difference is a check.** TypeScript
|
|
1111
|
+
* compares a method signature's parameters bivariantly, so a handler declaring a richer caller
|
|
1112
|
+
* context than `deps.context` produces compiled against the method form - measured - and failed
|
|
1113
|
+
* against this one.
|
|
730
1114
|
*/
|
|
731
|
-
const
|
|
732
|
-
|
|
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
|
-
const doc = route.summary === undefined ? "" : `\t/** ${route.summary} */\n`;
|
|
762
|
-
return `${doc}\t${route.operationId}(${signature}): Awaitable<Result<${output}>>;`;
|
|
1115
|
+
const doc = jsDocComment(route.summary, "\t");
|
|
1116
|
+
return `${doc}\treadonly ${objectKey(route.operationId)}: (ctx: C, input: ${input ?? EMPTY_INPUT}) => Awaitable<${capitaliseId(route.operationId)}Result>;`;
|
|
763
1117
|
});
|
|
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);
|
|
1118
|
+
const returnsAnything = entries.some((entry) => membersOf(entry).some((member) => {
|
|
1119
|
+
const serving = bodyServingOf(member);
|
|
1120
|
+
return serving === "json" || serving === "text";
|
|
1121
|
+
}));
|
|
774
1122
|
const declaredHelper = returnsAnything
|
|
775
1123
|
? `/**
|
|
776
1124
|
* What a handler must SUPPLY, as opposed to what it receives.
|
|
@@ -828,7 +1176,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
828
1176
|
|
|
829
1177
|
`
|
|
830
1178
|
: "";
|
|
831
|
-
const aliases = entries.map((entry) => `export type ${capitaliseId(entry.route.operationId)}Handler = Operations[${JSON.stringify(entry.route.operationId)}];`);
|
|
1179
|
+
const aliases = entries.map((entry) => `export type ${capitaliseId(entry.route.operationId)}Handler<C = unknown> = Operations<C>[${JSON.stringify(entry.route.operationId)}];`);
|
|
832
1180
|
/**
|
|
833
1181
|
* **Which resources get a sub-app, and which routes stay on the root.**
|
|
834
1182
|
*
|
|
@@ -871,6 +1219,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
871
1219
|
const slot = `${registrationVerbOf(route.verb)} ${route.path}`;
|
|
872
1220
|
slots.set(slot, [...(slots.get(slot) ?? []), group]);
|
|
873
1221
|
}
|
|
1222
|
+
const uses = { runtime: new Set() };
|
|
874
1223
|
const registrations = [...slots.values()].map((groupsInSlot) => {
|
|
875
1224
|
const headGroups = groupsInSlot.filter((g) => g[0].route.verb === "HEAD");
|
|
876
1225
|
const plainGroups = groupsInSlot.filter((g) => g[0].route.verb !== "HEAD");
|
|
@@ -888,7 +1237,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
888
1237
|
/** A HEAD with no GET beside it: registered under GET, and guarded so only a HEAD reaches it. */
|
|
889
1238
|
const headOnly = plainGroups.length === 0 && headGroups.length > 0;
|
|
890
1239
|
const method = HONO_METHOD[registrationVerbOf(route.verb)] ?? "on";
|
|
891
|
-
const path = toHonoPath(route.
|
|
1240
|
+
const path = toHonoPath(route.pathSegments, (template, name) => refuse.unsupportedPathTemplate(route, template, name));
|
|
892
1241
|
/**
|
|
893
1242
|
* A route inside a sub-app is registered RELATIVE to the prefix it is mounted at.
|
|
894
1243
|
*
|
|
@@ -914,8 +1263,21 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
914
1263
|
* all and rested entirely on `deps.context` returning null, which answers "is somebody here"
|
|
915
1264
|
* rather than "did they satisfy the scheme the contract names".
|
|
916
1265
|
*/
|
|
917
|
-
|
|
918
|
-
|
|
1266
|
+
/**
|
|
1267
|
+
* Read from the route record, which carries the document's own `security` (`{}` for an
|
|
1268
|
+
* anonymous alternative) and whether a caller is needed at all. **This package kept a second
|
|
1269
|
+
* copy of that rule** in `security.ts`, because the library's once dropped `{}`; one copy now.
|
|
1270
|
+
*/
|
|
1271
|
+
const gate = route.authentication === "none"
|
|
1272
|
+
? []
|
|
1273
|
+
: [`\t\tdeps.authorize(${renderSecurity(route.security)}),`];
|
|
1274
|
+
/**
|
|
1275
|
+
* A query string written into the route itself identifies it as much as the path does, so a
|
|
1276
|
+
* request without it is not a request for this operation: 404, as any unrouted request gets.
|
|
1277
|
+
*/
|
|
1278
|
+
const literalQueryGuard = route.literalQuery.length === 0
|
|
1279
|
+
? []
|
|
1280
|
+
: [`\t\tliteralQuery(${JSON.stringify(route.literalQuery)}),`];
|
|
919
1281
|
/**
|
|
920
1282
|
* A HEAD operation with no GET beside it is registered under GET, because that is the only verb
|
|
921
1283
|
* Hono dispatches. The guard keeps the registration honest: a real GET is not in the document,
|
|
@@ -948,8 +1310,33 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
948
1310
|
...entry.body.branches.map(([type, target]) => `\t\t\t[${JSON.stringify(type)}, ${JSON.stringify(target)}],`),
|
|
949
1311
|
"\t\t]),",
|
|
950
1312
|
];
|
|
1313
|
+
/**
|
|
1314
|
+
* Whether the operation requires a caller, and NOTHING else about the caller.
|
|
1315
|
+
*
|
|
1316
|
+
* **This used to pass `"account"` or `"resource"`, chosen by how many path parameters the
|
|
1317
|
+
* route had, and that was a rule no document states.** `@useAuth(NoAuth)` reaches OpenAPI as
|
|
1318
|
+
* `security: []`, so "does this need a caller" is a contract fact and is generated. "Is this
|
|
1319
|
+
* account-scoped or resource-scoped" is not: no OpenAPI keyword expresses it, and the
|
|
1320
|
+
* path-parameter heuristic happened to fit the first consumer.
|
|
1321
|
+
*
|
|
1322
|
+
* **Middleware, after the validators, and never inline in the handler.** Hono reads a route's
|
|
1323
|
+
* response types from its final handler, and one plain `Response` returned there collapses
|
|
1324
|
+
* every typed response `hc` would otherwise see - measured, `res.json()` fell back to
|
|
1325
|
+
* `unknown` for every status. A plain `Response` from middleware contributes nothing to those
|
|
1326
|
+
* types, so the refusal moves there and the handler returns typed responses only.
|
|
1327
|
+
*/
|
|
1328
|
+
const authentication = route.authentication;
|
|
1329
|
+
/**
|
|
1330
|
+
* Several operations, one route: the caller's `Accept` chooses which one answers, and a caller
|
|
1331
|
+
* accepting none of them is refused in middleware for the same reason the context is.
|
|
1332
|
+
*/
|
|
1333
|
+
const offers = group.length > 1
|
|
1334
|
+
? group.flatMap((member) => member.route.responseContentTypes.map((contentType) => ({ contentType, member })))
|
|
1335
|
+
: [];
|
|
1336
|
+
const offered = `[${offers.map((offer) => JSON.stringify(offer.contentType)).join(", ")}]`;
|
|
951
1337
|
const middleware = [
|
|
952
1338
|
...(headOnly ? ["\t\theadOnly,"] : []),
|
|
1339
|
+
...literalQueryGuard,
|
|
953
1340
|
...gate,
|
|
954
1341
|
...validators
|
|
955
1342
|
// The body's validator IS the middleware above; emitting a `zValidator` beside it would
|
|
@@ -957,43 +1344,12 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
957
1344
|
.filter(([target]) => entry.body === undefined || target !== VALIDATOR_TARGET.body)
|
|
958
1345
|
.map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid, SYNC),`),
|
|
959
1346
|
...bodyMiddleware,
|
|
1347
|
+
`\t\tcontextFor(${JSON.stringify(authentication)}),`,
|
|
1348
|
+
...(offers.length > 0 ? [`\t\tacceptable(${offered}),`] : []),
|
|
960
1349
|
];
|
|
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
1350
|
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
1351
|
// A dispatched body is in `validators` under the body target like any other, so there is
|
|
996
|
-
// nothing extra to spread:
|
|
1352
|
+
// nothing extra to spread: the body middleware published it there whichever parser ran.
|
|
997
1353
|
// Same rule as `inputTypeOf`: the body is identified by its schema, not by its target.
|
|
998
1354
|
const bodyTarget = validators.find(([, name]) => name === entry.names.body)?.[0];
|
|
999
1355
|
const pieces = validators.map(([target]) =>
|
|
@@ -1011,7 +1367,16 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1011
1367
|
const reader = rawBodyReaderFor(route.requestContentTypes);
|
|
1012
1368
|
pieces.push(`${objectKey(route.rawBodyProperty)}: ${reader.call}`);
|
|
1013
1369
|
}
|
|
1014
|
-
|
|
1370
|
+
/**
|
|
1371
|
+
* Call one operation's handler and serve what it returns, as statements at `depth` tabs.
|
|
1372
|
+
*
|
|
1373
|
+
* **Broken across lines rather than emitted as one.** Generated code is read far more often
|
|
1374
|
+
* than it is written (in review, in a stack trace, in a diff) and a single call reached 219
|
|
1375
|
+
* characters on a real service, against the 60-to-80 of every example in Hono's own
|
|
1376
|
+
* documentation. A call with no input stays on one line.
|
|
1377
|
+
*/
|
|
1378
|
+
const serve = (member, depth) => {
|
|
1379
|
+
const indent = "\t".repeat(depth);
|
|
1015
1380
|
// The member's own `accept` literal, which its input type requires and which the shared
|
|
1016
1381
|
// validator no longer supplies. We know it exactly: it is the branch we are in.
|
|
1017
1382
|
const own = group.length > 1 && member.route.accept !== undefined
|
|
@@ -1020,42 +1385,20 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1020
1385
|
]
|
|
1021
1386
|
: [];
|
|
1022
1387
|
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
|
|
1388
|
+
const call = `handlersFor(c).${member.route.operationId}(contextOf(c), {`;
|
|
1389
|
+
const invocation = input.length === 0
|
|
1039
1390
|
? // An empty object, because every operation takes `(ctx, input)`. See `EMPTY_INPUT`.
|
|
1040
|
-
|
|
1041
|
-
: [
|
|
1042
|
-
`handlersFor(c).${member.route.operationId}(ctx, {`,
|
|
1043
|
-
...input.map((piece) => `\t\t\t\t\t\t${piece},`),
|
|
1044
|
-
"\t\t\t\t\t})",
|
|
1045
|
-
].join("\n");
|
|
1046
|
-
return input.length === 0
|
|
1047
|
-
? `deps.respond(c, ${member.names.responses}, await ${call})`
|
|
1391
|
+
[`${indent}const result = await ${call}});`]
|
|
1048
1392
|
: [
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
].join("\n");
|
|
1393
|
+
`${indent}const result = await ${call}`,
|
|
1394
|
+
...input.map((piece) => `${indent}\t${piece},`),
|
|
1395
|
+
`${indent}});`,
|
|
1396
|
+
];
|
|
1397
|
+
return [...invocation, ...servingLines(member, depth, uses)];
|
|
1055
1398
|
};
|
|
1056
1399
|
/**
|
|
1057
1400
|
* 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
|
|
1401
|
+
* registration serves both and each operation keeps its own handler and its own responses.
|
|
1059
1402
|
* Hono strips the body on the HEAD branch itself.
|
|
1060
1403
|
*
|
|
1061
1404
|
* The HEAD branch is emitted FIRST because it is the narrower condition, and it returns, so the
|
|
@@ -1064,31 +1407,31 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1064
1407
|
if (headBranch !== undefined) {
|
|
1065
1408
|
const headEntry = headBranch[0];
|
|
1066
1409
|
body.push(`\t\t\tif (c.req.method === "HEAD") {`);
|
|
1067
|
-
body.push(
|
|
1410
|
+
body.push(...serve(headEntry, 4));
|
|
1068
1411
|
body.push("\t\t\t}");
|
|
1069
1412
|
}
|
|
1070
1413
|
if (group.length === 1) {
|
|
1071
|
-
body.push(
|
|
1414
|
+
body.push(...serve(entry, 3));
|
|
1072
1415
|
}
|
|
1073
1416
|
else {
|
|
1074
1417
|
/**
|
|
1075
|
-
* Several operations, one route: the caller's `Accept` chooses which one answers.
|
|
1076
|
-
*
|
|
1077
1418
|
* 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
|
|
1419
|
+
* `selectContentType` applies RFC 9110 section 12.5.1 to them. It lives in the runtime rather
|
|
1420
|
+
* than in `deps` because both halves are derivable, and an app forced to supply it would be
|
|
1080
1421
|
* re-implementing the standard.
|
|
1081
1422
|
*/
|
|
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
1423
|
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
1424
|
for (const offer of offers) {
|
|
1087
|
-
body.push(`\t\t\tif (served === ${JSON.stringify(offer.contentType)})
|
|
1425
|
+
body.push(`\t\t\tif (served === ${JSON.stringify(offer.contentType)}) {`);
|
|
1426
|
+
body.push(...serve(offer.member, 4));
|
|
1427
|
+
body.push("\t\t\t}");
|
|
1088
1428
|
}
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1429
|
+
/**
|
|
1430
|
+
* Unreachable: `acceptable` refused every request whose `Accept` matches none of these, and
|
|
1431
|
+
* `selectContentType` only returns a member of the list it was given. Thrown rather than
|
|
1432
|
+
* answered, because a plain `Response` here would erase every typed response on the route.
|
|
1433
|
+
*/
|
|
1434
|
+
body.push(`\t\t\tthrow new Error(${JSON.stringify(`typespec-hono: no operation on ${route.verb} ${route.path} serves the negotiated media type`)});`);
|
|
1092
1435
|
}
|
|
1093
1436
|
/**
|
|
1094
1437
|
* **`app.on` takes the METHOD first, and we were not passing one.** Only five verbs have a
|
|
@@ -1115,7 +1458,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1115
1458
|
"\t\t\t},",
|
|
1116
1459
|
"\t\t)",
|
|
1117
1460
|
].join("\n");
|
|
1118
|
-
return { target, text, headOnly };
|
|
1461
|
+
return { target, path: registeredPath, text, headOnly };
|
|
1119
1462
|
});
|
|
1120
1463
|
/**
|
|
1121
1464
|
* Every identifier this file names, imported from the module that declares it.
|
|
@@ -1136,7 +1479,12 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1136
1479
|
* Splitting on the language's own identifier rule and testing membership has neither failure: no
|
|
1137
1480
|
* metacharacter can be misread, and `Foo` cannot match `FooExtra`.
|
|
1138
1481
|
*/
|
|
1139
|
-
const rendered = [
|
|
1482
|
+
const rendered = [
|
|
1483
|
+
...registrations.map((r) => r.text),
|
|
1484
|
+
...resultTypes,
|
|
1485
|
+
...methods,
|
|
1486
|
+
...aliases,
|
|
1487
|
+
].join("\n");
|
|
1140
1488
|
const mentioned = new Set(rendered.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []);
|
|
1141
1489
|
const referenced = [
|
|
1142
1490
|
...new Set(entries.flatMap((entry) => [
|
|
@@ -1144,8 +1492,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1144
1492
|
entry.names.query,
|
|
1145
1493
|
entry.names.header,
|
|
1146
1494
|
entry.names.body,
|
|
1147
|
-
entry.names.
|
|
1148
|
-
entry.names.responses,
|
|
1495
|
+
...entry.names.arms.map((arm) => arm.schema),
|
|
1149
1496
|
].filter((name) => name !== undefined))),
|
|
1150
1497
|
]
|
|
1151
1498
|
.filter((identifier) => mentioned.has(identifier))
|
|
@@ -1173,6 +1520,27 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1173
1520
|
* bottom". A rule a reordering could break. A single expression cannot be reordered wrongly: the
|
|
1174
1521
|
* sub-app is complete at the point it is mounted because it is its own initialiser.
|
|
1175
1522
|
*/
|
|
1523
|
+
/**
|
|
1524
|
+
* **A concrete path is registered before a templated one it would otherwise lose to.**
|
|
1525
|
+
*
|
|
1526
|
+
* Hono runs the first registered handler that matches, so `GET /items/:id` registered before
|
|
1527
|
+
* `GET /items/plain` answered every request for `/items/plain` - measured, with the wrong handler's
|
|
1528
|
+
* body. OpenAPI's Paths Object states the opposite rule: "concrete (non-templated) paths would be
|
|
1529
|
+
* matched before their templated counterparts". Ordering the registrations is how a router that
|
|
1530
|
+
* matches in order applies it. Otherwise the document's own order is kept, because the sort is
|
|
1531
|
+
* stable.
|
|
1532
|
+
*/
|
|
1533
|
+
registrations.sort((a, b) => {
|
|
1534
|
+
const left = a.path.split("/");
|
|
1535
|
+
const right = b.path.split("/");
|
|
1536
|
+
for (let index = 0; index < Math.min(left.length, right.length); index++) {
|
|
1537
|
+
const leftTemplated = (left[index] ?? "").startsWith(":");
|
|
1538
|
+
const rightTemplated = (right[index] ?? "").startsWith(":");
|
|
1539
|
+
if (leftTemplated !== rightTemplated)
|
|
1540
|
+
return leftTemplated ? 1 : -1;
|
|
1541
|
+
}
|
|
1542
|
+
return 0;
|
|
1543
|
+
});
|
|
1176
1544
|
const byTarget = new Map();
|
|
1177
1545
|
for (const registration of registrations) {
|
|
1178
1546
|
byTarget.set(registration.target, [
|
|
@@ -1199,6 +1567,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1199
1567
|
const negotiates = [...grouped.values()].some((group) => group.length > 1);
|
|
1200
1568
|
// Same rule: imported only where a HEAD operation stands alone on its path.
|
|
1201
1569
|
const guardsHead = registrations.some((registration) => registration.headOnly);
|
|
1570
|
+
const guardsQuery = emitted.routes.some((route) => route.literalQuery.length > 0);
|
|
1202
1571
|
/**
|
|
1203
1572
|
* **Which runtime imports to write is read from the DATA, never from the rendered text.**
|
|
1204
1573
|
*
|
|
@@ -1242,8 +1611,9 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1242
1611
|
const usesZod = mountsBody ||
|
|
1243
1612
|
// `SYNC` annotates its schema parameter `z.ZodType`, so the value import is load-bearing.
|
|
1244
1613
|
validates ||
|
|
1245
|
-
entries.some((entry) => inputTypeOf(entry) !== undefined ||
|
|
1246
|
-
|
|
1614
|
+
entries.some((entry) => inputTypeOf(entry) !== undefined) ||
|
|
1615
|
+
returnsAnything;
|
|
1616
|
+
const runtimeModule = JSON.stringify(DEFAULT_RUNTIME_MODULE);
|
|
1247
1617
|
/**
|
|
1248
1618
|
* **One base sub-app, mounted with `app.route()`. Hono's own nesting, not a rewritten path on
|
|
1249
1619
|
* every registration.** Prefixing each path individually would fight the resource grouping, and
|
|
@@ -1253,17 +1623,59 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1253
1623
|
*/
|
|
1254
1624
|
const usesBasePath = basePaths.length > 0;
|
|
1255
1625
|
const needsHonoValue = subApps.size > 0 || usesBasePath;
|
|
1626
|
+
/**
|
|
1627
|
+
* Hono's status-code types the result unions name, read from the rendered unions by TOKEN, the
|
|
1628
|
+
* same way the validator identifiers above are: an identifier absent from the text is genuinely
|
|
1629
|
+
* not needed, so the check cannot be wrong in the direction that breaks a build.
|
|
1630
|
+
*/
|
|
1631
|
+
const renderedResults = new Set(resultTypes.join("\n").match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []);
|
|
1632
|
+
const statusTypes = [
|
|
1633
|
+
"ClientErrorStatusCode",
|
|
1634
|
+
"ContentfulStatusCode",
|
|
1635
|
+
"ContentlessStatusCode",
|
|
1636
|
+
"InfoStatusCode",
|
|
1637
|
+
"RedirectStatusCode",
|
|
1638
|
+
"ServerErrorStatusCode",
|
|
1639
|
+
"StatusCode",
|
|
1640
|
+
"SuccessStatusCode",
|
|
1641
|
+
"UnofficialStatusCode",
|
|
1642
|
+
].filter((name) => renderedResults.has(name));
|
|
1643
|
+
const operates = entries.length > 0;
|
|
1644
|
+
const honoTypes = [
|
|
1645
|
+
"Context",
|
|
1646
|
+
...(mountsBody ? ["Env"] : []),
|
|
1647
|
+
...(needsHonoValue ? [] : ["Hono"]),
|
|
1648
|
+
"Input",
|
|
1649
|
+
...(operates || mountsBody ? ["MiddlewareHandler"] : []),
|
|
1650
|
+
];
|
|
1651
|
+
const runtimeValues = [
|
|
1652
|
+
...uses.runtime,
|
|
1653
|
+
...(negotiates ? ["selectContentType"] : []),
|
|
1654
|
+
...(guardsHead ? ["headOnly"] : []),
|
|
1655
|
+
...(guardsQuery ? ["literalQuery"] : []),
|
|
1656
|
+
].toSorted();
|
|
1657
|
+
const runtimeTypes = ["AppEnv", ...(operates ? ["Awaitable"] : []), "RouteDeps"];
|
|
1256
1658
|
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 {
|
|
1659
|
+
${validates ? 'import { zValidator } from "@hono/zod-validator";\n' : ""}${needsHonoValue ? 'import { Hono } from "hono";\n' : ""}import type { ${honoTypes.join(", ")} } from "hono";
|
|
1660
|
+
${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
1661
|
${imports}
|
|
1662
|
+
${syncHelper}${bodyHelper}${fieldsHelper}${declaredHelper}/**
|
|
1663
|
+
* What each operation may answer with: one member per response the document declares.
|
|
1664
|
+
*
|
|
1665
|
+
* A handler returns \`{ status, body, headers }\` for whichever response it means. The generated route
|
|
1666
|
+
* serves it with the Hono call for that status, so \`hc\` sees a typed body per status, and a status
|
|
1667
|
+
* or body the document does not declare does not compile.
|
|
1668
|
+
*/
|
|
1669
|
+
${resultTypes.join("\n\n")}
|
|
1670
|
+
|
|
1260
1671
|
/**
|
|
1261
|
-
* One
|
|
1672
|
+
* One handler per operation, each concretely typed from the schemas it validates against.
|
|
1262
1673
|
*
|
|
1263
|
-
*
|
|
1264
|
-
*
|
|
1674
|
+
* \`C\` is the caller context, inferred by \`registerRoutes\` from \`deps.context\`. There is no cast
|
|
1675
|
+
* anywhere in this file, and no dynamic lookup: the generated call sites name the handler, so an
|
|
1676
|
+
* implementation whose input or result does not match the contract fails to compile.
|
|
1265
1677
|
*/
|
|
1266
|
-
|
|
1678
|
+
export interface Operations<C = unknown> {
|
|
1267
1679
|
${methods.join("\n")}
|
|
1268
1680
|
}
|
|
1269
1681
|
|
|
@@ -1304,12 +1716,12 @@ ${aliases.join("\n")}
|
|
|
1304
1716
|
export type Exhaustive<T> = T & Record<Exclude<keyof T, keyof Operations>, never>;
|
|
1305
1717
|
|
|
1306
1718
|
/** Mount every operation the service declares. */
|
|
1307
|
-
export function registerRoutes<T extends Operations
|
|
1719
|
+
export function registerRoutes<C, T extends Operations<C>>(
|
|
1308
1720
|
app: Hono<AppEnv>,
|
|
1309
1721
|
handlersFor: <P extends string, I extends Input>(c: Context<AppEnv, P, I>) => Exhaustive<T>,
|
|
1310
|
-
deps: RouteDeps,
|
|
1722
|
+
deps: RouteDeps<AppEnv, C>,
|
|
1311
1723
|
) {
|
|
1312
|
-
${subAppDeclarations}${usesBasePath
|
|
1724
|
+
${operates ? CONTEXT_MIDDLEWARE : ""}${negotiates ? ACCEPTABLE_MIDDLEWARE : ""}${subAppDeclarations}${usesBasePath
|
|
1313
1725
|
? `\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
1726
|
: `\treturn app\n${rootChain.join("\n")};`}
|
|
1315
1727
|
}
|