Skip to content

Commit

Permalink
feat(readBody): validate requests with application/json content type (
Browse files Browse the repository at this point in the history
#207)

Co-authored-by: Pooya Parsa <pooya@pi0.io>
  • Loading branch information
tobiasdiez and pi0 authored Jul 10, 2023
1 parent a5c8c9f commit d11f817
Show file tree
Hide file tree
Showing 2 changed files with 158 additions and 34 deletions.
95 changes: 66 additions & 29 deletions src/utils/body.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import type { IncomingMessage } from "node:http";
import destr from "destr";
import type { Encoding, HTTPMethod } from "../types";
import type { H3Event } from "../event";
import { createError } from "../error";
import { parse as parseMultipartData } from "./internal/multipart";
import { assertMethod, getRequestHeader } from "./request";

export type { MultiPartData } from "./internal/multipart";

const RawBodySymbol = Symbol.for("h3RawBody");
const ParsedBodySymbol = Symbol.for("h3ParsedBody");
type InternalRequest<T = any> = IncomingMessage & {
[RawBodySymbol]?: Promise<Buffer | undefined>;
[ParsedBodySymbol]?: T;
body?: string | undefined;
};

const PayloadMethods: HTTPMethod[] = ["PATCH", "POST", "PUT", "DELETE"];

/**
* Reads body of the request and returns encoded raw string (default) or `Buffer` if encoding if falsy.
* Reads body of the request and returns encoded raw string (default), or `Buffer` if encoding is falsy.
* @param event {H3Event} H3 event or req passed by h3 handler
* @param encoding {Encoding} encoding="utf-8" - The character encoding to use.
*
Expand Down Expand Up @@ -73,45 +80,42 @@ export function readRawBody<E extends Encoding = "utf8">(
}

/**
* Reads request body and try to safely parse using [destr](https://github.com/unjs/destr)
* @param event {H3Event} H3 event or req passed by h3 handler
* Reads request body and tries to safely parse using [destr](https://github.com/unjs/destr).
* @param event {H3Event} H3 event passed by h3 handler
* @param encoding {Encoding} encoding="utf-8" - The character encoding to use.
*
* @return {*} The `Object`, `Array`, `String`, `Number`, `Boolean`, or `null` value corresponding to the request JSON body
*
* ```ts
* const body = await readBody(req)
* const body = await readBody(event)
* ```
*/
export async function readBody<T = any>(event: H3Event): Promise<T> {
if (ParsedBodySymbol in event.node.req) {
return (event.node.req as any)[ParsedBodySymbol];
export async function readBody<T = any>(
event: H3Event,
options: { strict?: boolean } = {}
): Promise<T | undefined | string> {
const request = event.node.req as InternalRequest<T>;
if (ParsedBodySymbol in request) {
return request[ParsedBodySymbol];
}

const body = await readRawBody(event, "utf8");

if (
event.node.req.headers["content-type"] ===
"application/x-www-form-urlencoded"
) {
const form = new URLSearchParams(body);
const parsedForm: Record<string, any> = Object.create(null);
for (const [key, value] of form.entries()) {
if (key in parsedForm) {
if (!Array.isArray(parsedForm[key])) {
parsedForm[key] = [parsedForm[key]];
}
parsedForm[key].push(value);
} else {
parsedForm[key] = value;
}
}
return parsedForm as unknown as T;
const contentType = request.headers["content-type"] || "";
const body = await readRawBody(event);

let parsed: T;

if (contentType === "application/json") {
parsed = _parseJSON(body, options.strict ?? true) as T;
} else if (contentType === "application/x-www-form-urlencoded") {
parsed = _parseURLEncodedBody(body!) as T;
} else if (contentType.startsWith("text/")) {
parsed = body as T;
} else {
parsed = _parseJSON(body, options.strict ?? false) as T;
}

const json = destr(body) as T;
(event.node.req as any)[ParsedBodySymbol] = json;
return json;
request[ParsedBodySymbol] = parsed;
return parsed;
}

export async function readMultipartFormData(event: H3Event) {
Expand All @@ -129,3 +133,36 @@ export async function readMultipartFormData(event: H3Event) {
}
return parseMultipartData(body, boundary);
}

// --- Internal ---

function _parseJSON(body = "", strict: boolean) {
if (!body) {
return undefined;
}
try {
return destr(body, { strict });
} catch {
throw createError({
statusCode: 400,
statusMessage: "Bad Request",
message: "Invalid JSON body",
});
}
}

function _parseURLEncodedBody(body: string) {
const form = new URLSearchParams(body);
const parsedForm: Record<string, any> = Object.create(null);
for (const [key, value] of form.entries()) {
if (key in parsedForm) {
if (!Array.isArray(parsedForm[key])) {
parsedForm[key] = [parsedForm[key]];
}
parsedForm[key].push(value);
} else {
parsedForm[key] = value;
}
}
return parsedForm as unknown;
}
97 changes: 92 additions & 5 deletions test/body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe("", () => {
});

it("returns undefined if body is not present", async () => {
let body: string | undefined = "initial";
let body = "initial";
app.use(
"/",
eventHandler(async (request) => {
Expand All @@ -56,7 +56,7 @@ describe("", () => {
});

it("returns an empty string if body is empty", async () => {
let body: string | undefined = "initial";
let body = "initial";
app.use(
"/",
eventHandler(async (request) => {
Expand All @@ -71,7 +71,7 @@ describe("", () => {
});

it("returns an empty object string if body is empty object", async () => {
let body: string | undefined = "initial";
let body = "initial";
app.use(
"/",
eventHandler(async (request) => {
Expand Down Expand Up @@ -110,7 +110,7 @@ describe("", () => {
});

it("handles non-present body", async () => {
let _body = "initial";
let _body;
app.use(
"/",
eventHandler(async (request) => {
Expand All @@ -136,7 +136,7 @@ describe("", () => {
.post("/api/test")
.set("Content-Type", "text/plain")
.send('""');
expect(_body).toStrictEqual("");
expect(_body).toStrictEqual('""');
expect(result.text).toBe("200");
});

Expand Down Expand Up @@ -265,5 +265,92 @@ describe("", () => {
]
`);
});

it("returns undefined if body is not present with text/plain", async () => {
let body;
app.use(
"/",
eventHandler(async (request) => {
body = await readBody(request);
return "200";
})
);
const result = await request
.post("/api/test")
.set("Content-Type", "text/plain");

expect(body).toBeUndefined();
expect(result.text).toBe("200");
});

it("returns undefined if body is not present with json", async () => {
let body;
app.use(
"/",
eventHandler(async (request) => {
body = await readBody(request);
return "200";
})
);
const result = await request
.post("/api/test")
.set("Content-Type", "application/json");

expect(body).toBeUndefined();
expect(result.text).toBe("200");
});

it("returns the string if content type is text/*", async () => {
let body;
app.use(
"/",
eventHandler(async (request) => {
body = await readBody(request);
return "200";
})
);
const result = await request
.post("/api/test")
.set("Content-Type", "text/*")
.send('{ "hello": true }');

expect(body).toBe('{ "hello": true }');
expect(result.text).toBe("200");
});

it("returns string as is if cannot parse with unknown content type", async () => {
app.use(
"/",
eventHandler(async (request) => {
const _body = await readBody(request);
return _body;
})
);
const result = await request
.post("/api/test")
.set("Content-Type", "application/foobar")
.send("{ test: 123 }");

expect(result.statusCode).toBe(200);
expect(result.text).toBe("{ test: 123 }");
});

it("fails if json is invalid", async () => {
app.use(
"/",
eventHandler(async (request) => {
const _body = await readBody(request);
return _body;
})
);
const result = await request
.post("/api/test")
.set("Content-Type", "application/json")
.send('{ "hello": true');

expect(result.statusCode).toBe(400);
expect(result.body.statusMessage).toBe("Bad Request");
expect(result.body.stack[0]).toBe("Error: Invalid JSON body");
});
});
});

0 comments on commit d11f817

Please sign in to comment.