Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion packages/shared/src/fetch/error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { FetchContext, FetchOptions, FetchResponse, ResponseType } from "./types";
import type {
FetchContext,
FetchOptions,
FetchResponse,
ResponseType,
} from "./types";

export class FetchError<T = any> extends Error {
readonly request?: RequestInfo;
Expand Down Expand Up @@ -55,3 +60,16 @@ export class FetchError<T = any> extends Error {
return fetchError;
}
}

export class FetchSchemaValidationError<T = any> extends FetchError<T> {
readonly issues?: unknown;

constructor(message: string, opts?: { cause: unknown; issues?: unknown }) {
super(message, opts);
this.name = "FetchSchemaValidationError";

if (opts?.issues !== undefined) {
this.issues = opts.issues;
}
}
}
35 changes: 28 additions & 7 deletions packages/shared/src/fetch/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
} from "./types";
import { isMSWError } from "@luxass/msw-utils/runtime-guards";
import { safeJsonParse } from "../json";
import { FetchError } from "./error";
import { FetchError, FetchSchemaValidationError } from "./error";
import {
detectResponseType,
isJSONSerializable,
Expand Down Expand Up @@ -82,8 +82,22 @@ function createCustomFetch(): CustomFetch {
}
}

// Throw normalized error
const error = FetchError.from(context);
// Throw normalized error. Preserve explicit FetchError subclasses (e.g., schema validation).
let error: FetchError<T>;
if (context.error instanceof FetchError) {
error = context.error as FetchError<T>;
// Ensure common fields are attached for consumers
Object.assign(error, {
request: (error as any).request ?? context.request,
options: (error as any).options ?? context.options,
response: (error as any).response ?? context.response,
data: (error as any).data ?? context.response?.data,
status: (error as any).status ?? context.response?.status,
statusText: (error as any).statusText ?? context.response?.statusText,
});
} else {
error = FetchError.from(context);
}

// Only available on V8 based runtimes (https://v8.dev/docs/stack-trace-api)
if (Error.captureStackTrace) {
Expand Down Expand Up @@ -205,12 +219,19 @@ function createCustomFetch(): CustomFetch {
}

// Validate response data with schema if provided
if (context.options.schema && context.response.data !== undefined) {
if (
context.options.schema
&& context.response.data !== undefined
&& context.response.status < 400
) {
const result = await context.options.schema.safeParseAsync(context.response.data);
if (!result.success) {
context.error = new Error(`Response validation failed: ${result.error.message}`);
context.error.name = "ValidationError";
(context.error as any).issues = result.error.issues;
// Wrap schema failures in a dedicated error with issues attached
context.error = new FetchSchemaValidationError(
`Response validation failed: ${result.error.message}`,
{ cause: result.error, issues: result.error.issues },
);

return await handleError(context);
}

Expand Down
4 changes: 2 additions & 2 deletions packages/shared/src/fetch/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ZodType } from "zod";
import type { FetchError } from "./error";
import type { FetchError, FetchSchemaValidationError } from "./error";

export interface CustomFetch {
<T = any, R extends ResponseType = "json">(
Expand Down Expand Up @@ -28,7 +28,7 @@ export type MappedResponseType<

export interface SafeFetchResponse<T = any> {
data: T | null;
error: FetchError<T> | null;
error: FetchError<T> | FetchSchemaValidationError<T> | null;
response?: FetchResponse<T>;
}

Expand Down
58 changes: 54 additions & 4 deletions packages/shared/test/fetch/fetch-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { HttpResponse, mockFetch } from "#test-utils/msw";
import { UCDJS_API_BASE_URL } from "@ucdjs/env";
import { beforeEach, describe, expect, it } from "vitest";
import { z } from "zod";
import { FetchError } from "../../src/fetch/error";
import { FetchError, FetchSchemaValidationError } from "../../src/fetch/error";
import { customFetch } from "../../src/fetch/fetch";

describe("custom fetch - schema validation", () => {
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
email: z.email(),
});

const UsersListSchema = z.array(UserSchema);
Expand Down Expand Up @@ -75,6 +75,15 @@ describe("custom fetch - schema validation", () => {
["GET", `${UCDJS_API_BASE_URL}/null-response`, () => {
return HttpResponse.json(null);
}],
["GET", `${UCDJS_API_BASE_URL}/server-error`, () => {
return HttpResponse.json({ message: "oops" }, { status: 500 });
}],
["GET", `${UCDJS_API_BASE_URL}/error-valid`, () => {
return HttpResponse.json({ error: "bad" }, { status: 400 });
}],
["GET", `${UCDJS_API_BASE_URL}/error-invalid`, () => {
return HttpResponse.json({ error: 123 }, { status: 400 });
}],
]);
});

Expand Down Expand Up @@ -108,7 +117,7 @@ describe("custom fetch - schema validation", () => {
});

expect(result.data).toBeNull();
expect(result.error).toBeInstanceOf(FetchError);
expect(result.error).toBeInstanceOf(FetchSchemaValidationError);
expect(result.error?.message).toContain("Response validation failed");
});

Expand All @@ -118,8 +127,49 @@ describe("custom fetch - schema validation", () => {
});

expect(result.error).toBeDefined();
expect(result.error).toBeInstanceOf(FetchSchemaValidationError);
expect(result.error?.cause).toBeDefined();
expect((result.error?.cause as any)?.name).toBe("ValidationError");
expect((result.error?.cause as any)?.name).toBe("ZodError");
expect((result.error as FetchSchemaValidationError).issues).toBeDefined();
});

it("should surface schema validation error even on error responses when payload mismatches schema", async () => {
await expect(
customFetch(`${UCDJS_API_BASE_URL}/server-error`, {
schema: UserSchema,
}),
).rejects.toBeInstanceOf(FetchError);
});

it("should return schema validation error via safe fetch on error responses when payload mismatches schema", async () => {
const result = await customFetch.safe(`${UCDJS_API_BASE_URL}/server-error`, {
schema: UserSchema,
});

expect(result.data).toBeNull();
expect(result.error).toBeInstanceOf(FetchError);
expect(result.error).not.toBeInstanceOf(FetchSchemaValidationError);
});

it("should validate error responses and still throw FetchError when schema passes", async () => {
const ErrorSchema = z.object({ error: z.string() });

await expect(
customFetch(`${UCDJS_API_BASE_URL}/error-valid`, {
schema: ErrorSchema,
}),
).rejects.toBeInstanceOf(FetchError);
});

it("should emit FetchError when error response fails schema", async () => {
const ErrorSchema = z.object({ error: z.string() });

const result = await customFetch.safe(`${UCDJS_API_BASE_URL}/error-invalid`, {
schema: ErrorSchema,
});

expect(result.error).toBeInstanceOf(FetchError);
expect(result.error).not.toBeInstanceOf(FetchSchemaValidationError);
});
});

Expand Down
Loading