typespec-hono 0.3.3 → 0.5.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 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 validator
420
- * is chosen from the request's `Content-Type`. `byContentType` is a plain middleware, so the
421
- * final handler is untouched and Hono's RPC client still derives its surface from it.
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
- "\t\tbyContentType([",
427
- ...entry.dispatched.byType.map(([type, target]) => `\t\t\t[${JSON.stringify(type)}, ${JSON.stringify(target)}, zValidator(${JSON.stringify(target)}, ${identifierOf(entry)}, deps.invalid)],`),
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.map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid),`),
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
@@ -657,7 +671,16 @@ securityFor) {
657
671
  const negotiates = [...grouped.values()].some((group) => group.length > 1);
658
672
  // Same rule: imported only where a HEAD operation stands alone on its path.
659
673
  const guardsHead = registrations.some((registration) => registration.text.includes("headOnly,"));
660
- const dispatchesBody = registrations.some((r) => r.text.includes("byContentType(["));
674
+ /**
675
+ * Read from the entries rather than from the rendered text, which the two lines above still do.
676
+ *
677
+ * **Matching a substring of generated output is how this import went missing once already.** It
678
+ * tested for `byContentType([`, the emitted call gained arguments before its bracket, the substring
679
+ * stopped matching, the import stopped being written, and the emitted module threw
680
+ * `ReferenceError: byContentType is not defined` at registration. Nothing in the emitter objected,
681
+ * because the check was a string about a string.
682
+ */
683
+ const dispatchesBody = entries.some((entry) => entry.dispatched !== undefined);
661
684
  const runtimeModule = JSON.stringify(emitted.options.runtimeModule);
662
685
  /**
663
686
  * **One base sub-app, mounted with `app.route()`. Hono's own nesting, not a rewritten path on
@@ -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>(validators: readonly (readonly [
139
- mediaType: string,
140
- target: string,
141
- validator: MiddlewareHandler<E>
142
- ])[]): MiddlewareHandler<E>;
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
@@ -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(validators) {
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 = validators.find(([mediaType]) => mediaType.toLowerCase() === declared);
106
- const [, , validator] = matched ?? validators[0];
107
- return validator(c, next);
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.3",
3
+ "version": "0.5.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.3.0"
47
+ "typespec-http-zod": "^0.5.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
- validators: readonly (readonly [
188
- mediaType: string,
189
- target: string,
190
- validator: MiddlewareHandler<E>,
191
- ])[],
192
- ): MiddlewareHandler<E> {
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 = validators.find(([mediaType]) => mediaType.toLowerCase() === declared);
196
- const [, , validator] = matched ?? (validators[0] as (typeof validators)[number]);
197
- return validator(c, next);
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