From ef4882dcabb43173664424e088da7f4c24fca985 Mon Sep 17 00:00:00 2001 From: pooya parsa Date: Wed, 26 Jul 2023 19:41:54 +0200 Subject: [PATCH] feat: `readValidatedBody` and `getValidatedQuery` utils (#459) --- README.md | 2 + package.json | 3 +- pnpm-lock.yaml | 7 ++ src/types.ts | 5 ++ src/utils/body.ts | 9 ++ src/utils/internal/validate.ts | 36 ++++++++ src/utils/request.ts | 9 ++ test/validate.test.ts | 146 +++++++++++++++++++++++++++++++++ 8 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 src/utils/internal/validate.ts create mode 100644 test/validate.test.ts diff --git a/README.md b/README.md index 24081ef3..b4aad1c1 100644 --- a/README.md +++ b/README.md @@ -168,11 +168,13 @@ H3 has a concept of composable utilities that accept `event` (from `eventHandler - `readRawBody(event, encoding?)` - `readBody(event)` +- `readValidatedBody(event, validate)` - `readMultipartFormData(event)` #### Request - `getQuery(event)` +- `getValidatedBody(event, validate)` - `getRouterParams(event)` - `getMethod(event, default?)` - `isMethod(event, expected, allowHead?)` diff --git a/package.json b/package.json index a189b608..21c24227 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "supertest": "^6.3.3", "typescript": "^5.1.6", "unbuild": "^1.2.1", - "vitest": "^0.33.0" + "vitest": "^0.33.0", + "zod": "^3.21.4" }, "packageManager": "pnpm@8.6.9" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 519432c3..3f0ed76b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: vitest: specifier: ^0.33.0 version: 0.33.0 + zod: + specifier: ^3.21.4 + version: 3.21.4 playground: dependencies: @@ -6290,3 +6293,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/types.ts b/src/types.ts index c3fe7450..83466801 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,11 @@ import type { H3Event } from "./event"; import { Session } from "./utils/session"; +export type { + ValidateFunction, + ValidateResult, +} from "./utils/internal/validate"; + // https://www.rfc-editor.org/rfc/rfc7231#section-4.1 export type HTTPMethod = | "GET" diff --git a/src/utils/body.ts b/src/utils/body.ts index cb63eae5..513af1fe 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -5,6 +5,7 @@ import type { H3Event } from "../event"; import { createError } from "../error"; import { parse as parseMultipartData } from "./internal/multipart"; import { assertMethod, getRequestHeader } from "./request"; +import { ValidateFunction, validateData } from "./internal/validate"; export type { MultiPartData } from "./internal/multipart"; @@ -118,6 +119,14 @@ export async function readBody( return parsed; } +export async function readValidatedBody( + event: H3Event, + validate: ValidateFunction +): Promise { + const _body = await readBody(event, { strict: true }); + return validateData(_body, validate); +} + export async function readMultipartFormData(event: H3Event) { const contentType = getRequestHeader(event, "content-type"); if (!contentType || !contentType.startsWith("multipart/form-data")) { diff --git a/src/utils/internal/validate.ts b/src/utils/internal/validate.ts new file mode 100644 index 00000000..52fc0990 --- /dev/null +++ b/src/utils/internal/validate.ts @@ -0,0 +1,36 @@ +import { createError } from "../../error"; + +// TODO: Consider using similar method of typeschema for external library compatibility +// https://github.com/decs/typeschema/blob/v0.1.3/src/assert.ts + +export type ValidateResult = T | true | false | void; + +export type ValidateFunction = ( + data: unknown +) => ValidateResult | Promise>; + +export async function validateData( + data: unknown, + fn: ValidateFunction +): Promise { + try { + const res = await fn(data); + if (res === false) { + throw createValidationError(); + } + if (res === true) { + return data as T; + } + return res ?? (data as T); + } catch (error) { + throw createValidationError(error); + } +} + +function createValidationError(validateError?: any) { + throw createError({ + status: 400, + message: validateError.message || "Validation Failed", + ...validateError, + }); +} diff --git a/src/utils/request.ts b/src/utils/request.ts index cc28186d..4c2178f9 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -2,11 +2,20 @@ import { getQuery as _getQuery } from "ufo"; import { createError } from "../error"; import type { HTTPMethod, RequestHeaders } from "../types"; import type { H3Event } from "../event"; +import { validateData, ValidateFunction } from "./internal/validate"; export function getQuery(event: H3Event) { return _getQuery(event.path || ""); } +export function getValidatedQuery( + event: H3Event, + validate: ValidateFunction +): Promise { + const query = getQuery(event); + return validateData(query, validate); +} + export function getRouterParams( event: H3Event ): NonNullable { diff --git a/test/validate.test.ts b/test/validate.test.ts new file mode 100644 index 00000000..789ea59c --- /dev/null +++ b/test/validate.test.ts @@ -0,0 +1,146 @@ +import supertest, { SuperTest, Test } from "supertest"; +import { describe, it, expect, beforeEach } from "vitest"; +import { z } from "zod"; +import { + createApp, + toNodeListener, + App, + eventHandler, + readValidatedBody, + getValidatedQuery, + ValidateFunction, +} from "../src"; + +// Custom validator +const customValidate: ValidateFunction<{ + invalidKey: never; + default: string; + field?: string; +}> = (data: any) => { + if (data.invalid) { + throw new Error("Invalid key"); + } + data.default = "default"; + return data; +}; + +// Zod validator (example) +const zodValidate = z.object({ + default: z.string().default("default"), + field: z.string().optional(), + invalid: z.never().optional() /* WTF! */, +}).parse; + +describe("Validate", () => { + let app: App; + let request: SuperTest; + + beforeEach(() => { + app = createApp({ debug: true }); + request = supertest(toNodeListener(app)); + }); + + describe("readValidatedBody", () => { + beforeEach(() => { + app.use( + "/custom", + eventHandler(async (event) => { + console.log(event.headers); + const data = await readValidatedBody(event, customValidate); + return data; + }) + ); + + app.use( + "/zod", + eventHandler(async (event) => { + const data = await readValidatedBody(event, zodValidate); + return data; + }) + ); + }); + + describe("custom validator", () => { + it("Valid JSON", async () => { + const res = await request.post("/custom").send({ field: "value" }); + expect(res.body).toEqual({ field: "value", default: "default" }); + expect(res.status).toEqual(200); + }); + + it("Valid x-www-form-urlencoded", async () => { + const res = await request + .post("/custom") + .set("Content-Type", "application/x-www-form-urlencoded") + .send("field=value"); + expect(res.body).toEqual({ field: "value", default: "default" }); + expect(res.status).toEqual(200); + }); + + it("Invalid JSON", async () => { + const res = await request.post("/custom").send({ invalid: true }); + expect(res.text).include("Invalid key"); + expect(res.status).toEqual(400); + }); + }); + + describe("zod validator", () => { + it("Valid", async () => { + const res = await request.post("/zod").send({ field: "value" }); + expect(res.body).toEqual({ field: "value", default: "default" }); + expect(res.status).toEqual(200); + }); + + it("Invalid", async () => { + const res = await request.post("/zod").send({ invalid: true }); + expect(res.status).toEqual(400); + }); + }); + }); + + describe("getQuery", () => { + beforeEach(() => { + app.use( + "/custom", + eventHandler(async (event) => { + const data = await getValidatedQuery(event, customValidate); + return data; + }) + ); + + app.use( + "/zod", + eventHandler(async (event) => { + const data = await getValidatedQuery(event, zodValidate); + return data; + }) + ); + }); + + describe("custom validator", () => { + it("Valid", async () => { + const res = await request.get("/custom?field=value"); + expect(res.body).toEqual({ field: "value", default: "default" }); + expect(res.status).toEqual(200); + }); + + it("Invalid", async () => { + const res = await request.get("/custom?invalid=true"); + expect(res.text).include("Invalid key"); + expect(res.status).toEqual(400); + }); + }); + + describe("zod validator", () => { + it("Valid", async () => { + const res = await request.get("/zod?field=value"); + expect(res.body).toEqual({ field: "value", default: "default" }); + expect(res.status).toEqual(200); + }); + + it("Invalid", async () => { + const res = await request.get("/zod?invalid=true"); + expect(res.status).toEqual(400); + }); + }); + }); +});