Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ export {

export { readBody, readValidatedBody, assertBodySize } from "./utils/body.ts";

// Payload

export { getPayload, getValidatedPayload } from "./utils/payload.ts";

// Cookie

export {
Expand Down
75 changes: 75 additions & 0 deletions src/utils/payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { H3Event, HTTPEvent } from "../event.ts";
import type { ErrorDetails } from "../error.ts";
import type { StandardSchemaV1, FailureResult, InferOutput } from "./internal/standard-schema.ts";
import type { ValidateResult, OnValidateError } from "./internal/validate.ts";
import { getQuery } from "./request.ts";
import { readBody } from "./body.ts";
import { getRouterParams } from "./request.ts";
import { validateData } from "./internal/validate.ts";

const _payloadMethods = new Set(["PATCH", "POST", "PUT", "DELETE"]);

/**
* Get the request payload by merging route params, query params, and body data.
*
* For `GET` and `HEAD` requests, returns query params merged with route params.
* For `POST`, `PUT`, `PATCH`, and `DELETE` requests, returns parsed body merged with route params.
*
* Route params take lowest priority (body/query overrides them).
*
* @example
* app.post("/users/:id", async (event) => {
* const payload = await getPayload(event);
* // { id: "123", name: "Alice" } — id from route, name from body
* });
*
* @example
* app.get("/search/:category", async (event) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's also been useful in my experience to include query params for post requests, so that order of importance goes:

  1. body
  2. query params
  3. route params

For example a post request to:
/users/:id?email=test@test.com

with the body:
{ name: "Alice" }

would result in:

const { id, name, email } = await getPayload(event);

Why?
Mostly just when I'm moving fast and mix up query and body. But the idea being the payload is just everything that we pass as data to the backend

* const payload = await getPayload(event);
* // { category: "books", q: "h3" } — category from route, q from query
* });
*/
export async function getPayload<T = Record<string, unknown>>(
event: H3Event | HTTPEvent,
opts?: { decode?: boolean },
): Promise<T> {
const params = getRouterParams(event, opts);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

router params should not be included. only query+body

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in my original issue #785, I did include route params. This way has been most useful for me. Any downsides?

if (_payloadMethods.has(event.req.method)) {
const body = (await readBody(event)) || {};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should use event.req.json()

return { ...params, ...(typeof body === "object" ? body : { body }) } as T;
Comment on lines +38 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve falsy JSON primitive bodies instead of dropping them.

Line 38 uses || {}, so valid parsed bodies like 0, false, and "" are treated as empty and lost.

Suggested fix
-    const body = (await readBody(event)) || {};
-    return { ...params, ...(typeof body === "object" ? body : { body }) } as T;
+    const body = await readBody(event);
+    return {
+      ...params,
+      ...(body === undefined
+        ? {}
+        : body !== null && typeof body === "object"
+          ? body
+          : { body }),
+    } as T;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/payload.ts` around lines 38 - 39, The current return uses (await
readBody(event)) || {} which drops valid falsy parsed bodies (0, false, ""), so
change the check to only default when body is strictly undefined (e.g., const
body = await readBody(event); const safeBody = body === undefined ? {} : body)
and then merge using params and when safeBody is an object (typeof safeBody ===
"object" && safeBody !== null) spread it, otherwise include it as { body:
safeBody }; update references to readBody, body, params and the return
expression accordingly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need an object with null proto

}
const query = getQuery(event);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use event.url to get query instead of getQuery

return { ...params, ...query } as T;
}

/**
* Get and validate the request payload using a Standard Schema or custom validator.
*
* @example
* app.post("/users/:id", async (event) => {
* const payload = await getValidatedPayload(event, z.object({
* id: z.string(),
* name: z.string(),
* }));
* });
*/
export function getValidatedPayload<Event extends HTTPEvent, S extends StandardSchemaV1<any, any>>(
event: Event,
validate: S,
options?: { onError?: (result: FailureResult) => ErrorDetails },
): Promise<InferOutput<S>>;
export function getValidatedPayload<Event extends HTTPEvent, OutputT>(
event: Event,
validate: (
data: Record<string, unknown>,
) => ValidateResult<OutputT> | Promise<ValidateResult<OutputT>>,
options?: { onError?: () => ErrorDetails },
): Promise<OutputT>;
Comment on lines +61 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify onError callback signatures across validated helpers.
rg -nP --type=ts -C3 'export function getValidated(Payload|Query|Body)\(' src/utils
rg -nP --type=ts -C3 'onError\?:' src/utils/payload.ts src/utils/request.ts src/utils/body.ts src/utils/internal/validate.ts

Repository: h3js/h3

Length of output: 7268


🏁 Script executed:

rg -nP --type=ts 'type OnValidateError' src/
rg -nP --type=ts -A5 'onError\(' src/utils/internal/validate.ts

Repository: h3js/h3

Length of output: 1654


🏁 Script executed:

sed -n '10,20p' src/utils/internal/validate.ts

Repository: h3js/h3

Length of output: 360


Custom-validator overloads have inconsistent onError signatures across multiple functions.

The custom validator overloads narrow onError to a zero-arg callback, but the implementation always invokes it with a FailureResult object. This typing mismatch breaks type inference for accessing validation issues.

Affected overloads:

  • getValidatedPayload (line 66)
  • readValidatedBody (line 62)
  • getValidatedQuery (line 70)
  • getValidatedRouterParams (line 174)

Change custom validator overloads to match OnValidateError type and StandardSchema counterparts:

Example fix for getValidatedPayload
 export function getValidatedPayload<Event extends HTTPEvent, OutputT>(
   event: Event,
   validate: (
     data: Record<string, unknown>,
   ) => ValidateResult<OutputT> | Promise<ValidateResult<OutputT>>,
-  options?: { onError?: () => ErrorDetails },
+  options?: { onError?: (result: FailureResult) => ErrorDetails },
 ): Promise<OutputT>;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function getValidatedPayload<Event extends HTTPEvent, OutputT>(
event: Event,
validate: (
data: Record<string, unknown>,
) => ValidateResult<OutputT> | Promise<ValidateResult<OutputT>>,
options?: { onError?: () => ErrorDetails },
): Promise<OutputT>;
export function getValidatedPayload<Event extends HTTPEvent, OutputT>(
event: Event,
validate: (
data: Record<string, unknown>,
) => ValidateResult<OutputT> | Promise<ValidateResult<OutputT>>,
options?: { onError?: (result: FailureResult) => ErrorDetails },
): Promise<OutputT>;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/payload.ts` around lines 61 - 67, The custom-validator overloads
(getValidatedPayload, readValidatedBody, getValidatedQuery,
getValidatedRouterParams) declare options.onError as a zero-arg callback but the
implementation calls it with a FailureResult; update those overload signatures
to use the OnValidateError type (i.e., options?: { onError?: OnValidateError })
to match the StandardSchema overloads and the actual call site. Ensure the
onError param type accepts the FailureResult (or the existing FailureResult type
alias) so callers can inspect validation. Adjust any related type
imports/exports (OnValidateError, FailureResult) so the overloads and
implementation are consistent for ValidateResult/ValidateResult<OutputT> usage.

export async function getValidatedPayload(
event: H3Event | HTTPEvent,
validate: any,
options?: { onError?: OnValidateError },
): Promise<any> {
const payload = await getPayload(event);
return validateData(payload, validate, options);
}
2 changes: 2 additions & 0 deletions test/unit/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe("h3 package", () => {
"getHeader",
"getHeaders",
"getMethod",
"getPayload",
"getProxyRequestHeaders",
"getQuery",
"getRequestFingerprint",
Expand All @@ -76,6 +77,7 @@ describe("h3 package", () => {
"getRouterParam",
"getRouterParams",
"getSession",
"getValidatedPayload",
"getValidatedQuery",
"getValidatedRouterParams",
"handleCacheHeaders",
Expand Down
86 changes: 86 additions & 0 deletions test/unit/payload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import { H3, getPayload, getValidatedPayload } from "../../src/index.ts";
import { z } from "zod/v4";
import { describeMatrix } from "../_setup.ts";

describeMatrix("getPayload", (t, { it, expect }) => {
it("returns query params for GET requests", async () => {
t.app.get("/search", async (event) => {
return getPayload(event);
});
const res = await t.fetch("/search?q=hello&page=1");
expect(await res.json()).toMatchObject({ q: "hello", page: "1" });
});

it("returns body for POST requests", async () => {
t.app.post("/users", async (event) => {
return getPayload(event);
});
const res = await t.fetch("/users", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Alice" }),
});
expect(await res.json()).toMatchObject({ name: "Alice" });
});

it("merges route params with query for GET", async () => {
t.app.get("/search/:category", async (event) => {
return getPayload(event);
});
const res = await t.fetch("/search/books?q=h3");
const data = await res.json();
expect(data.category).toBe("books");
expect(data.q).toBe("h3");
});

it("merges route params with body for POST", async () => {
t.app.post("/users/:id", async (event) => {
return getPayload(event);
});
const res = await t.fetch("/users/123", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Bob" }),
});
const data = await res.json();
expect(data.id).toBe("123");
expect(data.name).toBe("Bob");
});

it("body overrides route params on conflict", async () => {
t.app.put("/items/:id", async (event) => {
return getPayload(event);
});
const res = await t.fetch("/items/old", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: "new" }),
});
expect((await res.json()).id).toBe("new");
});

it("getValidatedPayload validates with zod schema", async () => {
t.app.post("/items", async (event) => {
return getValidatedPayload(event, z.object({ name: z.string(), price: z.number() }));
});
const res = await t.fetch("/items", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Widget", price: 9.99 }),
});
expect(await res.json()).toMatchObject({ name: "Widget", price: 9.99 });
});

it("getValidatedPayload throws on invalid data", async () => {
t.app.post("/items", async (event) => {
return getValidatedPayload(event, z.object({ name: z.string(), price: z.number() }));
});
const res = await t.fetch("/items", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: 123 }),
});
expect(res.status).toBe(400);
});
});
Loading