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/README.md +113 -265
- package/dist/src/app.d.ts +25 -19
- package/dist/src/app.js +238 -113
- package/dist/src/base-path.d.ts +18 -9
- package/dist/src/base-path.js +20 -18
- package/dist/src/emitter.d.ts +9 -18
- package/dist/src/emitter.js +51 -26
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/lib.d.ts +13 -16
- package/dist/src/lib.js +28 -68
- package/dist/src/runtime.d.ts +102 -46
- package/dist/src/runtime.js +63 -17
- package/dist/src/security.d.ts +4 -4
- package/dist/src/security.js +2 -2
- package/dist/src/tsp-index.d.ts +1 -1
- package/dist/src/tsp-index.js +1 -1
- package/lib/main.tsp +1 -1
- package/package.json +6 -5
- package/src/runtime.ts +299 -0
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
|
+
}
|