From 55eab38657265b83288f13407c853dba0b521119 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sat, 5 Jul 2025 14:14:00 +0200 Subject: [PATCH 1/7] feat: customizable validation errors --- docs/2.utils/1.request.md | 70 ++++++++++++-- src/handler.ts | 9 +- src/utils/body.ts | 31 +++++-- src/utils/internal/validate.ts | 99 +++++++++++++++----- src/utils/request.ts | 55 +++++++++-- test/handler.test.ts | 107 +++++++++++++++++++-- test/validate.test.ts | 165 ++++++++++++++++++++++++++++++++- 7 files changed, 482 insertions(+), 54 deletions(-) diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index 28a5149e7..a8316f18e 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -22,7 +22,7 @@ app.get("/", async (event) => { }); ``` -### `readValidatedBody(event, validate)` +### `readValidatedBody(event, validate, error?)` Tries to read the request body via `readBody`, then uses the provided validation schema or function and either throws a validation error or returns the result. @@ -31,7 +31,7 @@ You can use a simple function to validate the body or use a Standard-Schema comp **Example:** ```ts -app.get("/", async (event) => { +app.post("/", async (event) => { const body = await readValidatedBody(event, (body) => { return typeof body === "object" && body !== null; }); @@ -42,7 +42,7 @@ app.get("/", async (event) => { ```ts import { z } from "zod"; -app.get("/", async (event) => { +app.post("/", async (event) => { const objectSchema = z.object({ name: z.string().min(3).max(20), age: z.number({ coerce: true }).positive().int(), @@ -51,6 +51,25 @@ app.get("/", async (event) => { }); ``` +**Example:** + +```ts +import * as v from "valibot"; +app.post("/", async (event) => { + const body = await readValidatedBody( + event, + v.object({ + name: v.pipe(v.string(), v.minLength(3), v.maxLength(20)), + age: v.pipe(v.number(), v.integer(), v.minValue(1)), + }), + (issues) => ({ + statusText: "Custom validation error", + message: v.summarize(issues), + }), + ); +}); +``` + ## Cache @@ -188,7 +207,7 @@ app.get("/", (event) => { }); ``` -### `getValidatedQuery(event, validate)` +### `getValidatedQuery(event, validate, error?)` Get the query param from the request URL validated with validate function. @@ -218,7 +237,25 @@ app.get("/", async (event) => { }); ``` -### `getValidatedRouterParams(event, validate, opts: { decode? })` +**Example:** + +```ts +import * as v from "valibot"; +app.get("/", async (event) => { + const params = await getValidatedQuery( + event, + v.object({ + key: v.string(), + }), + (issues) => ({ + statusText: "Custom validation error", + message: v.summarize(issues), + }), + ); +}); +``` + +### `getValidatedRouterParams(event, validate, opts: { decode? }, error?)` Get matched route params and validate with validate function. @@ -229,7 +266,7 @@ You can use a simple function to validate the params object or use a Standard-Sc **Example:** ```ts -app.get("/", async (event) => { +app.get("/:key", async (event) => { const params = await getValidatedRouterParams(event, (data) => { return "key" in data && typeof data.key === "string"; }); @@ -240,7 +277,7 @@ app.get("/", async (event) => { ```ts import { z } from "zod"; -app.get("/", async (event) => { +app.get("/:key", async (event) => { const params = await getValidatedRouterParams( event, z.object({ @@ -250,6 +287,25 @@ app.get("/", async (event) => { }); ``` +**Example:** + +```ts +import * as v from "valibot"; +app.get("/:key", async (event) => { + const params = await getValidatedRouterParams( + event, + v.object({ + key: v.pipe(v.string(), v.picklist(["route-1", "route-2", "route-3"])), + }), + { decode: true }, + (issues) => ({ + statusText: "Custom validation error", + message: v.summarize(issues), + }), + ); +}); +``` + ### `isMethod(event, expected, allowHead?)` Checks if the incoming request method is of the expected type. diff --git a/src/handler.ts b/src/handler.ts index 4986d0beb..0624312b0 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -18,7 +18,11 @@ import type { StandardSchemaV1, } from "./utils/internal/standard-schema.ts"; import type { TypedRequest } from "fetchdts"; -import { validatedRequest, validatedURL } from "./utils/internal/validate.ts"; +import { + type ValidateError, + validatedRequest, + validatedURL, +} from "./utils/internal/validate.ts"; // --- event handler --- @@ -61,8 +65,11 @@ export function defineValidatedHandler< >(def: { middleware?: Middleware[]; body?: RequestBody; + bodyErrors?: ValidateError; headers?: RequestHeaders; + headersErrors?: ValidateError; query?: RequestQuery; + queryErrors?: ValidateError; handler: EventHandler< { body: InferOutput; diff --git a/src/utils/body.ts b/src/utils/body.ts index 0c6079172..874c9118e 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -1,5 +1,5 @@ -import { HTTPError } from "../error.ts"; -import { validateData } from "./internal/validate.ts"; +import { type ErrorDetails, HTTPError } from "../error.ts"; +import { type ValidateError, validateData } from "./internal/validate.ts"; import { parseURLEncodedBody } from "./internal/body.ts"; import type { H3Event } from "../event.ts"; @@ -52,7 +52,7 @@ export async function readBody< export async function readValidatedBody< Event extends H3Event, S extends StandardSchemaV1, ->(event: Event, validate: S): Promise>; +>(event: Event, validate: S, error?: ValidateError): Promise>; export async function readValidatedBody< Event extends H3Event, OutputT, @@ -62,6 +62,7 @@ export async function readValidatedBody< validate: ( data: InputT, ) => ValidateResult | Promise>, + error?: ErrorDetails | (() => ErrorDetails), ): Promise; /** * Tries to read the request body via `readBody`, then uses the provided validation schema or function and either throws a validation error or returns the result. @@ -69,7 +70,7 @@ export async function readValidatedBody< * You can use a simple function to validate the body or use a Standard-Schema compatible library like `zod` to define a schema. * * @example - * app.get("/", async (event) => { + * app.post("/", async (event) => { * const body = await readValidatedBody(event, (body) => { * return typeof body === "object" && body !== null; * }); @@ -77,16 +78,33 @@ export async function readValidatedBody< * @example * import { z } from "zod"; * - * app.get("/", async (event) => { + * app.post("/", async (event) => { * const objectSchema = z.object({ * name: z.string().min(3).max(20), * age: z.number({ coerce: true }).positive().int(), * }); * const body = await readValidatedBody(event, objectSchema); * }); + * @example + * import * as v from "valibot"; + * + * app.post("/", async (event) => { + * const body = await readValidatedBody( + * event, + * v.object({ + * name: v.pipe(v.string(), v.minLength(3), v.maxLength(20)), + * age: v.pipe(v.number(), v.integer(), v.minValue(1)), + * }), + * (issues) => ({ + * statusText: "Custom validation error", + * message: v.summarize(issues), + * }), + * ); + * }); * * @param event The H3Event passed by the handler. * @param validate The function to use for body validation. It will be called passing the read request body. If the result is not false, the parsed body will be returned. + * @param error Optional error details or a function that returns error details if validation fails. If not provided, a default error will be thrown. * @throws If the validation function returns `false` or throws, a validation error will be thrown. * @return {*} The `Object`, `Array`, `String`, `Number`, `Boolean`, or `null` value corresponding to the request JSON body. * @see {readBody} @@ -94,7 +112,8 @@ export async function readValidatedBody< export async function readValidatedBody( event: H3Event, validate: any, + error?: ValidateError, ): Promise { const _body = await readBody(event); - return validateData(_body, validate); + return validateData(_body, validate, error); } diff --git a/src/utils/internal/validate.ts b/src/utils/internal/validate.ts index d1ffbc64d..aea10d9d7 100644 --- a/src/utils/internal/validate.ts +++ b/src/utils/internal/validate.ts @@ -1,7 +1,11 @@ -import { HTTPError } from "../../error.ts"; +import { type ErrorDetails, HTTPError } from "../../error.ts"; import type { ServerRequest } from "srvx"; -import type { StandardSchemaV1, InferOutput } from "./standard-schema.ts"; +import type { + StandardSchemaV1, + InferOutput, + Issue, +} from "./standard-schema.ts"; export type ValidateResult = T | true | false | void; @@ -12,33 +16,49 @@ export type ValidateFunction< | Schema | ((data: unknown) => ValidateResult | Promise>); +export type ValidateIssues = ReadonlyArray; +export type ValidateError = + | ErrorDetails + | ((issues: ValidateIssues) => ErrorDetails); + /** * Validates the given data using the provided validation function. * @template T The expected type of the validated data. * @param data The data to validate. * @param fn The validation schema or function to use - can be async. + * @param error Optional error details or a function that returns error details if validation fails. * @returns A Promise that resolves with the validated data if it passes validation, meaning the validation function does not throw and returns a value other than false. * @throws {ValidationError} If the validation function returns false or throws an error. */ export async function validateData( data: unknown, fn: Schema, + error?: ValidateError, ): Promise>; export async function validateData( data: unknown, fn: (data: unknown) => ValidateResult | Promise>, + error?: ErrorDetails | (() => ErrorDetails), ): Promise; export async function validateData( data: unknown, fn: ValidateFunction, + error?: ValidateError | (() => ErrorDetails), ): Promise { if ("~standard" in fn) { const result = await fn["~standard"].validate(data); if (result.issues) { - throw createValidationError({ - message: "Validation failed", - issues: result.issues, - }); + const errorDetails = + typeof error === "function" + ? error(result.issues) + : error || { + message: "Validation failed", + // eslint-disable-next-line unicorn/no-useless-fallback-in-spread + ...(error || {}), + issues: result.issues, + }; + + throw createValidationError(errorDetails); } return result.value; } @@ -46,7 +66,16 @@ export async function validateData( try { const res = await fn(data); if (res === false) { - throw createValidationError({ message: "Validation failed" }); + const errorDetails = + typeof error === "function" + ? (error as () => ErrorDetails)() + : error || { + message: "Validation failed", + // eslint-disable-next-line unicorn/no-useless-fallback-in-spread + ...(error || {}), + }; + + throw createValidationError(errorDetails); } if (res === true) { return data as T; @@ -67,7 +96,9 @@ export function validatedRequest< req: ServerRequest, validators: { body?: RequestBody; + bodyErrors?: ValidateError; headers?: RequestHeaders; + headersErrors?: ValidateError; }, ): ServerRequest { // Validate Headers @@ -76,6 +107,7 @@ export function validatedRequest< "headers", Object.fromEntries(req.headers.entries()), validators.headers as StandardSchemaV1>, + validators.headersErrors, ); for (const [key, value] of Object.entries(validatedheaders)) { req.headers.set(key, value); @@ -95,11 +127,21 @@ export function validatedRequest< req .json() .then((data) => validators.body!["~standard"].validate(data)) - .then((result) => - result.issues - ? Promise.reject(createValidationError(result)) - : result.value, - ); + .then((result) => { + if (result.issues) { + const errorDetails = + typeof validators.bodyErrors === "function" + ? validators.bodyErrors(result.issues) + : validators.bodyErrors || { + message: "Validation failed", + issues: result.issues, + }; + + throw createValidationError(errorDetails); + } + + return result.value; + }); } else if (reqBodyKeys.has(prop)) { throw new TypeError( `Cannot access .${prop} on request with JSON validation enabled. Use .json() instead.`, @@ -115,6 +157,7 @@ export function validatedURL( url: URL, validators: { query?: StandardSchemaV1; + queryErrors?: ValidateError; }, ): URL { if (!validators.query) { @@ -125,6 +168,7 @@ export function validatedURL( "query", Object.fromEntries(url.searchParams.entries()), validators.query as StandardSchemaV1>, + validators.queryErrors, ); for (const [key, value] of Object.entries(validatedQuery)) { @@ -138,25 +182,34 @@ function syncValidate( type: string, data: unknown, fn: StandardSchemaV1, + error?: ValidateError, ): T { const result = fn["~standard"].validate(data); if (result instanceof Promise) { throw new TypeError(`Asynchronous validation is not supported for ${type}`); } if (result.issues) { - throw createValidationError({ - issues: result.issues, - }); + const errorDetails = + typeof error === "function" + ? error(result.issues) + : error || { + message: "Validation failed", + issues: result.issues, + }; + + throw createValidationError(errorDetails); } return result.value; } -function createValidationError(validateError?: any) { - return new HTTPError({ - status: 400, - statusText: "Validation failed", - message: validateError?.message, - data: validateError, - cause: validateError, - }); +function createValidationError(validateError?: HTTPError | any) { + return HTTPError.isError(validateError) + ? validateError + : new HTTPError({ + status: validateError?.status || 400, + statusText: validateError?.statusText || "Validation failed", + message: validateError?.message, + data: validateError, + cause: validateError, + }); } diff --git a/src/utils/request.ts b/src/utils/request.ts index 2a99e6b6e..8e2e8e703 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -1,6 +1,6 @@ -import { HTTPError } from "../error.ts"; +import { type ErrorDetails, HTTPError } from "../error.ts"; +import { type ValidateError, validateData } from "./internal/validate.ts"; import { parseQuery } from "./internal/query.ts"; -import { validateData } from "./internal/validate.ts"; import type { StandardSchemaV1, @@ -30,7 +30,7 @@ export function getQuery< export function getValidatedQuery< Event extends H3Event, S extends StandardSchemaV1, ->(event: Event, validate: S): Promise>; +>(event: Event, validate: S, error?: ValidateError): Promise>; export function getValidatedQuery< Event extends H3Event, OutputT, @@ -40,6 +40,7 @@ export function getValidatedQuery< validate: ( data: InputT, ) => ValidateResult | Promise>, + error?: ErrorDetails | (() => ErrorDetails), ): Promise; /** * Get the query param from the request URL validated with validate function. @@ -63,10 +64,29 @@ export function getValidatedQuery< * }), * ); * }); + * @example + * import * as v from "valibot"; + * + * app.get("/", async (event) => { + * const params = await getValidatedQuery( + * event, + * v.object({ + * key: v.string(), + * }), + * (issues) => ({ + * statusText: "Custom validation error", + * message: v.summarize(issues), + * }) + * ); + * }); */ -export function getValidatedQuery(event: H3Event, validate: any): Promise { +export function getValidatedQuery( + event: H3Event, + validate: any, + error?: ValidateError | (() => ErrorDetails), +): Promise { const query = getQuery(event); - return validateData(query, validate); + return validateData(query, validate, error); } /** @@ -101,6 +121,7 @@ export function getValidatedRouterParams< event: Event, validate: S, opts?: { decode?: boolean }, + error?: ValidateError, ): Promise>; export function getValidatedRouterParams< Event extends H3Event, @@ -112,6 +133,7 @@ export function getValidatedRouterParams< data: InputT, ) => ValidateResult | Promise>, opts?: { decode?: boolean }, + error?: ErrorDetails | (() => ErrorDetails), ): Promise; /** * Get matched route params and validate with validate function. @@ -121,7 +143,7 @@ export function getValidatedRouterParams< * You can use a simple function to validate the params object or use a Standard-Schema compatible library like `zod` to define a schema. * * @example - * app.get("/", async (event) => { + * app.get("/:key", async (event) => { * const params = await getValidatedRouterParams(event, (data) => { * return "key" in data && typeof data.key === "string"; * }); @@ -129,7 +151,7 @@ export function getValidatedRouterParams< * @example * import { z } from "zod"; * - * app.get("/", async (event) => { + * app.get("/:key", async (event) => { * const params = await getValidatedRouterParams( * event, * z.object({ @@ -137,14 +159,31 @@ export function getValidatedRouterParams< * }), * ); * }); + * @example + * import * as v from "valibot"; + * + * app.get("/:key", async (event) => { + * const params = await getValidatedRouterParams( + * event, + * v.object({ + * key: v.pipe(v.string(), v.picklist(["route-1", "route-2", "route-3"])), + * }), + * { decode: true }, + * (issues) => ({ + * statusText: "Custom validation error", + * message: v.summarize(issues), + * }) + * ); + * }); */ export function getValidatedRouterParams( event: H3Event, validate: any, opts: { decode?: boolean } = {}, + error?: ValidateError | (() => ErrorDetails), ): Promise { const routerParams = getRouterParams(event, opts); - return validateData(routerParams, validate); + return validateData(routerParams, validate, error); } /** diff --git a/test/handler.test.ts b/test/handler.test.ts index 8ea6a17ab..cf2d0d94b 100644 --- a/test/handler.test.ts +++ b/test/handler.test.ts @@ -5,9 +5,10 @@ import { defineLazyEventHandler, defineValidatedHandler, } from "../src/index.ts"; +import type { ValidateIssues } from "../src/utils/internal/validate.ts"; import type { H3Event } from "../src/event.ts"; -import { z } from "zod"; +import { z } from "zod/v4"; describe("handler.ts", () => { describe("defineHandler", () => { @@ -99,6 +100,39 @@ describe("handler.ts", () => { }; }, }); + const handlerCustomError = defineValidatedHandler({ + body: z.object({ + name: z.string(), + age: z.number().optional().default(20), + }), + bodyErrors: (issues) => ({ + status: 500, + statusText: "Custom Zod body validation error", + message: summarize(issues), + }), + headers: z.object({ + "x-token": z.string("Missing required header"), + }), + headersErrors: (issues) => ({ + status: 500, + statusText: "Custom Zod headers validation error", + message: summarize(issues), + }), + query: z.object({ + id: z.string().min(3), + }), + queryErrors: (issues) => ({ + status: 500, + statusText: "Custom Zod query validation error", + message: summarize(issues), + }), + handler: async (event) => { + return { + body: await event.req.json(), + headers: event.req.headers, + }; + }, + }); it("valid request", async () => { const res = await handler.fetch("/?id=123", { @@ -123,7 +157,7 @@ describe("handler.ts", () => { status: 400, statusText: "Validation failed", message: "Validation failed", - data: { issues: [{ expected: "string", received: "number" }] }, + data: { issues: [{ expected: "string" }] }, }); expect(res.status).toBe(400); }); @@ -138,9 +172,7 @@ describe("handler.ts", () => { statusText: "Validation failed", message: "Validation failed", data: { - issues: [ - { path: ["x-token"], expected: "string", received: "undefined" }, - ], + issues: [{ path: ["x-token"], expected: "string" }], }, }); expect(res.status).toBe(400); @@ -160,12 +192,75 @@ describe("handler.ts", () => { issues: [ { path: ["id"], - message: "String must contain at least 3 character(s)", + message: "Too small: expected string to have >=3 characters", }, ], }, }); expect(res.status).toBe(400); }); + + describe("custom error messages", () => { + it("invalid body", async () => { + const res = await handlerCustomError.fetch("/?id=123", { + method: "POST", + headers: { "x-token": "abc" }, + body: JSON.stringify({ name: 123 }), + }); + expect(await res.json()).toMatchObject({ + status: 500, + statusText: "Custom Zod body validation error", + message: "- Invalid input: expected string, received number", + }); + expect(res.status).toBe(500); + }); + + it("invalid headers", async () => { + const res = await handlerCustomError.fetch("/?id=123", { + method: "POST", + body: JSON.stringify({ name: 123 }), + }); + expect(await res.json()).toMatchObject({ + status: 500, + statusText: "Custom Zod headers validation error", + message: "- Missing required header", + }); + expect(res.status).toBe(500); + }); + + it("invalid query", async () => { + const res = await handlerCustomError.fetch("/?id=", { + method: "POST", + headers: { "x-token": "abc" }, + body: JSON.stringify({ name: "tommy" }), + }); + expect(await res.json()).toMatchObject({ + status: 500, + statusText: "Custom Zod query validation error", + message: "- Too small: expected string to have >=3 characters", + }); + expect(res.status).toBe(500); + }); + }); }); }); + +/** + * Fork of valibot's `summarize` function. + * + * LICENSE: MIT + * SOURCE: https://github.com/fabian-hiller/valibot/blob/44b2e6499562e19d0a66ade1e25e44087e0d2c16/library/src/methods/summarize/summarize.ts + */ +function summarize(issues: ValidateIssues): string { + let summary = ""; + + for (const issue of issues) { + if (summary) { + summary += "\n"; + } + + summary += `- ${issue.message}`; + } + + return summary; +} diff --git a/test/validate.test.ts b/test/validate.test.ts index 8f9e7d2c0..44da3b3b2 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -1,6 +1,9 @@ -import type { ValidateFunction } from "../src/utils/internal/validate.ts"; +import type { + ValidateFunction, + ValidateIssues, +} from "../src/utils/internal/validate.ts"; import { beforeEach } from "vitest"; -import { z } from "zod"; +import { z } from "zod/v4"; import { readValidatedBody, getValidatedQuery, @@ -21,6 +24,17 @@ describeMatrix("validate", (t, { it, describe, expect }) => { data.default = "default"; return data; }; + const customValidateWithoutError: ValidateFunction<{ + invalidKey: never; + default: string; + field?: string; + }> = (data: any) => { + if (data.invalid) { + return false; + } + data.default = "default"; + return data; + }; // Zod validator (example) const zodValidate = z.object({ @@ -40,6 +54,29 @@ describeMatrix("validate", (t, { it, describe, expect }) => { const data = await readValidatedBody(event, zodValidate); return data; }); + + t.app.post("/custom-error", async (event) => { + const data = await readValidatedBody( + event, + customValidateWithoutError, + { + status: 500, + statusText: "Custom validation error", + }, + ); + + return data; + }); + + t.app.post("/custom-error-zod", async (event) => { + const data = await readValidatedBody(event, zodValidate, (issues) => ({ + status: 500, + statusText: "Custom Zod validation error", + message: summarize(issues), + })); + + return data; + }); }); describe("custom validator", () => { @@ -122,6 +159,34 @@ describeMatrix("validate", (t, { it, describe, expect }) => { }); }); }); + + describe("custom error", () => { + it("Custom error message", async () => { + const res = await t.fetch("/custom-error", { + method: "POST", + body: JSON.stringify({ invalid: true }), + }); + + expect(res.status).toEqual(500); + expect(await res.json()).toMatchObject({ + statusText: "Custom validation error", + }); + }); + + it("Custom error with zod", async () => { + const res = await t.fetch("/custom-error-zod", { + method: "POST", + body: JSON.stringify({ invalid: true, field: 2 }), + }); + + expect(res.status).toEqual(500); + expect(await res.json()).toMatchObject({ + statusText: "Custom Zod validation error", + message: + "- Invalid input: expected string, received number\n- Invalid input: expected never, received boolean", + }); + }); + }); }); describe("getQuery", () => { @@ -135,6 +200,29 @@ describeMatrix("validate", (t, { it, describe, expect }) => { const data = await getValidatedQuery(event, zodValidate); return data; }); + + t.app.get("/custom-error", async (event) => { + const data = await getValidatedQuery( + event, + customValidateWithoutError, + { + status: 500, + statusText: "Custom validation error", + }, + ); + + return data; + }); + + t.app.get("/custom-error-zod", async (event) => { + const data = await getValidatedQuery(event, zodValidate, (issues) => ({ + status: 500, + statusText: "Custom Zod validation error", + message: summarize(issues), + })); + + return data; + }); }); describe("custom validator", () => { @@ -169,6 +257,27 @@ describeMatrix("validate", (t, { it, describe, expect }) => { expect(res.status).toEqual(400); }); }); + + describe("custom error", () => { + it("Custom error message", async () => { + const res = await t.fetch("/custom-error?invalid=true"); + + expect(res.status).toEqual(500); + expect(await res.json()).toMatchObject({ + statusText: "Custom validation error", + }); + }); + + it("Custom error with zod", async () => { + const res = await t.fetch("/custom-error-zod?invalid=true"); + + expect(res.status).toEqual(500); + expect(await res.json()).toMatchObject({ + statusText: "Custom Zod validation error", + message: "- Invalid input: expected never, received string", + }); + }); + }); }); describe("getRouterParams", () => { @@ -194,7 +303,10 @@ describeMatrix("validate", (t, { it, describe, expect }) => { const zodParamValidate = z.object({ id: z .string() - .regex(REGEX_NUMBER_STRING, "Must be a number string") + .regex( + REGEX_NUMBER_STRING, + "Invalid input: expected number, received string", + ) .transform(Number), }); @@ -208,6 +320,21 @@ describeMatrix("validate", (t, { it, describe, expect }) => { const data = await getValidatedRouterParams(event, zodParamValidate); return data; }); + + t.app.get("/custom-error-zod/:id", async (event) => { + const data = await getValidatedRouterParams( + event, + zodParamValidate, + {}, + (issues) => ({ + status: 500, + statusText: "Custom Zod validation error", + message: summarize(issues), + }), + ); + + return data; + }); }); describe("custom validator", () => { @@ -240,5 +367,37 @@ describeMatrix("validate", (t, { it, describe, expect }) => { expect(res.status).toEqual(400); }); }); + + describe("custom error", () => { + it("Custom error with zod", async () => { + const res = await t.fetch("/custom-error-zod/abc"); + + expect(res.status).toEqual(500); + expect(await res.json()).toMatchObject({ + statusText: "Custom Zod validation error", + message: "- Invalid input: expected number, received string", + }); + }); + }); }); }); + +/** + * Fork of valibot's `summarize` function. + * + * LICENSE: MIT + * SOURCE: https://github.com/fabian-hiller/valibot/blob/44b2e6499562e19d0a66ade1e25e44087e0d2c16/library/src/methods/summarize/summarize.ts + */ +function summarize(issues: ValidateIssues): string { + let summary = ""; + + for (const issue of issues) { + if (summary) { + summary += "\n"; + } + + summary += `- ${issue.message}`; + } + + return summary; +} From dc7fd8afe93f6af87a0d76acc342df9b29992a12 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sun, 6 Jul 2025 12:32:41 +0200 Subject: [PATCH 2/7] fix(validation): remove error object format and move it to `options` --- docs/2.utils/1.request.md | 38 +++++++++------- src/utils/body.ts | 34 +++++++++----- src/utils/internal/validate.ts | 76 ++++++++++++++++--------------- src/utils/request.ts | 81 +++++++++++++++++++++++++--------- test/validate.test.ts | 49 +++++++++++--------- 5 files changed, 171 insertions(+), 107 deletions(-) diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index a8316f18e..bcd8e65cb 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -22,7 +22,7 @@ app.get("/", async (event) => { }); ``` -### `readValidatedBody(event, validate, error?)` +### `readValidatedBody(event, validate)` Tries to read the request body via `readBody`, then uses the provided validation schema or function and either throws a validation error or returns the result. @@ -62,10 +62,12 @@ app.post("/", async (event) => { name: v.pipe(v.string(), v.minLength(3), v.maxLength(20)), age: v.pipe(v.number(), v.integer(), v.minValue(1)), }), - (issues) => ({ - statusText: "Custom validation error", - message: v.summarize(issues), - }), + { + onError: (issues) => ({ + statusText: "Custom validation error", + message: v.summarize(issues), + }), + }, ); }); ``` @@ -207,7 +209,7 @@ app.get("/", (event) => { }); ``` -### `getValidatedQuery(event, validate, error?)` +### `getValidatedQuery(event, validate)` Get the query param from the request URL validated with validate function. @@ -247,15 +249,17 @@ app.get("/", async (event) => { v.object({ key: v.string(), }), - (issues) => ({ - statusText: "Custom validation error", - message: v.summarize(issues), - }), + { + onError: (issues) => ({ + statusText: "Custom validation error", + message: v.summarize(issues), + }), + }, ); }); ``` -### `getValidatedRouterParams(event, validate, opts: { decode? }, error?)` +### `getValidatedRouterParams(event, validate)` Get matched route params and validate with validate function. @@ -297,11 +301,13 @@ app.get("/:key", async (event) => { v.object({ key: v.pipe(v.string(), v.picklist(["route-1", "route-2", "route-3"])), }), - { decode: true }, - (issues) => ({ - statusText: "Custom validation error", - message: v.summarize(issues), - }), + { + decode: true, + onError: (issues) => ({ + statusText: "Custom validation error", + message: v.summarize(issues), + }), + }, ); }); ``` diff --git a/src/utils/body.ts b/src/utils/body.ts index 874c9118e..0385e616c 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -1,5 +1,9 @@ import { type ErrorDetails, HTTPError } from "../error.ts"; -import { type ValidateError, validateData } from "./internal/validate.ts"; +import { + type ValidateIssues, + type ValidateError, + validateData, +} from "./internal/validate.ts"; import { parseURLEncodedBody } from "./internal/body.ts"; import type { H3Event } from "../event.ts"; @@ -52,7 +56,11 @@ export async function readBody< export async function readValidatedBody< Event extends H3Event, S extends StandardSchemaV1, ->(event: Event, validate: S, error?: ValidateError): Promise>; +>( + event: Event, + validate: S, + options?: { onError?: (issues: ValidateIssues) => ErrorDetails }, +): Promise>; export async function readValidatedBody< Event extends H3Event, OutputT, @@ -62,7 +70,9 @@ export async function readValidatedBody< validate: ( data: InputT, ) => ValidateResult | Promise>, - error?: ErrorDetails | (() => ErrorDetails), + options?: { + onError?: () => ErrorDetails; + }, ): Promise; /** * Tries to read the request body via `readBody`, then uses the provided validation schema or function and either throws a validation error or returns the result. @@ -95,16 +105,18 @@ export async function readValidatedBody< * name: v.pipe(v.string(), v.minLength(3), v.maxLength(20)), * age: v.pipe(v.number(), v.integer(), v.minValue(1)), * }), - * (issues) => ({ - * statusText: "Custom validation error", - * message: v.summarize(issues), - * }), + * { + * onError: (issues) => ({ + * statusText: "Custom validation error", + * message: v.summarize(issues), + * }), + * }, * ); * }); * * @param event The H3Event passed by the handler. * @param validate The function to use for body validation. It will be called passing the read request body. If the result is not false, the parsed body will be returned. - * @param error Optional error details or a function that returns error details if validation fails. If not provided, a default error will be thrown. + * @param options Optional options. If provided, the `onError` function will be called with the validation issues if validation fails. * @throws If the validation function returns `false` or throws, a validation error will be thrown. * @return {*} The `Object`, `Array`, `String`, `Number`, `Boolean`, or `null` value corresponding to the request JSON body. * @see {readBody} @@ -112,8 +124,10 @@ export async function readValidatedBody< export async function readValidatedBody( event: H3Event, validate: any, - error?: ValidateError, + options?: { + onError?: ValidateError; + }, ): Promise { const _body = await readBody(event); - return validateData(_body, validate, error); + return validateData(_body, validate, options); } diff --git a/src/utils/internal/validate.ts b/src/utils/internal/validate.ts index aea10d9d7..8528654e1 100644 --- a/src/utils/internal/validate.ts +++ b/src/utils/internal/validate.ts @@ -18,7 +18,7 @@ export type ValidateFunction< export type ValidateIssues = ReadonlyArray; export type ValidateError = - | ErrorDetails + | (() => ErrorDetails) | ((issues: ValidateIssues) => ErrorDetails); /** @@ -33,30 +33,33 @@ export type ValidateError = export async function validateData( data: unknown, fn: Schema, - error?: ValidateError, + options?: { + onError?: (issues: ValidateIssues) => ErrorDetails; + }, ): Promise>; export async function validateData( data: unknown, fn: (data: unknown) => ValidateResult | Promise>, - error?: ErrorDetails | (() => ErrorDetails), + options?: { + onError?: () => ErrorDetails; + }, ): Promise; export async function validateData( data: unknown, fn: ValidateFunction, - error?: ValidateError | (() => ErrorDetails), + options?: { + onError?: ValidateError; + }, ): Promise { if ("~standard" in fn) { const result = await fn["~standard"].validate(data); if (result.issues) { - const errorDetails = - typeof error === "function" - ? error(result.issues) - : error || { - message: "Validation failed", - // eslint-disable-next-line unicorn/no-useless-fallback-in-spread - ...(error || {}), - issues: result.issues, - }; + const errorDetails = options?.onError + ? options.onError(result.issues) + : { + message: "Validation failed", + issues: result.issues, + }; throw createValidationError(errorDetails); } @@ -66,14 +69,11 @@ export async function validateData( try { const res = await fn(data); if (res === false) { - const errorDetails = - typeof error === "function" - ? (error as () => ErrorDetails)() - : error || { - message: "Validation failed", - // eslint-disable-next-line unicorn/no-useless-fallback-in-spread - ...(error || {}), - }; + const errorDetails = options?.onError + ? (options.onError as () => ErrorDetails)() + : { + message: "Validation failed", + }; throw createValidationError(errorDetails); } @@ -96,9 +96,9 @@ export function validatedRequest< req: ServerRequest, validators: { body?: RequestBody; - bodyErrors?: ValidateError; + bodyErrors?: (issues: ValidateIssues) => ErrorDetails; headers?: RequestHeaders; - headersErrors?: ValidateError; + headersErrors?: (issues: ValidateIssues) => ErrorDetails; }, ): ServerRequest { // Validate Headers @@ -129,13 +129,12 @@ export function validatedRequest< .then((data) => validators.body!["~standard"].validate(data)) .then((result) => { if (result.issues) { - const errorDetails = - typeof validators.bodyErrors === "function" - ? validators.bodyErrors(result.issues) - : validators.bodyErrors || { - message: "Validation failed", - issues: result.issues, - }; + const errorDetails = validators.bodyErrors + ? validators.bodyErrors(result.issues) + : { + message: "Validation failed", + issues: result.issues, + }; throw createValidationError(errorDetails); } @@ -157,7 +156,7 @@ export function validatedURL( url: URL, validators: { query?: StandardSchemaV1; - queryErrors?: ValidateError; + queryErrors?: (issues: ValidateIssues) => ErrorDetails; }, ): URL { if (!validators.query) { @@ -182,20 +181,19 @@ function syncValidate( type: string, data: unknown, fn: StandardSchemaV1, - error?: ValidateError, + error?: (issues: ValidateIssues) => ErrorDetails, ): T { const result = fn["~standard"].validate(data); if (result instanceof Promise) { throw new TypeError(`Asynchronous validation is not supported for ${type}`); } if (result.issues) { - const errorDetails = - typeof error === "function" - ? error(result.issues) - : error || { - message: "Validation failed", - issues: result.issues, - }; + const errorDetails = error + ? error(result.issues) + : { + message: "Validation failed", + issues: result.issues, + }; throw createValidationError(errorDetails); } diff --git a/src/utils/request.ts b/src/utils/request.ts index 8e2e8e703..5ee324a3d 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -1,5 +1,9 @@ import { type ErrorDetails, HTTPError } from "../error.ts"; -import { type ValidateError, validateData } from "./internal/validate.ts"; +import { + type ValidateIssues, + type ValidateError, + validateData, +} from "./internal/validate.ts"; import { parseQuery } from "./internal/query.ts"; import type { @@ -30,7 +34,11 @@ export function getQuery< export function getValidatedQuery< Event extends H3Event, S extends StandardSchemaV1, ->(event: Event, validate: S, error?: ValidateError): Promise>; +>( + event: Event, + validate: S, + options?: { onError?: (issues: ValidateIssues) => ErrorDetails }, +): Promise>; export function getValidatedQuery< Event extends H3Event, OutputT, @@ -40,7 +48,9 @@ export function getValidatedQuery< validate: ( data: InputT, ) => ValidateResult | Promise>, - error?: ErrorDetails | (() => ErrorDetails), + options?: { + onError?: () => ErrorDetails; + }, ): Promise; /** * Get the query param from the request URL validated with validate function. @@ -73,20 +83,31 @@ export function getValidatedQuery< * v.object({ * key: v.string(), * }), - * (issues) => ({ - * statusText: "Custom validation error", - * message: v.summarize(issues), - * }) + * { + * onError: (issues) => ({ + * statusText: "Custom validation error", + * message: v.summarize(issues), + * }), + * }, * ); * }); + * + * @param event The H3Event passed by the handler. + * @param validate The function to use for query validation. It will be called passing the read request query. If the result is not false, the parsed query will be returned. + * @param options Optional options. If provided, the `onError` function will be called with the validation issues if validation fails. + * @throws If the validation function returns `false` or throws, a validation error will be thrown. + * @return {*} The `Object`, `Array`, `String`, `Number`, `Boolean`, or `null` value corresponding to the request query. + * @see {getQuery} */ export function getValidatedQuery( event: H3Event, validate: any, - error?: ValidateError | (() => ErrorDetails), + options?: { + onError?: ValidateError; + }, ): Promise { const query = getQuery(event); - return validateData(query, validate, error); + return validateData(query, validate, options); } /** @@ -120,8 +141,10 @@ export function getValidatedRouterParams< >( event: Event, validate: S, - opts?: { decode?: boolean }, - error?: ValidateError, + options?: { + decode?: boolean; + onError?: (issues: ValidateIssues) => ErrorDetails; + }, ): Promise>; export function getValidatedRouterParams< Event extends H3Event, @@ -132,8 +155,10 @@ export function getValidatedRouterParams< validate: ( data: InputT, ) => ValidateResult | Promise>, - opts?: { decode?: boolean }, - error?: ErrorDetails | (() => ErrorDetails), + options?: { + decode?: boolean; + onError?: () => ErrorDetails; + }, ): Promise; /** * Get matched route params and validate with validate function. @@ -168,22 +193,34 @@ export function getValidatedRouterParams< * v.object({ * key: v.pipe(v.string(), v.picklist(["route-1", "route-2", "route-3"])), * }), - * { decode: true }, - * (issues) => ({ - * statusText: "Custom validation error", - * message: v.summarize(issues), - * }) + * { + * decode: true, + * onError: (issues) => ({ + * statusText: "Custom validation error", + * message: v.summarize(issues), + * }), + * }, * ); * }); + * + * @param event The H3Event passed by the handler. + * @param validate The function to use for router params validation. It will be called passing the read request router params. If the result is not false, the parsed router params will be returned. + * @param options Optional options. If provided, the `onError` function will be called with the validation issues if validation fails. + * @throws If the validation function returns `false` or throws, a validation error will be thrown. + * @return {*} The `Object`, `Array`, `String`, `Number`, `Boolean`, or `null` value corresponding to the request router params. + * @see {getRouterParams} */ export function getValidatedRouterParams( event: H3Event, validate: any, - opts: { decode?: boolean } = {}, - error?: ValidateError | (() => ErrorDetails), + options: { + decode?: boolean; + onError?: ValidateError; + } = {}, ): Promise { - const routerParams = getRouterParams(event, opts); - return validateData(routerParams, validate, error); + const { decode, ...opts } = options; + const routerParams = getRouterParams(event, { decode }); + return validateData(routerParams, validate, opts); } /** diff --git a/test/validate.test.ts b/test/validate.test.ts index 44da3b3b2..a157294d4 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -60,8 +60,12 @@ describeMatrix("validate", (t, { it, describe, expect }) => { event, customValidateWithoutError, { - status: 500, - statusText: "Custom validation error", + onError() { + return { + status: 500, + statusText: "Custom validation error", + }; + }, }, ); @@ -69,11 +73,13 @@ describeMatrix("validate", (t, { it, describe, expect }) => { }); t.app.post("/custom-error-zod", async (event) => { - const data = await readValidatedBody(event, zodValidate, (issues) => ({ - status: 500, - statusText: "Custom Zod validation error", - message: summarize(issues), - })); + const data = await readValidatedBody(event, zodValidate, { + onError: (issues) => ({ + status: 500, + statusText: "Custom Zod validation error", + message: summarize(issues), + }), + }); return data; }); @@ -206,8 +212,12 @@ describeMatrix("validate", (t, { it, describe, expect }) => { event, customValidateWithoutError, { - status: 500, - statusText: "Custom validation error", + onError() { + return { + status: 500, + statusText: "Custom validation error", + }; + }, }, ); @@ -215,11 +225,13 @@ describeMatrix("validate", (t, { it, describe, expect }) => { }); t.app.get("/custom-error-zod", async (event) => { - const data = await getValidatedQuery(event, zodValidate, (issues) => ({ - status: 500, - statusText: "Custom Zod validation error", - message: summarize(issues), - })); + const data = await getValidatedQuery(event, zodValidate, { + onError: (issues) => ({ + status: 500, + statusText: "Custom Zod validation error", + message: summarize(issues), + }), + }); return data; }); @@ -322,16 +334,13 @@ describeMatrix("validate", (t, { it, describe, expect }) => { }); t.app.get("/custom-error-zod/:id", async (event) => { - const data = await getValidatedRouterParams( - event, - zodParamValidate, - {}, - (issues) => ({ + const data = await getValidatedRouterParams(event, zodParamValidate, { + onError: (issues) => ({ status: 500, statusText: "Custom Zod validation error", message: summarize(issues), }), - ); + }); return data; }); From ec3aab8f075d0bcc5c58af2c4930715e9c118401 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sun, 6 Jul 2025 12:40:34 +0200 Subject: [PATCH 3/7] fix(`defineValidatedHandler`): group errors in single key --- src/handler.ts | 11 +++++++---- src/utils/internal/validate.ts | 19 ++++++++++++------- test/handler.test.ts | 32 +++++++++++++++++--------------- 3 files changed, 36 insertions(+), 26 deletions(-) diff --git a/src/handler.ts b/src/handler.ts index 0624312b0..1abeb8a23 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -18,8 +18,9 @@ import type { StandardSchemaV1, } from "./utils/internal/standard-schema.ts"; import type { TypedRequest } from "fetchdts"; +import type { ErrorDetails } from "./error.ts"; import { - type ValidateError, + type ValidateIssues, validatedRequest, validatedURL, } from "./utils/internal/validate.ts"; @@ -65,11 +66,13 @@ export function defineValidatedHandler< >(def: { middleware?: Middleware[]; body?: RequestBody; - bodyErrors?: ValidateError; headers?: RequestHeaders; - headersErrors?: ValidateError; query?: RequestQuery; - queryErrors?: ValidateError; + validationErrors?: { + body?: (issues: ValidateIssues) => ErrorDetails; + headers?: (issues: ValidateIssues) => ErrorDetails; + query?: (issues: ValidateIssues) => ErrorDetails; + }; handler: EventHandler< { body: InferOutput; diff --git a/src/utils/internal/validate.ts b/src/utils/internal/validate.ts index 8528654e1..73a26e097 100644 --- a/src/utils/internal/validate.ts +++ b/src/utils/internal/validate.ts @@ -96,9 +96,12 @@ export function validatedRequest< req: ServerRequest, validators: { body?: RequestBody; - bodyErrors?: (issues: ValidateIssues) => ErrorDetails; headers?: RequestHeaders; - headersErrors?: (issues: ValidateIssues) => ErrorDetails; + validationErrors?: { + body?: (issues: ValidateIssues) => ErrorDetails; + headers?: (issues: ValidateIssues) => ErrorDetails; + query?: (issues: ValidateIssues) => ErrorDetails; + }; }, ): ServerRequest { // Validate Headers @@ -107,7 +110,7 @@ export function validatedRequest< "headers", Object.fromEntries(req.headers.entries()), validators.headers as StandardSchemaV1>, - validators.headersErrors, + validators.validationErrors?.headers, ); for (const [key, value] of Object.entries(validatedheaders)) { req.headers.set(key, value); @@ -129,8 +132,8 @@ export function validatedRequest< .then((data) => validators.body!["~standard"].validate(data)) .then((result) => { if (result.issues) { - const errorDetails = validators.bodyErrors - ? validators.bodyErrors(result.issues) + const errorDetails = validators.validationErrors?.body + ? validators.validationErrors.body(result.issues) : { message: "Validation failed", issues: result.issues, @@ -156,7 +159,9 @@ export function validatedURL( url: URL, validators: { query?: StandardSchemaV1; - queryErrors?: (issues: ValidateIssues) => ErrorDetails; + validationErrors?: { + query?: (issues: ValidateIssues) => ErrorDetails; + }; }, ): URL { if (!validators.query) { @@ -167,7 +172,7 @@ export function validatedURL( "query", Object.fromEntries(url.searchParams.entries()), validators.query as StandardSchemaV1>, - validators.queryErrors, + validators.validationErrors?.query, ); for (const [key, value] of Object.entries(validatedQuery)) { diff --git a/test/handler.test.ts b/test/handler.test.ts index cf2d0d94b..4ebb8870f 100644 --- a/test/handler.test.ts +++ b/test/handler.test.ts @@ -105,27 +105,29 @@ describe("handler.ts", () => { name: z.string(), age: z.number().optional().default(20), }), - bodyErrors: (issues) => ({ - status: 500, - statusText: "Custom Zod body validation error", - message: summarize(issues), - }), headers: z.object({ "x-token": z.string("Missing required header"), }), - headersErrors: (issues) => ({ - status: 500, - statusText: "Custom Zod headers validation error", - message: summarize(issues), - }), query: z.object({ id: z.string().min(3), }), - queryErrors: (issues) => ({ - status: 500, - statusText: "Custom Zod query validation error", - message: summarize(issues), - }), + validationErrors: { + body: (issues) => ({ + status: 500, + statusText: "Custom Zod body validation error", + message: summarize(issues), + }), + headers: (issues) => ({ + status: 500, + statusText: "Custom Zod headers validation error", + message: summarize(issues), + }), + query: (issues) => ({ + status: 500, + statusText: "Custom Zod query validation error", + message: summarize(issues), + }), + }, handler: async (event) => { return { body: await event.req.json(), From 03ecef4eb8670f6522730df2afdb012329d00c55 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sun, 6 Jul 2025 13:15:19 +0200 Subject: [PATCH 4/7] fix(defineValidatedHandler): unify validation errors --- src/handler.ts | 9 ++++----- src/utils/internal/validate.ts | 32 ++++++++++++++++---------------- test/handler.test.ts | 20 +++++--------------- 3 files changed, 25 insertions(+), 36 deletions(-) diff --git a/src/handler.ts b/src/handler.ts index 1abeb8a23..d9eeef104 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -68,11 +68,10 @@ export function defineValidatedHandler< body?: RequestBody; headers?: RequestHeaders; query?: RequestQuery; - validationErrors?: { - body?: (issues: ValidateIssues) => ErrorDetails; - headers?: (issues: ValidateIssues) => ErrorDetails; - query?: (issues: ValidateIssues) => ErrorDetails; - }; + onValidationError?: ( + issues: ValidateIssues, + source: "headers" | "body" | "query", + ) => ErrorDetails; handler: EventHandler< { body: InferOutput; diff --git a/src/utils/internal/validate.ts b/src/utils/internal/validate.ts index 73a26e097..2ce55be17 100644 --- a/src/utils/internal/validate.ts +++ b/src/utils/internal/validate.ts @@ -97,11 +97,10 @@ export function validatedRequest< validators: { body?: RequestBody; headers?: RequestHeaders; - validationErrors?: { - body?: (issues: ValidateIssues) => ErrorDetails; - headers?: (issues: ValidateIssues) => ErrorDetails; - query?: (issues: ValidateIssues) => ErrorDetails; - }; + onValidationError?: ( + issues: ValidateIssues, + source: "headers" | "body", + ) => ErrorDetails; }, ): ServerRequest { // Validate Headers @@ -110,7 +109,7 @@ export function validatedRequest< "headers", Object.fromEntries(req.headers.entries()), validators.headers as StandardSchemaV1>, - validators.validationErrors?.headers, + validators.onValidationError, ); for (const [key, value] of Object.entries(validatedheaders)) { req.headers.set(key, value); @@ -132,8 +131,8 @@ export function validatedRequest< .then((data) => validators.body!["~standard"].validate(data)) .then((result) => { if (result.issues) { - const errorDetails = validators.validationErrors?.body - ? validators.validationErrors.body(result.issues) + const errorDetails = validators.onValidationError + ? validators.onValidationError(result.issues, "body") : { message: "Validation failed", issues: result.issues, @@ -159,9 +158,10 @@ export function validatedURL( url: URL, validators: { query?: StandardSchemaV1; - validationErrors?: { - query?: (issues: ValidateIssues) => ErrorDetails; - }; + onValidationError?: ( + issues: ValidateIssues, + source: "query", + ) => ErrorDetails; }, ): URL { if (!validators.query) { @@ -172,7 +172,7 @@ export function validatedURL( "query", Object.fromEntries(url.searchParams.entries()), validators.query as StandardSchemaV1>, - validators.validationErrors?.query, + validators.onValidationError, ); for (const [key, value] of Object.entries(validatedQuery)) { @@ -182,11 +182,11 @@ export function validatedURL( return url; } -function syncValidate( - type: string, +function syncValidate( + type: Source, data: unknown, fn: StandardSchemaV1, - error?: (issues: ValidateIssues) => ErrorDetails, + error?: (issues: ValidateIssues, source: Source) => ErrorDetails, ): T { const result = fn["~standard"].validate(data); if (result instanceof Promise) { @@ -194,7 +194,7 @@ function syncValidate( } if (result.issues) { const errorDetails = error - ? error(result.issues) + ? error(result.issues, type) : { message: "Validation failed", issues: result.issues, diff --git a/test/handler.test.ts b/test/handler.test.ts index 4ebb8870f..b5d7c3efd 100644 --- a/test/handler.test.ts +++ b/test/handler.test.ts @@ -88,7 +88,7 @@ describe("handler.ts", () => { age: z.number().optional().default(20), }), headers: z.object({ - "x-token": z.string(), + "x-token": z.string("Missing required header"), }), query: z.object({ id: z.string().min(3), @@ -111,22 +111,12 @@ describe("handler.ts", () => { query: z.object({ id: z.string().min(3), }), - validationErrors: { - body: (issues) => ({ - status: 500, - statusText: "Custom Zod body validation error", - message: summarize(issues), - }), - headers: (issues) => ({ - status: 500, - statusText: "Custom Zod headers validation error", - message: summarize(issues), - }), - query: (issues) => ({ + onValidationError: (issues, source) => { + return { status: 500, - statusText: "Custom Zod query validation error", + statusText: `Custom Zod ${source} validation error`, message: summarize(issues), - }), + }; }, handler: async (event) => { return { From f2480cbf8050e5c5a3d2f0876168ca0d3ee16aa2 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sun, 6 Jul 2025 13:28:02 +0200 Subject: [PATCH 5/7] refactor(`defineValidatedHandler): use new `validate` object --- src/handler.ts | 8 ++++---- src/utils/internal/validate.ts | 25 +++++++++++-------------- test/handler.test.ts | 34 ++++++++++++++++++---------------- 3 files changed, 33 insertions(+), 34 deletions(-) diff --git a/src/handler.ts b/src/handler.ts index b67f99b60..6439d4bdc 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -68,11 +68,11 @@ export function defineValidatedHandler< body?: RequestBody; headers?: RequestHeaders; query?: RequestQuery; + onError?: ( + issues: ValidateIssues, + source: "headers" | "body" | "query", + ) => ErrorDetails; }; - onValidationError?: ( - issues: ValidateIssues, - source: "headers" | "body" | "query", - ) => ErrorDetails; handler: EventHandler< { body: InferOutput; diff --git a/src/utils/internal/validate.ts b/src/utils/internal/validate.ts index 0fd442c84..3d557ca4b 100644 --- a/src/utils/internal/validate.ts +++ b/src/utils/internal/validate.ts @@ -97,7 +97,7 @@ export function validatedRequest< validate: { body?: RequestBody; headers?: RequestHeaders; - onValidationError?: ( + onError?: ( issues: ValidateIssues, source: "headers" | "body", ) => ErrorDetails; @@ -109,7 +109,7 @@ export function validatedRequest< "headers", Object.fromEntries(req.headers.entries()), validate.headers as StandardSchemaV1>, - validators.onValidationError, + validate.onError, ); for (const [key, value] of Object.entries(validatedheaders)) { req.headers.set(key, value); @@ -129,10 +129,10 @@ export function validatedRequest< req .json() .then((data) => validate.body!["~standard"].validate(data)) - .then((result) => + .then((result) => { if (result.issues) { - const errorDetails = validators.onValidationError - ? validators.onValidationError(result.issues, "body") + const errorDetails = validate.onError + ? validate.onError(result.issues, "body") : { message: "Validation failed", issues: result.issues, @@ -142,7 +142,7 @@ export function validatedRequest< } return result.value; - ); + }); } else if (reqBodyKeys.has(prop)) { throw new TypeError( `Cannot access .${prop} on request with JSON validation enabled. Use .json() instead.`, @@ -158,10 +158,7 @@ export function validatedURL( url: URL, validate: { query?: StandardSchemaV1; - onValidationError?: ( - issues: ValidateIssues, - source: "query", - ) => ErrorDetails; + onError?: (issues: ValidateIssues, source: "query") => ErrorDetails; }, ): URL { if (!validate.query) { @@ -172,7 +169,7 @@ export function validatedURL( "query", Object.fromEntries(url.searchParams.entries()), validate.query as StandardSchemaV1>, - validators.onValidationError, + validate.onError, ); for (const [key, value] of Object.entries(validatedQuery)) { @@ -186,15 +183,15 @@ function syncValidate( type: Source, data: unknown, fn: StandardSchemaV1, - error?: (issues: ValidateIssues, source: Source) => ErrorDetails, + onError?: (issues: ValidateIssues, source: Source) => ErrorDetails, ): T { const result = fn["~standard"].validate(data); if (result instanceof Promise) { throw new TypeError(`Asynchronous validation is not supported for ${type}`); } if (result.issues) { - const errorDetails = error - ? error(result.issues, type) + const errorDetails = onError + ? onError(result.issues, type) : { message: "Validation failed", issues: result.issues, diff --git a/test/handler.test.ts b/test/handler.test.ts index 43c3343a7..5ee701490 100644 --- a/test/handler.test.ts +++ b/test/handler.test.ts @@ -103,22 +103,24 @@ describe("handler.ts", () => { }, }); const handlerCustomError = defineValidatedHandler({ - body: z.object({ - name: z.string(), - age: z.number().optional().default(20), - }), - headers: z.object({ - "x-token": z.string("Missing required header"), - }), - query: z.object({ - id: z.string().min(3), - }), - onValidationError: (issues, source) => { - return { - status: 500, - statusText: `Custom Zod ${source} validation error`, - message: summarize(issues), - }; + validate: { + body: z.object({ + name: z.string(), + age: z.number().optional().default(20), + }), + headers: z.object({ + "x-token": z.string("Missing required header"), + }), + query: z.object({ + id: z.string().min(3), + }), + onError: (issues, source) => { + return { + status: 500, + statusText: `Custom Zod ${source} validation error`, + message: summarize(issues), + }; + }, }, handler: async (event) => { return { From 1e4d72079488c1d63bcb3f82491b8559883bbb71 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sun, 6 Jul 2025 16:27:04 +0200 Subject: [PATCH 6/7] fix(validation): pass whole result to error --- src/handler.ts | 11 ++++------- src/utils/body.ts | 9 +++------ src/utils/internal/validate.ts | 17 +++++++++-------- src/utils/request.ts | 11 ++++------- test/handler.test.ts | 2 +- test/validate.test.ts | 6 +++--- 6 files changed, 24 insertions(+), 32 deletions(-) diff --git a/src/handler.ts b/src/handler.ts index 6439d4bdc..abf93e40e 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -13,16 +13,13 @@ import type { EventHandlerWithFetch, } from "./types/handler.ts"; import type { - InferOutput, StandardSchemaV1, + FailureResult, + InferOutput, } from "./utils/internal/standard-schema.ts"; import type { TypedRequest } from "fetchdts"; import type { ErrorDetails } from "./error.ts"; -import { - type ValidateIssues, - validatedRequest, - validatedURL, -} from "./utils/internal/validate.ts"; +import { validatedRequest, validatedURL } from "./utils/internal/validate.ts"; // --- event handler --- @@ -69,7 +66,7 @@ export function defineValidatedHandler< headers?: RequestHeaders; query?: RequestQuery; onError?: ( - issues: ValidateIssues, + result: FailureResult, source: "headers" | "body" | "query", ) => ErrorDetails; }; diff --git a/src/utils/body.ts b/src/utils/body.ts index 0385e616c..24b5cb9bf 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -1,9 +1,5 @@ import { type ErrorDetails, HTTPError } from "../error.ts"; -import { - type ValidateIssues, - type ValidateError, - validateData, -} from "./internal/validate.ts"; +import { type ValidateError, validateData } from "./internal/validate.ts"; import { parseURLEncodedBody } from "./internal/body.ts"; import type { H3Event } from "../event.ts"; @@ -11,6 +7,7 @@ import type { InferEventInput } from "../types/handler.ts"; import type { ValidateResult } from "./internal/validate.ts"; import type { StandardSchemaV1, + FailureResult, InferOutput, } from "./internal/standard-schema.ts"; @@ -59,7 +56,7 @@ export async function readValidatedBody< >( event: Event, validate: S, - options?: { onError?: (issues: ValidateIssues) => ErrorDetails }, + options?: { onError?: (result: FailureResult) => ErrorDetails }, ): Promise>; export async function readValidatedBody< Event extends H3Event, diff --git a/src/utils/internal/validate.ts b/src/utils/internal/validate.ts index 3d557ca4b..9def7f546 100644 --- a/src/utils/internal/validate.ts +++ b/src/utils/internal/validate.ts @@ -3,6 +3,7 @@ import { type ErrorDetails, HTTPError } from "../../error.ts"; import type { ServerRequest } from "srvx"; import type { StandardSchemaV1, + FailureResult, InferOutput, Issue, } from "./standard-schema.ts"; @@ -19,7 +20,7 @@ export type ValidateFunction< export type ValidateIssues = ReadonlyArray; export type ValidateError = | (() => ErrorDetails) - | ((issues: ValidateIssues) => ErrorDetails); + | ((result: FailureResult) => ErrorDetails); /** * Validates the given data using the provided validation function. @@ -34,7 +35,7 @@ export async function validateData( data: unknown, fn: Schema, options?: { - onError?: (issues: ValidateIssues) => ErrorDetails; + onError?: (result: FailureResult) => ErrorDetails; }, ): Promise>; export async function validateData( @@ -55,7 +56,7 @@ export async function validateData( const result = await fn["~standard"].validate(data); if (result.issues) { const errorDetails = options?.onError - ? options.onError(result.issues) + ? options.onError(result) : { message: "Validation failed", issues: result.issues, @@ -98,7 +99,7 @@ export function validatedRequest< body?: RequestBody; headers?: RequestHeaders; onError?: ( - issues: ValidateIssues, + result: FailureResult, source: "headers" | "body", ) => ErrorDetails; }, @@ -132,7 +133,7 @@ export function validatedRequest< .then((result) => { if (result.issues) { const errorDetails = validate.onError - ? validate.onError(result.issues, "body") + ? validate.onError(result, "body") : { message: "Validation failed", issues: result.issues, @@ -158,7 +159,7 @@ export function validatedURL( url: URL, validate: { query?: StandardSchemaV1; - onError?: (issues: ValidateIssues, source: "query") => ErrorDetails; + onError?: (result: FailureResult, source: "query") => ErrorDetails; }, ): URL { if (!validate.query) { @@ -183,7 +184,7 @@ function syncValidate( type: Source, data: unknown, fn: StandardSchemaV1, - onError?: (issues: ValidateIssues, source: Source) => ErrorDetails, + onError?: (result: FailureResult, source: Source) => ErrorDetails, ): T { const result = fn["~standard"].validate(data); if (result instanceof Promise) { @@ -191,7 +192,7 @@ function syncValidate( } if (result.issues) { const errorDetails = onError - ? onError(result.issues, type) + ? onError(result, type) : { message: "Validation failed", issues: result.issues, diff --git a/src/utils/request.ts b/src/utils/request.ts index 5ee324a3d..02b87316c 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -1,13 +1,10 @@ import { type ErrorDetails, HTTPError } from "../error.ts"; -import { - type ValidateIssues, - type ValidateError, - validateData, -} from "./internal/validate.ts"; +import { type ValidateError, validateData } from "./internal/validate.ts"; import { parseQuery } from "./internal/query.ts"; import type { StandardSchemaV1, + FailureResult, InferOutput, } from "./internal/standard-schema.ts"; import type { ValidateResult } from "./internal/validate.ts"; @@ -37,7 +34,7 @@ export function getValidatedQuery< >( event: Event, validate: S, - options?: { onError?: (issues: ValidateIssues) => ErrorDetails }, + options?: { onError?: (result: FailureResult) => ErrorDetails }, ): Promise>; export function getValidatedQuery< Event extends H3Event, @@ -143,7 +140,7 @@ export function getValidatedRouterParams< validate: S, options?: { decode?: boolean; - onError?: (issues: ValidateIssues) => ErrorDetails; + onError?: (result: FailureResult) => ErrorDetails; }, ): Promise>; export function getValidatedRouterParams< diff --git a/test/handler.test.ts b/test/handler.test.ts index 5ee701490..44b06c6e8 100644 --- a/test/handler.test.ts +++ b/test/handler.test.ts @@ -114,7 +114,7 @@ describe("handler.ts", () => { query: z.object({ id: z.string().min(3), }), - onError: (issues, source) => { + onError: ({ issues }, source) => { return { status: 500, statusText: `Custom Zod ${source} validation error`, diff --git a/test/validate.test.ts b/test/validate.test.ts index a157294d4..89cbc3f13 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -74,7 +74,7 @@ describeMatrix("validate", (t, { it, describe, expect }) => { t.app.post("/custom-error-zod", async (event) => { const data = await readValidatedBody(event, zodValidate, { - onError: (issues) => ({ + onError: ({ issues }) => ({ status: 500, statusText: "Custom Zod validation error", message: summarize(issues), @@ -226,7 +226,7 @@ describeMatrix("validate", (t, { it, describe, expect }) => { t.app.get("/custom-error-zod", async (event) => { const data = await getValidatedQuery(event, zodValidate, { - onError: (issues) => ({ + onError: ({ issues }) => ({ status: 500, statusText: "Custom Zod validation error", message: summarize(issues), @@ -335,7 +335,7 @@ describeMatrix("validate", (t, { it, describe, expect }) => { t.app.get("/custom-error-zod/:id", async (event) => { const data = await getValidatedRouterParams(event, zodParamValidate, { - onError: (issues) => ({ + onError: ({ issues }) => ({ status: 500, statusText: "Custom Zod validation error", message: summarize(issues), From 17406703425fcf4f3aefd824060e4bf01dc2c832 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Wed, 30 Jul 2025 15:28:04 +0200 Subject: [PATCH 7/7] test(handler): update custom error tests --- test/handler.test.ts | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/test/handler.test.ts b/test/handler.test.ts index 0dd2c8f34..59e78d36b 100644 --- a/test/handler.test.ts +++ b/test/handler.test.ts @@ -207,11 +207,13 @@ describe("handler.ts", () => { describe("custom error messages", () => { it("invalid body", async () => { - const res = await handlerCustomError.fetch("/?id=123", { - method: "POST", - headers: { "x-token": "abc" }, - body: JSON.stringify({ name: 123 }), - }); + const res = await handlerCustomError.fetch( + toRequest("/?id=123", { + method: "POST", + headers: { "x-token": "abc" }, + body: JSON.stringify({ name: 123 }), + }), + ); expect(await res.json()).toMatchObject({ status: 500, statusText: "Custom Zod body validation error", @@ -221,10 +223,12 @@ describe("handler.ts", () => { }); it("invalid headers", async () => { - const res = await handlerCustomError.fetch("/?id=123", { - method: "POST", - body: JSON.stringify({ name: 123 }), - }); + const res = await handlerCustomError.fetch( + toRequest("/?id=123", { + method: "POST", + body: JSON.stringify({ name: 123 }), + }), + ); expect(await res.json()).toMatchObject({ status: 500, statusText: "Custom Zod headers validation error", @@ -234,11 +238,13 @@ describe("handler.ts", () => { }); it("invalid query", async () => { - const res = await handlerCustomError.fetch("/?id=", { - method: "POST", - headers: { "x-token": "abc" }, - body: JSON.stringify({ name: "tommy" }), - }); + const res = await handlerCustomError.fetch( + toRequest("/?id=", { + method: "POST", + headers: { "x-token": "abc" }, + body: JSON.stringify({ name: "tommy" }), + }), + ); expect(await res.json()).toMatchObject({ status: 500, statusText: "Custom Zod query validation error",