typespec-hono 0.18.1 → 0.19.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 +125 -10
- package/dist/src/runtime.d.ts +26 -0
- package/dist/src/runtime.js +68 -4
- package/package.json +2 -2
- package/src/runtime.ts +82 -4
package/dist/src/app.js
CHANGED
|
@@ -181,10 +181,31 @@ function bodyValidationFor(contentTypes) {
|
|
|
181
181
|
* `isRawBinaryMediaType` is the library's own rule for telling them apart, already applied on the
|
|
182
182
|
* response side; importing it rather than re-deriving it is what keeps the two halves from disagreeing
|
|
183
183
|
* again.
|
|
184
|
+
*
|
|
185
|
+
* **A binary body is handed over as the UNREAD stream, not as an `ArrayBuffer`.** `arrayBuffer()`
|
|
186
|
+
* materialises the whole payload in the isolate, and a Worker gets 128 MB against a request-body
|
|
187
|
+
* limit of 100 MB, so an upload at the documented maximum cannot be served at all. The document
|
|
188
|
+
* publishes only `contentMediaType` for such a body - **nothing a validator can check** - so
|
|
189
|
+
* materialising it produced a value nothing validates, at the cost of the one thing a gateway
|
|
190
|
+
* actually wants to do with it: pipe it somewhere without touching it.
|
|
191
|
+
*
|
|
192
|
+
* Handing over the stream is strictly more capable. A handler wanting the bytes writes
|
|
193
|
+
* `await new Response(input.body).arrayBuffer()`; a handler wanting to stream into R2 pipes it. The
|
|
194
|
+
* reverse was not possible.
|
|
195
|
+
*
|
|
196
|
+
* **It costs no ambient dependency, which was assumed to be a trade and then measured.**
|
|
197
|
+
* `ReadableStream` needs `lib.dom`, `@cloudflare/workers-types` or `@types/node` - and the generated
|
|
198
|
+
* server has needed exactly the same lib since it was first emitted, because it names `Response`.
|
|
199
|
+
* Whatever a project already supplies to satisfy that declares `ReadableStream` too, so there is no
|
|
200
|
+
* project that could build the old output and cannot build this. `test/streambody/` asserts both
|
|
201
|
+
* halves; `test/compiles.test.ts` cannot, because it sets `target` without `lib` and TypeScript then
|
|
202
|
+
* defaults to the `.full` variant, which includes DOM.
|
|
203
|
+
*
|
|
204
|
+
* `null` because a request may carry no body at all, which is what the platform reports.
|
|
184
205
|
*/
|
|
185
206
|
function rawBodyReaderFor(contentTypes) {
|
|
186
207
|
return isRawBinaryMediaType(contentTypes)
|
|
187
|
-
? { call: "
|
|
208
|
+
? { call: "c.req.raw.body", type: "ReadableStream<Uint8Array> | null" }
|
|
188
209
|
: { call: "await c.req.text()", type: "string" };
|
|
189
210
|
}
|
|
190
211
|
/**
|
|
@@ -195,6 +216,20 @@ function rawBodyReaderFor(contentTypes) {
|
|
|
195
216
|
* deriving the type from the same consts is what makes the call site check itself. A restated
|
|
196
217
|
* interface would be a second source of truth that drifts.
|
|
197
218
|
*/
|
|
219
|
+
/**
|
|
220
|
+
* The validators whose fields are INTERSECTED into the input, as opposed to named beside it.
|
|
221
|
+
*
|
|
222
|
+
* Shared with the decision to emit `Fields<>` at all, so "is this helper used" is answered by the
|
|
223
|
+
* same code that uses it rather than by searching the rendered output - an unused type declaration
|
|
224
|
+
* fails `noUnusedLocals`, which a generated file has to pass like any other.
|
|
225
|
+
*/
|
|
226
|
+
function intersectedValidatorsOf(entry) {
|
|
227
|
+
const bodyProperty = entry.route.bodyProperty;
|
|
228
|
+
const bodyName = entry.names.body;
|
|
229
|
+
return entry.validators
|
|
230
|
+
.filter(([, name]) => bodyProperty === undefined || name !== bodyName)
|
|
231
|
+
.map(([, name]) => name);
|
|
232
|
+
}
|
|
198
233
|
function inputTypeOf(entry) {
|
|
199
234
|
/**
|
|
200
235
|
* **A body the document says must not be flattened is NAMED, not intersected.**
|
|
@@ -215,11 +250,30 @@ function inputTypeOf(entry) {
|
|
|
215
250
|
* identifier is the same fact whichever parser reads it.
|
|
216
251
|
*/
|
|
217
252
|
const bodyName = entry.names.body;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
253
|
+
/**
|
|
254
|
+
* **`Fields<>` is what stops an EMPTY validator poisoning the intersection.**
|
|
255
|
+
*
|
|
256
|
+
* Zod 4 infers `Record<string, never>` for `z.object({})` - "no string key may exist" - which is
|
|
257
|
+
* a correct reading of an empty object on its own and lethal in an intersection: `{ id: string }
|
|
258
|
+
* & Record<string, never>` makes `id` into `string & never`, so the input type is uninhabitable
|
|
259
|
+
* and the generated call site fails `tsc` inside `app.gen.ts` itself. Measured on
|
|
260
|
+
* `model Empty {}` as an optional body: `TS2345: Argument of type '{ id: string; }' is not
|
|
261
|
+
* assignable to parameter of type '{ id: string; } & Record<string, never>'`.
|
|
262
|
+
*
|
|
263
|
+
* The emitted server not compiling is the worst shape available to this emitter, and no arm saw
|
|
264
|
+
* it because no fixture declared an empty model.
|
|
265
|
+
*/
|
|
266
|
+
const parts = intersectedValidatorsOf(entry).map((name) => `Fields<z.infer<typeof ${name}>>`);
|
|
221
267
|
if (bodyProperty !== undefined && bodyName !== undefined) {
|
|
222
|
-
|
|
268
|
+
/**
|
|
269
|
+
* **Optional on the input when the document says the body is optional**, which is the whole
|
|
270
|
+
* reason such a body is named rather than merged: `c.req.valid(...)` reads `undefined` when
|
|
271
|
+
* none arrived, and a merge has no way to say that. Spelled `| undefined` as every other
|
|
272
|
+
* optional here is, because `exactOptionalPropertyTypes` is on.
|
|
273
|
+
*/
|
|
274
|
+
const optionalMark = entry.route.optionalBody ? "?" : "";
|
|
275
|
+
const optionalValue = entry.route.optionalBody ? " | undefined" : "";
|
|
276
|
+
parts.push(`{ ${objectKey(bodyProperty)}${optionalMark}: z.infer<typeof ${bodyName}>${optionalValue} }`);
|
|
223
277
|
}
|
|
224
278
|
if (entry.route.rawBodyProperty !== undefined) {
|
|
225
279
|
const reader = rawBodyReaderFor(entry.route.requestContentTypes);
|
|
@@ -310,6 +364,7 @@ securityFor) {
|
|
|
310
364
|
if (names === undefined)
|
|
311
365
|
return [];
|
|
312
366
|
const validators = [];
|
|
367
|
+
let optional;
|
|
313
368
|
for (const location of ["path", "query", "header", "body"]) {
|
|
314
369
|
const identifier = names[location];
|
|
315
370
|
if (identifier === undefined)
|
|
@@ -330,6 +385,35 @@ securityFor) {
|
|
|
330
385
|
continue;
|
|
331
386
|
const targets = [...new Set(body.byType.map(([, target]) => target))];
|
|
332
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
|
+
*/
|
|
333
417
|
validators.push([targets[0], identifier]);
|
|
334
418
|
continue;
|
|
335
419
|
}
|
|
@@ -343,7 +427,7 @@ securityFor) {
|
|
|
343
427
|
validators.push([VALIDATOR_TARGET.body, identifier]);
|
|
344
428
|
dispatched = { byType: body.byType, identifier };
|
|
345
429
|
}
|
|
346
|
-
return [{ route, names, validators, dispatched }];
|
|
430
|
+
return [{ route, names, validators, dispatched, optional }];
|
|
347
431
|
});
|
|
348
432
|
const entries = mounted;
|
|
349
433
|
/**
|
|
@@ -376,6 +460,29 @@ securityFor) {
|
|
|
376
460
|
* which is the opposite of the claim being made.
|
|
377
461
|
*/
|
|
378
462
|
const EMPTY_INPUT = "Record<string, never>";
|
|
463
|
+
/**
|
|
464
|
+
* Emitted only where an operation INTERSECTS a validator's fields, which is what names it.
|
|
465
|
+
*
|
|
466
|
+
* Decided from the routes rather than by searching the rendered text - the mistake that once
|
|
467
|
+
* dropped the `byContentType` import when a call gained an argument.
|
|
468
|
+
*/
|
|
469
|
+
const usesFields = entries.some((entry) => intersectedValidatorsOf(entry).length > 0);
|
|
470
|
+
const fieldsHelper = usesFields
|
|
471
|
+
? `/**
|
|
472
|
+
* A validator's fields, or nothing at all when it declares none.
|
|
473
|
+
*
|
|
474
|
+
* **Zod 4 infers \`Record<string, never>\` for \`z.object({})\`.** That is a fair reading on its own -
|
|
475
|
+
* no string key may exist - and lethal in an intersection: \`{ id: string } & Record<string, never>\`
|
|
476
|
+
* makes \`id\` into \`string & never\`, so the input type is uninhabitable and the call site below
|
|
477
|
+
* cannot compile. An empty model contributes nothing to an operation's input, and \`unknown\` is the
|
|
478
|
+
* identity of \`&\`, so that is what it becomes.
|
|
479
|
+
*
|
|
480
|
+
* A DICTIONARY body is not this: \`Record<unknown>\` has a value type, so it survives untouched.
|
|
481
|
+
*/
|
|
482
|
+
type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown : T) : T;
|
|
483
|
+
|
|
484
|
+
`
|
|
485
|
+
: "";
|
|
379
486
|
const methods = entries.map((entry) => {
|
|
380
487
|
const { route, names } = entry;
|
|
381
488
|
/**
|
|
@@ -419,6 +526,7 @@ securityFor) {
|
|
|
419
526
|
* mentions a name is how this package lost the `byContentType` import: the call gained an argument
|
|
420
527
|
* and the substring stopped matching, so a module referenced a function it no longer imported.
|
|
421
528
|
*/
|
|
529
|
+
const mountsOptionalBody = entries.some((entry) => entry.optional !== undefined);
|
|
422
530
|
const returnsAnything = entries.some((entry) => entry.names.response !== undefined);
|
|
423
531
|
const declaredHelper = returnsAnything
|
|
424
532
|
? `/**
|
|
@@ -600,10 +708,17 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
600
708
|
...gate,
|
|
601
709
|
...validators
|
|
602
710
|
// The dispatched body's own `zValidator` is `byContentType`; emitting both would parse
|
|
603
|
-
// the body twice and reject every media type but one.
|
|
604
|
-
|
|
711
|
+
// the body twice and reject every media type but one. An optional body is the same
|
|
712
|
+
// story with `optionalBody`, which additionally lets a bodyless request through.
|
|
713
|
+
.filter(([target]) => (entry.dispatched === undefined && entry.optional === undefined) ||
|
|
714
|
+
target !== VALIDATOR_TARGET.body)
|
|
605
715
|
.map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid),`),
|
|
606
716
|
...dispatch,
|
|
717
|
+
...(entry.optional === undefined
|
|
718
|
+
? []
|
|
719
|
+
: [
|
|
720
|
+
`\t\toptionalBody(${entry.optional.identifier}, deps.invalid, ${JSON.stringify(entry.optional.target)}),`,
|
|
721
|
+
]),
|
|
607
722
|
];
|
|
608
723
|
/**
|
|
609
724
|
* Whether the operation requires a caller, and NOTHING else about the caller.
|
|
@@ -898,7 +1013,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
898
1013
|
const needsHonoValue = subApps.size > 0 || usesBasePath;
|
|
899
1014
|
return `${generatedBanner(emitted.options.regenerateHint)}
|
|
900
1015
|
${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";'}
|
|
901
|
-
${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};` : ""}${dispatchesBody ? `\nimport { byContentType } from ${runtimeModule};` : ""}
|
|
1016
|
+
${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};` : ""}${dispatchesBody ? `\nimport { byContentType } from ${runtimeModule};` : ""}${mountsOptionalBody ? `\nimport { optionalBody } from ${runtimeModule};` : ""}
|
|
902
1017
|
${imports}
|
|
903
1018
|
/**
|
|
904
1019
|
* One method per operation, each concretely typed from the schemas it validates against.
|
|
@@ -906,7 +1021,7 @@ ${imports}
|
|
|
906
1021
|
* There is no cast anywhere in this file, and no dynamic lookup: the generated call sites name the
|
|
907
1022
|
* method, so an implementation whose input or output does not match the contract fails to compile.
|
|
908
1023
|
*/
|
|
909
|
-
${declaredHelper}export interface Operations {
|
|
1024
|
+
${fieldsHelper}${declaredHelper}export interface Operations {
|
|
910
1025
|
${methods.join("\n")}
|
|
911
1026
|
}
|
|
912
1027
|
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -205,6 +205,32 @@ branches: readonly [
|
|
|
205
205
|
json: output<S>;
|
|
206
206
|
};
|
|
207
207
|
}>;
|
|
208
|
+
/**
|
|
209
|
+
* Validate a body the document says is OPTIONAL, and let a request carrying none through.
|
|
210
|
+
*
|
|
211
|
+
* **`requestBody.required: false` means a request with no body is one the contract permits**, and a
|
|
212
|
+
* plain `zValidator` refused it. Measured against @hono/zod-validator 0.9.0 and hono 4.12.26: a
|
|
213
|
+
* `POST` with `content-type: application/json` and no body answered **400 `Malformed JSON in request
|
|
214
|
+
* body`** as `text/plain` - raised before the `invalid` hook, so outside the app's error envelope
|
|
215
|
+
* entirely. A service refusing what its own document allows, in a shape that document forbids.
|
|
216
|
+
*
|
|
217
|
+
* **Nothing is published when the body is absent**, so `c.req.valid(...)` reads `undefined` and the
|
|
218
|
+
* handler is told the truth. That is why an optional body is a NAMED property on the input rather
|
|
219
|
+
* than merged into it: a merge has no way to say "these are here only sometimes" without making
|
|
220
|
+
* every one of them optional, which is a weaker and different claim about the body that IS sent.
|
|
221
|
+
*
|
|
222
|
+
* A body that is present but unreadable still fails, through `invalid`, so the app's envelope holds.
|
|
223
|
+
*/
|
|
224
|
+
export declare function optionalBody<E extends Env, S extends ZodType>(schema: S, invalid: <P extends string, I extends Input>(result: {
|
|
225
|
+
readonly success: boolean;
|
|
226
|
+
}, c: Context<E, P, I>) => Response | undefined, target: BodyTarget): MiddlewareHandler<E, string, {
|
|
227
|
+
in: {
|
|
228
|
+
json: input<S> | undefined;
|
|
229
|
+
};
|
|
230
|
+
out: {
|
|
231
|
+
json: output<S> | undefined;
|
|
232
|
+
};
|
|
233
|
+
}>;
|
|
208
234
|
/**
|
|
209
235
|
* What the app provides. One object, passed once, rather than a module the generated file imports by
|
|
210
236
|
* path. A generated server that hard-codes `../../backend.js` is only usable by the project it was
|
package/dist/src/runtime.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { HTTPException } from "hono/http-exception";
|
|
2
1
|
/**
|
|
3
2
|
* The arm that applies to a status, preferring an exact code, then its `NXX` range, then `default`.
|
|
4
3
|
*
|
|
@@ -111,6 +110,7 @@ export const headOnly = async (c, next) => c.req.method === "HEAD" ? next() : c.
|
|
|
111
110
|
* the JSON it claims to be. `parseBody({ all: true })` builds the object Hono's `"form"` target
|
|
112
111
|
* builds: a repeated key and a `key[]` name both become an array, everything else stays a string.
|
|
113
112
|
*/
|
|
113
|
+
const UNREADABLE = Symbol("a body that is not what its content type claims");
|
|
114
114
|
async function readBody(c, target) {
|
|
115
115
|
if (target === "form")
|
|
116
116
|
return c.req.parseBody({ all: true });
|
|
@@ -118,9 +118,26 @@ async function readBody(c, target) {
|
|
|
118
118
|
return await c.req.json();
|
|
119
119
|
}
|
|
120
120
|
catch {
|
|
121
|
-
|
|
121
|
+
return UNREADABLE;
|
|
122
122
|
}
|
|
123
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
|
+
}
|
|
124
141
|
/**
|
|
125
142
|
* The slot a validated request body is published under, whichever parser produced it.
|
|
126
143
|
*
|
|
@@ -189,7 +206,8 @@ branches) {
|
|
|
189
206
|
* `Input`. Same reason `RouteDeps` is parameterised.
|
|
190
207
|
*/
|
|
191
208
|
const parse = async (ctx, proceed) => {
|
|
192
|
-
const
|
|
209
|
+
const raw = await readBody(ctx, target);
|
|
210
|
+
const result = raw === UNREADABLE ? await unreadableResult(schema) : await schema.safeParseAsync(raw);
|
|
193
211
|
const response = invalid(result, ctx);
|
|
194
212
|
if (response !== undefined)
|
|
195
213
|
return response;
|
|
@@ -197,10 +215,56 @@ branches) {
|
|
|
197
215
|
// and a single-media-type one reject a bad body the same way.
|
|
198
216
|
if (!result.success)
|
|
199
217
|
return ctx.json(result, 400);
|
|
200
|
-
ctx.req.addValidatedData(BODY_TARGET, result.data ?? {});
|
|
218
|
+
ctx.req.addValidatedData(BODY_TARGET, ("data" in result ? result.data : undefined) ?? {});
|
|
201
219
|
await proceed();
|
|
202
220
|
return undefined;
|
|
203
221
|
};
|
|
204
222
|
return parse(c, next);
|
|
205
223
|
};
|
|
206
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.19.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.20.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@hono/zod-openapi": "^1.4.0",
|
package/src/runtime.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { HTTPException } from "hono/http-exception";
|
|
2
1
|
import type { Context, Env, Input, MiddlewareHandler } from "hono";
|
|
3
2
|
import type { input, output, ZodType } from "zod";
|
|
4
3
|
|
|
@@ -219,15 +218,35 @@ export type BodyTarget = "json" | "form";
|
|
|
219
218
|
* the JSON it claims to be. `parseBody({ all: true })` builds the object Hono's `"form"` target
|
|
220
219
|
* builds: a repeated key and a `key[]` name both become an array, everything else stays a string.
|
|
221
220
|
*/
|
|
221
|
+
const UNREADABLE = Symbol("a body that is not what its content type claims");
|
|
222
|
+
|
|
222
223
|
async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
|
|
223
224
|
if (target === "form") return c.req.parseBody({ all: true });
|
|
224
225
|
try {
|
|
225
226
|
return await c.req.json();
|
|
226
227
|
} catch {
|
|
227
|
-
|
|
228
|
+
return UNREADABLE;
|
|
228
229
|
}
|
|
229
230
|
}
|
|
230
231
|
|
|
232
|
+
/**
|
|
233
|
+
* The failure a malformed body produces, reported through the app's `invalid` hook like any other.
|
|
234
|
+
*
|
|
235
|
+
* **It used to `throw new HTTPException(400)`, which never reaches that hook.** The response was
|
|
236
|
+
* `text/plain` with a bare message, so an API whose document declares a JSON error envelope answered
|
|
237
|
+
* a shape its own contract forbids - and the app had no way to intervene, because the throw happened
|
|
238
|
+
* before its code ran.
|
|
239
|
+
*
|
|
240
|
+
* Parsing `undefined` against the body's own schema is what produces a real Zod failure, so
|
|
241
|
+
* `deps.invalid` receives the shape it receives for every other rejection rather than a special case
|
|
242
|
+
* it has to know about. A schema that somehow accepts `undefined` still fails here: a body that
|
|
243
|
+
* could not be read is not a body that validated.
|
|
244
|
+
*/
|
|
245
|
+
async function unreadableResult(schema: ZodType): Promise<{ readonly success: boolean }> {
|
|
246
|
+
const parsed = await schema.safeParseAsync(undefined);
|
|
247
|
+
return parsed.success ? { success: false } : parsed;
|
|
248
|
+
}
|
|
249
|
+
|
|
231
250
|
/**
|
|
232
251
|
* The slot a validated request body is published under, whichever parser produced it.
|
|
233
252
|
*
|
|
@@ -310,13 +329,15 @@ export function byContentType<E extends Env, S extends ZodType>(
|
|
|
310
329
|
string,
|
|
311
330
|
{ in: { json: input<S> }; out: { json: output<S> } }
|
|
312
331
|
> = async (ctx, proceed) => {
|
|
313
|
-
const
|
|
332
|
+
const raw = await readBody(ctx, target);
|
|
333
|
+
const result =
|
|
334
|
+
raw === UNREADABLE ? await unreadableResult(schema) : await schema.safeParseAsync(raw);
|
|
314
335
|
const response = invalid(result, ctx);
|
|
315
336
|
if (response !== undefined) return response;
|
|
316
337
|
// `zValidator`'s own answer when a hook declines to, kept identical so a dispatched route
|
|
317
338
|
// and a single-media-type one reject a bad body the same way.
|
|
318
339
|
if (!result.success) return ctx.json(result, 400);
|
|
319
|
-
ctx.req.addValidatedData(BODY_TARGET, result.data ?? {});
|
|
340
|
+
ctx.req.addValidatedData(BODY_TARGET, ("data" in result ? result.data : undefined) ?? {});
|
|
320
341
|
await proceed();
|
|
321
342
|
return undefined;
|
|
322
343
|
};
|
|
@@ -324,6 +345,63 @@ export function byContentType<E extends Env, S extends ZodType>(
|
|
|
324
345
|
};
|
|
325
346
|
}
|
|
326
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Validate a body the document says is OPTIONAL, and let a request carrying none through.
|
|
350
|
+
*
|
|
351
|
+
* **`requestBody.required: false` means a request with no body is one the contract permits**, and a
|
|
352
|
+
* plain `zValidator` refused it. Measured against @hono/zod-validator 0.9.0 and hono 4.12.26: a
|
|
353
|
+
* `POST` with `content-type: application/json` and no body answered **400 `Malformed JSON in request
|
|
354
|
+
* body`** as `text/plain` - raised before the `invalid` hook, so outside the app's error envelope
|
|
355
|
+
* entirely. A service refusing what its own document allows, in a shape that document forbids.
|
|
356
|
+
*
|
|
357
|
+
* **Nothing is published when the body is absent**, so `c.req.valid(...)` reads `undefined` and the
|
|
358
|
+
* handler is told the truth. That is why an optional body is a NAMED property on the input rather
|
|
359
|
+
* than merged into it: a merge has no way to say "these are here only sometimes" without making
|
|
360
|
+
* every one of them optional, which is a weaker and different claim about the body that IS sent.
|
|
361
|
+
*
|
|
362
|
+
* A body that is present but unreadable still fails, through `invalid`, so the app's envelope holds.
|
|
363
|
+
*/
|
|
364
|
+
export function optionalBody<E extends Env, S extends ZodType>(
|
|
365
|
+
schema: S,
|
|
366
|
+
invalid: <P extends string, I extends Input>(
|
|
367
|
+
result: { readonly success: boolean },
|
|
368
|
+
c: Context<E, P, I>,
|
|
369
|
+
) => Response | undefined,
|
|
370
|
+
target: BodyTarget,
|
|
371
|
+
): MiddlewareHandler<
|
|
372
|
+
E,
|
|
373
|
+
string,
|
|
374
|
+
{ in: { json: input<S> | undefined }; out: { json: output<S> | undefined } }
|
|
375
|
+
> {
|
|
376
|
+
return async (c, next) => {
|
|
377
|
+
if (!hasBody(c)) {
|
|
378
|
+
await next();
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
const raw = await readBody(c, target);
|
|
382
|
+
const result =
|
|
383
|
+
raw === UNREADABLE ? await unreadableResult(schema) : await schema.safeParseAsync(raw);
|
|
384
|
+
const response = invalid(result, c as never);
|
|
385
|
+
if (response !== undefined) return response;
|
|
386
|
+
if (!result.success) return c.json(result, 400);
|
|
387
|
+
c.req.addValidatedData(BODY_TARGET, ("data" in result ? result.data : undefined) ?? {});
|
|
388
|
+
await next();
|
|
389
|
+
return undefined;
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Whether the request carries a body at all.
|
|
395
|
+
*
|
|
396
|
+
* Both halves are load-bearing. The platform reports `null` for a request sent without one, and a
|
|
397
|
+
* caller may instead send `content-length: 0`, which is a body of no bytes and reads the same way to
|
|
398
|
+
* anything downstream. Neither alone covers what arrives.
|
|
399
|
+
*/
|
|
400
|
+
function hasBody(c: Context): boolean {
|
|
401
|
+
if (c.req.raw.body === null) return false;
|
|
402
|
+
return (c.req.header("content-length") ?? "").trim() !== "0";
|
|
403
|
+
}
|
|
404
|
+
|
|
327
405
|
/**
|
|
328
406
|
* What the app provides. One object, passed once, rather than a module the generated file imports by
|
|
329
407
|
* path. A generated server that hard-codes `../../backend.js` is only usable by the project it was
|