typespec-hono 0.1.0 → 0.3.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/src/runtime.ts ADDED
@@ -0,0 +1,299 @@
1
+ import type { Context, Env, Input, MiddlewareHandler } from "hono";
2
+ import type { ZodType } from "zod";
3
+
4
+ /**
5
+ * One arm of an operation's declared response set, as the document publishes it.
6
+ *
7
+ * Declared here rather than imported from `typespec-http-zod`, so that this module and the copy the
8
+ * emitter writes beside the generated code are both free of any package import at run time. The two
9
+ * declarations are structurally identical, which is all TypeScript requires, and
10
+ * `test/runtime-parity.test.ts` asserts that this `armFor` answers identically to the library's for
11
+ * every shape of arm list.
12
+ */
13
+ export interface ResponseArm {
14
+ readonly status: number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
15
+ readonly schema: ZodType | undefined;
16
+ readonly when?: {
17
+ readonly property: string;
18
+ readonly value: boolean | string;
19
+ };
20
+ }
21
+
22
+ /**
23
+ * The arm that applies to a status, preferring an exact code, then its `NXX` range, then `default`.
24
+ *
25
+ * That order is the document's own: OpenAPI resolves a response the same way, so an application
26
+ * choosing an arm by hand would have to re-derive this and could get it wrong differently.
27
+ */
28
+ export function armFor(arms: readonly ResponseArm[], status: number): ResponseArm | undefined {
29
+ return (
30
+ arms.find((arm) => arm.status === status) ??
31
+ arms.find((arm) => arm.status === `${Math.floor(status / 100) as 1 | 2 | 3 | 4 | 5}XX`) ??
32
+ arms.find((arm) => arm.status === "default")
33
+ );
34
+ }
35
+
36
+ /**
37
+ * One acceptable combination of credentials, exactly as OpenAPI's `security` states it: scheme id to
38
+ * the scopes that scheme requires. Every entry in one requirement must be satisfied TOGETHER, and
39
+ * satisfying ANY ONE requirement authorises the caller.
40
+ *
41
+ * **Declared here rather than beside the code that derives it**, because `./runtime` is what a
42
+ * running server imports and must stay free of every build-time dependency, a packaging arm asserts
43
+ * it names no `@typespec/*` package at all. An application should not drag a compiler into its
44
+ * Worker to read one type.
45
+ */
46
+ export type SecurityRequirement = Readonly<Record<string, readonly string[]>>;
47
+
48
+ /**
49
+ * The contract between the GENERATED server and the app that mounts it.
50
+ *
51
+ * **This exists because "here is a data table, write your own router" is not a deliverable.**
52
+ * The emitter used to produce `GENERATED_ROUTES` (one array of plain objects) and every consumer
53
+ * had to hand-write a loop that interpreted it at run time. In this repository that loop is 220
54
+ * lines, it sits outside every oracle the emitter is judged by, and it carries a cast that exists
55
+ * *only* because iterating a homogeneous array throws away the per-operation types the emitter knew:
56
+ * `backend[operationId]` is a union of 104 differently-typed methods, so nothing about the call can
57
+ * be checked. Generating the server removes the loop, the cast, and the compensating type-level
58
+ * assertion invented to put the guarantee back.
59
+ *
60
+ * What is left for the app to supply is genuinely app-specific: how a request becomes a caller's
61
+ * context, and how a result becomes a response. Everything else, routing, validation, which
62
+ * validator applies to which target, what status each arm answers, is generated.
63
+ */
64
+
65
+ /**
66
+ * How an operation's return value is wrapped.
67
+ *
68
+ * Identity by default, so an operation may simply return its value. An app with a result envelope
69
+ * points `runtime-module` at its own module and re-declares this as, say, `ServiceResult<T>`, which
70
+ * is what keeps the generated `Operations` interface concretely typed end to end instead of falling
71
+ * back to `unknown` and reintroducing the cast this whole change exists to delete.
72
+ */
73
+ export type Result<T> = T;
74
+
75
+ /**
76
+ * The Hono environment the generated server mounts on, and the caller context its operations take.
77
+ *
78
+ * **Concrete on purpose.** Making `registerRoutes` generic over the environment does not work:
79
+ * Hono narrows `Context` per route and its conditional types cannot reduce
80
+ * `IfAnyThenEmptyObject<E extends Env ? ...>` while `E` is an unbound parameter, so nothing the app
81
+ * supplies is ever assignable and every call site needs a cast. Naming the types here instead, an
82
+ * app points `runtime-module` at its own module and re-declares them, keeps every generated call
83
+ * site concrete and cast-free. Identity defaults, so an app with neither can ignore both.
84
+ */
85
+ export type AppEnv = Env;
86
+ export type Ctx = unknown;
87
+
88
+ /** Anything an operation may hand back: the value, or a promise of it. */
89
+ export type Awaitable<T> = T | Promise<T>;
90
+
91
+ /**
92
+ * Pick the media type to serve, per RFC 9110 section 12.5.1.
93
+ *
94
+ * **In the runtime rather than in {@link RouteDeps}, deliberately.** The test for admitting
95
+ * anything to `deps` is *the generated code cannot proceed without an answer*, and this is not that:
96
+ * which media types an operation offers is a contract fact the emitter reads from the document, and
97
+ * how `Accept` selects among them is specified by the RFC. Both sides are derivable, so an app that
98
+ * had to supply this would be re-implementing the standard, and could get it wrong differently from
99
+ * everybody else.
100
+ *
101
+ * The rules that matter, and that a naive `includes()` gets wrong:
102
+ * - **absent or empty `Accept` means anything is acceptable**, serve the first offer;
103
+ * - **`q=0` is a REFUSAL**, not a weak preference, so a range scoring zero can never be chosen;
104
+ * - **specificity breaks ties before quality does**: an exact type beats a subtype wildcard
105
+ * (`text/*`), which beats the fully wildcard range, at equal `q`. That is why the score is a
106
+ * pair rather than a number. The fully wildcard range is not written literally here because it
107
+ * would close this comment;
108
+ * - parameters after the media range (`;charset=utf-8`) are not part of the match.
109
+ *
110
+ * Returns `undefined` when nothing offered is acceptable. The caller answers 406, and the
111
+ * difference between "no preference" and "no acceptable option" is exactly what that turns on.
112
+ */
113
+ export function selectContentType(
114
+ accept: string | undefined,
115
+ offered: readonly string[],
116
+ ): string | undefined {
117
+ if (offered.length === 0) return undefined;
118
+ const header = accept?.trim();
119
+ if (header === undefined || header === "") return offered[0];
120
+
121
+ const ranges = header.split(",").map((entry) => {
122
+ const [range = "", ...parameters] = entry.split(";").map((part) => part.trim());
123
+ const quality = parameters
124
+ .map((parameter) => /^q=(?<value>[\d.]+)$/i.exec(parameter)?.groups?.value)
125
+ .find((value) => value !== undefined);
126
+ return { range: range.toLowerCase(), q: quality === undefined ? 1 : Number(quality) };
127
+ });
128
+
129
+ let best: { type: string; q: number; specificity: number } | undefined;
130
+ for (const type of offered) {
131
+ const [group] = type.toLowerCase().split("/");
132
+ for (const { range, q } of ranges) {
133
+ // `q=0` is "I will not accept this", so it never becomes a candidate.
134
+ if (!Number.isFinite(q) || q <= 0) continue;
135
+ const specificity =
136
+ range === type.toLowerCase() ? 2 : range === `${group}/*` ? 1 : range === "*/*" ? 0 : -1;
137
+ if (specificity < 0) continue;
138
+ if (best === undefined || q > best.q || (q === best.q && specificity > best.specificity)) {
139
+ best = { type, q, specificity };
140
+ }
141
+ }
142
+ }
143
+ return best?.type;
144
+ }
145
+
146
+ /**
147
+ * Let only a real HEAD request through.
148
+ *
149
+ * Hono rewrites HEAD to GET before matching, so a `@head` operation has to be registered under GET
150
+ * to be reachable at all. Where the document declares no GET on that path, this keeps the
151
+ * registration honest: a GET gets the 404 it would have got if the route had never been registered,
152
+ * and only a HEAD reaches the validators and the handler. `c.req.method` still reads `HEAD` after the
153
+ * rewrite, which is what makes the distinction possible.
154
+ *
155
+ * In the runtime rather than in {@link RouteDeps} on the usual test: the generated code can proceed
156
+ * without asking the app anything. Which verbs the document declares is a contract fact, and the
157
+ * answer for a verb it does not declare is the same 404 any unrouted request already gets, through
158
+ * whatever `app.notFound()` the application has set.
159
+ *
160
+ * A plain middleware rather than `except()` from `hono/combine`, because `except` wraps the final
161
+ * handler and erases its response type, and Hono's RPC client derives its whole surface from that
162
+ * type. Measured: `hc<typeof app>` resolved a wrapped route's body to `unknown`.
163
+ */
164
+ export const headOnly: MiddlewareHandler = async (c, next) =>
165
+ c.req.method === "HEAD" ? next() : c.notFound();
166
+
167
+ /**
168
+ * Apply the validator that parses the media type the request actually carries.
169
+ *
170
+ * A route may declare several request media types needing different parsers -- `addPet` in the
171
+ * Swagger Petstore accepts JSON, XML and urlencoded on one path. `zValidator`'s target is fixed when
172
+ * the server is generated; which parser applies is decided by the caller's `Content-Type` when the
173
+ * request arrives. Those are different times, and only the second one has the answer.
174
+ *
175
+ * **Before this, one target was chosen for the whole route and everything else was rejected.** A
176
+ * form-encoded body to a route declaring JSON first was handed to `c.req.json()` and answered 400,
177
+ * with no diagnostic anywhere. The status looked like the caller's fault and was not.
178
+ *
179
+ * Parameters after the media type (`; charset=utf-8`, `; boundary=...`) are not part of the match,
180
+ * which matters because a multipart request always carries a boundary.
181
+ *
182
+ * A `Content-Type` matching nothing declared falls through to the first validator, which reproduces
183
+ * the previous behaviour exactly for that case: the body fails to parse and `deps.invalid` answers.
184
+ * No status is invented here that the document does not describe.
185
+ */
186
+ export function byContentType<E extends Env>(
187
+ validators: readonly (readonly [
188
+ mediaType: string,
189
+ target: string,
190
+ validator: MiddlewareHandler<E>,
191
+ ])[],
192
+ ): MiddlewareHandler<E> {
193
+ return async (c, next) => {
194
+ const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
195
+ const matched = validators.find(([mediaType]) => mediaType.toLowerCase() === declared);
196
+ const [, , validator] = matched ?? (validators[0] as (typeof validators)[number]);
197
+ return validator(c, next);
198
+ };
199
+ }
200
+
201
+ /**
202
+ * What the app provides. One object, passed once, rather than a module the generated file imports by
203
+ * path. A generated server that hard-codes `../../backend.js` is only usable by the project it was
204
+ * generated in, and this one has to be usable by any.
205
+ *
206
+ * **The hooks are generic over Hono's path and input parameters, deliberately.** Hono narrows
207
+ * `Context` per route, by the literal path, and by whatever the validators on that route produced,
208
+ * so a hook typed against a single `Context<E>` is not assignable at any real call site. Making the
209
+ * hooks generic lets the app write functions that ignore both, without a cast anywhere.
210
+ *
211
+ * **`E` and `C` are PARAMETERS, and they have to be.** The defaults keep the bare `RouteDeps` the
212
+ * generated server writes working for an app that substitutes nothing. An app that substitutes
213
+ * anything binds them once, `export type RouteDeps = BaseRouteDeps<AppEnv, Ctx>` in the module it
214
+ * points `runtime-module` at, and every hook is then typed against its own environment and its own
215
+ * caller context.
216
+ *
217
+ * Re-exporting this interface unparameterised instead does not work, and the reason is not obvious:
218
+ * **Hono's `Context` is INVARIANT in its environment**, because `Context.set` takes `E` as an
219
+ * argument. So `Context<AppEnv, ...>` is not assignable to `Context<Env, ...>` however plain the
220
+ * substituted environment is, and every generated `deps.*` call site fails. Separately, `context`
221
+ * would keep returning the identity `Ctx` (`unknown`) which the app's own handlers then reject.
222
+ * Measured before this was parameterised: **19 errors on a four-operation service.**
223
+ */
224
+ export interface RouteDeps<E extends Env = AppEnv, C = Ctx> {
225
+ /**
226
+ * The gate the DOCUMENT publishes, as middleware.
227
+ *
228
+ * **Which scopes an operation demands is a contract fact; how a token is verified is not.**
229
+ * `@useAuth(OAuth2Auth<...>)` reaches OpenAPI as `security` per operation, so the requirement is
230
+ * generated and this implements the check. The same split as `context` and `respond`. Emitted
231
+ * only where the operation declares scopes, which is why an internal surface with none is
232
+ * unaffected.
233
+ *
234
+ * Its absence was a real defect for one commit: the generated server carried **zero** references
235
+ * to scopes while the document published eleven, so a surface mounted with its gate silently
236
+ * dropped.
237
+ *
238
+ * **It receives the document's REQUIREMENTS, not a flat list of scopes, and that is the second
239
+ * half of the same defect.** `@useAuth(BearerAuth)` publishes `security: [{ "BearerAuth": [] }]`
240
+ * with no scopes, so a scopes-only gate was emitted for OAuth2 and for nothing else. Bearer, api-key
241
+ * and basic, which is most services, carried no gate at all and rested entirely on `context`
242
+ * returning null. An app whose `context` read a cookie would serve a route the document says needs
243
+ * a bearer token.
244
+ *
245
+ * Satisfying ANY ONE requirement authorises the caller, and every scheme WITHIN a requirement must
246
+ * be satisfied together, which is exactly what an array of OpenAPI `security` objects means.
247
+ */
248
+ readonly authorize: (requirements: readonly SecurityRequirement[]) => MiddlewareHandler<E>;
249
+ /**
250
+ * The caller's context, or `null` when there is none to establish.
251
+ *
252
+ * `authentication` is what the DOCUMENT says, and only that: `"none"` where the operation
253
+ * declares `@useAuth(NoAuth)` (`security: []` in OpenAPI) and `"required"` otherwise. Deciding
254
+ * it at generation time is the point: the gate the document publishes is the gate that runs.
255
+ *
256
+ * **It used to be `"none" | "account" | "resource"`, and the last two were an invention.** They
257
+ * were chosen by whether the path had parameters, which no OpenAPI keyword expresses and which
258
+ * merely happened to fit the first consumer. A generated server enforcing a rule derived from
259
+ * nothing published is the defect class this emitter exists to remove, so it is gone. An app that
260
+ * needs the distinction can read the request, which is the one thing it definitely has.
261
+ */
262
+ readonly context: <P extends string, I extends Input>(
263
+ c: Context<E, P, I>,
264
+ authentication: "none" | "required",
265
+ ) => C | null;
266
+ /** The response when `context` returns `null`. */
267
+ readonly noContext: <P extends string, I extends Input>(c: Context<E, P, I>) => Response;
268
+ /**
269
+ * The response when the caller's `Accept` matches nothing the operation offers, a 406.
270
+ *
271
+ * Emitted only on routes where the document declares more than one media type for a status, so
272
+ * a service without content negotiation never sees it. Same shape of hook as {@link noContext}
273
+ * and admitted on the same test: the status and the `offered` list are contract facts the
274
+ * generated code already has, but the body they are reported in is the app's envelope, and it
275
+ * cannot proceed without one. {@link selectContentType} does the choosing; this reports failure.
276
+ */
277
+ readonly notAcceptable: <P extends string, I extends Input>(
278
+ c: Context<E, P, I>,
279
+ offered: readonly string[],
280
+ ) => Response;
281
+ /**
282
+ * Passed straight to `zValidator`'s hook. Returning `undefined` lets a successful validation
283
+ * through; returning a `Response` is how a rejection becomes the status this API promises rather
284
+ * than the middleware's default.
285
+ */
286
+ readonly invalid: <P extends string, I extends Input>(
287
+ result: { readonly success: boolean },
288
+ c: Context<E, P, I>,
289
+ ) => Response | undefined;
290
+ /**
291
+ * Turn an operation's result into a response, checked against the schema the document publishes
292
+ * for the arm that applies. A bodyless success is an arm whose `schema` is `undefined`.
293
+ */
294
+ readonly respond: <P extends string, I extends Input>(
295
+ c: Context<E, P, I>,
296
+ arms: readonly ResponseArm[],
297
+ result: unknown,
298
+ ) => Awaitable<Response>;
299
+ }