diff --git a/src/index.ts b/src/index.ts index 827dedddd..e696f8175 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, getValidatedPayload } from "./utils/payload.ts"; + // Cookie export { diff --git a/src/utils/payload.ts b/src/utils/payload.ts new file mode 100644 index 000000000..a005285b9 --- /dev/null +++ b/src/utils/payload.ts @@ -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) => { + * 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; +} + +/** + * 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 184839f12..c8adfe13f 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", @@ -76,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 new file mode 100644 index 000000000..5c3751fb4 --- /dev/null +++ b/test/unit/payload.test.ts @@ -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); + }); +});