From 9070052530d6daa7914bc3c36f5791becfc0c5a5 Mon Sep 17 00:00:00 2001 From: productdevbook Date: Sun, 15 Mar 2026 10:39:57 +0300 Subject: [PATCH 1/2] feat: add getPayload utility for unified request data access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges route params, query params, and body into a single object: - GET/HEAD: route params + query params - POST/PUT/PATCH/DELETE: route params + parsed body Route params have lowest priority — body/query values override them. Usage: app.post("/users/:id", async (event) => { const payload = await getPayload(event); // { id: "123", name: "Alice" } }); Closes #785 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 4 +++ src/utils/payload.ts | 39 +++++++++++++++++++++++++ test/unit/package.test.ts | 1 + test/unit/payload.test.ts | 61 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 src/utils/payload.ts create mode 100644 test/unit/payload.test.ts diff --git a/src/index.ts b/src/index.ts index 827dedddd..4a4c8c0ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -118,6 +118,10 @@ export { export { readBody, readValidatedBody, assertBodySize } from "./utils/body.ts"; +// Payload + +export { getPayload } from "./utils/payload.ts"; + // Cookie export { diff --git a/src/utils/payload.ts b/src/utils/payload.ts new file mode 100644 index 000000000..5922aa1c7 --- /dev/null +++ b/src/utils/payload.ts @@ -0,0 +1,39 @@ +import type { H3Event, HTTPEvent } from "../event.ts"; +import { getQuery } from "./request.ts"; +import { readBody } from "./body.ts"; +import { getRouterParams } from "./request.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) => { + * const payload = await getPayload(event); + * // { category: "books", q: "h3" } — category from route, q from query + * }); + */ +export async function getPayload>( + event: H3Event | HTTPEvent, + opts?: { decode?: boolean }, +): Promise { + const params = getRouterParams(event, opts); + if (_payloadMethods.has(event.req.method)) { + const body = (await readBody(event)) || {}; + return { ...params, ...(typeof body === "object" ? body : { body }) } as T; + } + const query = getQuery(event); + return { ...params, ...query } as T; +} diff --git a/test/unit/package.test.ts b/test/unit/package.test.ts index 184839f12..6c9f7795d 100644 --- a/test/unit/package.test.ts +++ b/test/unit/package.test.ts @@ -58,6 +58,7 @@ describe("h3 package", () => { "getHeader", "getHeaders", "getMethod", + "getPayload", "getProxyRequestHeaders", "getQuery", "getRequestFingerprint", diff --git a/test/unit/payload.test.ts b/test/unit/payload.test.ts new file mode 100644 index 000000000..66684fa7e --- /dev/null +++ b/test/unit/payload.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { H3, getPayload } from "../../src/index.ts"; +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"); + }); +}); From 358918e20dde690007ffb4c714e9801c10fec5a3 Mon Sep 17 00:00:00 2001 From: productdevbook Date: Sun, 15 Mar 2026 10:41:55 +0300 Subject: [PATCH 2/2] feat: add getValidatedPayload for schema validation Add getValidatedPayload(event, validate, options?) following the same pattern as getValidatedQuery and readValidatedBody. Validates the merged payload (route params + query/body) against a Standard Schema or custom validator function. As requested in the original issue. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 2 +- src/utils/payload.ts | 36 ++++++++++++++++++++++++++++++++++++ test/unit/package.test.ts | 1 + test/unit/payload.test.ts | 27 ++++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4a4c8c0ea..e696f8175 100644 --- a/src/index.ts +++ b/src/index.ts @@ -120,7 +120,7 @@ export { readBody, readValidatedBody, assertBodySize } from "./utils/body.ts"; // Payload -export { getPayload } from "./utils/payload.ts"; +export { getPayload, getValidatedPayload } from "./utils/payload.ts"; // Cookie diff --git a/src/utils/payload.ts b/src/utils/payload.ts index 5922aa1c7..a005285b9 100644 --- a/src/utils/payload.ts +++ b/src/utils/payload.ts @@ -1,7 +1,11 @@ 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"]); @@ -37,3 +41,35 @@ export async function getPayload>( const query = getQuery(event); 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: Event, + validate: S, + options?: { onError?: (result: FailureResult) => ErrorDetails }, +): Promise>; +export function getValidatedPayload( + event: Event, + validate: ( + data: Record, + ) => ValidateResult | Promise>, + options?: { onError?: () => ErrorDetails }, +): Promise; +export async function getValidatedPayload( + event: H3Event | HTTPEvent, + validate: any, + options?: { onError?: OnValidateError }, +): Promise { + const payload = await getPayload(event); + return validateData(payload, validate, options); +} diff --git a/test/unit/package.test.ts b/test/unit/package.test.ts index 6c9f7795d..c8adfe13f 100644 --- a/test/unit/package.test.ts +++ b/test/unit/package.test.ts @@ -77,6 +77,7 @@ describe("h3 package", () => { "getRouterParam", "getRouterParams", "getSession", + "getValidatedPayload", "getValidatedQuery", "getValidatedRouterParams", "handleCacheHeaders", diff --git a/test/unit/payload.test.ts b/test/unit/payload.test.ts index 66684fa7e..5c3751fb4 100644 --- a/test/unit/payload.test.ts +++ b/test/unit/payload.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { H3, getPayload } from "../../src/index.ts"; +import { H3, getPayload, getValidatedPayload } from "../../src/index.ts"; +import { z } from "zod/v4"; import { describeMatrix } from "../_setup.ts"; describeMatrix("getPayload", (t, { it, expect }) => { @@ -58,4 +59,28 @@ describeMatrix("getPayload", (t, { it, expect }) => { }); 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); + }); });