typespec-hono 0.1.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/LICENSE +21 -0
- package/README.md +333 -0
- package/dist/src/app.d.ts +61 -0
- package/dist/src/app.js +609 -0
- package/dist/src/base-path.d.ts +16 -0
- package/dist/src/base-path.js +47 -0
- package/dist/src/emitter.d.ts +24 -0
- package/dist/src/emitter.js +108 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.js +12 -0
- package/dist/src/lib.d.ts +71 -0
- package/dist/src/lib.js +123 -0
- package/dist/src/runtime.d.ts +174 -0
- package/dist/src/runtime.js +63 -0
- package/dist/src/security.d.ts +29 -0
- package/dist/src/security.js +48 -0
- package/dist/src/tsp-index.d.ts +9 -0
- package/dist/src/tsp-index.js +9 -0
- package/lib/main.tsp +3 -0
- package/package.json +90 -0
package/dist/src/app.js
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { renderSecurity } from "./security.js";
|
|
2
|
+
import { isRawBinaryMediaType, objectKey, } from "typespec-http-zod";
|
|
3
|
+
/** The header every emitted file carries. */
|
|
4
|
+
const GENERATED_BANNER = `// GENERATED by typespec-hono from the TypeSpec service definition. DO NOT EDIT.
|
|
5
|
+
// Recompile the spec that produced it; edits here are overwritten on the next run.
|
|
6
|
+
`;
|
|
7
|
+
/** A parameter name Hono can carry verbatim — measured against Hono, not assumed. */
|
|
8
|
+
const PLAIN_PATH_PARAMETER = /^[A-Za-z0-9_.~-]+$/;
|
|
9
|
+
/**
|
|
10
|
+
* TypeSpec publishes `/widgets/{widget-id}`; Hono routes on `/widgets/:widget-id`.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ **This used to match `\w+`, so any parameter carrying a hyphen was left ALONE.**
|
|
13
|
+
* `@path("thing-id")` produced the literal route `/things/{thing-id}` — mounted, counted by every arm
|
|
14
|
+
* that counts routes, and reachable by nobody. It answered 404 to the only requests it was for. Hono
|
|
15
|
+
* handles `:thing-id` and `:x.y` perfectly well; the narrow character class was ours.
|
|
16
|
+
*
|
|
17
|
+
* A name that is not plain is REFUSED rather than approximated. Hono reads a parameter up to the next
|
|
18
|
+
* `/`, so an RFC 6570 modifier would survive into the name and `*` would become Hono's wildcard — a
|
|
19
|
+
* route that matches the wrong requests and answers them, which is worse than one that fails.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ **This runs at RENDER time, not during collection.** It used to run inside `collectRoutes`, which
|
|
22
|
+
* put one framework's spelling into the shared intermediate representation and refused the whole
|
|
23
|
+
* operation — validators included — over a template no router could mount. What a request body must
|
|
24
|
+
* look like does not depend on that.
|
|
25
|
+
*/
|
|
26
|
+
export function toHonoPath(template, refuse) {
|
|
27
|
+
return template.replace(/\{([^}]+)\}/g, (match, name) => {
|
|
28
|
+
if (PLAIN_PATH_PARAMETER.test(name))
|
|
29
|
+
return `:${name}`;
|
|
30
|
+
refuse(template, name);
|
|
31
|
+
return match;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Verbs Hono cannot dispatch to, whatever they are registered with.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ **`HEAD` is rewritten to `GET` before matching** — `hono-base.js` does it unconditionally at the
|
|
38
|
+
* top of `#dispatch` — so a route registered under it is never reached. See the `unroutable-verb`
|
|
39
|
+
* diagnostic for the measurements.
|
|
40
|
+
*/
|
|
41
|
+
const UNROUTABLE_VERBS = new Set(["HEAD"]);
|
|
42
|
+
/** `GET` → `get`. Hono's per-verb helpers are the idiom; `app.on` is the escape hatch. */
|
|
43
|
+
const HONO_METHOD = {
|
|
44
|
+
GET: "get",
|
|
45
|
+
POST: "post",
|
|
46
|
+
PUT: "put",
|
|
47
|
+
PATCH: "patch",
|
|
48
|
+
DELETE: "delete",
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* A request location, as the DOCUMENT names it, mapped to the target `@hono/zod-validator` reads.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ **The two vocabularies are not the same, and only one of them is a contract fact.** OpenAPI says
|
|
54
|
+
* `path`, `query`, `header` and a request body; zValidator says `param`, `query`, `header` and
|
|
55
|
+
* `json`. The library publishes the first because that is what the document states; translating is
|
|
56
|
+
* this package's job, and it is a map rather than a coincidence.
|
|
57
|
+
*/
|
|
58
|
+
const VALIDATOR_TARGET = {
|
|
59
|
+
path: "param",
|
|
60
|
+
query: "query",
|
|
61
|
+
header: "header",
|
|
62
|
+
body: "json",
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* The `@hono/zod-validator` target for a request body, decided by what the wire actually carries.
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ **This was `"json"` unconditionally, and that made every non-JSON body unservable.**
|
|
68
|
+
* `zValidator("json", …)` reads `c.req.json()`, so a `multipart/form-data` upload was parsed as JSON
|
|
69
|
+
* and refused. Measured: 17 such registrations in `payload/multipart` alone, and **11 of the 17
|
|
70
|
+
* request bodies in the Swagger Petstore are not JSON** — 5 urlencoded, 5 XML, 1 octet-stream.
|
|
71
|
+
*
|
|
72
|
+
* Hono's own target for both multipart and urlencoded is `"form"` — `c.req.parseBody()` handles the
|
|
73
|
+
* two together, which is why one branch covers both.
|
|
74
|
+
*
|
|
75
|
+
* ⚠️ **Anything else stays `"json"` deliberately.** A media type this function does not recognise is
|
|
76
|
+
* not a licence to guess: `application/xml` has no Hono target and no Zod representation the document
|
|
77
|
+
* justifies, so it keeps the existing behaviour rather than acquiring a new one on the way past. That
|
|
78
|
+
* gap is real and is stated in the README rather than papered over here.
|
|
79
|
+
*/
|
|
80
|
+
function bodyTargetFor(contentTypes) {
|
|
81
|
+
/**
|
|
82
|
+
* ⚠️ **EVERY, not some, and the difference is a regression I shipped for one measurement.**
|
|
83
|
+
* `addPet` in the Swagger Petstore accepts `application/json`, `application/xml` **and**
|
|
84
|
+
* `application/x-www-form-urlencoded`. Choosing `"form"` because one member is form-ish made
|
|
85
|
+
* `zValidator` call `c.req.parseBody()` on JSON bodies, and a request that answered **200** before
|
|
86
|
+
* the change answered **400** after it. Measured both ways against the same server.
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ **A route offering several media types cannot be validated by one target at all**, because
|
|
89
|
+
* which parser applies is decided by the caller's `Content-Type` at REQUEST time and `zValidator`
|
|
90
|
+
* is chosen at generation time. So a mixed body keeps `"json"`: unchanged behaviour, and a stated
|
|
91
|
+
* limitation rather than a guess that breaks the common case to fix the rare one. Only a body that
|
|
92
|
+
* is form-encoded in every form it may take is unambiguous enough to switch.
|
|
93
|
+
*/
|
|
94
|
+
const form = contentTypes.length > 0 &&
|
|
95
|
+
contentTypes.every((type) => type.startsWith("multipart/") || type === "application/x-www-form-urlencoded");
|
|
96
|
+
return form ? "form" : VALIDATOR_TARGET.body;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* How an unparsed body reaches the handler, and as what.
|
|
100
|
+
*
|
|
101
|
+
* ⚠️ **This was always `c.req.text()` typed as `string`, and for a binary body that silently
|
|
102
|
+
* corrupts.** `text()` UTF-8-decodes, so every byte outside ASCII becomes U+FFFD. Measured against a
|
|
103
|
+
* Petstore `application/octet-stream` upload under `wrangler dev`:
|
|
104
|
+
*
|
|
105
|
+
* ```
|
|
106
|
+
* sent : 89 50 4e 47 0d 0a 1a 0a ff d8 ff e0 00 10 4a 46 49 46 (18 bytes)
|
|
107
|
+
* received : fffd 50 4e 47 0d 0a 1a 0a fffd fffd fffd fffd 00 10 4a 46 49 46
|
|
108
|
+
* ```
|
|
109
|
+
*
|
|
110
|
+
* Five bytes destroyed, unrecoverably, and the request answered **200**. Success status, corrupt
|
|
111
|
+
* payload, no signal — the worst shape a defect can take.
|
|
112
|
+
*
|
|
113
|
+
* ⚠️ **`rawBodyProperty` conflated two different requirements, which is why one reader looked
|
|
114
|
+
* sufficient.** Its docblock justifies `text()` by a webhook's MAC covering exactly what arrived — true,
|
|
115
|
+
* and true only when what arrived is text. An upload is the other case, and it needs the bytes.
|
|
116
|
+
* `isRawBinaryMediaType` is the library's own rule for telling them apart, already applied on the
|
|
117
|
+
* response side; importing it rather than re-deriving it is what keeps the two halves from disagreeing
|
|
118
|
+
* again.
|
|
119
|
+
*/
|
|
120
|
+
function rawBodyReaderFor(contentTypes) {
|
|
121
|
+
return isRawBinaryMediaType(contentTypes)
|
|
122
|
+
? { call: "await c.req.arrayBuffer()", type: "ArrayBuffer" }
|
|
123
|
+
: { call: "await c.req.text()", type: "string" };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Everything an operation's input is made of, as a TypeScript type built from the schemas the library
|
|
127
|
+
* declared.
|
|
128
|
+
*
|
|
129
|
+
* `z.infer` rather than a restatement: the handler receives exactly what the validators produced, so
|
|
130
|
+
* deriving the type from the same consts is what makes the call site check itself. A restated
|
|
131
|
+
* interface would be a second source of truth that drifts.
|
|
132
|
+
*/
|
|
133
|
+
function inputTypeOf(entry) {
|
|
134
|
+
const parts = entry.validators.map(([, name]) => `z.infer<typeof ${name}>`);
|
|
135
|
+
if (entry.route.rawBodyProperty !== undefined) {
|
|
136
|
+
const reader = rawBodyReaderFor(entry.route.requestContentTypes);
|
|
137
|
+
parts.push(`{ ${objectKey(entry.route.rawBodyProperty)}: ${reader.type} }`);
|
|
138
|
+
}
|
|
139
|
+
return parts.length === 0 ? undefined : parts.join(" & ");
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* `getWidget` -> `GetWidget`, `Avatar_getAvatarAsJson` -> `Avatar_getAvatarAsJson`.
|
|
143
|
+
*
|
|
144
|
+
* Only the first character moves. An operation id is already the name the DOCUMENT publishes, and
|
|
145
|
+
* rewriting more of it would invent a second spelling of a contract fact for the sake of house style
|
|
146
|
+
* — the `_` that `resolveOperationId` inserts is part of the published name.
|
|
147
|
+
*/
|
|
148
|
+
function capitaliseId(operationId) {
|
|
149
|
+
return `${operationId.charAt(0).toUpperCase()}${operationId.slice(1)}`;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The resource a route belongs to — its first path segment — or `undefined` when it has none.
|
|
153
|
+
*
|
|
154
|
+
* ⚠️ **A parameter is not a resource.** `/{id}/things` has no groupable head: mounting a sub-app at
|
|
155
|
+
* `/:id` would make the parameter the resource name, which is not what the document says and not what
|
|
156
|
+
* anybody would write.
|
|
157
|
+
*/
|
|
158
|
+
function resourceOf(path) {
|
|
159
|
+
const segment = path.split("/").filter((part) => part !== "")[0];
|
|
160
|
+
if (segment === undefined || segment.startsWith(":") || segment.startsWith("{"))
|
|
161
|
+
return undefined;
|
|
162
|
+
return segment;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* A safe identifier for a resource's sub-app.
|
|
166
|
+
*
|
|
167
|
+
* Suffixed rather than named for the resource alone: every validator this file imports is named from
|
|
168
|
+
* an operation id, and a bare `widgets` could collide with one. A collision here is a file that does
|
|
169
|
+
* not compile, which is cheap to prevent and expensive to debug in generated output.
|
|
170
|
+
*/
|
|
171
|
+
function subAppNameOf(resource) {
|
|
172
|
+
const cleaned = resource.replace(/[^A-Za-z0-9]+(.)?/g, (_match, next) => next === undefined ? "" : next.toUpperCase());
|
|
173
|
+
const head = cleaned.charAt(0).toLowerCase() + cleaned.slice(1);
|
|
174
|
+
return `${/^[A-Za-z_$]/.test(head) ? head : `r${head}`}Routes`;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The generated Hono server.
|
|
178
|
+
*
|
|
179
|
+
* ⚠️ **This replaces a data table that a hand-written loop interpreted at run time.** The emitter knew,
|
|
180
|
+
* per operation, which validators applied and what the call looked like — and then flattened all of it
|
|
181
|
+
* into one homogeneous array, so the consumer had to recover it dynamically and could not. Emitting
|
|
182
|
+
* the call sites keeps that knowledge, and every one of them is monomorphic and checked.
|
|
183
|
+
*
|
|
184
|
+
* ⚠️ **Plain `Hono` and `@hono/zod-validator`, deliberately not `@hono/zod-openapi`.** The latter is
|
|
185
|
+
* the same validation plus a document generated FROM the code — spec-last, and a second source of
|
|
186
|
+
* truth competing with the one `@typespec/openapi3` publishes from the spec. We want its validation,
|
|
187
|
+
* not its documentation.
|
|
188
|
+
*
|
|
189
|
+
* ⚠️ **It declares no schema of its own.** Every validator this file names was declared by
|
|
190
|
+
* `typespec-http-zod` into `schemas.gen.ts` and is imported by name. That is what makes the two
|
|
191
|
+
* packages one emitter rather than two that must agree by coincidence — and it is why a consumer who
|
|
192
|
+
* wants the validators without a server can simply not install this one.
|
|
193
|
+
*/
|
|
194
|
+
export function renderApp(emitted, refuse,
|
|
195
|
+
/**
|
|
196
|
+
* The path the DOCUMENT says this service is served under, when it says one unambiguously.
|
|
197
|
+
*
|
|
198
|
+
* ⚠️ **An OpenAPI path is relative to its server**, so `@server("/api/v1")` plus `/accounts` means
|
|
199
|
+
* the document publishes `/api/v1/accounts`. Mounting at the root made every client generated from
|
|
200
|
+
* the document, and every "try it" in a rendered document, 404.
|
|
201
|
+
*/
|
|
202
|
+
basePath,
|
|
203
|
+
/**
|
|
204
|
+
* What the DOCUMENT says a caller must satisfy, per operation id.
|
|
205
|
+
*
|
|
206
|
+
* ⚠️ **Resolved by the caller rather than read off `EmittedRoute`**, because which schemes an
|
|
207
|
+
* operation accepts is a fact about the HTTP program and not part of the validator IR the library
|
|
208
|
+
* publishes. Keeping it out of that IR is what stops a Hono concern leaking into a package whose
|
|
209
|
+
* audience is wider.
|
|
210
|
+
*/
|
|
211
|
+
securityFor) {
|
|
212
|
+
const entries = emitted.routes.flatMap((route) => {
|
|
213
|
+
/**
|
|
214
|
+
* ⚠️ **Refused AND skipped, in that order.** Emitting the route anyway would put a registration
|
|
215
|
+
* in the file that `app.routes` lists and Hono never dispatches to — which is precisely how
|
|
216
|
+
* fifteen unreachable routes passed a differential written to catch unreachable routes.
|
|
217
|
+
*/
|
|
218
|
+
if (UNROUTABLE_VERBS.has(route.verb)) {
|
|
219
|
+
refuse.unroutableVerb(route);
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
const names = emitted.schemaNames.get(route.operationId);
|
|
223
|
+
// The library declares a `Responses` const for every operation, so a missing entry is a bug in
|
|
224
|
+
// this package's pairing rather than a spec the emitter chose not to serve.
|
|
225
|
+
if (names === undefined)
|
|
226
|
+
return [];
|
|
227
|
+
const validators = [];
|
|
228
|
+
for (const location of ["path", "query", "header", "body"]) {
|
|
229
|
+
const identifier = names[location];
|
|
230
|
+
if (identifier === undefined)
|
|
231
|
+
continue;
|
|
232
|
+
// The body's target depends on what the document says the wire carries; the rest are fixed.
|
|
233
|
+
const target = location === "body" ? bodyTargetFor(route.requestContentTypes) : VALIDATOR_TARGET[location];
|
|
234
|
+
validators.push([target, identifier]);
|
|
235
|
+
}
|
|
236
|
+
return [{ route, names, validators }];
|
|
237
|
+
});
|
|
238
|
+
/**
|
|
239
|
+
* ⚠️ **One registration per verb+path, not per operation.**
|
|
240
|
+
*
|
|
241
|
+
* TypeSpec models content negotiation as several operations on one route; OpenAPI models it as one
|
|
242
|
+
* path entry whose response lists every media type. This emitter used to register all of them, and
|
|
243
|
+
* Hono matches in registration order — so every operation after the first was dead code that looked
|
|
244
|
+
* mounted, and the only symptom was an operation count two higher than the document's.
|
|
245
|
+
*/
|
|
246
|
+
const grouped = new Map();
|
|
247
|
+
for (const entry of entries) {
|
|
248
|
+
const slot = `${entry.route.verb} ${entry.route.path}`;
|
|
249
|
+
grouped.set(slot, [...(grouped.get(slot) ?? []), entry]);
|
|
250
|
+
}
|
|
251
|
+
const methods = entries.map((entry) => {
|
|
252
|
+
const { route, names } = entry;
|
|
253
|
+
/**
|
|
254
|
+
* A negotiated member's `accept` is not in its validator — the negotiation supplies it — but it
|
|
255
|
+
* IS in the operation's declared input, so the interface has to keep it. The literal is known
|
|
256
|
+
* exactly: it is the only value that reaches this member.
|
|
257
|
+
*/
|
|
258
|
+
const negotiated = (grouped.get(`${route.verb} ${route.path}`)?.length ?? 0) > 1 && route.accept !== undefined
|
|
259
|
+
? `{ ${objectKey(route.accept.name)}: ${JSON.stringify(route.accept.value)} }`
|
|
260
|
+
: undefined;
|
|
261
|
+
const validated = inputTypeOf(entry);
|
|
262
|
+
const input = negotiated === undefined
|
|
263
|
+
? validated
|
|
264
|
+
: validated === undefined
|
|
265
|
+
? negotiated
|
|
266
|
+
: `${validated} & ${negotiated}`;
|
|
267
|
+
const output = names.response === undefined ? "void" : `z.infer<typeof ${names.response}>`;
|
|
268
|
+
const signature = input === undefined ? "ctx: Ctx" : `ctx: Ctx, input: ${input}`;
|
|
269
|
+
const doc = route.summary === undefined ? "" : `\t/** ${route.summary} */\n`;
|
|
270
|
+
return `${doc}\t${route.operationId}(${signature}): Awaitable<Result<${output}>>;`;
|
|
271
|
+
});
|
|
272
|
+
const aliases = entries.map((entry) => `export type ${capitaliseId(entry.route.operationId)}Handler = Operations[${JSON.stringify(entry.route.operationId)}];`);
|
|
273
|
+
/**
|
|
274
|
+
* **Which resources get a sub-app, and which routes stay on the root.**
|
|
275
|
+
*
|
|
276
|
+
* ⚠️ **Hono's own guidance, followed rather than guessed at.** Its best-practices page says to use
|
|
277
|
+
* `app.route()` to build a larger application instead of Ruby-on-Rails-like controllers — while
|
|
278
|
+
* writing handlers *directly after the path definitions*, because a handler in a separate file
|
|
279
|
+
* cannot infer its path parameters. Both halves are honoured here: routes are grouped by resource,
|
|
280
|
+
* and every handler stays inline where `c.req.valid()` is typed.
|
|
281
|
+
*
|
|
282
|
+
* ⚠️ **Measured before relying on it:** `app.route(prefix, sub)` composes paths exactly, including a
|
|
283
|
+
* parameter in the prefix, and the PARENT's `app.routes` reports the fully composed path — so a
|
|
284
|
+
* route mounted through a sub-app is still countable, which is what every arm that counts routes
|
|
285
|
+
* depends on.
|
|
286
|
+
*
|
|
287
|
+
* A resource with ONE route is not a group, and gets no sub-app: that is what a Hono author writes,
|
|
288
|
+
* and a one-route sub-app is ceremony around a single line.
|
|
289
|
+
*/
|
|
290
|
+
const slotsByResource = new Map();
|
|
291
|
+
for (const slot of grouped.keys()) {
|
|
292
|
+
const resource = resourceOf(slot.slice(slot.indexOf(" ") + 1));
|
|
293
|
+
if (resource === undefined)
|
|
294
|
+
continue;
|
|
295
|
+
slotsByResource.set(resource, [...(slotsByResource.get(resource) ?? []), slot]);
|
|
296
|
+
}
|
|
297
|
+
const subApps = new Map();
|
|
298
|
+
for (const [resource, slots] of slotsByResource) {
|
|
299
|
+
if (slots.length > 1)
|
|
300
|
+
subApps.set(resource, subAppNameOf(resource));
|
|
301
|
+
}
|
|
302
|
+
const registrations = [...grouped.values()].map((group) => {
|
|
303
|
+
const entry = group[0];
|
|
304
|
+
const { route, validators } = entry;
|
|
305
|
+
const method = HONO_METHOD[route.verb] ?? "on";
|
|
306
|
+
const path = toHonoPath(route.path, (template, name) => refuse.unsupportedPathTemplate(route, template, name));
|
|
307
|
+
/**
|
|
308
|
+
* A route inside a sub-app is registered RELATIVE to the prefix it is mounted at.
|
|
309
|
+
*
|
|
310
|
+
* ⚠️ **The collection route is `"/"`, never the empty string.** Measured: `sub.get("/")` under
|
|
311
|
+
* `app.route("/widgets", sub)` answers `/widgets`, and `/widgets/` is a 404 — so the composed
|
|
312
|
+
* path carries no trailing slash and the document's own path is what a caller reaches.
|
|
313
|
+
*/
|
|
314
|
+
const resource = resourceOf(path);
|
|
315
|
+
const mountOn = resource === undefined ? undefined : subApps.get(resource);
|
|
316
|
+
const target = mountOn ?? "app";
|
|
317
|
+
const registeredPath = mountOn === undefined ? path : path.slice(`/${resource ?? ""}`.length) || "/";
|
|
318
|
+
/**
|
|
319
|
+
* The scope gate goes FIRST, before any validator.
|
|
320
|
+
*
|
|
321
|
+
* A caller without the scope must be refused whatever their body looks like: validating first
|
|
322
|
+
* answers `400` to a request the contract says is not theirs to make, which tells somebody who
|
|
323
|
+
* may not call the operation at all which payloads are well-formed.
|
|
324
|
+
*/
|
|
325
|
+
/**
|
|
326
|
+
* ⚠️ **Emitted whenever the document declares ANY security, not only when it declares scopes.**
|
|
327
|
+
* `@useAuth(BearerAuth)` publishes `security: [{ "BearerAuth": [] }]` — no scopes — so a
|
|
328
|
+
* scopes-only gate covered OAuth2 and nothing else. Bearer, api-key and basic carried no gate at
|
|
329
|
+
* all and rested entirely on `deps.context` returning null, which answers "is somebody here"
|
|
330
|
+
* rather than "did they satisfy the scheme the contract names".
|
|
331
|
+
*/
|
|
332
|
+
const requirements = securityFor?.(route.verb, route.path) ?? [];
|
|
333
|
+
const gate = requirements.length === 0 ? [] : [`\t\tdeps.authorize(${renderSecurity(requirements)}),`];
|
|
334
|
+
const middleware = [
|
|
335
|
+
...gate,
|
|
336
|
+
...validators.map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid),`),
|
|
337
|
+
];
|
|
338
|
+
/**
|
|
339
|
+
* Whether the operation requires a caller — and NOTHING else about the caller.
|
|
340
|
+
*
|
|
341
|
+
* ⚠️ **This used to pass `"account"` or `"resource"`, chosen by how many path parameters the
|
|
342
|
+
* route had, and that was a rule no document states.** `@useAuth(NoAuth)` reaches OpenAPI as
|
|
343
|
+
* `security: []`, so "does this need a caller" is a contract fact and is generated. "Is this
|
|
344
|
+
* account-scoped or resource-scoped" is not: no OpenAPI keyword expresses it, and the
|
|
345
|
+
* path-parameter heuristic happened to fit the first consumer.
|
|
346
|
+
*/
|
|
347
|
+
const body = [];
|
|
348
|
+
if (route.noAuth !== true) {
|
|
349
|
+
body.push(`\t\t\tconst ctx = deps.context(c, "required");`);
|
|
350
|
+
body.push("\t\t\tif (ctx === null) return deps.noContext(c);");
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
/**
|
|
354
|
+
* ⚠️ **The same null check as an authenticated route, and it is what removes a CAST from
|
|
355
|
+
* generated output.** This used to emit `deps.context(c, "none") as Ctx`, because one
|
|
356
|
+
* signature returning `C | null` cannot express "this argument makes null impossible". A cast
|
|
357
|
+
* in generated code is worse than one in hand-written code: nobody reviews it, and it
|
|
358
|
+
* reappears on every compile.
|
|
359
|
+
*
|
|
360
|
+
* ⚠️ **Overloading `context` was tried and is worse.** It removes the cast from here and puts
|
|
361
|
+
* one in every consumer's `deps`, because an overloaded property type stops contextually
|
|
362
|
+
* typing a single implementation — measured, the wiring consumer lost inference on every
|
|
363
|
+
* hook. Trading a cast in generated code for a cast in hand-written code is the wrong
|
|
364
|
+
* direction.
|
|
365
|
+
*
|
|
366
|
+
* Checking is also more honest than asserting: an app that returns null here has said it
|
|
367
|
+
* could not build a context, and the old cast handed the handler that null typed as `Ctx`.
|
|
368
|
+
*/
|
|
369
|
+
body.push(`\t\t\tconst ctx = deps.context(c, "none");`);
|
|
370
|
+
body.push("\t\t\tif (ctx === null) return deps.noContext(c);");
|
|
371
|
+
}
|
|
372
|
+
const pieces = validators.map(([target]) => `...c.req.valid(${JSON.stringify(target)})`);
|
|
373
|
+
if (route.rawBodyProperty !== undefined) {
|
|
374
|
+
// The bytes ARE the contract: a signature covers exactly what arrived, so parsing and
|
|
375
|
+
// re-serialising would verify a different string than the sender signed. WHICH reader
|
|
376
|
+
// preserves them depends on the media type — see `rawBodyReaderFor`.
|
|
377
|
+
const reader = rawBodyReaderFor(route.requestContentTypes);
|
|
378
|
+
pieces.push(`${objectKey(route.rawBodyProperty)}: ${reader.call}`);
|
|
379
|
+
}
|
|
380
|
+
const invoke = (member) => {
|
|
381
|
+
// The member's own `accept` literal, which its input type requires and which the shared
|
|
382
|
+
// validator no longer supplies. We know it exactly: it is the branch we are in.
|
|
383
|
+
const own = group.length > 1 && member.route.accept !== undefined
|
|
384
|
+
? [`${objectKey(member.route.accept.name)}: ${JSON.stringify(member.route.accept.value)}`]
|
|
385
|
+
: [];
|
|
386
|
+
const input = [...pieces, ...own];
|
|
387
|
+
/**
|
|
388
|
+
* ⚠️ **Broken across lines rather than emitted as one.** Generated code is read far more often
|
|
389
|
+
* than it is written — in review, in a stack trace, in a diff — and a single call reached 219
|
|
390
|
+
* characters on a real service, against the 60-to-80 of every example in Hono's own
|
|
391
|
+
* documentation. Nothing about the behaviour changes; a reader's ability to see it does.
|
|
392
|
+
*
|
|
393
|
+
* A call with no input stays on one line, because wrapping it would add ceremony to something
|
|
394
|
+
* already short.
|
|
395
|
+
*/
|
|
396
|
+
/**
|
|
397
|
+
* ⚠️ **Indented literally, because the surrounding `+1 tab` only reaches the FIRST physical
|
|
398
|
+
* line.** A multi-line fragment keeps whatever tabs it was written with, so the depths here
|
|
399
|
+
* are absolute: the `return` sits at four, its arguments at five, and the handler's input
|
|
400
|
+
* properties at six.
|
|
401
|
+
*/
|
|
402
|
+
const call = input.length === 0
|
|
403
|
+
? `handlersFor(c).${member.route.operationId}(ctx)`
|
|
404
|
+
: [
|
|
405
|
+
`handlersFor(c).${member.route.operationId}(ctx, {`,
|
|
406
|
+
...input.map((piece) => `\t\t\t\t\t\t${piece},`),
|
|
407
|
+
"\t\t\t\t\t})",
|
|
408
|
+
].join("\n");
|
|
409
|
+
return input.length === 0
|
|
410
|
+
? `deps.respond(c, ${member.names.responses}, await ${call})`
|
|
411
|
+
: [
|
|
412
|
+
`deps.respond(`,
|
|
413
|
+
`\t\t\t\t\tc,`,
|
|
414
|
+
`\t\t\t\t\t${member.names.responses},`,
|
|
415
|
+
`\t\t\t\t\tawait ${call},`,
|
|
416
|
+
"\t\t\t\t)",
|
|
417
|
+
].join("\n");
|
|
418
|
+
};
|
|
419
|
+
if (group.length === 1) {
|
|
420
|
+
body.push(`\t\t\treturn ${invoke(entry)};`);
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
/**
|
|
424
|
+
* Several operations, one route: the caller's `Accept` chooses which one answers.
|
|
425
|
+
*
|
|
426
|
+
* The offered list and which operation serves each type are both read from the document.
|
|
427
|
+
* `selectContentType` applies RFC 9110 §12.5.1 to them — it lives in the runtime rather than
|
|
428
|
+
* in `deps` because both halves are derivable, and an app forced to supply it would be
|
|
429
|
+
* re-implementing the standard.
|
|
430
|
+
*/
|
|
431
|
+
const offers = group.flatMap((member) => member.route.responseContentTypes.map((contentType) => ({ contentType, member })));
|
|
432
|
+
const offered = `[${offers.map((offer) => JSON.stringify(offer.contentType)).join(", ")}]`;
|
|
433
|
+
body.push(`\t\t\tconst served = selectContentType(c.req.header("accept"), ${offered});`);
|
|
434
|
+
body.push(`\t\t\tif (served === undefined) return deps.notAcceptable(c, ${offered});`);
|
|
435
|
+
for (const offer of offers) {
|
|
436
|
+
body.push(`\t\t\tif (served === ${JSON.stringify(offer.contentType)}) return ${invoke(offer.member)};`);
|
|
437
|
+
}
|
|
438
|
+
// `selectContentType` only ever returns a member of the list it was given, so this is
|
|
439
|
+
// unreachable — and stating that is cheaper than a cast that would hide it if it were not.
|
|
440
|
+
body.push(`\t\t\treturn deps.notAcceptable(c, ${offered});`);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* ⚠️ **`app.on` takes the METHOD first, and we were not passing one.** Only five verbs have a
|
|
444
|
+
* dedicated Hono method; everything else — `HEAD`, `OPTIONS`, anything a spec invents — fell
|
|
445
|
+
* through to `app.on(path, handler)`, which Hono reads as `on(method, path)`. The route was
|
|
446
|
+
* emitted, counted by every arm that counted rows, and mounted nowhere.
|
|
447
|
+
*/
|
|
448
|
+
/**
|
|
449
|
+
* ⚠️ **Emitted as a CHAINED call fragment rather than a statement, and that is what makes Hono's
|
|
450
|
+
* RPC client work at all.** `hc<typeof app>` derives its entire surface from the `Schema` type
|
|
451
|
+
* parameter Hono accumulates through chaining — not from what is registered at run time. As
|
|
452
|
+
* separate statements each call's type was discarded, `registerRoutes` returned `void`, and
|
|
453
|
+
* measured in a fresh project `hc<typeof app>` resolved to **`unknown`**: not an empty client, an
|
|
454
|
+
* unusable one. A hand-chained app with the same sub-app-per-resource shape supports it
|
|
455
|
+
* perfectly, so the shape was never the obstacle — only the statements were.
|
|
456
|
+
*/
|
|
457
|
+
const text = [
|
|
458
|
+
`\t\t.${method}(`,
|
|
459
|
+
...(method === "on" ? [`\t\t\t${JSON.stringify(route.verb)},`] : []),
|
|
460
|
+
`\t\t\t${JSON.stringify(registeredPath)},`,
|
|
461
|
+
...middleware.map((line) => `\t${line}`),
|
|
462
|
+
"\t\t\tasync (c) => {",
|
|
463
|
+
...body.map((line) => `\t${line}`),
|
|
464
|
+
"\t\t\t},",
|
|
465
|
+
"\t\t)",
|
|
466
|
+
].join("\n");
|
|
467
|
+
return { target, text };
|
|
468
|
+
});
|
|
469
|
+
/**
|
|
470
|
+
* Every identifier this file names, imported from the module that declares it.
|
|
471
|
+
*
|
|
472
|
+
* ⚠️ **Derived from what the rendered text actually references, not from what was available.** An
|
|
473
|
+
* unused import fails the lint a generated file has to pass like any other, and a missing one is a
|
|
474
|
+
* file that does not compile. Both have happened.
|
|
475
|
+
*/
|
|
476
|
+
const rendered = [...registrations.map((r) => r.text), ...methods, ...aliases].join("\n");
|
|
477
|
+
const referenced = [
|
|
478
|
+
...new Set(entries.flatMap((entry) => [
|
|
479
|
+
entry.names.path,
|
|
480
|
+
entry.names.query,
|
|
481
|
+
entry.names.header,
|
|
482
|
+
entry.names.body,
|
|
483
|
+
entry.names.response,
|
|
484
|
+
entry.names.responses,
|
|
485
|
+
].filter((name) => name !== undefined))),
|
|
486
|
+
]
|
|
487
|
+
.filter((identifier) => new RegExp(`\\b${identifier}\\b`).test(rendered))
|
|
488
|
+
.toSorted();
|
|
489
|
+
const imports = referenced.length === 0
|
|
490
|
+
? ""
|
|
491
|
+
: `import {\n${referenced.map((id) => `\t${id},`).join("\n")}\n} from "./schemas.gen.js";\n`;
|
|
492
|
+
/**
|
|
493
|
+
* One `Hono` per resource, declared before the routes and mounted after them.
|
|
494
|
+
*
|
|
495
|
+
* ⚠️ **Mounted AFTER, deliberately.** `app.route()` copies the sub-app's routes into the parent at
|
|
496
|
+
* the moment it is called, so mounting before the routes are registered mounts nothing — the same
|
|
497
|
+
* shape of defect as Hono middleware, which only applies to routes registered after it. Declaring
|
|
498
|
+
* at the top and mounting at the bottom is the arrangement that cannot be got wrong by reordering.
|
|
499
|
+
*/
|
|
500
|
+
/**
|
|
501
|
+
* ⚠️ **One chained expression per app, because that is the only shape `hc` can read.**
|
|
502
|
+
*
|
|
503
|
+
* Hono accumulates its `Schema` type through the chain, so a sub-app declared and then registered
|
|
504
|
+
* across separate statements throws every route's type away. Declaring it AS the chain keeps them,
|
|
505
|
+
* and mounting with a chained `.route()` carries them up into the parent.
|
|
506
|
+
*
|
|
507
|
+
* ⚠️ **The ordering property that used to need a comment is now structural.** Mounting before the
|
|
508
|
+
* routes were registered mounted nothing, and the fix was "declare at the top, mount at the
|
|
509
|
+
* bottom" — a rule a reordering could break. A single expression cannot be reordered wrongly: the
|
|
510
|
+
* sub-app is complete at the point it is mounted because it is its own initialiser.
|
|
511
|
+
*/
|
|
512
|
+
const byTarget = new Map();
|
|
513
|
+
for (const registration of registrations) {
|
|
514
|
+
byTarget.set(registration.target, [
|
|
515
|
+
...(byTarget.get(registration.target) ?? []),
|
|
516
|
+
registration.text,
|
|
517
|
+
]);
|
|
518
|
+
}
|
|
519
|
+
const subAppDeclarations = subApps.size === 0
|
|
520
|
+
? ""
|
|
521
|
+
: `${[...subApps.values()]
|
|
522
|
+
.map((name) => `\tconst ${name} = new Hono<AppEnv>()\n${(byTarget.get(name) ?? []).join("\n")};`)
|
|
523
|
+
.join("\n\n")}\n\n`;
|
|
524
|
+
const subAppMounts = [...subApps].map(([resource, name]) => `\t\t.route(${JSON.stringify(`/${resource}`)}, ${name})`);
|
|
525
|
+
/**
|
|
526
|
+
* Everything that stays on the root, then every mount, as one returned chain.
|
|
527
|
+
*
|
|
528
|
+
* Returned rather than discarded: `registerRoutes` used to be `void`, and measured in a fresh
|
|
529
|
+
* project that made `hc<typeof app>` resolve to **`unknown`** — Hono's RPC client, which is one of
|
|
530
|
+
* the framework's headline features, was categorically unavailable to anything this emitter
|
|
531
|
+
* produced. Handing back the chained value costs nothing and restores it.
|
|
532
|
+
*/
|
|
533
|
+
const rootChain = [...(byTarget.get("app") ?? []), ...subAppMounts];
|
|
534
|
+
// Imported only where a route actually negotiates: an unused import fails the repo's own lint.
|
|
535
|
+
const negotiates = [...grouped.values()].some((group) => group.length > 1);
|
|
536
|
+
const runtimeModule = JSON.stringify(emitted.options.runtimeModule);
|
|
537
|
+
/**
|
|
538
|
+
* ⚠️ **One base sub-app, mounted with `app.route()` — Hono's own nesting, not a rewritten path on
|
|
539
|
+
* every registration.** Prefixing each path individually would fight the resource grouping, and
|
|
540
|
+
* would put the prefix in `hc`'s type surface as part of every route name rather than once. A
|
|
541
|
+
* nested `.route()` composes exactly, and the parent's `app.routes` still reports the fully
|
|
542
|
+
* composed path, which every arm that counts routes depends on.
|
|
543
|
+
*/
|
|
544
|
+
const usesBasePath = basePath !== undefined && basePath !== "";
|
|
545
|
+
const needsHonoValue = subApps.size > 0 || usesBasePath;
|
|
546
|
+
return `${GENERATED_BANNER}
|
|
547
|
+
import { zValidator } from "@hono/zod-validator";
|
|
548
|
+
${needsHonoValue ? 'import { Hono } from "hono";\nimport type { Context, Input } from "hono";' : 'import type { Context, Hono, Input } from "hono";'}
|
|
549
|
+
import { z } from "zod";
|
|
550
|
+
import type { AppEnv, Awaitable, Ctx, Result, RouteDeps } from ${runtimeModule};${negotiates ? `\nimport { selectContentType } from ${runtimeModule};` : ""}
|
|
551
|
+
${imports}
|
|
552
|
+
/**
|
|
553
|
+
* One method per operation, each concretely typed from the schemas it validates against.
|
|
554
|
+
*
|
|
555
|
+
* There is no cast anywhere in this file, and no dynamic lookup: the generated call sites name the
|
|
556
|
+
* method, so an implementation whose input or output does not match the contract fails to compile.
|
|
557
|
+
*/
|
|
558
|
+
export interface Operations {
|
|
559
|
+
${methods.join("\n")}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* One alias per operation, so a handler can live beside the module it belongs to.
|
|
564
|
+
*
|
|
565
|
+
* ⚠️ **Derived by indexed access, never restated.** An indexed access into Operations cannot drift
|
|
566
|
+
* from the interface; a second hand-written signature could, and a signature that silently disagrees
|
|
567
|
+
* with the contract is the whole failure mode this emitter exists to remove.
|
|
568
|
+
*/
|
|
569
|
+
${aliases.join("\n")}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* ⚠️ **There is deliberately NO exported HandlersFor alias to annotate the factory with.**
|
|
573
|
+
*
|
|
574
|
+
* There was one, and it silently disabled the guard below. Annotating widens the value to
|
|
575
|
+
* Operations, so T infers as Operations, Exclude<keyof T, keyof Operations> is never, and the
|
|
576
|
+
* surplus-key constraint evaporates. Measured: a handler for an operation the spec no longer
|
|
577
|
+
* declares produced no error at all through the annotated form, and TS2345 without it.
|
|
578
|
+
*
|
|
579
|
+
* Write the factory unannotated — inference then carries the real shape into registerRoutes:
|
|
580
|
+
*
|
|
581
|
+
* const handlersFor = (c) => backendFor(c.env);
|
|
582
|
+
* registerRoutes(app, handlersFor, deps);
|
|
583
|
+
*/
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Refuses a handler set carrying an operation the spec no longer declares.
|
|
587
|
+
*
|
|
588
|
+
* ⚠️ **Removal is the change the type system misses, and it is the one that matters most.** Adding an
|
|
589
|
+
* operation is a missing property; changing one is a wrong signature; both fail loudly. Removing one
|
|
590
|
+
* leaves a handler that compiles forever against a route nobody mounts.
|
|
591
|
+
*
|
|
592
|
+
* The excess-property check catches it, but ONLY on an object literal assigned straight to an
|
|
593
|
+
* annotated target. Mapping every surplus key to never makes the refusal structural instead: a key
|
|
594
|
+
* outside Operations can only be satisfied by a value that cannot exist.
|
|
595
|
+
*/
|
|
596
|
+
export type Exhaustive<T> = T & Record<Exclude<keyof T, keyof Operations>, never>;
|
|
597
|
+
|
|
598
|
+
/** Mount every operation the service declares. */
|
|
599
|
+
export function registerRoutes<T extends Operations>(
|
|
600
|
+
app: Hono<AppEnv>,
|
|
601
|
+
handlersFor: <P extends string, I extends Input>(c: Context<AppEnv, P, I>) => Exhaustive<T>,
|
|
602
|
+
deps: RouteDeps,
|
|
603
|
+
) {
|
|
604
|
+
${subAppDeclarations}${usesBasePath
|
|
605
|
+
? `\tconst basePathRoutes = new Hono<AppEnv>()\n${rootChain.join("\n")};\n\n\treturn app.route(${JSON.stringify(basePath)}, basePathRoutes);`
|
|
606
|
+
: `\treturn app\n${rootChain.join("\n")};`}
|
|
607
|
+
}
|
|
608
|
+
`;
|
|
609
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Namespace, Program } from "@typespec/compiler";
|
|
2
|
+
export interface BasePathResolution {
|
|
3
|
+
/** The prefix to mount every route under, or `undefined` to mount at the root. */
|
|
4
|
+
readonly basePath: string | undefined;
|
|
5
|
+
/** The distinct paths found, when the document declares more than one and they disagree. */
|
|
6
|
+
readonly ambiguous: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Read the service's declared servers and decide what to mount under.
|
|
10
|
+
*
|
|
11
|
+
* - no `@server`, or a templated one → the root, which is what the document means;
|
|
12
|
+
* - one static path, or several that agree → that path;
|
|
13
|
+
* - several that DISAGREE → the root, and the caller reports it. There is no answer that serves all
|
|
14
|
+
* of them, and picking one would silently serve the wrong URLs for the others.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveBasePath(program: Program, namespace: Namespace): BasePathResolution;
|