Skip to content

Commit

Permalink
feat: readValidatedBody and getValidatedQuery utils
Browse files Browse the repository at this point in the history
  • Loading branch information
pi0 committed Jul 25, 2023
1 parent 78aec63 commit 232c4cd
Show file tree
Hide file tree
Showing 7 changed files with 199 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, validateFunction)`
- `readMultipartFormData(event)`

#### Request

- `getQuery(event)`
- `getValidatedBody(event, validateFunction)`
- `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": "[email protected]"
}
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.

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
33 changes: 33 additions & 0 deletions src/utils/internal/validate.ts
Original file line number Diff line number Diff line change
@@ -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> = T | 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();
}
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
137 changes: 137 additions & 0 deletions test/validate.test.ts
Original file line number Diff line number Diff line change
@@ -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<Test>;

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

0 comments on commit 232c4cd

Please sign in to comment.