From 232c4cd7e7a2f3e167ca2c0d862d23bf464a906d Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 25 Jul 2023 20:32:44 +0200 Subject: [PATCH 1/6] feat: `readValidatedBody` and `getValidatedQuery` utils --- README.md | 2 + package.json | 3 +- pnpm-lock.yaml | 7 ++ src/utils/body.ts | 9 +++ src/utils/internal/validate.ts | 33 ++++++++ src/utils/request.ts | 9 +++ test/validate.test.ts | 137 +++++++++++++++++++++++++++++++++ 7 files changed, 199 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..432133ef 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, validateFunction)` - `readMultipartFormData(event)` #### Request - `getQuery(event)` +- `getValidatedBody(event, validateFunction)` - `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/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..5de25dce --- /dev/null +++ b/src/utils/internal/validate.ts @@ -0,0 +1,33 @@ +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 | 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(); + } + 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..c8c47760 --- /dev/null +++ b/test/validate.test.ts @@ -0,0 +1,137 @@ +import supertest, { SuperTest, Test } from "supertest"; +import { describe, it, expect, beforeEach } from "vitest"; +import { z } from "zod"; +import { + createApp, + toNodeListener, + App, + eventHandler, + readValidatedBody, + getValidatedQuery, +} from "../src"; +// TODO: Export +import { ValidateFunction } from "../src/utils/internal/validate"; + +// 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) => { + 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", async () => { + const res = await request.post("/custom").send({ field: "value" }); + expect(res.body).toEqual({ field: "value", default: "default" }); + expect(res.status).toEqual(200); + }); + + it("Invalid", 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); + }); + }); + }); +}); From 4e94f4ea32935744db497f2159ff1bfc50fa29d2 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 25 Jul 2023 20:40:52 +0200 Subject: [PATCH 2/6] simplify docs --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 432133ef..b4aad1c1 100644 --- a/README.md +++ b/README.md @@ -168,13 +168,13 @@ H3 has a concept of composable utilities that accept `event` (from `eventHandler - `readRawBody(event, encoding?)` - `readBody(event)` -- `readValidatedBody(event, validateFunction)` +- `readValidatedBody(event, validate)` - `readMultipartFormData(event)` #### Request - `getQuery(event)` -- `getValidatedBody(event, validateFunction)` +- `getValidatedBody(event, validate)` - `getRouterParams(event)` - `getMethod(event, default?)` - `isMethod(event, expected, allowHead?)` From 373cbfe31b0907778170f3306116e524ea146452 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 25 Jul 2023 21:00:14 +0200 Subject: [PATCH 3/6] export types --- src/utils/body.ts | 2 +- src/utils/index.ts | 2 ++ src/utils/request.ts | 2 +- src/utils/{internal => }/validate.ts | 2 +- test/validate.test.ts | 3 +-- 5 files changed, 6 insertions(+), 5 deletions(-) rename src/utils/{internal => }/validate.ts (94%) diff --git a/src/utils/body.ts b/src/utils/body.ts index 513af1fe..631cdf05 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -5,7 +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"; +import { ValidateFunction, validateData } from "./validate"; export type { MultiPartData } from "./internal/multipart"; diff --git a/src/utils/index.ts b/src/utils/index.ts index 35d6efbc..f6a82c2d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,3 +9,5 @@ export * from "./response"; export * from "./session"; export * from "./cors"; export * from "./sanitize"; + +export { ValidateFunction, ValidateResult } from "./validate"; diff --git a/src/utils/request.ts b/src/utils/request.ts index 4c2178f9..da221ad6 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -2,7 +2,7 @@ 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"; +import { validateData, ValidateFunction } from "./validate"; export function getQuery(event: H3Event) { return _getQuery(event.path || ""); diff --git a/src/utils/internal/validate.ts b/src/utils/validate.ts similarity index 94% rename from src/utils/internal/validate.ts rename to src/utils/validate.ts index 5de25dce..2eb6f19f 100644 --- a/src/utils/internal/validate.ts +++ b/src/utils/validate.ts @@ -1,4 +1,4 @@ -import { createError } from "../../error"; +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 diff --git a/test/validate.test.ts b/test/validate.test.ts index c8c47760..e943d57b 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -8,9 +8,8 @@ import { eventHandler, readValidatedBody, getValidatedQuery, + ValidateFunction, } from "../src"; -// TODO: Export -import { ValidateFunction } from "../src/utils/internal/validate"; // Custom validator const customValidate: ValidateFunction<{ From dce1f18cfa771cc1bf119c5acf7dbc11a821a980 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 25 Jul 2023 21:04:13 +0200 Subject: [PATCH 4/6] move back to internal + types export --- src/types.ts | 5 +++++ src/utils/body.ts | 2 +- src/utils/index.ts | 2 -- src/utils/{ => internal}/validate.ts | 2 +- src/utils/request.ts | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) rename src/utils/{ => internal}/validate.ts (94%) 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 631cdf05..513af1fe 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -5,7 +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 "./validate"; +import { ValidateFunction, validateData } from "./internal/validate"; export type { MultiPartData } from "./internal/multipart"; diff --git a/src/utils/index.ts b/src/utils/index.ts index f6a82c2d..35d6efbc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,5 +9,3 @@ export * from "./response"; export * from "./session"; export * from "./cors"; export * from "./sanitize"; - -export { ValidateFunction, ValidateResult } from "./validate"; diff --git a/src/utils/validate.ts b/src/utils/internal/validate.ts similarity index 94% rename from src/utils/validate.ts rename to src/utils/internal/validate.ts index 2eb6f19f..5de25dce 100644 --- a/src/utils/validate.ts +++ b/src/utils/internal/validate.ts @@ -1,4 +1,4 @@ -import { createError } from "../error"; +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 diff --git a/src/utils/request.ts b/src/utils/request.ts index da221ad6..4c2178f9 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -2,7 +2,7 @@ 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 "./validate"; +import { validateData, ValidateFunction } from "./internal/validate"; export function getQuery(event: H3Event) { return _getQuery(event.path || ""); From 92bb5ef865556da7c7c90e7b6862f27d9c4aee95 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 25 Jul 2023 21:13:44 +0200 Subject: [PATCH 5/6] support `true` as validation result --- src/utils/internal/validate.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/utils/internal/validate.ts b/src/utils/internal/validate.ts index 5de25dce..52fc0990 100644 --- a/src/utils/internal/validate.ts +++ b/src/utils/internal/validate.ts @@ -3,7 +3,7 @@ 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 | false | void; +export type ValidateResult = T | true | false | void; export type ValidateFunction = ( data: unknown @@ -18,6 +18,9 @@ export async function validateData( if (res === false) { throw createValidationError(); } + if (res === true) { + return data as T; + } return res ?? (data as T); } catch (error) { throw createValidationError(error); From 7f73c13bb52d8312ddad585f99ab24bcf3267e36 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 26 Jul 2023 19:40:10 +0200 Subject: [PATCH 6/6] add test for www-form-urlencoded --- test/validate.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/test/validate.test.ts b/test/validate.test.ts index e943d57b..789ea59c 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -45,6 +45,7 @@ describe("Validate", () => { app.use( "/custom", eventHandler(async (event) => { + console.log(event.headers); const data = await readValidatedBody(event, customValidate); return data; }) @@ -60,13 +61,22 @@ describe("Validate", () => { }); describe("custom validator", () => { - it("Valid", async () => { + 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("Invalid", async () => { + 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);