typespec-hono 0.4.0 → 0.6.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/app.js +63 -18
- package/dist/src/runtime.d.ts +29 -6
- package/dist/src/runtime.js +71 -4
- package/package.json +2 -2
- package/src/runtime.ts +87 -11
package/dist/src/app.js
CHANGED
|
@@ -279,6 +279,14 @@ securityFor) {
|
|
|
279
279
|
validators.push([targets[0], identifier]);
|
|
280
280
|
continue;
|
|
281
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* **Recorded as an ordinary body validator as well**, because `byContentType` publishes what
|
|
284
|
+
* it validated under the body target whichever parser produced it. So a dispatched route is
|
|
285
|
+
* the same shape as every other one downstream: the handler's input type includes the body,
|
|
286
|
+
* and the handler reads it with one `c.req.valid` like anywhere else. What differs is only
|
|
287
|
+
* which middleware is emitted, below.
|
|
288
|
+
*/
|
|
289
|
+
validators.push([VALIDATOR_TARGET.body, identifier]);
|
|
282
290
|
dispatched = { byType: body.byType, identifier };
|
|
283
291
|
}
|
|
284
292
|
return [{ route, names, validators, dispatched }];
|
|
@@ -416,21 +424,33 @@ securityFor) {
|
|
|
416
424
|
* from that type -- measured, `hc<typeof app>` resolved the wrapped route's body to `unknown`.
|
|
417
425
|
*/
|
|
418
426
|
/**
|
|
419
|
-
* Where the document declares request media types needing different parsers, the
|
|
420
|
-
*
|
|
421
|
-
*
|
|
427
|
+
* Where the document declares request media types needing different parsers, the parser is
|
|
428
|
+
* chosen from the request's `Content-Type`. `byContentType` takes the body's schema and
|
|
429
|
+
* `deps.invalid` and validates with them, so it stands in for that target's `zValidator`
|
|
430
|
+
* rather than wrapping one, and publishes under the body target either way.
|
|
431
|
+
*
|
|
432
|
+
* **It used to wrap pre-built `zValidator`s and publish under whichever one ran, and that
|
|
433
|
+
* emitted code a consumer could not compile.** A plain `MiddlewareHandler` contributes no
|
|
434
|
+
* `Input` to Hono's chain, so `c.req.valid("json")` in the handler below was
|
|
435
|
+
* `TS2345: Argument of type '"json"' is not assignable to parameter of type '"header"'`, and
|
|
436
|
+
* the handler's declared input type omitted the body entirely because the body was not among
|
|
437
|
+
* the route's validators. One published slot removes both.
|
|
422
438
|
*/
|
|
423
439
|
const dispatch = entry.dispatched === undefined
|
|
424
440
|
? []
|
|
425
441
|
: [
|
|
426
|
-
|
|
427
|
-
...entry.dispatched.byType.map(([type, target]) => `\t\t\t[${JSON.stringify(type)}, ${JSON.stringify(target)}
|
|
442
|
+
`\t\tbyContentType(${identifierOf(entry)}, deps.invalid, [`,
|
|
443
|
+
...entry.dispatched.byType.map(([type, target]) => `\t\t\t[${JSON.stringify(type)}, ${JSON.stringify(target)}],`),
|
|
428
444
|
"\t\t]),",
|
|
429
445
|
];
|
|
430
446
|
const middleware = [
|
|
431
447
|
...(headOnly ? ["\t\theadOnly,"] : []),
|
|
432
448
|
...gate,
|
|
433
|
-
...validators
|
|
449
|
+
...validators
|
|
450
|
+
// The dispatched body's own `zValidator` is `byContentType`; emitting both would parse
|
|
451
|
+
// the body twice and reject every media type but one.
|
|
452
|
+
.filter(([target]) => entry.dispatched === undefined || target !== VALIDATOR_TARGET.body)
|
|
453
|
+
.map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid),`),
|
|
434
454
|
...dispatch,
|
|
435
455
|
];
|
|
436
456
|
/**
|
|
@@ -467,15 +487,9 @@ securityFor) {
|
|
|
467
487
|
body.push(`\t\t\tconst ctx = deps.context(c, "none");`);
|
|
468
488
|
body.push("\t\t\tif (ctx === null) return deps.noContext(c);");
|
|
469
489
|
}
|
|
490
|
+
// A dispatched body is in `validators` under the body target like any other, so there is
|
|
491
|
+
// nothing extra to spread: `byContentType` published it there whichever parser ran.
|
|
470
492
|
const pieces = validators.map(([target]) => `...c.req.valid(${JSON.stringify(target)})`);
|
|
471
|
-
/**
|
|
472
|
-
* Only one dispatched branch runs, and `zValidator` writes under its own target. Spreading every
|
|
473
|
-
* possible target is how the handler reads whichever one it was: an unset target reads
|
|
474
|
-
* `undefined`, and spreading `undefined` into an object literal contributes nothing.
|
|
475
|
-
*/
|
|
476
|
-
for (const target of new Set((entry.dispatched?.byType ?? []).map(([, name]) => name))) {
|
|
477
|
-
pieces.push(`...c.req.valid(${JSON.stringify(target)})`);
|
|
478
|
-
}
|
|
479
493
|
if (route.rawBodyProperty !== undefined) {
|
|
480
494
|
// The bytes ARE the contract: a signature covers exactly what arrived, so parsing and
|
|
481
495
|
// re-serialising would verify a different string than the sender signed. WHICH reader
|
|
@@ -586,7 +600,7 @@ securityFor) {
|
|
|
586
600
|
"\t\t\t},",
|
|
587
601
|
"\t\t)",
|
|
588
602
|
].join("\n");
|
|
589
|
-
return { target, text };
|
|
603
|
+
return { target, text, headOnly };
|
|
590
604
|
});
|
|
591
605
|
/**
|
|
592
606
|
* Every identifier this file names, imported from the module that declares it.
|
|
@@ -594,8 +608,21 @@ securityFor) {
|
|
|
594
608
|
* **Derived from what the rendered text actually references, not from what was available.** An
|
|
595
609
|
* unused import fails the lint a generated file has to pass like any other, and a missing one is a
|
|
596
610
|
* file that does not compile. Both have happened.
|
|
611
|
+
*
|
|
612
|
+
* **The text is TOKENISED and compared by equality, never matched name by name.** It used to build
|
|
613
|
+
* `new RegExp("\\b" + identifier + "\\b")` per name, and that was wrong twice over for any
|
|
614
|
+
* identifier containing a `$`, which TypeSpec permits in a model or operation name: `$` is an
|
|
615
|
+
* anchor in a regular expression, and escaping it alone does not help because `\b` is a WORD
|
|
616
|
+
* boundary and `$` is not a word character, so `\b\$select\b` cannot match either. Measured on
|
|
617
|
+
* `op list$Items(...): Item$Ref[]`, every identifier reported ABSENT, the whole
|
|
618
|
+
* `./schemas.gen.js` import was dropped, and the emitted file failed with four
|
|
619
|
+
* `TS2304: Cannot find name`. No diagnostic anywhere.
|
|
620
|
+
*
|
|
621
|
+
* Splitting on the language's own identifier rule and testing membership has neither failure: no
|
|
622
|
+
* metacharacter can be misread, and `Foo` cannot match `FooExtra`.
|
|
597
623
|
*/
|
|
598
624
|
const rendered = [...registrations.map((r) => r.text), ...methods, ...aliases].join("\n");
|
|
625
|
+
const mentioned = new Set(rendered.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []);
|
|
599
626
|
const referenced = [
|
|
600
627
|
...new Set(entries.flatMap((entry) => [
|
|
601
628
|
entry.names.path,
|
|
@@ -606,7 +633,7 @@ securityFor) {
|
|
|
606
633
|
entry.names.responses,
|
|
607
634
|
].filter((name) => name !== undefined))),
|
|
608
635
|
]
|
|
609
|
-
.filter((identifier) =>
|
|
636
|
+
.filter((identifier) => mentioned.has(identifier))
|
|
610
637
|
.toSorted();
|
|
611
638
|
const imports = referenced.length === 0
|
|
612
639
|
? ""
|
|
@@ -656,8 +683,26 @@ securityFor) {
|
|
|
656
683
|
// Imported only where a route actually negotiates: an unused import fails the repo's own lint.
|
|
657
684
|
const negotiates = [...grouped.values()].some((group) => group.length > 1);
|
|
658
685
|
// Same rule: imported only where a HEAD operation stands alone on its path.
|
|
659
|
-
const guardsHead = registrations.some((registration) => registration.
|
|
660
|
-
|
|
686
|
+
const guardsHead = registrations.some((registration) => registration.headOnly);
|
|
687
|
+
/**
|
|
688
|
+
* **Which runtime imports to write is read from the DATA, never from the rendered text.**
|
|
689
|
+
*
|
|
690
|
+
* Matching a substring of generated output is how one of these went missing already: it tested for
|
|
691
|
+
* `byContentType([`, the emitted call gained arguments before its bracket, the substring stopped
|
|
692
|
+
* matching, the import stopped being written, and the emitted module threw
|
|
693
|
+
* `ReferenceError: byContentType is not defined` at registration. Nothing in the emitter objected,
|
|
694
|
+
* because the check was a string about a string.
|
|
695
|
+
*
|
|
696
|
+
* `guardsHead` above had the same shape, and the sibling library had it in a worse place, deciding
|
|
697
|
+
* whether a parameter gets a wire decoder by `declared.startsWith("z.number()")`. A literal union
|
|
698
|
+
* (`@query size: 10 | 25 | 50`) begins with neither prefix, so every conformant caller of that
|
|
699
|
+
* parameter got a 400 that no document comparison could see.
|
|
700
|
+
*
|
|
701
|
+
* The rendered text is still read to decide which VALIDATOR identifiers to import, and that one is
|
|
702
|
+
* safe in a way these are not: an identifier absent from the text is genuinely not needed, so the
|
|
703
|
+
* check cannot be wrong in the direction that breaks a build.
|
|
704
|
+
*/
|
|
705
|
+
const dispatchesBody = entries.some((entry) => entry.dispatched !== undefined);
|
|
661
706
|
const runtimeModule = JSON.stringify(emitted.options.runtimeModule);
|
|
662
707
|
/**
|
|
663
708
|
* **One base sub-app, mounted with `app.route()`. Hono's own nesting, not a rewritten path on
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Context, Env, Input, MiddlewareHandler } from "hono";
|
|
2
|
-
import type { ZodType } from "zod";
|
|
2
|
+
import type { input, output, ZodType } from "zod";
|
|
3
3
|
/**
|
|
4
4
|
* One arm of an operation's declared response set, as the document publishes it.
|
|
5
5
|
*
|
|
@@ -116,6 +116,11 @@ export declare function selectContentType(accept: string | undefined, offered: r
|
|
|
116
116
|
* type. Measured: `hc<typeof app>` resolved a wrapped route's body to `unknown`.
|
|
117
117
|
*/
|
|
118
118
|
export declare const headOnly: MiddlewareHandler;
|
|
119
|
+
/**
|
|
120
|
+
* The `zValidator` targets a request BODY can be read from. Hono extracts `"json"` with
|
|
121
|
+
* `c.req.json()` and `"form"` with `c.req.parseBody()`, and those are the only two that read a body.
|
|
122
|
+
*/
|
|
123
|
+
export type BodyTarget = "json" | "form";
|
|
119
124
|
/**
|
|
120
125
|
* Apply the validator that parses the media type the request actually carries.
|
|
121
126
|
*
|
|
@@ -134,12 +139,30 @@ export declare const headOnly: MiddlewareHandler;
|
|
|
134
139
|
* A `Content-Type` matching nothing declared falls through to the first validator, which reproduces
|
|
135
140
|
* the previous behaviour exactly for that case: the body fails to parse and `deps.invalid` answers.
|
|
136
141
|
* No status is invented here that the document does not describe.
|
|
142
|
+
*
|
|
143
|
+
* **The validated body is published under `"json"` whichever parser produced it**, and that is what
|
|
144
|
+
* makes a dispatched route the same shape as every other one downstream. `ValidationTargets` is a
|
|
145
|
+
* closed union in Hono, so there is no seventh name to coin for "the body"; `"json"` is already the
|
|
146
|
+
* slot this emitter reads a body from, and using it here means the handler spreads one
|
|
147
|
+
* `c.req.valid("json")` exactly as it does for a route declaring a single media type.
|
|
148
|
+
*
|
|
149
|
+
* **The alternative was a middleware that publishes under whichever target ran**, and it was worse
|
|
150
|
+
* in two ways that both shipped. `byContentType` was a bare `MiddlewareHandler`, which contributes no
|
|
151
|
+
* `Input` to the chain, so the generated `c.req.valid("json")` did not compile at all
|
|
152
|
+
* (`TS2345: Argument of type '"json"' is not assignable to parameter of type '"header"'`), and the
|
|
153
|
+
* handler's declared input type omitted the body entirely because the body was not among the route's
|
|
154
|
+
* ordinary validators. Publishing to one known slot removes both, rather than typing around them.
|
|
137
155
|
*/
|
|
138
|
-
export declare function byContentType<E extends Env>(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
156
|
+
export declare function byContentType<E extends Env, S extends ZodType>(schema: S, invalid: <P extends string, I extends Input>(result: {
|
|
157
|
+
readonly success: boolean;
|
|
158
|
+
}, c: Context<E, P, I>) => Response | undefined, branches: readonly (readonly [mediaType: string, target: BodyTarget])[]): MiddlewareHandler<E, string, {
|
|
159
|
+
in: {
|
|
160
|
+
json: input<S>;
|
|
161
|
+
};
|
|
162
|
+
out: {
|
|
163
|
+
json: output<S>;
|
|
164
|
+
};
|
|
165
|
+
}>;
|
|
143
166
|
/**
|
|
144
167
|
* What the app provides. One object, passed once, rather than a module the generated file imports by
|
|
145
168
|
* path. A generated server that hard-codes `../../backend.js` is only usable by the project it was
|
package/dist/src/runtime.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HTTPException } from "hono/http-exception";
|
|
1
2
|
/**
|
|
2
3
|
* The arm that applies to a status, preferring an exact code, then its `NXX` range, then `default`.
|
|
3
4
|
*
|
|
@@ -80,6 +81,31 @@ export function selectContentType(accept, offered) {
|
|
|
80
81
|
* type. Measured: `hc<typeof app>` resolved a wrapped route's body to `unknown`.
|
|
81
82
|
*/
|
|
82
83
|
export const headOnly = async (c, next) => c.req.method === "HEAD" ? next() : c.notFound();
|
|
84
|
+
/**
|
|
85
|
+
* The request body, read the way the target says to read it.
|
|
86
|
+
*
|
|
87
|
+
* The same two readers Hono's own `validator` uses, and the same rejection for a body that is not
|
|
88
|
+
* the JSON it claims to be. `parseBody({ all: true })` builds the object Hono's `"form"` target
|
|
89
|
+
* builds: a repeated key and a `key[]` name both become an array, everything else stays a string.
|
|
90
|
+
*/
|
|
91
|
+
async function readBody(c, target) {
|
|
92
|
+
if (target === "form")
|
|
93
|
+
return c.req.parseBody({ all: true });
|
|
94
|
+
try {
|
|
95
|
+
return await c.req.json();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new HTTPException(400, { message: "Malformed JSON in request body" });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The slot a validated request body is published under, whichever parser produced it.
|
|
103
|
+
*
|
|
104
|
+
* `ValidationTargets` is a closed union in Hono, so a body cannot be given a name of its own. This
|
|
105
|
+
* is the slot the generated code already reads a body from when one media type is declared, so using
|
|
106
|
+
* it for a dispatched body is what keeps the two cases identical downstream.
|
|
107
|
+
*/
|
|
108
|
+
const BODY_TARGET = "json";
|
|
83
109
|
/**
|
|
84
110
|
* Apply the validator that parses the media type the request actually carries.
|
|
85
111
|
*
|
|
@@ -98,12 +124,53 @@ export const headOnly = async (c, next) => c.req.method === "HEAD" ? next() : c.
|
|
|
98
124
|
* A `Content-Type` matching nothing declared falls through to the first validator, which reproduces
|
|
99
125
|
* the previous behaviour exactly for that case: the body fails to parse and `deps.invalid` answers.
|
|
100
126
|
* No status is invented here that the document does not describe.
|
|
127
|
+
*
|
|
128
|
+
* **The validated body is published under `"json"` whichever parser produced it**, and that is what
|
|
129
|
+
* makes a dispatched route the same shape as every other one downstream. `ValidationTargets` is a
|
|
130
|
+
* closed union in Hono, so there is no seventh name to coin for "the body"; `"json"` is already the
|
|
131
|
+
* slot this emitter reads a body from, and using it here means the handler spreads one
|
|
132
|
+
* `c.req.valid("json")` exactly as it does for a route declaring a single media type.
|
|
133
|
+
*
|
|
134
|
+
* **The alternative was a middleware that publishes under whichever target ran**, and it was worse
|
|
135
|
+
* in two ways that both shipped. `byContentType` was a bare `MiddlewareHandler`, which contributes no
|
|
136
|
+
* `Input` to the chain, so the generated `c.req.valid("json")` did not compile at all
|
|
137
|
+
* (`TS2345: Argument of type '"json"' is not assignable to parameter of type '"header"'`), and the
|
|
138
|
+
* handler's declared input type omitted the body entirely because the body was not among the route's
|
|
139
|
+
* ordinary validators. Publishing to one known slot removes both, rather than typing around them.
|
|
101
140
|
*/
|
|
102
|
-
export function byContentType(
|
|
141
|
+
export function byContentType(schema, invalid, branches) {
|
|
103
142
|
return async (c, next) => {
|
|
104
143
|
const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
105
|
-
const matched =
|
|
106
|
-
const [,
|
|
107
|
-
|
|
144
|
+
const matched = branches.find(([mediaType]) => mediaType.toLowerCase() === declared);
|
|
145
|
+
const [, target] = matched ?? branches[0];
|
|
146
|
+
/**
|
|
147
|
+
* **Registered against the body slot whichever parser runs**, so the validated body is
|
|
148
|
+
* published under one target and the handler reads it exactly as it reads a single-media-type
|
|
149
|
+
* one. Hono's `validator` is what `@hono/zod-validator` is built on, so this is the same
|
|
150
|
+
* extraction, the same `HTTPException` on malformed JSON, and the same default rejection.
|
|
151
|
+
*
|
|
152
|
+
* `validator("json", ...)` hands over `{}` rather than throwing when the request is not JSON,
|
|
153
|
+
* because it checks the `Content-Type` before reading. That is what leaves room for the form
|
|
154
|
+
* branch to read the body itself with `c.req.parseBody({ all: true })`, which builds the same
|
|
155
|
+
* object hono's own `"form"` target builds: a repeated key and a `key[]` name both become an
|
|
156
|
+
* array, everything else stays a string.
|
|
157
|
+
*
|
|
158
|
+
* Annotated rather than inlined because `Context` is invariant in its environment and in its
|
|
159
|
+
* `Input`, and `validator` infers the first as `any`. Same reason `RouteDeps` is parameterised.
|
|
160
|
+
*/
|
|
161
|
+
const parse = async (ctx, proceed) => {
|
|
162
|
+
const result = await schema.safeParseAsync(await readBody(ctx, target));
|
|
163
|
+
const response = invalid(result, ctx);
|
|
164
|
+
if (response !== undefined)
|
|
165
|
+
return response;
|
|
166
|
+
// `zValidator`'s own answer when a hook declines to, kept identical so a dispatched route
|
|
167
|
+
// and a single-media-type one reject a bad body the same way.
|
|
168
|
+
if (!result.success)
|
|
169
|
+
return ctx.json(result, 400);
|
|
170
|
+
ctx.req.addValidatedData(BODY_TARGET, result.data ?? {});
|
|
171
|
+
await proceed();
|
|
172
|
+
return undefined;
|
|
173
|
+
};
|
|
174
|
+
return parse(c, next);
|
|
108
175
|
};
|
|
109
176
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typespec-hono",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"provenance": true
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"typespec-http-zod": "^0.
|
|
47
|
+
"typespec-http-zod": "^0.6.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@hono/zod-openapi": "^1.4.0",
|
package/src/runtime.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { HTTPException } from "hono/http-exception";
|
|
1
2
|
import type { Context, Env, Input, MiddlewareHandler } from "hono";
|
|
2
|
-
import type { ZodType } from "zod";
|
|
3
|
+
import type { input, output, ZodType } from "zod";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* One arm of an operation's declared response set, as the document publishes it.
|
|
@@ -164,6 +165,37 @@ export function selectContentType(
|
|
|
164
165
|
export const headOnly: MiddlewareHandler = async (c, next) =>
|
|
165
166
|
c.req.method === "HEAD" ? next() : c.notFound();
|
|
166
167
|
|
|
168
|
+
/**
|
|
169
|
+
* The `zValidator` targets a request BODY can be read from. Hono extracts `"json"` with
|
|
170
|
+
* `c.req.json()` and `"form"` with `c.req.parseBody()`, and those are the only two that read a body.
|
|
171
|
+
*/
|
|
172
|
+
export type BodyTarget = "json" | "form";
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The request body, read the way the target says to read it.
|
|
176
|
+
*
|
|
177
|
+
* The same two readers Hono's own `validator` uses, and the same rejection for a body that is not
|
|
178
|
+
* the JSON it claims to be. `parseBody({ all: true })` builds the object Hono's `"form"` target
|
|
179
|
+
* builds: a repeated key and a `key[]` name both become an array, everything else stays a string.
|
|
180
|
+
*/
|
|
181
|
+
async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
|
|
182
|
+
if (target === "form") return c.req.parseBody({ all: true });
|
|
183
|
+
try {
|
|
184
|
+
return await c.req.json();
|
|
185
|
+
} catch {
|
|
186
|
+
throw new HTTPException(400, { message: "Malformed JSON in request body" });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The slot a validated request body is published under, whichever parser produced it.
|
|
192
|
+
*
|
|
193
|
+
* `ValidationTargets` is a closed union in Hono, so a body cannot be given a name of its own. This
|
|
194
|
+
* is the slot the generated code already reads a body from when one media type is declared, so using
|
|
195
|
+
* it for a dispatched body is what keeps the two cases identical downstream.
|
|
196
|
+
*/
|
|
197
|
+
const BODY_TARGET = "json";
|
|
198
|
+
|
|
167
199
|
/**
|
|
168
200
|
* Apply the validator that parses the media type the request actually carries.
|
|
169
201
|
*
|
|
@@ -182,19 +214,63 @@ export const headOnly: MiddlewareHandler = async (c, next) =>
|
|
|
182
214
|
* A `Content-Type` matching nothing declared falls through to the first validator, which reproduces
|
|
183
215
|
* the previous behaviour exactly for that case: the body fails to parse and `deps.invalid` answers.
|
|
184
216
|
* No status is invented here that the document does not describe.
|
|
217
|
+
*
|
|
218
|
+
* **The validated body is published under `"json"` whichever parser produced it**, and that is what
|
|
219
|
+
* makes a dispatched route the same shape as every other one downstream. `ValidationTargets` is a
|
|
220
|
+
* closed union in Hono, so there is no seventh name to coin for "the body"; `"json"` is already the
|
|
221
|
+
* slot this emitter reads a body from, and using it here means the handler spreads one
|
|
222
|
+
* `c.req.valid("json")` exactly as it does for a route declaring a single media type.
|
|
223
|
+
*
|
|
224
|
+
* **The alternative was a middleware that publishes under whichever target ran**, and it was worse
|
|
225
|
+
* in two ways that both shipped. `byContentType` was a bare `MiddlewareHandler`, which contributes no
|
|
226
|
+
* `Input` to the chain, so the generated `c.req.valid("json")` did not compile at all
|
|
227
|
+
* (`TS2345: Argument of type '"json"' is not assignable to parameter of type '"header"'`), and the
|
|
228
|
+
* handler's declared input type omitted the body entirely because the body was not among the route's
|
|
229
|
+
* ordinary validators. Publishing to one known slot removes both, rather than typing around them.
|
|
185
230
|
*/
|
|
186
|
-
export function byContentType<E extends Env>(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
231
|
+
export function byContentType<E extends Env, S extends ZodType>(
|
|
232
|
+
schema: S,
|
|
233
|
+
invalid: <P extends string, I extends Input>(
|
|
234
|
+
result: { readonly success: boolean },
|
|
235
|
+
c: Context<E, P, I>,
|
|
236
|
+
) => Response | undefined,
|
|
237
|
+
branches: readonly (readonly [mediaType: string, target: BodyTarget])[],
|
|
238
|
+
): MiddlewareHandler<E, string, { in: { json: input<S> }; out: { json: output<S> } }> {
|
|
193
239
|
return async (c, next) => {
|
|
194
240
|
const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
195
|
-
const matched =
|
|
196
|
-
const [,
|
|
197
|
-
|
|
241
|
+
const matched = branches.find(([mediaType]) => mediaType.toLowerCase() === declared);
|
|
242
|
+
const [, target] = matched ?? (branches[0] as (typeof branches)[number]);
|
|
243
|
+
/**
|
|
244
|
+
* **Registered against the body slot whichever parser runs**, so the validated body is
|
|
245
|
+
* published under one target and the handler reads it exactly as it reads a single-media-type
|
|
246
|
+
* one. Hono's `validator` is what `@hono/zod-validator` is built on, so this is the same
|
|
247
|
+
* extraction, the same `HTTPException` on malformed JSON, and the same default rejection.
|
|
248
|
+
*
|
|
249
|
+
* `validator("json", ...)` hands over `{}` rather than throwing when the request is not JSON,
|
|
250
|
+
* because it checks the `Content-Type` before reading. That is what leaves room for the form
|
|
251
|
+
* branch to read the body itself with `c.req.parseBody({ all: true })`, which builds the same
|
|
252
|
+
* object hono's own `"form"` target builds: a repeated key and a `key[]` name both become an
|
|
253
|
+
* array, everything else stays a string.
|
|
254
|
+
*
|
|
255
|
+
* Annotated rather than inlined because `Context` is invariant in its environment and in its
|
|
256
|
+
* `Input`, and `validator` infers the first as `any`. Same reason `RouteDeps` is parameterised.
|
|
257
|
+
*/
|
|
258
|
+
const parse: MiddlewareHandler<
|
|
259
|
+
E,
|
|
260
|
+
string,
|
|
261
|
+
{ in: { json: input<S> }; out: { json: output<S> } }
|
|
262
|
+
> = async (ctx, proceed) => {
|
|
263
|
+
const result = await schema.safeParseAsync(await readBody(ctx, target));
|
|
264
|
+
const response = invalid(result, ctx);
|
|
265
|
+
if (response !== undefined) return response;
|
|
266
|
+
// `zValidator`'s own answer when a hook declines to, kept identical so a dispatched route
|
|
267
|
+
// and a single-media-type one reject a bad body the same way.
|
|
268
|
+
if (!result.success) return ctx.json(result, 400);
|
|
269
|
+
ctx.req.addValidatedData(BODY_TARGET, result.data ?? {});
|
|
270
|
+
await proceed();
|
|
271
|
+
return undefined;
|
|
272
|
+
};
|
|
273
|
+
return parse(c, next);
|
|
198
274
|
};
|
|
199
275
|
}
|
|
200
276
|
|