From bb69d5a58b9f3f9709f5b00495fdf2862a3eede5 Mon Sep 17 00:00:00 2001 From: OJ Kwon <1210596+kwonoj@users.noreply.github.com> Date: Sat, 19 Jul 2025 14:49:07 -0700 Subject: [PATCH 1/3] feat(fetch): pass validation error to hooks --- packages/better-fetch/src/fetch.ts | 41 +++++++++++++++++++++++++--- packages/better-fetch/src/plugins.ts | 20 ++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/better-fetch/src/fetch.ts b/packages/better-fetch/src/fetch.ts index a14b9ac..76449d9 100644 --- a/packages/better-fetch/src/fetch.ts +++ b/packages/better-fetch/src/fetch.ts @@ -14,6 +14,7 @@ import { isJSONParsable, jsonParse, parseStandardSchema, + ValidationError, } from "./utils"; export const betterFetch = async < @@ -129,10 +130,41 @@ export const betterFetch = async < */ if (context?.output) { if (context.output && !context.disableValidation) { - successContext.data = await parseStandardSchema( - context.output as StandardSchemaV1, - successContext.data, - ); + try { + successContext.data = await parseStandardSchema( + context.output as StandardSchemaV1, + successContext.data, + ); + } catch (err) { + const validationError = err as ValidationError; + + // Create validation error context for hooks + const validationErrorContext = { + response: response ?? Response.json({ error: validationError.message }), + request: context, + error: { + type: 'validation' as const, + issues: validationError.issues, + message: validationError.message, + status: response.status, + statusText: response.statusText, + }, + }; + + + if (options?.onError) { + await options.onError(validationErrorContext); + } + + // Call all onError hooks with validation error + for (const onError of hooks.onError) { + if (onError) { + await onError(validationErrorContext); + } + } + + throw validationError; + } } } @@ -169,6 +201,7 @@ export const betterFetch = async < request: context, error: { ...errorObject, + type: 'http' as const, status: response.status, statusText: response.statusText, }, diff --git a/packages/better-fetch/src/plugins.ts b/packages/better-fetch/src/plugins.ts index 6e0a605..cdd91ff 100644 --- a/packages/better-fetch/src/plugins.ts +++ b/packages/better-fetch/src/plugins.ts @@ -19,11 +19,27 @@ export type SuccessContext = { response: Response; request: RequestContext; }; -export type ErrorContext = { +export type ValidationErrorContext = { response: Response; request: RequestContext; - error: BetterFetchError & Record; + error: { + type: 'validation'; + issues: ReadonlyArray; + message: string; + status?: number; + statusText?: string; + }; }; + +export type HttpErrorContext = { + response: Response; + request: RequestContext; + error: BetterFetchError & Record & { + type: 'http'; + }; +}; + +export type ErrorContext = HttpErrorContext | ValidationErrorContext; export interface FetchHooks { /** * a callback function that will be called when a From 090caf08f647e12373ae543f76100965de10e8a1 Mon Sep 17 00:00:00 2001 From: OJ Kwon <1210596+kwonoj@users.noreply.github.com> Date: Sat, 19 Jul 2025 14:49:23 -0700 Subject: [PATCH 2/3] test(fetch): add test cases --- packages/better-fetch/src/test/fetch.test.ts | 411 ++++++++++++++++++- 1 file changed, 410 insertions(+), 1 deletion(-) diff --git a/packages/better-fetch/src/test/fetch.test.ts b/packages/better-fetch/src/test/fetch.test.ts index 6c1e6ff..17f539b 100644 --- a/packages/better-fetch/src/test/fetch.test.ts +++ b/packages/better-fetch/src/test/fetch.test.ts @@ -1,9 +1,11 @@ import { createApp, toNodeListener } from "h3"; import { type Listener, listen } from "listhen"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { BetterFetchError, betterFetch, createFetch } from ".."; +import { BetterFetchError, betterFetch, createFetch, createSchema } from ".."; import { getURL } from "../url"; import { router } from "./test-router"; +import { z } from "zod"; +import { ValidationError } from "../utils"; describe("fetch", () => { const getURL = (path?: string) => @@ -355,6 +357,7 @@ describe("hooks", () => { response: expect.any(Response), responseText: '{"message":"Server Error"}', error: { + type: "http", message: "Server Error", status: 500, statusText: "", @@ -534,3 +537,409 @@ describe("url", () => { expect(url.toString()).toBe("http://localhost:4001/param/%23test/item%201"); }); }); + + +describe("validation", () => { + it("should call onError hook for output validation failures", async () => { + const onErrorSpy = vi.fn(); + + const $fetch = createFetch({ + baseURL: "http://localhost:4001", + customFetchImpl: async () => { + return new Response(JSON.stringify({ invalid: "data" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + onError: onErrorSpy, + }); + + try { + await $fetch("/test", { + output: z.object({ + id: z.number(), + name: z.string(), + }), + }); + } catch (_) { + // Ignore + } finally { + expect(onErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + type: "validation", + issues: expect.any(Array), + message: expect.any(String), + }), + request: expect.any(Object), + response: expect.any(Object), + }) + ); + } + }); + + it("should call onError hook for schema-based output validation failures", async () => { + const onErrorSpy = vi.fn(); + + const $fetch = createFetch({ + schema: createSchema({ + "/user": { + output: z.object({ + id: z.number(), + name: z.string(), + }), + }, + }), + baseURL: "http://localhost:4001", + customFetchImpl: async () => { + return new Response(JSON.stringify({ invalid: "data" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + onError: onErrorSpy, + }); + + try { + await $fetch("/user"); + } catch (_) { + // Ignore + } finally { + expect(onErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + type: "validation", + issues: expect.any(Array), + }), + }) + ); + } + }); + + it("should handle input validation failures for body schema", async () => { + const onErrorSpy = vi.fn(); + + const $fetch = createFetch({ + schema: createSchema({ + "/user": { + input: z.object({ + name: z.string(), + age: z.number(), + }), + output: z.object({ + id: z.number(), + name: z.string(), + age: z.number(), + }), + }, + }), + baseURL: "http://localhost:4001", + customFetchImpl: async () => { + return new Response(JSON.stringify({ id: 1, name: "John", age: 30 }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + onError: onErrorSpy, + }); + + // Input validation should throw since we can't proceed without valid input + await expect($fetch("/user", { + body: { name: "John", age: "invalid" as any }, // age should be number + })).rejects.toThrow(ValidationError); + }); + + it("should handle input validation failures for params schema", async () => { + const onErrorSpy = vi.fn(); + + const $fetch = createFetch({ + schema: createSchema({ + "/user/:id": { + params: z.object({ + id: z.number(), + }), + output: z.object({ + id: z.number(), + name: z.string(), + }), + }, + }), + baseURL: "http://localhost:4001", + customFetchImpl: async () => { + return new Response(JSON.stringify({ id: 1, name: "John" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + onError: onErrorSpy, + }); + + // Params validation should throw since we can't proceed without valid params + await expect($fetch("/user/:id", { + params: { id: "invalid" as any }, // id should be number + })).rejects.toThrow(ValidationError); + }); + + it("should handle input validation failures for query schema", async () => { + const onErrorSpy = vi.fn(); + + const $fetch = createFetch({ + schema: createSchema({ + "/users": { + query: z.object({ + page: z.number(), + limit: z.number(), + }), + output: z.array(z.object({ + id: z.number(), + name: z.string(), + })), + }, + }), + baseURL: "http://localhost:4001", + customFetchImpl: async () => { + return new Response(JSON.stringify([{ id: 1, name: "John" }]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + onError: onErrorSpy, + }); + + // Query validation should throw since we can't proceed without valid query + await expect($fetch("/users", { + query: { page: "invalid" as any, limit: 10 }, // page should be number + })).rejects.toThrow(ValidationError); + }); + + it("should work with catchAllError option for input validation failures", async () => { + const onErrorSpy = vi.fn(); + + const $fetch = createFetch({ + schema: createSchema({ + "/user": { + input: z.object({ + name: z.string(), + age: z.number(), + }), + output: z.object({ + id: z.number(), + name: z.string(), + age: z.number(), + }), + }, + }), + baseURL: "http://localhost:4001", + customFetchImpl: async () => { + return new Response(JSON.stringify({ id: 1, name: "John", age: 30 }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + onError: onErrorSpy, + catchAllError: true, // This should catch validation errors and wrap them + }); + + const result = await $fetch("/user", { + body: { name: "John", age: "invalid" as any }, // age should be number + }); + + // With catchAllError, validation errors should be wrapped in the response + expect(result.error).toEqual( + expect.objectContaining({ + status: 500, + statusText: "Fetch Error", + message: expect.stringContaining("catchAllError"), + error: expect.any(ValidationError), + }) + ); + expect(result.data).toBeNull(); + }); + + it("should still throw validation errors when throw: true", async () => { + const onErrorSpy = vi.fn(); + + const $fetch = createFetch({ + baseURL: "http://localhost:4001", + throw: true, + customFetchImpl: async () => { + return new Response(JSON.stringify({ invalid: "data" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + onError: onErrorSpy, + }); + + await expect($fetch("/test", { + output: z.object({ + id: z.number(), + name: z.string(), + }), + })).rejects.toThrow(ValidationError); + + expect(onErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + type: "validation", + }), + }) + ); + }); + + it("should distinguish between HTTP and validation errors in onError hook", async () => { + const onErrorSpy = vi.fn(); + + const $fetch = createFetch({ + baseURL: "http://localhost:4001", + customFetchImpl: async (url) => { + if (url.toString().includes("http-error")) { + return new Response(JSON.stringify({ error: "Not found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ invalid: "data" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + onError: onErrorSpy, + }); + + // Test HTTP error + await $fetch("/http-error"); + expect(onErrorSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + type: "http", + status: 404, + }), + }) + ); + + onErrorSpy.mockClear(); + + try { + // Test validation error + await $fetch("/validation-error", { + output: z.object({ + id: z.number(), + }), + }); + } catch(_) { + // Ignore + } finally { + expect(onErrorSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + type: "validation", + issues: expect.any(Array), + }), + }) + ); + } + + }); + + it("should preserve backward compatibility when no hooks are defined", async () => { + const $fetch = createFetch({ + baseURL: "http://localhost:4001", + customFetchImpl: async () => { + return new Response(JSON.stringify({ invalid: "data" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const fetch = $fetch("/test", { + output: z.object({ + id: z.number(), + name: z.string(), + }), + }); + + expect(fetch).rejects.toThrowError(ValidationError); + }); + + it("should call multiple onError hooks for validation errors", async () => { + const onError1 = vi.fn(); + const onError2 = vi.fn(); + + const plugin1 = { + id: "plugin1", + name: "Plugin 1", + hooks: { onError: onError1 }, + }; + + const plugin2 = { + id: "plugin2", + name: "Plugin 2", + hooks: { onError: onError2 }, + }; + + const $fetch = createFetch({ + baseURL: "http://localhost:4001", + customFetchImpl: async () => { + return new Response(JSON.stringify({ invalid: "data" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + plugins: [plugin1, plugin2], + }); + + try { + await $fetch("/test", { + output: z.object({ + id: z.number(), + }), + }); + } catch (_) { + // Ignore + } finally { + expect(onError1).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + type: "validation", + }), + }) + ); + + expect(onError2).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + type: "validation", + }), + }) + ); + } + }); + + it("should not call onError hook when validation is disabled", async () => { + const onErrorSpy = vi.fn(); + + const $fetch = createFetch({ + baseURL: "http://localhost:4001", + customFetchImpl: async () => { + return new Response(JSON.stringify({ invalid: "data" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + onError: onErrorSpy, + }); + + const result = await $fetch("/test", { + output: z.object({ + id: z.number(), + }), + disableValidation: true, + }); + + expect(onErrorSpy).not.toHaveBeenCalled(); + expect(result.data).toEqual({ invalid: "data" }); + expect(result.error).toBeNull(); + }); +}); From cc68c287100755f06bda8c162e9169ca2d6b9512 Mon Sep 17 00:00:00 2001 From: OJ Kwon <1210596+kwonoj@users.noreply.github.com> Date: Sat, 19 Jul 2025 14:49:33 -0700 Subject: [PATCH 3/3] docs(readme): update docs --- doc/content/docs/hooks.mdx | 46 +++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/doc/content/docs/hooks.mdx b/doc/content/docs/hooks.mdx index cccbb21..276f512 100644 --- a/doc/content/docs/hooks.mdx +++ b/doc/content/docs/hooks.mdx @@ -3,7 +3,7 @@ title: Hooks description: Hooks --- -Hooks are functions that are called at different stages of the request lifecycle. +Hooks are functions that are called at different stages of the request lifecycle. ```ts twoslash title="fetch.ts" import { createFetch } from "@better-fetch/fetch"; @@ -50,7 +50,7 @@ import { createFetch } from "@better-fetch/fetch"; const $fetch = createFetch({ baseURL: "http://localhost:3000", - onResponse(context) { + onResponse(context) { // do something with the context return context.response // return the response }, @@ -61,6 +61,7 @@ const $fetch = createFetch({ ## On Success and On Error on success and on error are callbacks that will be called when a request is successful or when an error occurs. The function will be called with the response context as an argument and it's not expeceted to return anything. +If schema validation fails, the `onError` hook will be called as well. ```ts twoslash title="fetch.ts" import { createFetch } from "@better-fetch/fetch"; @@ -72,6 +73,45 @@ const $fetch = createFetch({ }, onError(context) { // do something with the context + // console.log(context.error.type); // "validation" | "http" }, }) -``` \ No newline at end of file +``` + +### Error Types + +The `onError` hook receives different error types depending on what caused the error: + +#### HTTP Errors +When an HTTP request fails (non-2xx status codes), the error context contains: + +```ts +{ + response: Response, + request: RequestContext, + error: { + type: "http", + status: number, + statusText: string, + // ... other response data + }, + responseText: string +} +``` + +#### Validation Errors +When schema validation fails on the response data, the error context contains: + +```ts +{ + response: Response, + request: RequestContext, + error: { + type: "validation", + issues: Array, + message: string, + status: number, + statusText: string, + } +} +```