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 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
@@ -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) => new RegExp(`\\b${identifier}\\b`).test(rendered))
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.text.includes("headOnly,"));
660
- const dispatchesBody = registrations.some((r) => r.text.includes("byContentType(["));
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
@@ -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.4.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.4.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
- 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