typespec-hono 0.15.0 → 0.16.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/dist/src/runtime.d.ts +26 -6
- package/dist/src/runtime.js +50 -20
- package/package.json +1 -1
- package/src/runtime.ts +52 -21
package/dist/src/runtime.d.ts
CHANGED
|
@@ -108,13 +108,23 @@ export type Awaitable<T> = T | Promise<T>;
|
|
|
108
108
|
*
|
|
109
109
|
* The rules that matter, and that a naive `includes()` gets wrong:
|
|
110
110
|
* - **absent or empty `Accept` means anything is acceptable**, serve the first offer;
|
|
111
|
-
* -
|
|
112
|
-
*
|
|
113
|
-
* (`text/*`), which beats the fully wildcard range
|
|
114
|
-
*
|
|
115
|
-
*
|
|
111
|
+
* - **specificity SELECTS which rule applies, before quality is read at all.** For each offered
|
|
112
|
+
* type, the most specific range that matches it decides its quality: an exact type beats a
|
|
113
|
+
* subtype wildcard (`text/*`), which beats the fully wildcard range. The fully wildcard range is
|
|
114
|
+
* not written literally here because it would close this comment;
|
|
115
|
+
* - **`q=0` is a REFUSAL**, not a weak preference, so a type whose applicable rule scores zero is
|
|
116
|
+
* never chosen;
|
|
117
|
+
* - equal quality keeps the order the document offers, so nothing in the header displaces it;
|
|
118
|
+
* - a malformed `q` is IGNORED rather than read as zero - a typo should not turn into a 406;
|
|
116
119
|
* - parameters after the media range (`;charset=utf-8`) are not part of the match.
|
|
117
120
|
*
|
|
121
|
+
* **Specificity was implemented as a tie-break and that was wrong three ways at once**, all of them
|
|
122
|
+
* live in a published runtime until `test/negotiation.test.ts` was written. Scoring every matching
|
|
123
|
+
* range and keeping the best `(q, specificity)` pair lets a permissive wildcard out-vote the precise
|
|
124
|
+
* rule a caller wrote about that exact type - so `Accept: */*, application/json;q=0` was served
|
|
125
|
+
* JSON, which is the one outcome an explicit refusal must never produce. The prose above stated the
|
|
126
|
+
* right rules the whole time; nothing compared it to the code.
|
|
127
|
+
*
|
|
118
128
|
* Returns `undefined` when nothing offered is acceptable. The caller answers 406, and the
|
|
119
129
|
* difference between "no preference" and "no acceptable option" is exactly what that turns on.
|
|
120
130
|
*/
|
|
@@ -177,7 +187,17 @@ export type BodyTarget = "json" | "form";
|
|
|
177
187
|
*/
|
|
178
188
|
export declare function byContentType<E extends Env, S extends ZodType>(schema: S, invalid: <P extends string, I extends Input>(result: {
|
|
179
189
|
readonly success: boolean;
|
|
180
|
-
}, c: Context<E, P, I>) => Response | undefined,
|
|
190
|
+
}, c: Context<E, P, I>) => Response | undefined,
|
|
191
|
+
/**
|
|
192
|
+
* **A NON-EMPTY list, stated in the type.** The fallback below reads the first branch, and
|
|
193
|
+
* `branches[0]` on a plain array is `T | undefined`, which was silenced with a cast. A tuple says
|
|
194
|
+
* the same thing the emitter already guarantees - this middleware is only ever emitted for a route
|
|
195
|
+
* declaring at least one request media type - and removes the cast rather than typing around it.
|
|
196
|
+
*/
|
|
197
|
+
branches: readonly [
|
|
198
|
+
readonly [mediaType: string, target: BodyTarget],
|
|
199
|
+
...(readonly [mediaType: string, target: BodyTarget])[]
|
|
200
|
+
]): MiddlewareHandler<E, string, {
|
|
181
201
|
in: {
|
|
182
202
|
json: input<S>;
|
|
183
203
|
};
|
package/dist/src/runtime.js
CHANGED
|
@@ -22,13 +22,23 @@ export function armFor(arms, status) {
|
|
|
22
22
|
*
|
|
23
23
|
* The rules that matter, and that a naive `includes()` gets wrong:
|
|
24
24
|
* - **absent or empty `Accept` means anything is acceptable**, serve the first offer;
|
|
25
|
-
* -
|
|
26
|
-
*
|
|
27
|
-
* (`text/*`), which beats the fully wildcard range
|
|
28
|
-
*
|
|
29
|
-
*
|
|
25
|
+
* - **specificity SELECTS which rule applies, before quality is read at all.** For each offered
|
|
26
|
+
* type, the most specific range that matches it decides its quality: an exact type beats a
|
|
27
|
+
* subtype wildcard (`text/*`), which beats the fully wildcard range. The fully wildcard range is
|
|
28
|
+
* not written literally here because it would close this comment;
|
|
29
|
+
* - **`q=0` is a REFUSAL**, not a weak preference, so a type whose applicable rule scores zero is
|
|
30
|
+
* never chosen;
|
|
31
|
+
* - equal quality keeps the order the document offers, so nothing in the header displaces it;
|
|
32
|
+
* - a malformed `q` is IGNORED rather than read as zero - a typo should not turn into a 406;
|
|
30
33
|
* - parameters after the media range (`;charset=utf-8`) are not part of the match.
|
|
31
34
|
*
|
|
35
|
+
* **Specificity was implemented as a tie-break and that was wrong three ways at once**, all of them
|
|
36
|
+
* live in a published runtime until `test/negotiation.test.ts` was written. Scoring every matching
|
|
37
|
+
* range and keeping the best `(q, specificity)` pair lets a permissive wildcard out-vote the precise
|
|
38
|
+
* rule a caller wrote about that exact type - so `Accept: */*, application/json;q=0` was served
|
|
39
|
+
* JSON, which is the one outcome an explicit refusal must never produce. The prose above stated the
|
|
40
|
+
* right rules the whole time; nothing compared it to the code.
|
|
41
|
+
*
|
|
32
42
|
* Returns `undefined` when nothing offered is acceptable. The caller answers 406, and the
|
|
33
43
|
* difference between "no preference" and "no acceptable option" is exactly what that turns on.
|
|
34
44
|
*/
|
|
@@ -43,22 +53,35 @@ export function selectContentType(accept, offered) {
|
|
|
43
53
|
const quality = parameters
|
|
44
54
|
.map((parameter) => /^q=(?<value>[\d.]+)$/i.exec(parameter)?.groups?.value)
|
|
45
55
|
.find((value) => value !== undefined);
|
|
46
|
-
|
|
56
|
+
const q = quality === undefined ? 1 : Number(quality);
|
|
57
|
+
// A malformed `q` is treated as unstated. Reading `q=1.2.3` as zero would 406 a typo.
|
|
58
|
+
return { range: range.toLowerCase(), q: Number.isFinite(q) ? q : 1 };
|
|
47
59
|
});
|
|
48
60
|
let best;
|
|
49
61
|
for (const type of offered) {
|
|
50
|
-
const
|
|
62
|
+
const lowered = type.toLowerCase();
|
|
63
|
+
const [group] = lowered.split("/");
|
|
64
|
+
/**
|
|
65
|
+
* **The most specific matching range decides this type's quality**, which is what makes an
|
|
66
|
+
* explicit `application/json;q=0` beat a wildcard that would otherwise accept it. Reading the
|
|
67
|
+
* best-scoring range instead lets a permissive rule override a precise one.
|
|
68
|
+
*/
|
|
69
|
+
let applicable;
|
|
51
70
|
for (const { range, q } of ranges) {
|
|
52
|
-
|
|
53
|
-
if (!Number.isFinite(q) || q <= 0)
|
|
54
|
-
continue;
|
|
55
|
-
const specificity = range === type.toLowerCase() ? 2 : range === `${group}/*` ? 1 : range === "*/*" ? 0 : -1;
|
|
71
|
+
const specificity = range === lowered ? 2 : range === `${group}/*` ? 1 : range === "*/*" ? 0 : -1;
|
|
56
72
|
if (specificity < 0)
|
|
57
73
|
continue;
|
|
58
|
-
|
|
59
|
-
|
|
74
|
+
// Strictly greater, so two rules of equal specificity leave the first one in force.
|
|
75
|
+
if (applicable === undefined || specificity > applicable.specificity) {
|
|
76
|
+
applicable = { q, specificity };
|
|
60
77
|
}
|
|
61
78
|
}
|
|
79
|
+
// `q=0` is "I will not accept this", so a type its own rule scores zero is never a candidate.
|
|
80
|
+
if (applicable === undefined || applicable.q <= 0)
|
|
81
|
+
continue;
|
|
82
|
+
// Strictly greater, so equal quality keeps the order the document offers.
|
|
83
|
+
if (best === undefined || applicable.q > best.q)
|
|
84
|
+
best = { type, q: applicable.q };
|
|
62
85
|
}
|
|
63
86
|
return best?.type;
|
|
64
87
|
}
|
|
@@ -138,7 +161,14 @@ const BODY_TARGET = "json";
|
|
|
138
161
|
* handler's declared input type omitted the body entirely because the body was not among the route's
|
|
139
162
|
* ordinary validators. Publishing to one known slot removes both, rather than typing around them.
|
|
140
163
|
*/
|
|
141
|
-
export function byContentType(schema, invalid,
|
|
164
|
+
export function byContentType(schema, invalid,
|
|
165
|
+
/**
|
|
166
|
+
* **A NON-EMPTY list, stated in the type.** The fallback below reads the first branch, and
|
|
167
|
+
* `branches[0]` on a plain array is `T | undefined`, which was silenced with a cast. A tuple says
|
|
168
|
+
* the same thing the emitter already guarantees - this middleware is only ever emitted for a route
|
|
169
|
+
* declaring at least one request media type - and removes the cast rather than typing around it.
|
|
170
|
+
*/
|
|
171
|
+
branches) {
|
|
142
172
|
return async (c, next) => {
|
|
143
173
|
const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
144
174
|
const matched = branches.find(([mediaType]) => mediaType.toLowerCase() === declared);
|
|
@@ -149,14 +179,14 @@ export function byContentType(schema, invalid, branches) {
|
|
|
149
179
|
* one. Hono's `validator` is what `@hono/zod-validator` is built on, so this is the same
|
|
150
180
|
* extraction, the same `HTTPException` on malformed JSON, and the same default rejection.
|
|
151
181
|
*
|
|
152
|
-
* `validator
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
182
|
+
* **Hono's own `validator` is deliberately not called here.** Its target fixes the reader at
|
|
183
|
+
* registration time, and the whole point of this function is that the reader is chosen when the
|
|
184
|
+
* request arrives. What it does instead is reproduce `validator`'s observable behaviour for both
|
|
185
|
+
* targets: {@link readBody} performs the same two extractions and raises the same
|
|
186
|
+
* `HTTPException` on malformed JSON, and the rejection below is `zValidator`'s own.
|
|
157
187
|
*
|
|
158
188
|
* Annotated rather than inlined because `Context` is invariant in its environment and in its
|
|
159
|
-
* `Input
|
|
189
|
+
* `Input`. Same reason `RouteDeps` is parameterised.
|
|
160
190
|
*/
|
|
161
191
|
const parse = async (ctx, proceed) => {
|
|
162
192
|
const result = await schema.safeParseAsync(await readBody(ctx, target));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typespec-hono",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "TypeSpec emitter: generate a Hono server, and the Zod validators it enforces, from an HTTP service definition, agreeing with the OpenAPI document @typespec/openapi3 publishes from the same source.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
package/src/runtime.ts
CHANGED
|
@@ -120,13 +120,23 @@ export type Awaitable<T> = T | Promise<T>;
|
|
|
120
120
|
*
|
|
121
121
|
* The rules that matter, and that a naive `includes()` gets wrong:
|
|
122
122
|
* - **absent or empty `Accept` means anything is acceptable**, serve the first offer;
|
|
123
|
-
* -
|
|
124
|
-
*
|
|
125
|
-
* (`text/*`), which beats the fully wildcard range
|
|
126
|
-
*
|
|
127
|
-
*
|
|
123
|
+
* - **specificity SELECTS which rule applies, before quality is read at all.** For each offered
|
|
124
|
+
* type, the most specific range that matches it decides its quality: an exact type beats a
|
|
125
|
+
* subtype wildcard (`text/*`), which beats the fully wildcard range. The fully wildcard range is
|
|
126
|
+
* not written literally here because it would close this comment;
|
|
127
|
+
* - **`q=0` is a REFUSAL**, not a weak preference, so a type whose applicable rule scores zero is
|
|
128
|
+
* never chosen;
|
|
129
|
+
* - equal quality keeps the order the document offers, so nothing in the header displaces it;
|
|
130
|
+
* - a malformed `q` is IGNORED rather than read as zero - a typo should not turn into a 406;
|
|
128
131
|
* - parameters after the media range (`;charset=utf-8`) are not part of the match.
|
|
129
132
|
*
|
|
133
|
+
* **Specificity was implemented as a tie-break and that was wrong three ways at once**, all of them
|
|
134
|
+
* live in a published runtime until `test/negotiation.test.ts` was written. Scoring every matching
|
|
135
|
+
* range and keeping the best `(q, specificity)` pair lets a permissive wildcard out-vote the precise
|
|
136
|
+
* rule a caller wrote about that exact type - so `Accept: */*, application/json;q=0` was served
|
|
137
|
+
* JSON, which is the one outcome an explicit refusal must never produce. The prose above stated the
|
|
138
|
+
* right rules the whole time; nothing compared it to the code.
|
|
139
|
+
*
|
|
130
140
|
* Returns `undefined` when nothing offered is acceptable. The caller answers 406, and the
|
|
131
141
|
* difference between "no preference" and "no acceptable option" is exactly what that turns on.
|
|
132
142
|
*/
|
|
@@ -143,22 +153,34 @@ export function selectContentType(
|
|
|
143
153
|
const quality = parameters
|
|
144
154
|
.map((parameter) => /^q=(?<value>[\d.]+)$/i.exec(parameter)?.groups?.value)
|
|
145
155
|
.find((value) => value !== undefined);
|
|
146
|
-
|
|
156
|
+
const q = quality === undefined ? 1 : Number(quality);
|
|
157
|
+
// A malformed `q` is treated as unstated. Reading `q=1.2.3` as zero would 406 a typo.
|
|
158
|
+
return { range: range.toLowerCase(), q: Number.isFinite(q) ? q : 1 };
|
|
147
159
|
});
|
|
148
160
|
|
|
149
|
-
let best: { type: string; q: number
|
|
161
|
+
let best: { type: string; q: number } | undefined;
|
|
150
162
|
for (const type of offered) {
|
|
151
|
-
const
|
|
163
|
+
const lowered = type.toLowerCase();
|
|
164
|
+
const [group] = lowered.split("/");
|
|
165
|
+
/**
|
|
166
|
+
* **The most specific matching range decides this type's quality**, which is what makes an
|
|
167
|
+
* explicit `application/json;q=0` beat a wildcard that would otherwise accept it. Reading the
|
|
168
|
+
* best-scoring range instead lets a permissive rule override a precise one.
|
|
169
|
+
*/
|
|
170
|
+
let applicable: { q: number; specificity: number } | undefined;
|
|
152
171
|
for (const { range, q } of ranges) {
|
|
153
|
-
// `q=0` is "I will not accept this", so it never becomes a candidate.
|
|
154
|
-
if (!Number.isFinite(q) || q <= 0) continue;
|
|
155
172
|
const specificity =
|
|
156
|
-
range ===
|
|
173
|
+
range === lowered ? 2 : range === `${group}/*` ? 1 : range === "*/*" ? 0 : -1;
|
|
157
174
|
if (specificity < 0) continue;
|
|
158
|
-
|
|
159
|
-
|
|
175
|
+
// Strictly greater, so two rules of equal specificity leave the first one in force.
|
|
176
|
+
if (applicable === undefined || specificity > applicable.specificity) {
|
|
177
|
+
applicable = { q, specificity };
|
|
160
178
|
}
|
|
161
179
|
}
|
|
180
|
+
// `q=0` is "I will not accept this", so a type its own rule scores zero is never a candidate.
|
|
181
|
+
if (applicable === undefined || applicable.q <= 0) continue;
|
|
182
|
+
// Strictly greater, so equal quality keeps the order the document offers.
|
|
183
|
+
if (best === undefined || applicable.q > best.q) best = { type, q: applicable.q };
|
|
162
184
|
}
|
|
163
185
|
return best?.type;
|
|
164
186
|
}
|
|
@@ -253,26 +275,35 @@ export function byContentType<E extends Env, S extends ZodType>(
|
|
|
253
275
|
result: { readonly success: boolean },
|
|
254
276
|
c: Context<E, P, I>,
|
|
255
277
|
) => Response | undefined,
|
|
256
|
-
|
|
278
|
+
/**
|
|
279
|
+
* **A NON-EMPTY list, stated in the type.** The fallback below reads the first branch, and
|
|
280
|
+
* `branches[0]` on a plain array is `T | undefined`, which was silenced with a cast. A tuple says
|
|
281
|
+
* the same thing the emitter already guarantees - this middleware is only ever emitted for a route
|
|
282
|
+
* declaring at least one request media type - and removes the cast rather than typing around it.
|
|
283
|
+
*/
|
|
284
|
+
branches: readonly [
|
|
285
|
+
readonly [mediaType: string, target: BodyTarget],
|
|
286
|
+
...(readonly [mediaType: string, target: BodyTarget])[],
|
|
287
|
+
],
|
|
257
288
|
): MiddlewareHandler<E, string, { in: { json: input<S> }; out: { json: output<S> } }> {
|
|
258
289
|
return async (c, next) => {
|
|
259
290
|
const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
260
291
|
const matched = branches.find(([mediaType]) => mediaType.toLowerCase() === declared);
|
|
261
|
-
const [, target] = matched ??
|
|
292
|
+
const [, target] = matched ?? branches[0];
|
|
262
293
|
/**
|
|
263
294
|
* **Registered against the body slot whichever parser runs**, so the validated body is
|
|
264
295
|
* published under one target and the handler reads it exactly as it reads a single-media-type
|
|
265
296
|
* one. Hono's `validator` is what `@hono/zod-validator` is built on, so this is the same
|
|
266
297
|
* extraction, the same `HTTPException` on malformed JSON, and the same default rejection.
|
|
267
298
|
*
|
|
268
|
-
* `validator
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
299
|
+
* **Hono's own `validator` is deliberately not called here.** Its target fixes the reader at
|
|
300
|
+
* registration time, and the whole point of this function is that the reader is chosen when the
|
|
301
|
+
* request arrives. What it does instead is reproduce `validator`'s observable behaviour for both
|
|
302
|
+
* targets: {@link readBody} performs the same two extractions and raises the same
|
|
303
|
+
* `HTTPException` on malformed JSON, and the rejection below is `zValidator`'s own.
|
|
273
304
|
*
|
|
274
305
|
* Annotated rather than inlined because `Context` is invariant in its environment and in its
|
|
275
|
-
* `Input
|
|
306
|
+
* `Input`. Same reason `RouteDeps` is parameterised.
|
|
276
307
|
*/
|
|
277
308
|
const parse: MiddlewareHandler<
|
|
278
309
|
E,
|