Skip to content

Commit

Permalink
feat: readValidatedBody and getValidatedQuery utils (#459)
Browse files Browse the repository at this point in the history
  • Loading branch information
pi0 authored Jul 26, 2023
1 parent 7764b99 commit ef4882d
Show file tree
Hide file tree
Showing 8 changed files with 216 additions and 1 deletion.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?)`
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

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

5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
9 changes: 9 additions & 0 deletions src/utils/body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -118,6 +119,14 @@ export async function readBody<T = any>(
return parsed;
}

export async function readValidatedBody<T>(
event: H3Event,
validate: ValidateFunction<T>
): Promise<T> {
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")) {
Expand Down
36 changes: 36 additions & 0 deletions src/utils/internal/validate.ts
Original file line number Diff line number Diff line change
@@ -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> = T | true | false | void;

export type ValidateFunction<T> = (
data: unknown
) => ValidateResult<T> | Promise<ValidateResult<T>>;

export async function validateData<T>(
data: unknown,
fn: ValidateFunction<T>
): Promise<T> {
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,
});
}
9 changes: 9 additions & 0 deletions src/utils/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
event: H3Event,
validate: ValidateFunction<T>
): Promise<T> {
const query = getQuery(event);
return validateData(query, validate);
}

export function getRouterParams(
event: H3Event
): NonNullable<H3Event["context"]["params"]> {
Expand Down
146 changes: 146 additions & 0 deletions test/validate.test.ts
Original file line number Diff line number Diff line change
@@ -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<Test>;

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);
});
});
});
});

0 comments on commit ef4882d

Please sign in to comment.