Safe Fetch
Fetch x Zod (Schema Validation)
safeFetch.ts
ts
import { z } from "zod";
class HttpError extends Error {
public statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = "HttpError";
this.statusCode = statusCode;
}
}
export const safeFetch = async <T extends z.ZodType>(
url: string,
schema: T,
options?: RequestInit
): Promise<z.infer<T>> => {
const response = await fetch(url, options);
if (!response.ok) {
throw new HttpError(response.statusText, response.status);
}
try {
const data = await response.json();
return schema.parse(data);
} catch (err) {
if (err instanceof z.ZodError) {
throw new Error("Validation Error");
}
throw new Error("Parsing Failure");
}
};safeFetch.spec.ts
ts
import { vi } from "vitest";
import { z } from "zod";
import { safeFetch } from "./safeFetch";
describe("safeFetch", () => {
const accessTokenSchema = z.object({
access_token: z.string(),
expires_in: z.number()
});
type AccessToken = z.infer<typeof accessTokenSchema>;
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.restoreAllMocks());
it("should return data", async () => {
const mockData = {
access_token: "eyJiZWFyZXIiOiJ0b2tlbiJ9",
expires_in: 300
};
globalThis.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValueOnce(mockData)
});
const data: AccessToken = await safeFetch("http://localhost:3000", accessTokenSchema);
expect(data).toEqual(mockData);
});
it("should throw an (http) error", async () => {
globalThis.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error"
});
await expect(safeFetch("http://localhost:3000", accessTokenSchema)).rejects.toThrow("Internal Server Error");
});
it("should pass request options to fetch", async () => {
const options = {
method: "POST",
headers: { "Content-Type": "application/json" }
};
globalThis.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValueOnce({
access_token: "eyJiZWFyZXIiOiJ0b2tlbiJ9",
expires_in: 300
})
});
await safeFetch("http://localhost:3000", accessTokenSchema, options);
expect(globalThis.fetch).toHaveBeenCalledWith("http://localhost:3000", options);
});
it("should throw an (validation) error", async () => {
globalThis.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValueOnce({
bearer_token: "eyJiZWFyZXIiOiJ0b2tlbiJ9",
expires_in: "300"
})
});
await expect(safeFetch("http://localhost:3000", accessTokenSchema)).rejects.toThrow("Validation Error");
});
});