-
Notifications
You must be signed in to change notification settings - Fork 358
feat: add getPayload and getValidatedPayload utilities #1339
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) => { | ||||||||||||||||||||||||||||||
| * 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); | ||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. router params should not be included. only query+body There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) || {}; | ||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Preserve falsy JSON primitive bodies instead of dropping them. Line 38 uses 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need an object with null proto |
||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| const query = getQuery(event); | ||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can use |
||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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.tsRepository: 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.tsRepository: h3js/h3 Length of output: 1654 🏁 Script executed: sed -n '10,20p' src/utils/internal/validate.tsRepository: h3js/h3 Length of output: 360 Custom-validator overloads have inconsistent The custom validator overloads narrow Affected overloads:
Change custom validator overloads to match 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| export async function getValidatedPayload( | ||||||||||||||||||||||||||||||
| event: H3Event | HTTPEvent, | ||||||||||||||||||||||||||||||
| validate: any, | ||||||||||||||||||||||||||||||
| options?: { onError?: OnValidateError }, | ||||||||||||||||||||||||||||||
| ): Promise<any> { | ||||||||||||||||||||||||||||||
| const payload = await getPayload(event); | ||||||||||||||||||||||||||||||
| return validateData(payload, validate, options); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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:
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