typespec-hono 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/src/app.js +286 -86
- package/dist/src/runtime.d.ts +12 -80
- package/dist/src/runtime.js +0 -165
- package/package.json +4 -4
- package/src/runtime.ts +12 -193
package/README.md
CHANGED
|
@@ -175,6 +175,8 @@ and exists only for the duration of a request.
|
|
|
175
175
|
request bodies, streaming, observability
|
|
176
176
|
- [Cloudflare Workers](docs/cloudflare-workers.md): which router to pick, and what the bundle costs
|
|
177
177
|
- [Reference](docs/reference.md): every option, every diagnostic, and the known limits
|
|
178
|
+
- [Releasing](docs/releasing.md): rehearsing a two-package release against a local registry,
|
|
179
|
+
because the server resolves the library through npm and CI cannot verify a change spanning both
|
|
178
180
|
|
|
179
181
|
## Licence
|
|
180
182
|
|
package/dist/src/app.js
CHANGED
|
@@ -160,6 +160,222 @@ function bodyValidationFor(contentTypes) {
|
|
|
160
160
|
}
|
|
161
161
|
return { byType, unparseable };
|
|
162
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* The request-body middleware, emitted INTO the generated file rather than imported from the runtime
|
|
165
|
+
* module.
|
|
166
|
+
*
|
|
167
|
+
* **Because the runtime module is a contract too, and it is the one that breaks quietly.** An
|
|
168
|
+
* application may point `runtime-module` at a module of its own, and everything the generated file
|
|
169
|
+
* imports from there is something that application has to supply. A required, single-media-type body
|
|
170
|
+
* used to be mounted by `zValidator`, which throws `HTTPException` on a body it cannot read - a
|
|
171
|
+
* `text/plain` 400 raised before `deps.invalid` is called, so an API whose document declares a JSON
|
|
172
|
+
* error envelope answered a shape its own contract forbids. Routing those through the runtime's
|
|
173
|
+
* `byContentType` fixes it in one line and makes that export mandatory for every substituting app:
|
|
174
|
+
* measured, 15 arms red, every one an app whose module has no such export.
|
|
175
|
+
*
|
|
176
|
+
* Emitting the middleware removes the choice. The runtime contract does not grow to close the gap -
|
|
177
|
+
* it SHRINKS by the two names that used to serve it, and the mechanism now lives in the file that
|
|
178
|
+
* uses it, where a reader can see what their route is mounted with.
|
|
179
|
+
*
|
|
180
|
+
* **One reader, one rejection, three positions.** Required, optional and dispatched bodies differ in
|
|
181
|
+
* exactly two ways - whether an absent body is permitted, and how many media types may be declared -
|
|
182
|
+
* and nothing else. `validateBody` covers required and dispatched, which are the same function with
|
|
183
|
+
* one branch or several. `validateOptionalBody` is separate only because its `Input` type differs:
|
|
184
|
+
* what it publishes may be `undefined`, and that cannot be a runtime argument.
|
|
185
|
+
*
|
|
186
|
+
* **`z.input` on the way in, `z.output` on the way out.** A `.default(...)` makes a property optional
|
|
187
|
+
* to a caller and guaranteed to a handler, and Hono's chain types those two positions separately, so
|
|
188
|
+
* saying `z.output` on both would require callers to send what the document says they may omit.
|
|
189
|
+
*
|
|
190
|
+
* Every rejection goes through `deps.invalid` first, and falls back to `zValidator`'s own answer when
|
|
191
|
+
* the hook declines - kept identical so a route that changed shape does not change how it refuses.
|
|
192
|
+
*/
|
|
193
|
+
/**
|
|
194
|
+
* **Parameter validators parse SYNCHRONOUSLY, through `zValidator`'s own typed extension point.**
|
|
195
|
+
*
|
|
196
|
+
* `zValidator` calls `safeParseAsync` by default, and nothing this emitter writes is asynchronous:
|
|
197
|
+
* `vocabulary.test.ts` refuses every construct that could be - no `.transform()`, no `.pipe()`, and
|
|
198
|
+
* the single permitted `.refine()` is a synchronous predicate on a multipart file part. So the async
|
|
199
|
+
* path was buying nothing and costing on every request.
|
|
200
|
+
*
|
|
201
|
+
* Measured on an emitted five-property model, zod 4.5.2, 200k iterations:
|
|
202
|
+
*
|
|
203
|
+
* | call | ns/parse |
|
|
204
|
+
* | --- | --- |
|
|
205
|
+
* | `safeParseAsync` | 958 |
|
|
206
|
+
* | `safeParse` | 371 |
|
|
207
|
+
*
|
|
208
|
+
* **2.6x, on every parameter group of every request**, for a promise nothing awaited a result from.
|
|
209
|
+
* It also unblocks `z.compile()`, whose fast path is bypassed for any async parse - measured from
|
|
210
|
+
* `zod/compile`'s own shim, which returns the uncompiled run for `ctx.async`, and confirmed on the
|
|
211
|
+
* same fixture: compiled `safeParse` is 51 ns and compiled `safeParseAsync` is 923 ns.
|
|
212
|
+
*
|
|
213
|
+
* `validationFunction` is `@hono/zod-validator`'s published option and its return type admits a
|
|
214
|
+
* synchronous result, so this is the package's own seam rather than a way around it.
|
|
215
|
+
*
|
|
216
|
+
* `test/sync.test.ts` asserts that every schema emitted across the whole corpus really does parse
|
|
217
|
+
* synchronously, because `safeParse` THROWS on a schema that cannot - a claim about output this file
|
|
218
|
+
* does not produce is exactly the kind that needs an arm rather than a comment.
|
|
219
|
+
*/
|
|
220
|
+
const SYNC_PARSE = `/** Parse synchronously: nothing emitted here is async, and the async path costs 2.6x. */
|
|
221
|
+
const SYNC = { validationFunction: (schema: z.ZodType, value: unknown) => schema.safeParse(value) };
|
|
222
|
+
|
|
223
|
+
`;
|
|
224
|
+
const BODY_READER = `/** The two ways Hono can read a request body: \`c.req.json()\` and \`c.req.parseBody()\`. */
|
|
225
|
+
type BodyTarget = "json" | "form";
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A body that is not what its content type claims.
|
|
229
|
+
*
|
|
230
|
+
* **BOTH readers can fail.** Hono's own validator catches in both branches and throws
|
|
231
|
+
* \`HTTPException\` - which is what escapes the app's envelope. Catching here is what lets the failure
|
|
232
|
+
* be reported like any other. Measured: an unreadable multipart body answered 500 while the JSON case
|
|
233
|
+
* answered 400, from the same missing \`try\`.
|
|
234
|
+
*/
|
|
235
|
+
const UNREADABLE = Symbol("a body that is not what its content type claims");
|
|
236
|
+
|
|
237
|
+
async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
|
|
238
|
+
try {
|
|
239
|
+
return target === "form" ? await c.req.parseBody({ all: true }) : await c.req.json();
|
|
240
|
+
} catch {
|
|
241
|
+
return UNREADABLE;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The failure an unreadable body produces, in the shape every other rejection has.
|
|
247
|
+
*
|
|
248
|
+
* Parsing \`undefined\` against the body's own schema is what makes a real Zod failure, so
|
|
249
|
+
* \`deps.invalid\` receives what it receives for a failed parse rather than a special case it has to
|
|
250
|
+
* know about. A schema that somehow accepts \`undefined\` still fails: a body that could not be read
|
|
251
|
+
* is not a body that validated.
|
|
252
|
+
*/
|
|
253
|
+
function unreadableResult(schema: z.ZodType): { readonly success: boolean } {
|
|
254
|
+
const parsed = schema.safeParse(undefined);
|
|
255
|
+
return parsed.success ? { success: false } : parsed;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Which reader parses the media type the request actually carries.
|
|
260
|
+
*
|
|
261
|
+
* A route may declare several request media types needing different parsers, and which one applies is
|
|
262
|
+
* decided by the caller's \`Content-Type\` when the request arrives - not when this file was
|
|
263
|
+
* generated. Parameters (\`; charset=utf-8\`, \`; boundary=...\`) are not part of the match, which
|
|
264
|
+
* matters because a multipart request always carries a boundary.
|
|
265
|
+
*
|
|
266
|
+
* A \`Content-Type\` matching nothing declared falls through to the first branch, so the body simply
|
|
267
|
+
* fails to parse and the app answers. No status is invented that the document does not describe.
|
|
268
|
+
*/
|
|
269
|
+
function bodyTarget(c: Context, branches: readonly (readonly [string, BodyTarget])[]): BodyTarget {
|
|
270
|
+
const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
271
|
+
const matched = branches.find(([mediaType]) => mediaType.toLowerCase() === declared);
|
|
272
|
+
return matched?.[1] ?? branches[0]?.[1] ?? "json";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The app's rejection hook, as this file needs to name it. */
|
|
276
|
+
type BodyInvalid<E extends Env> = <P extends string, I extends Input>(
|
|
277
|
+
result: { readonly success: boolean },
|
|
278
|
+
c: Context<E, P, I>,
|
|
279
|
+
) => Response | undefined;
|
|
280
|
+
|
|
281
|
+
`;
|
|
282
|
+
/**
|
|
283
|
+
* The required-body half, emitted only where a route declares one.
|
|
284
|
+
*
|
|
285
|
+
* **Split from the optional half because `noUnusedLocals` makes an unused declaration an error**, and
|
|
286
|
+
* a generated file has to pass the lint of whatever project it lands in. Three declarations have
|
|
287
|
+
* shipped that way already - `zValidator` and `z` imported unconditionally, and `Simplify<T>` written
|
|
288
|
+
* into a contracts file with nothing to flatten. Emitting both functions whenever any body exists
|
|
289
|
+
* made it four, in eight fixtures at once.
|
|
290
|
+
*/
|
|
291
|
+
const REQUIRED_BODY_MIDDLEWARE = `/**
|
|
292
|
+
* Validate a REQUIRED request body, whatever media type it arrives as.
|
|
293
|
+
*
|
|
294
|
+
* The validated value is published under \`"json"\` whichever reader produced it: \`ValidationTargets\`
|
|
295
|
+
* is a closed union in Hono, so there is no name to coin for "the body", and using one slot is what
|
|
296
|
+
* makes every route the same shape downstream.
|
|
297
|
+
*/
|
|
298
|
+
function validateBody<E extends Env, S extends z.ZodType>(
|
|
299
|
+
schema: S,
|
|
300
|
+
invalid: BodyInvalid<E>,
|
|
301
|
+
branches: readonly (readonly [string, BodyTarget])[],
|
|
302
|
+
): MiddlewareHandler<E, string, { in: { json: z.input<S> }; out: { json: z.output<S> } }> {
|
|
303
|
+
return async (c, next) => {
|
|
304
|
+
/**
|
|
305
|
+
* Annotated rather than inlined, and that is what removes a CAST from this file. \`Context\` is
|
|
306
|
+
* invariant in its environment and in its \`Input\`, so passing the outer \`c\` to a hook typed
|
|
307
|
+
* against the route's own input needs either an annotation here or an \`as never\` there.
|
|
308
|
+
*/
|
|
309
|
+
const parse: MiddlewareHandler<
|
|
310
|
+
E,
|
|
311
|
+
string,
|
|
312
|
+
{ in: { json: z.input<S> }; out: { json: z.output<S> } }
|
|
313
|
+
> = async (ctx, proceed) => {
|
|
314
|
+
const raw = await readBody(ctx, bodyTarget(ctx, branches));
|
|
315
|
+
const result =
|
|
316
|
+
raw === UNREADABLE ? unreadableResult(schema) : schema.safeParse(raw);
|
|
317
|
+
const response = invalid(result, ctx);
|
|
318
|
+
if (response !== undefined) return response;
|
|
319
|
+
if (!result.success) return ctx.json(result, 400);
|
|
320
|
+
ctx.req.addValidatedData("json", ("data" in result ? result.data : undefined) ?? {});
|
|
321
|
+
await proceed();
|
|
322
|
+
return undefined;
|
|
323
|
+
};
|
|
324
|
+
return parse(c, next);
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
`;
|
|
329
|
+
/** The optional-body half. Emitted only where the document publishes `requestBody.required: false`. */
|
|
330
|
+
const OPTIONAL_BODY_MIDDLEWARE = `/**
|
|
331
|
+
* Validate a body the document says is OPTIONAL, and let a request carrying none through.
|
|
332
|
+
*
|
|
333
|
+
* \`requestBody.required: false\` means a request with no body is one the contract permits. Nothing is
|
|
334
|
+
* published when the body is absent, so \`c.req.valid("json")\` reads \`undefined\` and the handler is
|
|
335
|
+
* told the truth - which is why an optional body is a NAMED property on the input rather than merged
|
|
336
|
+
* into it. A body that is present but unreadable still fails, through the same hook.
|
|
337
|
+
*/
|
|
338
|
+
function validateOptionalBody<E extends Env, S extends z.ZodType>(
|
|
339
|
+
schema: S,
|
|
340
|
+
invalid: BodyInvalid<E>,
|
|
341
|
+
branches: readonly (readonly [string, BodyTarget])[],
|
|
342
|
+
): MiddlewareHandler<
|
|
343
|
+
E,
|
|
344
|
+
string,
|
|
345
|
+
{ in: { json: z.input<S> | undefined }; out: { json: z.output<S> | undefined } }
|
|
346
|
+
> {
|
|
347
|
+
return async (c, next) => {
|
|
348
|
+
const parse: MiddlewareHandler<
|
|
349
|
+
E,
|
|
350
|
+
string,
|
|
351
|
+
{ in: { json: z.input<S> | undefined }; out: { json: z.output<S> | undefined } }
|
|
352
|
+
> = async (ctx, proceed) => {
|
|
353
|
+
/**
|
|
354
|
+
* Both halves are load-bearing. The platform reports \`null\` for a request sent without a
|
|
355
|
+
* body, and a caller may instead send \`content-length: 0\`, which is a body of no bytes and
|
|
356
|
+
* reads the same way to anything downstream. Neither alone covers what arrives.
|
|
357
|
+
*/
|
|
358
|
+
const absent =
|
|
359
|
+
ctx.req.raw.body === null || (ctx.req.header("content-length") ?? "").trim() === "0";
|
|
360
|
+
if (absent) {
|
|
361
|
+
await proceed();
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
const raw = await readBody(ctx, bodyTarget(ctx, branches));
|
|
365
|
+
const result =
|
|
366
|
+
raw === UNREADABLE ? unreadableResult(schema) : schema.safeParse(raw);
|
|
367
|
+
const response = invalid(result, ctx);
|
|
368
|
+
if (response !== undefined) return response;
|
|
369
|
+
if (!result.success) return ctx.json(result, 400);
|
|
370
|
+
ctx.req.addValidatedData("json", ("data" in result ? result.data : undefined) ?? {});
|
|
371
|
+
await proceed();
|
|
372
|
+
return undefined;
|
|
373
|
+
};
|
|
374
|
+
return parse(c, next);
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
`;
|
|
163
379
|
/**
|
|
164
380
|
* How an unparsed body reaches the handler, and as what.
|
|
165
381
|
*
|
|
@@ -291,10 +507,6 @@ function inputTypeOf(entry) {
|
|
|
291
507
|
function capitaliseId(operationId) {
|
|
292
508
|
return `${operationId.charAt(0).toUpperCase()}${operationId.slice(1)}`;
|
|
293
509
|
}
|
|
294
|
-
/** The schema identifier a dispatched body validates against. */
|
|
295
|
-
function identifierOf(entry) {
|
|
296
|
-
return entry.dispatched?.identifier ?? "";
|
|
297
|
-
}
|
|
298
510
|
/**
|
|
299
511
|
* The resource a route belongs to (its first path segment) or `undefined` when it has none.
|
|
300
512
|
*
|
|
@@ -357,14 +569,13 @@ basePaths = [],
|
|
|
357
569
|
*/
|
|
358
570
|
securityFor) {
|
|
359
571
|
const mounted = emitted.routes.flatMap((route) => {
|
|
360
|
-
let dispatched;
|
|
361
572
|
const names = emitted.schemaNames.get(route.operationId);
|
|
362
573
|
// The library declares a `Responses` const for every operation, so a missing entry is a bug in
|
|
363
574
|
// this package's pairing rather than a spec the emitter chose not to serve.
|
|
364
575
|
if (names === undefined)
|
|
365
576
|
return [];
|
|
366
577
|
const validators = [];
|
|
367
|
-
let
|
|
578
|
+
let body;
|
|
368
579
|
for (const location of ["path", "query", "header", "body"]) {
|
|
369
580
|
const identifier = names[location];
|
|
370
581
|
if (identifier === undefined)
|
|
@@ -374,60 +585,29 @@ securityFor) {
|
|
|
374
585
|
continue;
|
|
375
586
|
}
|
|
376
587
|
/**
|
|
377
|
-
* The body is the one location whose parser the document does not fix at generation
|
|
378
|
-
*
|
|
379
|
-
*
|
|
588
|
+
* **The body is the one location whose parser the document does not fix at generation
|
|
589
|
+
* time**, and it is the one whose rejection used to depend on which of three mountings it
|
|
590
|
+
* got. All three are now one emitted middleware; what the document decides is only the
|
|
591
|
+
* branches it is given and whether an absent body is permitted.
|
|
380
592
|
*/
|
|
381
|
-
const
|
|
382
|
-
if (
|
|
383
|
-
refuse.unvalidatableMediaType(route,
|
|
384
|
-
if (body.byType.length === 0)
|
|
385
|
-
continue;
|
|
386
|
-
const targets = [...new Set(body.byType.map(([, target]) => target))];
|
|
387
|
-
if (targets.length === 1) {
|
|
388
|
-
/**
|
|
389
|
-
* **An optional body is mounted by `optionalBody`, not `zValidator`**, so a request
|
|
390
|
-
* carrying none is let through instead of refused - see the runtime's docblock for the
|
|
391
|
-
* 400 that produced. It publishes under the canonical body slot whichever parser read
|
|
392
|
-
* it, exactly as `byContentType` does, so the handler reads it the same way everywhere.
|
|
393
|
-
*/
|
|
394
|
-
if (route.optionalBody) {
|
|
395
|
-
validators.push([VALIDATOR_TARGET.body, identifier]);
|
|
396
|
-
optional = { target: targets[0], identifier };
|
|
397
|
-
continue;
|
|
398
|
-
}
|
|
399
|
-
/**
|
|
400
|
-
* **A required body keeps `zValidator`, and unifying it was tried and reverted.**
|
|
401
|
-
*
|
|
402
|
-
* `zValidator` raises `HTTPException` on a body that is not the JSON its content type
|
|
403
|
-
* claims, BEFORE `deps.invalid` runs, so that 400 is `text/plain` and escapes the app's
|
|
404
|
-
* error envelope. Routing every body through `byContentType` fixes it and is the obvious
|
|
405
|
-
* move - one code path, one place the rejection is decided.
|
|
406
|
-
*
|
|
407
|
-
* **It is a breaking change to the RUNTIME CONTRACT, not just to emitted output.** An app
|
|
408
|
-
* that points `runtime-module` at its own module would have to export `byContentType`,
|
|
409
|
-
* which today it needs only if some operation declares several request media types - rare
|
|
410
|
-
* enough that no consumer exports it. Measured: 15 arms red, every one of them an app
|
|
411
|
-
* whose substituted module has no such export.
|
|
412
|
-
*
|
|
413
|
-
* The envelope gap is real and stays open for a required single-media-type body. Closing
|
|
414
|
-
* it wants a form that adds no export to the runtime contract - most likely emitting the
|
|
415
|
-
* middleware into `app.gen.ts` itself - and that is its own change, not a line here.
|
|
416
|
-
*/
|
|
417
|
-
validators.push([targets[0], identifier]);
|
|
418
|
-
continue;
|
|
593
|
+
const validation = bodyValidationFor(route.requestContentTypes);
|
|
594
|
+
if (validation.unparseable.length > 0) {
|
|
595
|
+
refuse.unvalidatableMediaType(route, validation.unparseable);
|
|
419
596
|
}
|
|
597
|
+
if (validation.byType.length === 0)
|
|
598
|
+
continue;
|
|
420
599
|
/**
|
|
421
|
-
* **Recorded
|
|
422
|
-
* it validated under
|
|
423
|
-
* the
|
|
424
|
-
*
|
|
425
|
-
*
|
|
600
|
+
* **Recorded under the canonical body slot whatever media type arrives.** The middleware
|
|
601
|
+
* publishes what it validated under one target, so the handler reads it with a single
|
|
602
|
+
* `c.req.valid("json")` whether the body was JSON, a form, or chosen from the request's
|
|
603
|
+
* `Content-Type`. A required form body used to be published under `"form"` instead, which
|
|
604
|
+
* made it the one route shape that read differently downstream for no reason the document
|
|
605
|
+
* states.
|
|
426
606
|
*/
|
|
427
607
|
validators.push([VALIDATOR_TARGET.body, identifier]);
|
|
428
|
-
|
|
608
|
+
body = { branches: validation.byType, identifier, optional: route.optionalBody === true };
|
|
429
609
|
}
|
|
430
|
-
return [{ route, names, validators,
|
|
610
|
+
return [{ route, names, validators, body }];
|
|
431
611
|
});
|
|
432
612
|
const entries = mounted;
|
|
433
613
|
/**
|
|
@@ -483,6 +663,28 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
|
|
|
483
663
|
|
|
484
664
|
`
|
|
485
665
|
: "";
|
|
666
|
+
/**
|
|
667
|
+
* **Which halves of the body middleware this file needs**, decided from the routes rather than from
|
|
668
|
+
* the rendered text - the rule the import block below states, and once broke by matching a
|
|
669
|
+
* substring that a new argument had moved.
|
|
670
|
+
*
|
|
671
|
+
* Each half is emitted only where something mounts it: `noUnusedLocals` makes an unused declaration
|
|
672
|
+
* an error, and a generated file has to pass the lint of whatever project it lands in.
|
|
673
|
+
*/
|
|
674
|
+
const mountsRequiredBody = entries.some((entry) => entry.body?.optional === false);
|
|
675
|
+
const mountsOptionalBody = entries.some((entry) => entry.body?.optional === true);
|
|
676
|
+
const mountsBody = mountsRequiredBody || mountsOptionalBody;
|
|
677
|
+
const bodyHelper = mountsBody
|
|
678
|
+
? `${BODY_READER}${mountsRequiredBody ? REQUIRED_BODY_MIDDLEWARE : ""}${mountsOptionalBody ? OPTIONAL_BODY_MIDDLEWARE : ""}`
|
|
679
|
+
: "";
|
|
680
|
+
/**
|
|
681
|
+
* **Whether any `zValidator` is actually emitted**, which is the same question the import block
|
|
682
|
+
* below asks - stated once, because `SYNC` is passed to every one of those calls and a second
|
|
683
|
+
* spelling of the rule is a second thing that can drift. A body's target is filtered out: it is
|
|
684
|
+
* mounted by the emitted middleware instead, so a route with only a body mounts no `zValidator`.
|
|
685
|
+
*/
|
|
686
|
+
const validates = entries.some((entry) => entry.validators.filter(([target]) => entry.body === undefined || target !== VALIDATOR_TARGET.body).length > 0);
|
|
687
|
+
const syncHelper = validates ? SYNC_PARSE : "";
|
|
486
688
|
const methods = entries.map((entry) => {
|
|
487
689
|
const { route, names } = entry;
|
|
488
690
|
/**
|
|
@@ -568,7 +770,6 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
|
|
|
568
770
|
* mentions a name is how this package lost the `byContentType` import: the call gained an argument
|
|
569
771
|
* and the substring stopped matching, so a module referenced a function it no longer imported.
|
|
570
772
|
*/
|
|
571
|
-
const mountsOptionalBody = entries.some((entry) => entry.optional !== undefined);
|
|
572
773
|
const returnsAnything = entries.some((entry) => entry.names.response !== undefined);
|
|
573
774
|
const declaredHelper = returnsAnything
|
|
574
775
|
? `/**
|
|
@@ -726,41 +927,36 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
726
927
|
* from that type -- measured, `hc<typeof app>` resolved the wrapped route's body to `unknown`.
|
|
727
928
|
*/
|
|
728
929
|
/**
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
* `deps.invalid` and validates with them, so it stands in for that target's `zValidator`
|
|
732
|
-
* rather than wrapping one, and publishes under the body target either way.
|
|
930
|
+
* The body's own middleware, emitted into this file rather than imported - see
|
|
931
|
+
* `BODY_MIDDLEWARE` for why the runtime module is the wrong place for it.
|
|
733
932
|
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
*
|
|
933
|
+
* It stands IN FOR the body's `zValidator` rather than wrapping one, which is what lets it
|
|
934
|
+
* decide the rejection. A plain `MiddlewareHandler` contributes no `Input` to Hono's chain,
|
|
935
|
+
* so a wrapper made `c.req.valid("json")` in the handler below
|
|
737
936
|
* `TS2345: Argument of type '"json"' is not assignable to parameter of type '"header"'`, and
|
|
738
|
-
* the handler's declared input type
|
|
739
|
-
*
|
|
937
|
+
* dropped the body from the handler's declared input type entirely. Publishing to one known
|
|
938
|
+
* slot removes both.
|
|
939
|
+
*
|
|
940
|
+
* The branches are always emitted, even the single one: a route declaring one media type is
|
|
941
|
+
* the same question with one answer, and a special case there is what let three mountings
|
|
942
|
+
* drift apart in the first place.
|
|
740
943
|
*/
|
|
741
|
-
const
|
|
944
|
+
const bodyMiddleware = entry.body === undefined
|
|
742
945
|
? []
|
|
743
946
|
: [
|
|
744
|
-
`\t\
|
|
745
|
-
...entry.
|
|
947
|
+
`\t\t${entry.body.optional ? "validateOptionalBody" : "validateBody"}(${entry.body.identifier}, deps.invalid, [`,
|
|
948
|
+
...entry.body.branches.map(([type, target]) => `\t\t\t[${JSON.stringify(type)}, ${JSON.stringify(target)}],`),
|
|
746
949
|
"\t\t]),",
|
|
747
950
|
];
|
|
748
951
|
const middleware = [
|
|
749
952
|
...(headOnly ? ["\t\theadOnly,"] : []),
|
|
750
953
|
...gate,
|
|
751
954
|
...validators
|
|
752
|
-
// The
|
|
753
|
-
// the body twice and reject every media type but one.
|
|
754
|
-
|
|
755
|
-
.
|
|
756
|
-
|
|
757
|
-
.map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid),`),
|
|
758
|
-
...dispatch,
|
|
759
|
-
...(entry.optional === undefined
|
|
760
|
-
? []
|
|
761
|
-
: [
|
|
762
|
-
`\t\toptionalBody(${entry.optional.identifier}, deps.invalid, ${JSON.stringify(entry.optional.target)}),`,
|
|
763
|
-
]),
|
|
955
|
+
// The body's validator IS the middleware above; emitting a `zValidator` beside it would
|
|
956
|
+
// parse the body twice and reject every media type but one.
|
|
957
|
+
.filter(([target]) => entry.body === undefined || target !== VALIDATOR_TARGET.body)
|
|
958
|
+
.map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid, SYNC),`),
|
|
959
|
+
...bodyMiddleware,
|
|
764
960
|
];
|
|
765
961
|
/**
|
|
766
962
|
* Whether the operation requires a caller, and NOTHING else about the caller.
|
|
@@ -1021,7 +1217,6 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1021
1217
|
* safe in a way these are not: an identifier absent from the text is genuinely not needed, so the
|
|
1022
1218
|
* check cannot be wrong in the direction that breaks a build.
|
|
1023
1219
|
*/
|
|
1024
|
-
const dispatchesBody = entries.some((entry) => entry.dispatched !== undefined);
|
|
1025
1220
|
/**
|
|
1026
1221
|
* **Imported only where a route actually validates something, like every other value import here.**
|
|
1027
1222
|
*
|
|
@@ -1032,7 +1227,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1032
1227
|
* `TS6133: 'zValidator' is declared but its value is never read`.
|
|
1033
1228
|
*
|
|
1034
1229
|
* Counted from the same filtered list the middleware is rendered from, so it cannot disagree with
|
|
1035
|
-
* what was emitted: a
|
|
1230
|
+
* what was emitted: a body's validator is the middleware this file declares, not a `zValidator`.
|
|
1036
1231
|
*/
|
|
1037
1232
|
/**
|
|
1038
1233
|
* **`z` is only ever reached through `z.infer`**, which appears where an operation has an input
|
|
@@ -1040,9 +1235,14 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1040
1235
|
* neither, so the import was written and never used:
|
|
1041
1236
|
* `TS6133: 'z' is declared but its value is never read`. Same shape as the `zValidator` one above,
|
|
1042
1237
|
* one step further along.
|
|
1238
|
+
*
|
|
1239
|
+
* The emitted body middleware names `z.input` and `z.output` as well, so a service whose only use
|
|
1240
|
+
* of Zod is a request body still needs it.
|
|
1043
1241
|
*/
|
|
1044
|
-
const usesZod =
|
|
1045
|
-
|
|
1242
|
+
const usesZod = mountsBody ||
|
|
1243
|
+
// `SYNC` annotates its schema parameter `z.ZodType`, so the value import is load-bearing.
|
|
1244
|
+
validates ||
|
|
1245
|
+
entries.some((entry) => inputTypeOf(entry) !== undefined || entry.names.response !== undefined);
|
|
1046
1246
|
const runtimeModule = JSON.stringify(emitted.options.runtimeModule);
|
|
1047
1247
|
/**
|
|
1048
1248
|
* **One base sub-app, mounted with `app.route()`. Hono's own nesting, not a rewritten path on
|
|
@@ -1054,8 +1254,8 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1054
1254
|
const usesBasePath = basePaths.length > 0;
|
|
1055
1255
|
const needsHonoValue = subApps.size > 0 || usesBasePath;
|
|
1056
1256
|
return `${generatedBanner(emitted.options.regenerateHint)}
|
|
1057
|
-
${validates ? 'import { zValidator } from "@hono/zod-validator";\n' : ""}${needsHonoValue ? 'import { Hono } from "hono";\nimport type { Context, Input } from "hono";' : 'import type { Context, Hono, Input } from "hono";'}
|
|
1058
|
-
${usesZod ? 'import { z } from "zod";\n' : ""}import type { AppEnv, Awaitable, Ctx, Result, RouteDeps } from ${runtimeModule};${negotiates ? `\nimport { selectContentType } from ${runtimeModule};` : ""}${guardsHead ? `\nimport { headOnly } from ${runtimeModule};` : ""}
|
|
1257
|
+
${validates ? 'import { zValidator } from "@hono/zod-validator";\n' : ""}${needsHonoValue ? 'import { Hono } from "hono";\nimport type { Context, Input } from "hono";' : 'import type { Context, Hono, Input } from "hono";'}${mountsBody ? '\nimport type { Env, MiddlewareHandler } from "hono";' : ""}
|
|
1258
|
+
${usesZod ? 'import { z } from "zod";\n' : ""}import type { AppEnv, Awaitable, Ctx, Result, RouteDeps } from ${runtimeModule};${negotiates ? `\nimport { selectContentType } from ${runtimeModule};` : ""}${guardsHead ? `\nimport { headOnly } from ${runtimeModule};` : ""}
|
|
1059
1259
|
${imports}
|
|
1060
1260
|
/**
|
|
1061
1261
|
* One method per operation, each concretely typed from the schemas it validates against.
|
|
@@ -1063,7 +1263,7 @@ ${imports}
|
|
|
1063
1263
|
* There is no cast anywhere in this file, and no dynamic lookup: the generated call sites name the
|
|
1064
1264
|
* method, so an implementation whose input or output does not match the contract fails to compile.
|
|
1065
1265
|
*/
|
|
1066
|
-
${fieldsHelper}${declaredHelper}export interface Operations {
|
|
1266
|
+
${syncHelper}${bodyHelper}${fieldsHelper}${declaredHelper}export interface Operations {
|
|
1067
1267
|
${methods.join("\n")}
|
|
1068
1268
|
}
|
|
1069
1269
|
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Context, Env, Input, MiddlewareHandler } from "hono";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ZodType } from "zod";
|
|
3
3
|
/**
|
|
4
4
|
* One arm of an operation's declared response set, as the document publishes it.
|
|
5
5
|
*
|
|
@@ -157,88 +157,20 @@ export declare function selectContentType(accept: string | undefined, offered: r
|
|
|
157
157
|
*/
|
|
158
158
|
export declare const headOnly: MiddlewareHandler;
|
|
159
159
|
/**
|
|
160
|
-
* The
|
|
161
|
-
* `c.req.json()` and `"form"` with `c.req.parseBody()`, and those are the only two that read a body.
|
|
162
|
-
*/
|
|
163
|
-
export type BodyTarget = "json" | "form";
|
|
164
|
-
/**
|
|
165
|
-
* Apply the validator that parses the media type the request actually carries.
|
|
166
|
-
*
|
|
167
|
-
* A route may declare several request media types needing different parsers -- `addPet` in the
|
|
168
|
-
* Swagger Petstore accepts JSON, XML and urlencoded on one path. `zValidator`'s target is fixed when
|
|
169
|
-
* the server is generated; which parser applies is decided by the caller's `Content-Type` when the
|
|
170
|
-
* request arrives. Those are different times, and only the second one has the answer.
|
|
171
|
-
*
|
|
172
|
-
* **Before this, one target was chosen for the whole route and everything else was rejected.** A
|
|
173
|
-
* form-encoded body to a route declaring JSON first was handed to `c.req.json()` and answered 400,
|
|
174
|
-
* with no diagnostic anywhere. The status looked like the caller's fault and was not.
|
|
175
|
-
*
|
|
176
|
-
* Parameters after the media type (`; charset=utf-8`, `; boundary=...`) are not part of the match,
|
|
177
|
-
* which matters because a multipart request always carries a boundary.
|
|
160
|
+
* **The request-body middleware used to live here, and it moved into `app.gen.ts`.**
|
|
178
161
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
162
|
+
* `byContentType` and `optionalBody` were exported from this module and imported by the generated
|
|
163
|
+
* server, which made them part of a SECOND contract this package has: what an application that
|
|
164
|
+
* points `runtime-module` at a module of its own must export. That contract is easy to break without
|
|
165
|
+
* noticing, and it was the reason a required single-media-type body kept `zValidator` - whose
|
|
166
|
+
* `HTTPException` on an unreadable body is a `text/plain` 400 raised before `deps.invalid`, escaping
|
|
167
|
+
* the app's error envelope. Routing those through `byContentType` closed the gap in one line and
|
|
168
|
+
* made that export mandatory for every substituting app: measured, 15 arms red.
|
|
182
169
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* slot this emitter reads a body from, and using it here means the handler spreads one
|
|
187
|
-
* `c.req.valid("json")` exactly as it does for a route declaring a single media type.
|
|
188
|
-
*
|
|
189
|
-
* **The alternative was a middleware that publishes under whichever target ran**, and it was worse
|
|
190
|
-
* in two ways that both shipped. `byContentType` was a bare `MiddlewareHandler`, which contributes no
|
|
191
|
-
* `Input` to the chain, so the generated `c.req.valid("json")` did not compile at all
|
|
192
|
-
* (`TS2345: Argument of type '"json"' is not assignable to parameter of type '"header"'`), and the
|
|
193
|
-
* handler's declared input type omitted the body entirely because the body was not among the route's
|
|
194
|
-
* ordinary validators. Publishing to one known slot removes both, rather than typing around them.
|
|
195
|
-
*/
|
|
196
|
-
export declare function byContentType<E extends Env, S extends ZodType>(schema: S, invalid: <P extends string, I extends Input>(result: {
|
|
197
|
-
readonly success: boolean;
|
|
198
|
-
}, c: Context<E, P, I>) => Response | undefined,
|
|
199
|
-
/**
|
|
200
|
-
* **A NON-EMPTY list, stated in the type.** The fallback below reads the first branch, and
|
|
201
|
-
* `branches[0]` on a plain array is `T | undefined`, which was silenced with a cast. A tuple says
|
|
202
|
-
* the same thing the emitter already guarantees - this middleware is only ever emitted for a route
|
|
203
|
-
* declaring at least one request media type - and removes the cast rather than typing around it.
|
|
170
|
+
* Emitting the middleware instead closes the gap and SHRINKS the runtime contract by these two
|
|
171
|
+
* names. Nothing generated imports them, so keeping them here would leave a second implementation of
|
|
172
|
+
* body reading that nothing exercises - which is how two copies of one rule drift apart.
|
|
204
173
|
*/
|
|
205
|
-
branches: readonly [
|
|
206
|
-
readonly [mediaType: string, target: BodyTarget],
|
|
207
|
-
...(readonly [mediaType: string, target: BodyTarget])[]
|
|
208
|
-
]): MiddlewareHandler<E, string, {
|
|
209
|
-
in: {
|
|
210
|
-
json: input<S>;
|
|
211
|
-
};
|
|
212
|
-
out: {
|
|
213
|
-
json: output<S>;
|
|
214
|
-
};
|
|
215
|
-
}>;
|
|
216
|
-
/**
|
|
217
|
-
* Validate a body the document says is OPTIONAL, and let a request carrying none through.
|
|
218
|
-
*
|
|
219
|
-
* **`requestBody.required: false` means a request with no body is one the contract permits**, and a
|
|
220
|
-
* plain `zValidator` refused it. Measured against @hono/zod-validator 0.9.0 and hono 4.12.26: a
|
|
221
|
-
* `POST` with `content-type: application/json` and no body answered **400 `Malformed JSON in request
|
|
222
|
-
* body`** as `text/plain` - raised before the `invalid` hook, so outside the app's error envelope
|
|
223
|
-
* entirely. A service refusing what its own document allows, in a shape that document forbids.
|
|
224
|
-
*
|
|
225
|
-
* **Nothing is published when the body is absent**, so `c.req.valid(...)` reads `undefined` and the
|
|
226
|
-
* handler is told the truth. That is why an optional body is a NAMED property on the input rather
|
|
227
|
-
* than merged into it: a merge has no way to say "these are here only sometimes" without making
|
|
228
|
-
* every one of them optional, which is a weaker and different claim about the body that IS sent.
|
|
229
|
-
*
|
|
230
|
-
* A body that is present but unreadable still fails, through `invalid`, so the app's envelope holds.
|
|
231
|
-
*/
|
|
232
|
-
export declare function optionalBody<E extends Env, S extends ZodType>(schema: S, invalid: <P extends string, I extends Input>(result: {
|
|
233
|
-
readonly success: boolean;
|
|
234
|
-
}, c: Context<E, P, I>) => Response | undefined, target: BodyTarget): MiddlewareHandler<E, string, {
|
|
235
|
-
in: {
|
|
236
|
-
json: input<S> | undefined;
|
|
237
|
-
};
|
|
238
|
-
out: {
|
|
239
|
-
json: output<S> | undefined;
|
|
240
|
-
};
|
|
241
|
-
}>;
|
|
242
174
|
/**
|
|
243
175
|
* What the app provides. One object, passed once, rather than a module the generated file imports by
|
|
244
176
|
* path. A generated server that hard-codes `../../backend.js` is only usable by the project it was
|
package/dist/src/runtime.js
CHANGED
|
@@ -103,168 +103,3 @@ export function selectContentType(accept, offered) {
|
|
|
103
103
|
* type. Measured: `hc<typeof app>` resolved a wrapped route's body to `unknown`.
|
|
104
104
|
*/
|
|
105
105
|
export const headOnly = async (c, next) => c.req.method === "HEAD" ? next() : c.notFound();
|
|
106
|
-
/**
|
|
107
|
-
* The request body, read the way the target says to read it.
|
|
108
|
-
*
|
|
109
|
-
* The same two readers Hono's own `validator` uses, and the same rejection for a body that is not
|
|
110
|
-
* the JSON it claims to be. `parseBody({ all: true })` builds the object Hono's `"form"` target
|
|
111
|
-
* builds: a repeated key and a `key[]` name both become an array, everything else stays a string.
|
|
112
|
-
*/
|
|
113
|
-
const UNREADABLE = Symbol("a body that is not what its content type claims");
|
|
114
|
-
async function readBody(c, target) {
|
|
115
|
-
if (target === "form")
|
|
116
|
-
return c.req.parseBody({ all: true });
|
|
117
|
-
try {
|
|
118
|
-
return await c.req.json();
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
return UNREADABLE;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* The failure a malformed body produces, reported through the app's `invalid` hook like any other.
|
|
126
|
-
*
|
|
127
|
-
* **It used to `throw new HTTPException(400)`, which never reaches that hook.** The response was
|
|
128
|
-
* `text/plain` with a bare message, so an API whose document declares a JSON error envelope answered
|
|
129
|
-
* a shape its own contract forbids - and the app had no way to intervene, because the throw happened
|
|
130
|
-
* before its code ran.
|
|
131
|
-
*
|
|
132
|
-
* Parsing `undefined` against the body's own schema is what produces a real Zod failure, so
|
|
133
|
-
* `deps.invalid` receives the shape it receives for every other rejection rather than a special case
|
|
134
|
-
* it has to know about. A schema that somehow accepts `undefined` still fails here: a body that
|
|
135
|
-
* could not be read is not a body that validated.
|
|
136
|
-
*/
|
|
137
|
-
async function unreadableResult(schema) {
|
|
138
|
-
const parsed = await schema.safeParseAsync(undefined);
|
|
139
|
-
return parsed.success ? { success: false } : parsed;
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* The slot a validated request body is published under, whichever parser produced it.
|
|
143
|
-
*
|
|
144
|
-
* `ValidationTargets` is a closed union in Hono, so a body cannot be given a name of its own. This
|
|
145
|
-
* is the slot the generated code already reads a body from when one media type is declared, so using
|
|
146
|
-
* it for a dispatched body is what keeps the two cases identical downstream.
|
|
147
|
-
*/
|
|
148
|
-
const BODY_TARGET = "json";
|
|
149
|
-
/**
|
|
150
|
-
* Apply the validator that parses the media type the request actually carries.
|
|
151
|
-
*
|
|
152
|
-
* A route may declare several request media types needing different parsers -- `addPet` in the
|
|
153
|
-
* Swagger Petstore accepts JSON, XML and urlencoded on one path. `zValidator`'s target is fixed when
|
|
154
|
-
* the server is generated; which parser applies is decided by the caller's `Content-Type` when the
|
|
155
|
-
* request arrives. Those are different times, and only the second one has the answer.
|
|
156
|
-
*
|
|
157
|
-
* **Before this, one target was chosen for the whole route and everything else was rejected.** A
|
|
158
|
-
* form-encoded body to a route declaring JSON first was handed to `c.req.json()` and answered 400,
|
|
159
|
-
* with no diagnostic anywhere. The status looked like the caller's fault and was not.
|
|
160
|
-
*
|
|
161
|
-
* Parameters after the media type (`; charset=utf-8`, `; boundary=...`) are not part of the match,
|
|
162
|
-
* which matters because a multipart request always carries a boundary.
|
|
163
|
-
*
|
|
164
|
-
* A `Content-Type` matching nothing declared falls through to the first validator, which reproduces
|
|
165
|
-
* the previous behaviour exactly for that case: the body fails to parse and `deps.invalid` answers.
|
|
166
|
-
* No status is invented here that the document does not describe.
|
|
167
|
-
*
|
|
168
|
-
* **The validated body is published under `"json"` whichever parser produced it**, and that is what
|
|
169
|
-
* makes a dispatched route the same shape as every other one downstream. `ValidationTargets` is a
|
|
170
|
-
* closed union in Hono, so there is no seventh name to coin for "the body"; `"json"` is already the
|
|
171
|
-
* slot this emitter reads a body from, and using it here means the handler spreads one
|
|
172
|
-
* `c.req.valid("json")` exactly as it does for a route declaring a single media type.
|
|
173
|
-
*
|
|
174
|
-
* **The alternative was a middleware that publishes under whichever target ran**, and it was worse
|
|
175
|
-
* in two ways that both shipped. `byContentType` was a bare `MiddlewareHandler`, which contributes no
|
|
176
|
-
* `Input` to the chain, so the generated `c.req.valid("json")` did not compile at all
|
|
177
|
-
* (`TS2345: Argument of type '"json"' is not assignable to parameter of type '"header"'`), and the
|
|
178
|
-
* handler's declared input type omitted the body entirely because the body was not among the route's
|
|
179
|
-
* ordinary validators. Publishing to one known slot removes both, rather than typing around them.
|
|
180
|
-
*/
|
|
181
|
-
export function byContentType(schema, invalid,
|
|
182
|
-
/**
|
|
183
|
-
* **A NON-EMPTY list, stated in the type.** The fallback below reads the first branch, and
|
|
184
|
-
* `branches[0]` on a plain array is `T | undefined`, which was silenced with a cast. A tuple says
|
|
185
|
-
* the same thing the emitter already guarantees - this middleware is only ever emitted for a route
|
|
186
|
-
* declaring at least one request media type - and removes the cast rather than typing around it.
|
|
187
|
-
*/
|
|
188
|
-
branches) {
|
|
189
|
-
return async (c, next) => {
|
|
190
|
-
const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
191
|
-
const matched = branches.find(([mediaType]) => mediaType.toLowerCase() === declared);
|
|
192
|
-
const [, target] = matched ?? branches[0];
|
|
193
|
-
/**
|
|
194
|
-
* **Registered against the body slot whichever parser runs**, so the validated body is
|
|
195
|
-
* published under one target and the handler reads it exactly as it reads a single-media-type
|
|
196
|
-
* one. Hono's `validator` is what `@hono/zod-validator` is built on, so this is the same
|
|
197
|
-
* extraction, the same `HTTPException` on malformed JSON, and the same default rejection.
|
|
198
|
-
*
|
|
199
|
-
* **Hono's own `validator` is deliberately not called here.** Its target fixes the reader at
|
|
200
|
-
* registration time, and the whole point of this function is that the reader is chosen when the
|
|
201
|
-
* request arrives. What it does instead is reproduce `validator`'s observable behaviour for both
|
|
202
|
-
* targets: {@link readBody} performs the same two extractions and raises the same
|
|
203
|
-
* `HTTPException` on malformed JSON, and the rejection below is `zValidator`'s own.
|
|
204
|
-
*
|
|
205
|
-
* Annotated rather than inlined because `Context` is invariant in its environment and in its
|
|
206
|
-
* `Input`. Same reason `RouteDeps` is parameterised.
|
|
207
|
-
*/
|
|
208
|
-
const parse = async (ctx, proceed) => {
|
|
209
|
-
const raw = await readBody(ctx, target);
|
|
210
|
-
const result = raw === UNREADABLE ? await unreadableResult(schema) : await schema.safeParseAsync(raw);
|
|
211
|
-
const response = invalid(result, ctx);
|
|
212
|
-
if (response !== undefined)
|
|
213
|
-
return response;
|
|
214
|
-
// `zValidator`'s own answer when a hook declines to, kept identical so a dispatched route
|
|
215
|
-
// and a single-media-type one reject a bad body the same way.
|
|
216
|
-
if (!result.success)
|
|
217
|
-
return ctx.json(result, 400);
|
|
218
|
-
ctx.req.addValidatedData(BODY_TARGET, ("data" in result ? result.data : undefined) ?? {});
|
|
219
|
-
await proceed();
|
|
220
|
-
return undefined;
|
|
221
|
-
};
|
|
222
|
-
return parse(c, next);
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
/**
|
|
226
|
-
* Validate a body the document says is OPTIONAL, and let a request carrying none through.
|
|
227
|
-
*
|
|
228
|
-
* **`requestBody.required: false` means a request with no body is one the contract permits**, and a
|
|
229
|
-
* plain `zValidator` refused it. Measured against @hono/zod-validator 0.9.0 and hono 4.12.26: a
|
|
230
|
-
* `POST` with `content-type: application/json` and no body answered **400 `Malformed JSON in request
|
|
231
|
-
* body`** as `text/plain` - raised before the `invalid` hook, so outside the app's error envelope
|
|
232
|
-
* entirely. A service refusing what its own document allows, in a shape that document forbids.
|
|
233
|
-
*
|
|
234
|
-
* **Nothing is published when the body is absent**, so `c.req.valid(...)` reads `undefined` and the
|
|
235
|
-
* handler is told the truth. That is why an optional body is a NAMED property on the input rather
|
|
236
|
-
* than merged into it: a merge has no way to say "these are here only sometimes" without making
|
|
237
|
-
* every one of them optional, which is a weaker and different claim about the body that IS sent.
|
|
238
|
-
*
|
|
239
|
-
* A body that is present but unreadable still fails, through `invalid`, so the app's envelope holds.
|
|
240
|
-
*/
|
|
241
|
-
export function optionalBody(schema, invalid, target) {
|
|
242
|
-
return async (c, next) => {
|
|
243
|
-
if (!hasBody(c)) {
|
|
244
|
-
await next();
|
|
245
|
-
return undefined;
|
|
246
|
-
}
|
|
247
|
-
const raw = await readBody(c, target);
|
|
248
|
-
const result = raw === UNREADABLE ? await unreadableResult(schema) : await schema.safeParseAsync(raw);
|
|
249
|
-
const response = invalid(result, c);
|
|
250
|
-
if (response !== undefined)
|
|
251
|
-
return response;
|
|
252
|
-
if (!result.success)
|
|
253
|
-
return c.json(result, 400);
|
|
254
|
-
c.req.addValidatedData(BODY_TARGET, ("data" in result ? result.data : undefined) ?? {});
|
|
255
|
-
await next();
|
|
256
|
-
return undefined;
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
/**
|
|
260
|
-
* Whether the request carries a body at all.
|
|
261
|
-
*
|
|
262
|
-
* Both halves are load-bearing. The platform reports `null` for a request sent without one, and a
|
|
263
|
-
* caller may instead send `content-length: 0`, which is a body of no bytes and reads the same way to
|
|
264
|
-
* anything downstream. Neither alone covers what arrives.
|
|
265
|
-
*/
|
|
266
|
-
function hasBody(c) {
|
|
267
|
-
if (c.req.raw.body === null)
|
|
268
|
-
return false;
|
|
269
|
-
return (c.req.header("content-length") ?? "").trim() !== "0";
|
|
270
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typespec-hono",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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.24.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@hono/zod-openapi": "^1.4.0",
|
|
@@ -67,14 +67,14 @@
|
|
|
67
67
|
"typescript": "~7.0.2",
|
|
68
68
|
"typespec-hono": "link:.",
|
|
69
69
|
"vitest": "^4.1.9",
|
|
70
|
-
"zod": "^4.
|
|
70
|
+
"zod": "^4.5.2"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"@hono/zod-validator": "^0.8.0 || ^0.9.0",
|
|
74
74
|
"@typespec/compiler": "^1.15.0",
|
|
75
75
|
"@typespec/http": "^1.15.0",
|
|
76
76
|
"hono": "^4.12.0",
|
|
77
|
-
"zod": "^4.
|
|
77
|
+
"zod": "^4.5.0"
|
|
78
78
|
},
|
|
79
79
|
"engines": {
|
|
80
80
|
"node": ">=22.0.0"
|
package/src/runtime.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Context, Env, Input, MiddlewareHandler } from "hono";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ZodType } from "zod";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* One arm of an operation's declared response set, as the document publishes it.
|
|
@@ -214,201 +214,20 @@ export const headOnly: MiddlewareHandler = async (c, next) =>
|
|
|
214
214
|
c.req.method === "HEAD" ? next() : c.notFound();
|
|
215
215
|
|
|
216
216
|
/**
|
|
217
|
-
* The
|
|
218
|
-
* `c.req.json()` and `"form"` with `c.req.parseBody()`, and those are the only two that read a body.
|
|
219
|
-
*/
|
|
220
|
-
export type BodyTarget = "json" | "form";
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* The request body, read the way the target says to read it.
|
|
224
|
-
*
|
|
225
|
-
* The same two readers Hono's own `validator` uses, and the same rejection for a body that is not
|
|
226
|
-
* the JSON it claims to be. `parseBody({ all: true })` builds the object Hono's `"form"` target
|
|
227
|
-
* builds: a repeated key and a `key[]` name both become an array, everything else stays a string.
|
|
228
|
-
*/
|
|
229
|
-
const UNREADABLE = Symbol("a body that is not what its content type claims");
|
|
230
|
-
|
|
231
|
-
async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
|
|
232
|
-
if (target === "form") return c.req.parseBody({ all: true });
|
|
233
|
-
try {
|
|
234
|
-
return await c.req.json();
|
|
235
|
-
} catch {
|
|
236
|
-
return UNREADABLE;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* The failure a malformed body produces, reported through the app's `invalid` hook like any other.
|
|
242
|
-
*
|
|
243
|
-
* **It used to `throw new HTTPException(400)`, which never reaches that hook.** The response was
|
|
244
|
-
* `text/plain` with a bare message, so an API whose document declares a JSON error envelope answered
|
|
245
|
-
* a shape its own contract forbids - and the app had no way to intervene, because the throw happened
|
|
246
|
-
* before its code ran.
|
|
247
|
-
*
|
|
248
|
-
* Parsing `undefined` against the body's own schema is what produces a real Zod failure, so
|
|
249
|
-
* `deps.invalid` receives the shape it receives for every other rejection rather than a special case
|
|
250
|
-
* it has to know about. A schema that somehow accepts `undefined` still fails here: a body that
|
|
251
|
-
* could not be read is not a body that validated.
|
|
252
|
-
*/
|
|
253
|
-
async function unreadableResult(schema: ZodType): Promise<{ readonly success: boolean }> {
|
|
254
|
-
const parsed = await schema.safeParseAsync(undefined);
|
|
255
|
-
return parsed.success ? { success: false } : parsed;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* The slot a validated request body is published under, whichever parser produced it.
|
|
260
|
-
*
|
|
261
|
-
* `ValidationTargets` is a closed union in Hono, so a body cannot be given a name of its own. This
|
|
262
|
-
* is the slot the generated code already reads a body from when one media type is declared, so using
|
|
263
|
-
* it for a dispatched body is what keeps the two cases identical downstream.
|
|
264
|
-
*/
|
|
265
|
-
const BODY_TARGET = "json";
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Apply the validator that parses the media type the request actually carries.
|
|
269
|
-
*
|
|
270
|
-
* A route may declare several request media types needing different parsers -- `addPet` in the
|
|
271
|
-
* Swagger Petstore accepts JSON, XML and urlencoded on one path. `zValidator`'s target is fixed when
|
|
272
|
-
* the server is generated; which parser applies is decided by the caller's `Content-Type` when the
|
|
273
|
-
* request arrives. Those are different times, and only the second one has the answer.
|
|
274
|
-
*
|
|
275
|
-
* **Before this, one target was chosen for the whole route and everything else was rejected.** A
|
|
276
|
-
* form-encoded body to a route declaring JSON first was handed to `c.req.json()` and answered 400,
|
|
277
|
-
* with no diagnostic anywhere. The status looked like the caller's fault and was not.
|
|
278
|
-
*
|
|
279
|
-
* Parameters after the media type (`; charset=utf-8`, `; boundary=...`) are not part of the match,
|
|
280
|
-
* which matters because a multipart request always carries a boundary.
|
|
281
|
-
*
|
|
282
|
-
* A `Content-Type` matching nothing declared falls through to the first validator, which reproduces
|
|
283
|
-
* the previous behaviour exactly for that case: the body fails to parse and `deps.invalid` answers.
|
|
284
|
-
* No status is invented here that the document does not describe.
|
|
285
|
-
*
|
|
286
|
-
* **The validated body is published under `"json"` whichever parser produced it**, and that is what
|
|
287
|
-
* makes a dispatched route the same shape as every other one downstream. `ValidationTargets` is a
|
|
288
|
-
* closed union in Hono, so there is no seventh name to coin for "the body"; `"json"` is already the
|
|
289
|
-
* slot this emitter reads a body from, and using it here means the handler spreads one
|
|
290
|
-
* `c.req.valid("json")` exactly as it does for a route declaring a single media type.
|
|
217
|
+
* **The request-body middleware used to live here, and it moved into `app.gen.ts`.**
|
|
291
218
|
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
* `
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
|
|
299
|
-
export function byContentType<E extends Env, S extends ZodType>(
|
|
300
|
-
schema: S,
|
|
301
|
-
invalid: <P extends string, I extends Input>(
|
|
302
|
-
result: { readonly success: boolean },
|
|
303
|
-
c: Context<E, P, I>,
|
|
304
|
-
) => Response | undefined,
|
|
305
|
-
/**
|
|
306
|
-
* **A NON-EMPTY list, stated in the type.** The fallback below reads the first branch, and
|
|
307
|
-
* `branches[0]` on a plain array is `T | undefined`, which was silenced with a cast. A tuple says
|
|
308
|
-
* the same thing the emitter already guarantees - this middleware is only ever emitted for a route
|
|
309
|
-
* declaring at least one request media type - and removes the cast rather than typing around it.
|
|
310
|
-
*/
|
|
311
|
-
branches: readonly [
|
|
312
|
-
readonly [mediaType: string, target: BodyTarget],
|
|
313
|
-
...(readonly [mediaType: string, target: BodyTarget])[],
|
|
314
|
-
],
|
|
315
|
-
): MiddlewareHandler<E, string, { in: { json: input<S> }; out: { json: output<S> } }> {
|
|
316
|
-
return async (c, next) => {
|
|
317
|
-
const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
318
|
-
const matched = branches.find(([mediaType]) => mediaType.toLowerCase() === declared);
|
|
319
|
-
const [, target] = matched ?? branches[0];
|
|
320
|
-
/**
|
|
321
|
-
* **Registered against the body slot whichever parser runs**, so the validated body is
|
|
322
|
-
* published under one target and the handler reads it exactly as it reads a single-media-type
|
|
323
|
-
* one. Hono's `validator` is what `@hono/zod-validator` is built on, so this is the same
|
|
324
|
-
* extraction, the same `HTTPException` on malformed JSON, and the same default rejection.
|
|
325
|
-
*
|
|
326
|
-
* **Hono's own `validator` is deliberately not called here.** Its target fixes the reader at
|
|
327
|
-
* registration time, and the whole point of this function is that the reader is chosen when the
|
|
328
|
-
* request arrives. What it does instead is reproduce `validator`'s observable behaviour for both
|
|
329
|
-
* targets: {@link readBody} performs the same two extractions and raises the same
|
|
330
|
-
* `HTTPException` on malformed JSON, and the rejection below is `zValidator`'s own.
|
|
331
|
-
*
|
|
332
|
-
* Annotated rather than inlined because `Context` is invariant in its environment and in its
|
|
333
|
-
* `Input`. Same reason `RouteDeps` is parameterised.
|
|
334
|
-
*/
|
|
335
|
-
const parse: MiddlewareHandler<
|
|
336
|
-
E,
|
|
337
|
-
string,
|
|
338
|
-
{ in: { json: input<S> }; out: { json: output<S> } }
|
|
339
|
-
> = async (ctx, proceed) => {
|
|
340
|
-
const raw = await readBody(ctx, target);
|
|
341
|
-
const result =
|
|
342
|
-
raw === UNREADABLE ? await unreadableResult(schema) : await schema.safeParseAsync(raw);
|
|
343
|
-
const response = invalid(result, ctx);
|
|
344
|
-
if (response !== undefined) return response;
|
|
345
|
-
// `zValidator`'s own answer when a hook declines to, kept identical so a dispatched route
|
|
346
|
-
// and a single-media-type one reject a bad body the same way.
|
|
347
|
-
if (!result.success) return ctx.json(result, 400);
|
|
348
|
-
ctx.req.addValidatedData(BODY_TARGET, ("data" in result ? result.data : undefined) ?? {});
|
|
349
|
-
await proceed();
|
|
350
|
-
return undefined;
|
|
351
|
-
};
|
|
352
|
-
return parse(c, next);
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Validate a body the document says is OPTIONAL, and let a request carrying none through.
|
|
219
|
+
* `byContentType` and `optionalBody` were exported from this module and imported by the generated
|
|
220
|
+
* server, which made them part of a SECOND contract this package has: what an application that
|
|
221
|
+
* points `runtime-module` at a module of its own must export. That contract is easy to break without
|
|
222
|
+
* noticing, and it was the reason a required single-media-type body kept `zValidator` - whose
|
|
223
|
+
* `HTTPException` on an unreadable body is a `text/plain` 400 raised before `deps.invalid`, escaping
|
|
224
|
+
* the app's error envelope. Routing those through `byContentType` closed the gap in one line and
|
|
225
|
+
* made that export mandatory for every substituting app: measured, 15 arms red.
|
|
358
226
|
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
* body`** as `text/plain` - raised before the `invalid` hook, so outside the app's error envelope
|
|
363
|
-
* entirely. A service refusing what its own document allows, in a shape that document forbids.
|
|
364
|
-
*
|
|
365
|
-
* **Nothing is published when the body is absent**, so `c.req.valid(...)` reads `undefined` and the
|
|
366
|
-
* handler is told the truth. That is why an optional body is a NAMED property on the input rather
|
|
367
|
-
* than merged into it: a merge has no way to say "these are here only sometimes" without making
|
|
368
|
-
* every one of them optional, which is a weaker and different claim about the body that IS sent.
|
|
369
|
-
*
|
|
370
|
-
* A body that is present but unreadable still fails, through `invalid`, so the app's envelope holds.
|
|
371
|
-
*/
|
|
372
|
-
export function optionalBody<E extends Env, S extends ZodType>(
|
|
373
|
-
schema: S,
|
|
374
|
-
invalid: <P extends string, I extends Input>(
|
|
375
|
-
result: { readonly success: boolean },
|
|
376
|
-
c: Context<E, P, I>,
|
|
377
|
-
) => Response | undefined,
|
|
378
|
-
target: BodyTarget,
|
|
379
|
-
): MiddlewareHandler<
|
|
380
|
-
E,
|
|
381
|
-
string,
|
|
382
|
-
{ in: { json: input<S> | undefined }; out: { json: output<S> | undefined } }
|
|
383
|
-
> {
|
|
384
|
-
return async (c, next) => {
|
|
385
|
-
if (!hasBody(c)) {
|
|
386
|
-
await next();
|
|
387
|
-
return undefined;
|
|
388
|
-
}
|
|
389
|
-
const raw = await readBody(c, target);
|
|
390
|
-
const result =
|
|
391
|
-
raw === UNREADABLE ? await unreadableResult(schema) : await schema.safeParseAsync(raw);
|
|
392
|
-
const response = invalid(result, c as never);
|
|
393
|
-
if (response !== undefined) return response;
|
|
394
|
-
if (!result.success) return c.json(result, 400);
|
|
395
|
-
c.req.addValidatedData(BODY_TARGET, ("data" in result ? result.data : undefined) ?? {});
|
|
396
|
-
await next();
|
|
397
|
-
return undefined;
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Whether the request carries a body at all.
|
|
403
|
-
*
|
|
404
|
-
* Both halves are load-bearing. The platform reports `null` for a request sent without one, and a
|
|
405
|
-
* caller may instead send `content-length: 0`, which is a body of no bytes and reads the same way to
|
|
406
|
-
* anything downstream. Neither alone covers what arrives.
|
|
227
|
+
* Emitting the middleware instead closes the gap and SHRINKS the runtime contract by these two
|
|
228
|
+
* names. Nothing generated imports them, so keeping them here would leave a second implementation of
|
|
229
|
+
* body reading that nothing exercises - which is how two copies of one rule drift apart.
|
|
407
230
|
*/
|
|
408
|
-
function hasBody(c: Context): boolean {
|
|
409
|
-
if (c.req.raw.body === null) return false;
|
|
410
|
-
return (c.req.header("content-length") ?? "").trim() !== "0";
|
|
411
|
-
}
|
|
412
231
|
|
|
413
232
|
/**
|
|
414
233
|
* What the app provides. One object, passed once, rather than a module the generated file imports by
|