diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index f142cb1e7..9df412e76 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -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,27 @@ 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)), + }), + { + onError: (issues) => ({ + statusText: "Custom validation error", + message: v.summarize(issues), + }), + }, + ); +}); +``` + ## Cache @@ -218,7 +239,27 @@ 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(), + }), + { + onError: (issues) => ({ + statusText: "Custom validation error", + message: v.summarize(issues), + }), + }, + ); +}); +``` + +### `getValidatedRouterParams(event, validate)` Get matched route params and validate with validate function. @@ -229,7 +270,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 +281,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 +291,27 @@ 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, + onError: (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 9298b2ebe..aeab199af 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -12,10 +12,12 @@ 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 { validatedRequest, validatedURL } from "./utils/internal/validate.ts"; // --- event handler --- @@ -62,6 +64,10 @@ export function defineValidatedHandler< body?: RequestBody; headers?: RequestHeaders; query?: RequestQuery; + onError?: ( + result: FailureResult, + source: "headers" | "body" | "query", + ) => ErrorDetails; }; handler: EventHandler< { diff --git a/src/utils/body.ts b/src/utils/body.ts index d73b06c4f..d763fef1d 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 { HTTPEvent } from "../event.ts"; @@ -7,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"; @@ -52,7 +53,11 @@ export async function readBody< export async function readValidatedBody< Event extends HTTPEvent, S extends StandardSchemaV1, ->(event: Event, validate: S): Promise>; +>( + event: Event, + validate: S, + options?: { onError?: (result: FailureResult) => ErrorDetails }, +): Promise>; export async function readValidatedBody< Event extends HTTPEvent, OutputT, @@ -62,6 +67,9 @@ export async function readValidatedBody< validate: ( data: InputT, ) => ValidateResult | Promise>, + 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. @@ -69,7 +77,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 +85,35 @@ 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)), + * }), + * { + * onError: (issues) => ({ + * statusText: "Custom validation error", + * message: v.summarize(issues), + * }), + * }, + * ); + * }); * * @param event The HTTPEvent 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 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} @@ -94,7 +121,10 @@ export async function readValidatedBody< export async function readValidatedBody( event: HTTPEvent, validate: any, + options?: { + onError?: ValidateError; + }, ): Promise { const _body = await readBody(event); - return validateData(_body, validate); + return validateData(_body, validate, options); } diff --git a/src/utils/internal/validate.ts b/src/utils/internal/validate.ts index 268e17965..9def7f546 100644 --- a/src/utils/internal/validate.ts +++ b/src/utils/internal/validate.ts @@ -1,7 +1,12 @@ -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, + FailureResult, + InferOutput, + Issue, +} from "./standard-schema.ts"; export type ValidateResult = T | true | false | void; @@ -12,33 +17,52 @@ export type ValidateFunction< | Schema | ((data: unknown) => ValidateResult | Promise>); +export type ValidateIssues = ReadonlyArray; +export type ValidateError = + | (() => ErrorDetails) + | ((result: FailureResult) => 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, + options?: { + onError?: (result: FailureResult) => ErrorDetails; + }, ): Promise>; export async function validateData( data: unknown, fn: (data: unknown) => ValidateResult | Promise>, + options?: { + onError?: () => ErrorDetails; + }, ): Promise; export async function validateData( data: unknown, fn: ValidateFunction, + options?: { + onError?: ValidateError; + }, ): 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 = options?.onError + ? options.onError(result) + : { + message: "Validation failed", + issues: result.issues, + }; + + throw createValidationError(errorDetails); } return result.value; } @@ -46,7 +70,13 @@ export async function validateData( try { const res = await fn(data); if (res === false) { - throw createValidationError({ message: "Validation failed" }); + const errorDetails = options?.onError + ? (options.onError as () => ErrorDetails)() + : { + message: "Validation failed", + }; + + throw createValidationError(errorDetails); } if (res === true) { return data as T; @@ -68,6 +98,10 @@ export function validatedRequest< validate: { body?: RequestBody; headers?: RequestHeaders; + onError?: ( + result: FailureResult, + source: "headers" | "body", + ) => ErrorDetails; }, ): ServerRequest { // Validate Headers @@ -76,6 +110,7 @@ export function validatedRequest< "headers", Object.fromEntries(req.headers.entries()), validate.headers as StandardSchemaV1>, + validate.onError, ); for (const [key, value] of Object.entries(validatedheaders)) { req.headers.set(key, value); @@ -95,11 +130,20 @@ export function validatedRequest< req .json() .then((data) => validate.body!["~standard"].validate(data)) - .then((result) => - result.issues - ? Promise.reject(createValidationError(result)) - : result.value, - ); + .then((result) => { + if (result.issues) { + const errorDetails = validate.onError + ? validate.onError(result, "body") + : { + 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 +159,7 @@ export function validatedURL( url: URL, validate: { query?: StandardSchemaV1; + onError?: (result: FailureResult, source: "query") => ErrorDetails; }, ): URL { if (!validate.query) { @@ -125,6 +170,7 @@ export function validatedURL( "query", Object.fromEntries(url.searchParams.entries()), validate.query as StandardSchemaV1>, + validate.onError, ); for (const [key, value] of Object.entries(validatedQuery)) { @@ -134,29 +180,37 @@ export function validatedURL( return url; } -function syncValidate( - type: string, +function syncValidate( + type: Source, data: unknown, fn: StandardSchemaV1, + onError?: (result: FailureResult, 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) { - throw createValidationError({ - issues: result.issues, - }); + const errorDetails = onError + ? onError(result, type) + : { + 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 107980918..592b2f047 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -1,13 +1,14 @@ -import { HTTPError } from "../error.ts"; +import { type ErrorDetails, HTTPError } from "../error.ts"; import { parseQuery } from "./internal/query.ts"; import { validateData } from "./internal/validate.ts"; import { getEventContext } from "./event.ts"; import type { StandardSchemaV1, + FailureResult, InferOutput, } from "./internal/standard-schema.ts"; -import type { ValidateResult } from "./internal/validate.ts"; +import type { ValidateResult, ValidateError } from "./internal/validate.ts"; import type { H3Event, HTTPEvent } from "../event.ts"; import type { InferEventInput } from "../types/handler.ts"; import type { HTTPMethod } from "../types/h3.ts"; @@ -63,7 +64,11 @@ export function getQuery< export function getValidatedQuery< Event extends HTTPEvent, S extends StandardSchemaV1, ->(event: Event, validate: S): Promise>; +>( + event: Event, + validate: S, + options?: { onError?: (result: FailureResult) => ErrorDetails }, +): Promise>; export function getValidatedQuery< Event extends HTTPEvent, OutputT, @@ -73,6 +78,9 @@ export function getValidatedQuery< validate: ( data: InputT, ) => ValidateResult | Promise>, + options?: { + onError?: () => ErrorDetails; + }, ): Promise; /** * Get the query param from the request URL validated with validate function. @@ -96,13 +104,40 @@ export function getValidatedQuery< * }), * ); * }); + * @example + * import * as v from "valibot"; + * + * app.get("/", async (event) => { + * const params = await getValidatedQuery( + * event, + * v.object({ + * key: v.string(), + * }), + * { + * 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: HTTPEvent, validate: any, + options?: { + onError?: ValidateError; + }, ): Promise { const query = getQuery(event); - return validateData(query, validate); + return validateData(query, validate, options); } /** @@ -139,7 +174,10 @@ export function getValidatedRouterParams< >( event: Event, validate: S, - opts?: { decode?: boolean }, + options?: { + decode?: boolean; + onError?: (result: FailureResult) => ErrorDetails; + }, ): Promise>; export function getValidatedRouterParams< Event extends HTTPEvent, @@ -150,7 +188,10 @@ export function getValidatedRouterParams< validate: ( data: InputT, ) => ValidateResult | Promise>, - opts?: { decode?: boolean }, + options?: { + decode?: boolean; + onError?: () => ErrorDetails; + }, ): Promise; /** * Get matched route params and validate with validate function. @@ -160,7 +201,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"; * }); @@ -168,7 +209,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({ @@ -176,14 +217,43 @@ 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, + * 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: HTTPEvent, validate: any, - opts: { decode?: boolean } = {}, + options: { + decode?: boolean; + onError?: ValidateError; + } = {}, ): Promise { - const routerParams = getRouterParams(event, opts); - return validateData(routerParams, validate); + const { decode, ...opts } = options; + const routerParams = getRouterParams(event, { decode }); + return validateData(routerParams, validate, opts); } /** diff --git a/test/handler.test.ts b/test/handler.test.ts index fd3a3575a..81f0d58e1 100644 --- a/test/handler.test.ts +++ b/test/handler.test.ts @@ -6,9 +6,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", () => { @@ -89,7 +90,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), @@ -102,6 +103,33 @@ describe("handler.ts", () => { }; }, }); + const handlerCustomError = defineValidatedHandler({ + 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 { + body: await event.req.json(), + headers: event.req.headers, + }; + }, + }); it("valid request", async () => { const res = await handler.fetch( @@ -176,5 +204,74 @@ describe("handler.ts", () => { }); expect(res.status).toBe(400); }); + + describe("custom error messages", () => { + it("invalid body", async () => { + 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", + message: "- Invalid input: expected string, received number", + }); + expect(res.status).toBe(500); + }); + + it("invalid headers", async () => { + 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", + message: "- Missing required header", + }); + expect(res.status).toBe(500); + }); + + it("invalid query", async () => { + 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", + 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..89cbc3f13 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,35 @@ 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, + { + onError() { + return { + status: 500, + statusText: "Custom validation error", + }; + }, + }, + ); + + return data; + }); + + t.app.post("/custom-error-zod", async (event) => { + const data = await readValidatedBody(event, zodValidate, { + onError: ({ issues }) => ({ + status: 500, + statusText: "Custom Zod validation error", + message: summarize(issues), + }), + }); + + return data; + }); }); describe("custom validator", () => { @@ -122,6 +165,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 +206,35 @@ 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, + { + onError() { + return { + status: 500, + statusText: "Custom validation error", + }; + }, + }, + ); + + return data; + }); + + t.app.get("/custom-error-zod", async (event) => { + const data = await getValidatedQuery(event, zodValidate, { + onError: ({ issues }) => ({ + status: 500, + statusText: "Custom Zod validation error", + message: summarize(issues), + }), + }); + + return data; + }); }); describe("custom validator", () => { @@ -169,6 +269,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 +315,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 +332,18 @@ 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, { + onError: ({ issues }) => ({ + status: 500, + statusText: "Custom Zod validation error", + message: summarize(issues), + }), + }); + + return data; + }); }); describe("custom validator", () => { @@ -240,5 +376,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; +}