Skip to content
Merged
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
14 changes: 12 additions & 2 deletions docs/2.utils/1.request.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,28 @@ app.get("/", async (event) => {
});
```

### `readBody(event)`
### `readBody(event, options?)`

Reads request body and tries to parse using JSON.parse or URLSearchParams.

By default the body is parsed as JSON (falling back to URL-encoded parsing when the `Content-Type` is `application/x-www-form-urlencoded`). Other body types, such as `multipart/form-data`, must be opted into explicitly via `options.type` and are never auto-detected from the request headers.

**Example:**

```ts
app.get("/", async (event) => {
app.post("/", async (event) => {
const body = await readBody(event);
});
```

**Example:**

```ts
app.post("/upload", async (event) => {
const body = await readBody(event, { type: "formData" });
});
```

### `readValidatedBody(event, validate)`

Tries to read the request body via `readBody`, then uses the provided validation schema or function and either throws a validation error or returns the result.
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export {

// Body

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

// Cookie

Expand Down
66 changes: 59 additions & 7 deletions src/utils/body.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,92 @@
import { type ErrorDetails, HTTPError } from "../error.ts";
import { type OnValidateError, validateData } from "./internal/validate.ts";
import { parseURLEncodedBody } from "./internal/body.ts";
import { parseURLEncodedBody, parseFormData } from "./internal/body.ts";

import type { HTTPEvent } from "../event.ts";
import type { InferEventInput } from "../types/handler.ts";
import type { ValidateResult } from "./internal/validate.ts";
import type { StandardSchemaV1, FailureResult, InferOutput } from "./internal/standard-schema.ts";

export interface ReadBodyOptions {
/**
* Force a parser instead of inferring it from the request `Content-Type`.
*
* - `"json"` (default): parse as JSON.
* - `"text"`: return the raw string body.
* - `"urlencoded"`: parse as `application/x-www-form-urlencoded`.
* - `"formData"`: parse as `multipart/form-data` (or url-encoded) form data.
*/
type?: "json" | "text" | "urlencoded" | "formData";
}

/**
* Reads request body and tries to parse using JSON.parse or URLSearchParams.
*
* By default the body is parsed as JSON (falling back to URL-encoded parsing
* when the `Content-Type` is `application/x-www-form-urlencoded`). Other body
* types, such as `multipart/form-data`, must be opted into explicitly via
* `options.type` and are never auto-detected from the request headers.
*
* @example
* app.get("/", async (event) => {
* app.post("/", async (event) => {
* const body = await readBody(event);
* });
* @example
* app.post("/upload", async (event) => {
* const body = await readBody(event, { type: "formData" });
* });
*
* @param event H3 event passed by h3 handler
* @param encoding The character encoding to use, defaults to 'utf-8'.
* @param options Parsing options. Set `type` to force a parser instead of
* inferring it from the request `Content-Type`.
*
* @return {*} The `Object`, `Array`, `String`, `Number`, `Boolean`, or `null` value corresponding to the request JSON body
* @return {*} The `Object`, `Array`, `String`, `Number`, `Boolean`, or `null` value corresponding to the request body
*/
export async function readBody<
T,
_Event extends HTTPEvent = HTTPEvent,
_T = InferEventInput<"body", _Event, T>,
>(event: _Event): Promise<undefined | _T> {
>(event: _Event, options?: ReadBodyOptions): Promise<undefined | _T> {
const contentType = event.req.headers.get("content-type") || "";
const type = options?.type;

// `formData` (multipart or url-encoded) is strictly opt-in: unlike JSON it
// is never auto-detected from the `Content-Type` header, so an untrusted
// request cannot push readBody into (potentially expensive) multipart
// parsing without the handler explicitly asking for it. See #875.
if (type === "formData") {
let form: FormData;
try {
form = await event.req.formData();
} catch {
throw new HTTPError({
status: 400,
statusText: "Bad Request",
message: "Invalid form data body",
});
}
return parseFormData(form) as _T;
}

const text = await event.req.text();

// Text is returned verbatim, including an empty body as `""`.
if (type === "text") {
return text as _T;
}

if (!text) {
return undefined;
}

const contentType = event.req.headers.get("content-type") || "";
if (contentType.startsWith("application/x-www-form-urlencoded")) {
if (
type === "urlencoded" ||
(!type && contentType.startsWith("application/x-www-form-urlencoded"))
) {
return parseURLEncodedBody(text) as _T;
}

// Default, and explicit `type: "json"`.
try {
return JSON.parse(text) as _T;
} catch {
Expand Down
29 changes: 19 additions & 10 deletions src/utils/internal/body.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import { EmptyObject } from "./obj.ts";
import { hasProp } from "./object.ts";

export function parseURLEncodedBody(body: string) {
const form = new URLSearchParams(body);
const parsedForm: Record<string, any> = new EmptyObject();
for (const [key, value] of form.entries()) {
if (hasProp(parsedForm, key)) {
if (!Array.isArray(parsedForm[key])) {
parsedForm[key] = [parsedForm[key]];
export function parseURLEncodedBody(body: string): unknown {
return collectEntries(new URLSearchParams(body).entries());
}

export function parseFormData(form: FormData): unknown {
return collectEntries(form.entries());
}

// Collect key/value entries into an object, keeping repeated keys as arrays
// (e.g. multi-selects or `foo=1&foo=2`) instead of dropping earlier values.
function collectEntries(entries: IterableIterator<[string, unknown]>): unknown {
const parsed: Record<string, any> = new EmptyObject();
for (const [key, value] of entries) {
if (hasProp(parsed, key)) {
if (!Array.isArray(parsed[key])) {
parsed[key] = [parsed[key]];
}
parsedForm[key].push(value);
parsed[key].push(value);
} else {
parsedForm[key] = value;
parsed[key] = value;
}
}
return parsedForm as unknown;
return parsed as unknown;
}
120 changes: 120 additions & 0 deletions test/body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,126 @@ describeMatrix("body", (t, { it, expect, describe }) => {
]);
});

describe("readBody with multipart/form-data", () => {
// Reusable multipart payload with a repeated `number` field.
const multipart = {
headers: {
"content-type":
"multipart/form-data; boundary=---------------------------12537827810750053901680552518",
},
body: '-----------------------------12537827810750053901680552518\r\nContent-Disposition: form-data; name="field"\r\n\r\nvalue\r\n-----------------------------12537827810750053901680552518\r\nContent-Disposition: form-data; name="number"\r\n\r\n20\r\n-----------------------------12537827810750053901680552518\r\nContent-Disposition: form-data; name="number"\r\n\r\n30\r\n-----------------------------12537827810750053901680552518--\r\n',
};

it("parses form data into an object when opted in via type: formData", async () => {
let _body: any;
t.app.all("/api/test", async (event) => {
_body = await readBody(event, { type: "formData" });
return "200";
});
const result = await t.fetch("/api/test", { method: "POST", ...multipart });
// Repeated keys are preserved as an array, not collapsed to the last value.
expect(_body).toMatchObject({ field: "value", number: ["20", "30"] });
expect(await result.text()).toBe("200");
});

it("does NOT auto-parse multipart without an explicit opt-in", async () => {
// Without `type: "formData"` a multipart body falls through to the JSON
// parser and is rejected — parsing form data is never header-driven.
t.app.all("/api/test", async (event) => {
await readBody(event);
return "200";
});
const result = await t.fetch("/api/test", { method: "POST", ...multipart });
expect(result.status).toBe(400);
});

it("throws a 400 on a malformed multipart body", async () => {
t.app.all("/api/test", async (event) => {
await readBody(event, { type: "formData" });
return "200";
});
const result = await t.fetch("/api/test", {
method: "POST",
headers: {
"content-type": "multipart/form-data; boundary=----broken",
},
// Body does not match the declared boundary → formData() throws.
body: "not a valid multipart body",
});
expect(result.status).toBe(400);
});
});

describe("readBody with an explicit type", () => {
it("type: text returns the raw string, ignoring the content-type", async () => {
let _body: any;
t.app.all("/api/test", async (event) => {
_body = await readBody(event, { type: "text" });
return "200";
});
// JSON content-type, but `type: text` forces the raw string.
await t.fetch("/api/test", {
method: "POST",
headers: { "content-type": "application/json" },
body: '{ "hello": true }',
});
expect(_body).toBe('{ "hello": true }');
});

it("type: text returns an empty string for an empty body", async () => {
let _body: any = "unset";
t.app.all("/api/test", async (event) => {
_body = await readBody(event, { type: "text" });
return "200";
});
await t.fetch("/api/test", { method: "POST" });
expect(_body).toBe("");
});

it("type: urlencoded parses regardless of the content-type", async () => {
let _body: any;
t.app.all("/api/test", async (event) => {
_body = await readBody(event, { type: "urlencoded" });
return "200";
});
// No urlencoded content-type, but `type: urlencoded` forces the parser.
await t.fetch("/api/test", {
method: "POST",
headers: { "content-type": "text/plain" },
body: "field=value&number=20&number=30",
});
expect(_body).toMatchObject({ field: "value", number: ["20", "30"] });
});

it("type: json parses even when the content-type is urlencoded", async () => {
let _body: any;
t.app.all("/api/test", async (event) => {
_body = await readBody(event, { type: "json" });
return "200";
});
// urlencoded content-type is ignored in favor of the forced JSON parser.
await t.fetch("/api/test", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: '{ "hello": true }',
});
expect(_body).toMatchObject({ hello: true });
});

it("type: json throws a 400 on an invalid JSON body", async () => {
t.app.all("/api/test", async (event) => {
await readBody(event, { type: "json" });
return "200";
});
const result = await t.fetch("/api/test", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "field=value",
});
expect(result.status).toBe(400);
});
});

it("returns empty string if body is not present with text/plain", async () => {
let _body: string | undefined;
t.app.all("/api/test", async (event) => {
Expand Down
Loading