Skip to content

Commit

Permalink
feat: add readBodySafe and getQuerySafe helpers
Browse files Browse the repository at this point in the history
  • Loading branch information
Hebilicious committed Jul 8, 2023
1 parent d9a9f76 commit 34c8373
Show file tree
Hide file tree
Showing 6 changed files with 278 additions and 2 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"test": "pnpm lint && vitest run --coverage"
},
"dependencies": {
"@decs/typeschema": "^0.1.3",
"cookie-es": "^1.0.0",
"defu": "^6.1.2",
"destr": "^2.0.0",
Expand Down Expand Up @@ -58,7 +59,8 @@
"supertest": "^6.3.3",
"typescript": "^5.1.3",
"unbuild": "^1.2.1",
"vitest": "^0.32.2"
"vitest": "^0.32.2",
"zod": "^3.21.4"
},
"packageManager": "pnpm@8.6.3"
}
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 31 additions & 1 deletion src/utils/body.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import destr from "destr";
import type { Infer, Schema } from "@decs/typeschema";
import destr, { safeDestr } from "destr";
import type { Encoding, HTTPMethod } from "../types";
import type { H3Event } from "../event";
import { parse as parseMultipartData } from "./internal/multipart";
import { assertSchema } from "./internal/validation";
import { assertMethod, eventToRequest, getRequestHeader } from "./request";

export type { MultiPartData } from "./internal/multipart";
Expand Down Expand Up @@ -146,3 +148,31 @@ export async function readMultipartFormData(event: H3Event) {
export async function readFormData(event: H3Event) {
return (await eventToRequest(event)).formData();
}

/**
* Accept an event and a schema, and return a typed and runtime validated object.
* Throws an error if the object doesn't match the schema.
* @param event {H3Event}
* @param schema {Schema} Any valid schema: zod, yup, joi, superstruct, typia, runtypes, arktype or custom validation function.
* @param onError {Function} Optional error handler. Will receive the error thrown by the schema validation as first argument.
*/
export async function readBodySafe<TSchema extends Schema<unknown>>(
event: H3Event,
schema: TSchema,
onError?: (err: any) => any
) {
if (ParsedBodySymbol in event.node.req) {
return (event.node.req as any)[ParsedBodySymbol] as Infer<typeof schema>;
}
const contentType = getRequestHeader(event, "content-type");
if (contentType === "application/x-www-form-urlencoded") {
const formPayload = Object.fromEntries(await readFormData(event));
const result = await assertSchema(schema, formPayload, onError);
(event.node.req as any)[ParsedBodySymbol] = result;
return result as Infer<typeof schema>;
}
const json = safeDestr(await readRawBody(event));
const result = await assertSchema(schema, json, onError);
(event.node.req as any)[ParsedBodySymbol] = result;
return result as Infer<typeof schema>;
}
18 changes: 18 additions & 0 deletions src/utils/internal/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Schema } from "@decs/typeschema";
import { assert } from "@decs/typeschema";
import { createError } from "src/error";

export const assertSchema = async (
schema: Schema<unknown>,
payload: unknown,
onError?: (err: any) => any
) => {
try {
return await assert(schema, payload);
} catch (error) {
if (onError) {
return onError(error);
}
throw createError({ statusCode: 500, statusMessage: "Assertion Error." });
}
};
19 changes: 19 additions & 0 deletions src/utils/request.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Infer, Schema } from "@decs/typeschema";
import { getQuery as _getQuery } from "ufo";
import { createError } from "../error";
import type { HTTPMethod, RequestHeaders } from "../types";
import type { H3Event } from "../event";
import { readRawBody } from "./body";
import { assertSchema } from "./internal/validation";

export function getQuery(event: H3Event) {
return _getQuery(event.node.req.url || "");
Expand Down Expand Up @@ -152,3 +154,20 @@ export async function eventToRequest(event: H3Event) {
body,
});
}

