From 34c8373975dcbbdb4ceb8102daa38f6316515ff2 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Sun, 9 Jul 2023 01:04:17 +0700 Subject: [PATCH] feat: add readBodySafe and getQuerySafe helpers --- package.json | 4 +- pnpm-lock.yaml | 14 +++ src/utils/body.ts | 32 ++++- src/utils/internal/validation.ts | 18 +++ src/utils/request.ts | 19 +++ test/validation.test.ts | 193 +++++++++++++++++++++++++++++++ 6 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 src/utils/internal/validation.ts create mode 100644 test/validation.test.ts diff --git a/package.json b/package.json index 96d765e8..7b20cff3 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec98fb5a..19e298ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + '@decs/typeschema': + specifier: ^0.1.3 + version: 0.1.3 cookie-es: specifier: ^1.0.0 version: 1.0.0 @@ -88,6 +91,9 @@ devDependencies: vitest: specifier: ^0.32.2 version: 0.32.2 + zod: + specifier: ^3.21.4 + version: 3.21.4 packages: @@ -351,6 +357,10 @@ packages: dev: true optional: true + /@decs/typeschema@0.1.3: + resolution: {integrity: sha512-VJIYlsev2eULzrfo1DiW35spgauAuU+FmLRdnNrR+KlN5RoLNzvzVvsDTMLddIwikGuzNS+8oe1gHsxZeBn5zg==} + dev: false + /@esbuild/android-arm64@0.17.19: resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} engines: {node: '>=12'} @@ -6064,3 +6074,7 @@ packages: resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} engines: {node: '>=12.20'} dev: true + + /zod@3.21.4: + resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} + dev: true diff --git a/src/utils/body.ts b/src/utils/body.ts index d7e13f1f..76024deb 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -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"; @@ -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>( + event: H3Event, + schema: TSchema, + onError?: (err: any) => any +) { + if (ParsedBodySymbol in event.node.req) { + return (event.node.req as any)[ParsedBodySymbol] as Infer; + } + 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; + } + const json = safeDestr(await readRawBody(event)); + const result = await assertSchema(schema, json, onError); + (event.node.req as any)[ParsedBodySymbol] = result; + return result as Infer; +} diff --git a/src/utils/internal/validation.ts b/src/utils/internal/validation.ts new file mode 100644 index 00000000..a434c660 --- /dev/null +++ b/src/utils/internal/validation.ts @@ -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, + 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." }); + } +}; diff --git a/src/utils/request.ts b/src/utils/request.ts index 13f3577d..a9b2a21d 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -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 || ""); @@ -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>( + event: H3Event, + schema: TSchema, + onError?: (err: any) => any +) { + const query = getQuery(event); + const result = await assertSchema(schema, query, onError); + return result as Infer; +} diff --git a/test/validation.test.ts b/test/validation.test.ts new file mode 100644 index 00000000..4a4a2122 --- /dev/null +++ b/test/validation.test.ts @@ -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; + + 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, + }); + }); + }); +});