/**
* Accept an event and a schema, and return a typed and runtime validated object.
* Throws an error if the object doesn't match the schema.
* @param event {H3Event}
* @param schema {Schema} Any valid schema: zod, yup, joi, superstruct, typia, runtypes, arktype or custom validation function.
* @param onError {Function} Optional error handler. Will receive the error thrown by the schema validation as first argument.
*/
export async function getQuerySafe<TSchema extends Schema<unknown>>(
event: H3Event,
schema: TSchema,
onError?: (err: any) => any
) {
const query = getQuery(event);
const result = await assertSchema(schema, query, onError);
return result as Infer<typeof schema>;
}
193 changes: 193 additions & 0 deletions test/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import supertest, { SuperTest, Test } from "supertest";
import { describe, it, expect, expectTypeOf, beforeEach } from "vitest";
import { z } from "zod";
import {
createApp,
toNodeListener,
App,
eventHandler,
readBodySafe,
createError,
getQuerySafe,
} from "../src";

describe("", () => {
let app: App;
let request: SuperTest<Test>;

beforeEach(() => {
app = createApp({ debug: true });
request = supertest(toNodeListener(app));
});

describe("query validation", () => {
it("can parse and return safe query params", async () => {
app.use(
"/",
eventHandler(async (event) => {
const schema = z.object({
bool: z.string(),
name: z.string(),
number: z.string(),
});
const query = await getQuerySafe(event, schema);
expectTypeOf(query).toMatchTypeOf<{
bool: string;
name: string;
number: string;
}>();
return query;
})
);
const result = await request.get(
"/api/test?bool=true&name=string&number=1"
);
expect(result.statusCode).toBe(200);
expect(result.body).toMatchObject({
bool: "true",
name: "string",
number: "1",
});
});
});

describe("body validation", () => {
it("can parse and return safe x-www-form-urlencoded data", async () => {
app.use(
"/",
eventHandler(async (event) => {
const schema = z.object({
firstName: z.string(),
lastName: z.string(),
});
const data = await readBodySafe(event, schema);
expectTypeOf(data).toMatchTypeOf<{
firstName: string;
lastName: string;
}>();
return { ...data };
})
);

const result = await request
.post("/api/test")
.set("content-type", "application/x-www-form-urlencoded")
.send("firstName=John&lastName=Doe");

expect(result.status).toBe(200);
expect(result.body).toMatchObject({ firstName: "John", lastName: "Doe" });
});

it("can parse and return safe json data", async () => {
app.use(
"/",
eventHandler(async (event) => {
const schema = z.object({
firstName: z.string(),
lastName: z.string(),
});
const data = await readBodySafe(event, schema);
expectTypeOf(data).toMatchTypeOf<{
firstName: string;
lastName: string;
}>();
return { ...data };
})
);

const result = await request
.post("/api/test")
.set("content-type", "application/json")
.send({ firstName: "John", lastName: "Doe", age: 30 });

expect(result.status).toBe(200);
expect(result.body).toMatchObject({
firstName: "John",
lastName: "Doe",
});
});

it("can throw an error on schema mismatch", async () => {
app.use(
"/",
eventHandler(async (event) => {
const schema = z.object({
firstName: z.string(),
lastName: z.number(),
});
const data = await readBodySafe(event, schema);
return { ...data };
})
);

const result = await request
.post("/api/test")
.set("content-type", "application/x-www-form-urlencoded")
.send("firstName=John&lastName=Doe");

expect(result.status).toBe(500);
expect(result.body).toMatchObject({ statusCode: 500 });
});

it("can throw a custom error when assertion fails", async () => {
app.use(
"/",
eventHandler(async (event) => {
const schema = z.object({
firstName: z.string(),
lastName: z.number(),
});
const data = await readBodySafe(event, schema, () => {
throw createError({
statusMessage: "Invalid data",
statusCode: 400,
});
});
return { ...data };
})
);

const result = await request
.post("/api/test")
.set("content-type", "application/x-www-form-urlencoded")
.send("firstName=John&lastName=Doe");

expect(result.status).toBe(400);
expect(result.body).toMatchObject({
statusMessage: "Invalid data",
statusCode: 400,
});
});

it("can throw with a custom validator schema", async () => {
app.use(
"/",
eventHandler(async (event) => {
const data = await readBodySafe(event, (body) => {
if (
body &&
typeof body === "object" &&
"firstName" in body &&
"lastName" in body &&
"age" in body
) {
return body;
}
throw new TypeError("Custom Error");
});
return data;
})
);

const result = await request
.post("/api/test")
.set("content-type", "application/x-www-form-urlencoded")
.send("firstName=John&lastName=Doe");

expect(result.status).toBe(500);
expect(result.body).toMatchObject({
statusCode: 500,
});
});
});
});

0 comments on commit 34c8373

Please sign in to comment.