refactor: Returns 400 error for ZodErrors (#20831)
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
import type { NextApiRequest } from "next";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { ZodIssue } from "zod";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type { AnyRouter } from "@trpc/server";
|
||||
|
||||
import { createNextApiHandler } from "./createNextApiHandler";
|
||||
import { errorFormatter } from "./errorFormatter";
|
||||
import { onErrorHandler } from "./onErrorHandler";
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("createNextApiHandler", () => {
|
||||
const mockRouter = {
|
||||
createCaller: vi.fn(),
|
||||
_def: {
|
||||
procedures: {},
|
||||
middleware: [],
|
||||
contextOptions: {},
|
||||
},
|
||||
} as unknown as AnyRouter;
|
||||
|
||||
const mockReq = {
|
||||
method: "POST",
|
||||
url: "/api/trpc/test",
|
||||
} as NextApiRequest;
|
||||
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe("error formatting", () => {
|
||||
it("should format ZodError as BAD_REQUEST", () => {
|
||||
const zodError = new ZodError([
|
||||
{
|
||||
path: ["name"],
|
||||
message: "Required",
|
||||
code: "invalid_type",
|
||||
expected: "string",
|
||||
received: "undefined",
|
||||
} as ZodIssue,
|
||||
]);
|
||||
|
||||
const error = new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Internal Server Error",
|
||||
cause: zodError,
|
||||
});
|
||||
|
||||
const result = errorFormatter({
|
||||
shape: {
|
||||
message: "Internal Server Error",
|
||||
code: 500,
|
||||
data: {
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
httpStatus: 500,
|
||||
path: "test",
|
||||
},
|
||||
},
|
||||
error,
|
||||
type: "query",
|
||||
path: "test",
|
||||
input: undefined,
|
||||
ctx: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
message: "Invalid input",
|
||||
code: 400,
|
||||
data: {
|
||||
code: "BAD_REQUEST",
|
||||
httpStatus: 400,
|
||||
path: "test",
|
||||
zodError: {
|
||||
formErrors: [],
|
||||
fieldErrors: {
|
||||
name: ["Required"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not modify non-ZodError errors", () => {
|
||||
const baseError = new Error("Database connection failed");
|
||||
const error = new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Internal Server Error",
|
||||
cause: baseError,
|
||||
});
|
||||
|
||||
const shape = {
|
||||
message: "Internal Server Error",
|
||||
code: 500,
|
||||
data: {
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
httpStatus: 500,
|
||||
path: "test",
|
||||
},
|
||||
};
|
||||
|
||||
const result = errorFormatter({
|
||||
shape,
|
||||
error,
|
||||
type: "query",
|
||||
path: "test",
|
||||
input: undefined,
|
||||
ctx: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(shape);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle TRPC errors correctly", () => {
|
||||
const handler = createNextApiHandler(mockRouter);
|
||||
const error = new TRPCError({ code: "BAD_REQUEST", message: "Invalid input" });
|
||||
|
||||
onErrorHandler({
|
||||
error,
|
||||
req: mockReq,
|
||||
});
|
||||
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle internal server errors correctly", () => {
|
||||
const handler = createNextApiHandler(mockRouter);
|
||||
const baseError = new Error("Database connection failed");
|
||||
const error = new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Internal Server Error",
|
||||
cause: baseError,
|
||||
});
|
||||
|
||||
onErrorHandler({
|
||||
error,
|
||||
req: mockReq,
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Something went wrong", error);
|
||||
expect(captureException).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it("should not log client errors", () => {
|
||||
const handler = createNextApiHandler(mockRouter);
|
||||
const error = new TRPCError({ code: "BAD_REQUEST", message: "Invalid input" });
|
||||
|
||||
onErrorHandler({
|
||||
error,
|
||||
req: mockReq,
|
||||
});
|
||||
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type { AnyRouter } from "@trpc/server";
|
||||
import { createNextApiHandler as _createNextApiHandler } from "@trpc/server/adapters/next";
|
||||
|
||||
import { createContext as createTrpcContext } from "./createContext";
|
||||
import { onErrorHandler } from "./onErrorHandler";
|
||||
|
||||
/**
|
||||
* Creates an API handler executed by Next.js.
|
||||
@@ -20,12 +21,7 @@ export function createNextApiHandler(router: AnyRouter, isPublic = false, namesp
|
||||
/**
|
||||
* @link https://trpc.io/docs/error-handling
|
||||
*/
|
||||
onError({ error }) {
|
||||
if (error.code === "INTERNAL_SERVER_ERROR") {
|
||||
// send to bug reporting
|
||||
console.error("Something went wrong", error);
|
||||
}
|
||||
},
|
||||
onError: onErrorHandler,
|
||||
/**
|
||||
* Enable query batching
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { errorFormatter } from "./errorFormatter";
|
||||
|
||||
describe("errorFormatter", () => {
|
||||
const baseShape = {
|
||||
message: "Internal Server Error",
|
||||
code: 500,
|
||||
data: {
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
httpStatus: 500,
|
||||
path: "test",
|
||||
},
|
||||
};
|
||||
|
||||
it("should format ZodError as BAD_REQUEST", () => {
|
||||
const zodError = new z.ZodError([
|
||||
{
|
||||
path: ["name"],
|
||||
message: "Required",
|
||||
code: "invalid_type",
|
||||
expected: "string",
|
||||
received: "undefined",
|
||||
},
|
||||
]);
|
||||
|
||||
const error = new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Internal Server Error",
|
||||
cause: zodError,
|
||||
});
|
||||
|
||||
const result = errorFormatter({
|
||||
shape: baseShape,
|
||||
error,
|
||||
type: "query",
|
||||
path: "test",
|
||||
input: undefined,
|
||||
ctx: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
message: "Invalid input",
|
||||
code: 400,
|
||||
data: {
|
||||
code: "BAD_REQUEST",
|
||||
httpStatus: 400,
|
||||
path: "test",
|
||||
zodError: {
|
||||
formErrors: [],
|
||||
fieldErrors: {
|
||||
name: ["Required"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle nested ZodError", () => {
|
||||
const schema = z.object({
|
||||
user: z.object({
|
||||
name: z.string(),
|
||||
age: z.number().min(18),
|
||||
}),
|
||||
});
|
||||
|
||||
const zodError = new z.ZodError([
|
||||
{
|
||||
path: ["user", "age"],
|
||||
message: "Number must be greater than or equal to 18",
|
||||
code: "too_small",
|
||||
minimum: 18,
|
||||
type: "number",
|
||||
inclusive: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const error = new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Internal Server Error",
|
||||
cause: zodError,
|
||||
});
|
||||
|
||||
const result = errorFormatter({
|
||||
shape: baseShape,
|
||||
error,
|
||||
type: "query",
|
||||
path: "test",
|
||||
input: undefined,
|
||||
ctx: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
message: "Invalid input",
|
||||
code: 400,
|
||||
data: {
|
||||
code: "BAD_REQUEST",
|
||||
httpStatus: 400,
|
||||
path: "test",
|
||||
zodError: {
|
||||
formErrors: [],
|
||||
fieldErrors: {
|
||||
user: ["Number must be greater than or equal to 18"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiple ZodError issues", () => {
|
||||
const zodError = new z.ZodError([
|
||||
{
|
||||
path: ["name"],
|
||||
message: "Required",
|
||||
code: "invalid_type",
|
||||
expected: "string",
|
||||
received: "undefined",
|
||||
},
|
||||
{
|
||||
path: ["age"],
|
||||
message: "Number must be greater than or equal to 18",
|
||||
code: "too_small",
|
||||
minimum: 18,
|
||||
type: "number",
|
||||
inclusive: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const error = new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Internal Server Error",
|
||||
cause: zodError,
|
||||
});
|
||||
|
||||
const result = errorFormatter({
|
||||
shape: baseShape,
|
||||
error,
|
||||
type: "query",
|
||||
path: "test",
|
||||
input: undefined,
|
||||
ctx: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
message: "Invalid input",
|
||||
code: 400,
|
||||
data: {
|
||||
code: "BAD_REQUEST",
|
||||
httpStatus: 400,
|
||||
path: "test",
|
||||
zodError: {
|
||||
formErrors: [],
|
||||
fieldErrors: {
|
||||
name: ["Required"],
|
||||
age: ["Number must be greater than or equal to 18"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not modify TRPCError", () => {
|
||||
const error = new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid input",
|
||||
});
|
||||
|
||||
const result = errorFormatter({
|
||||
shape: baseShape,
|
||||
error,
|
||||
type: "query",
|
||||
path: "test",
|
||||
input: undefined,
|
||||
ctx: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(baseShape);
|
||||
});
|
||||
|
||||
it("should not modify generic Error", () => {
|
||||
const baseError = new Error("Database connection failed");
|
||||
const error = new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Internal Server Error",
|
||||
cause: baseError,
|
||||
});
|
||||
|
||||
const result = errorFormatter({
|
||||
shape: baseShape,
|
||||
error,
|
||||
type: "query",
|
||||
path: "test",
|
||||
input: undefined,
|
||||
ctx: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(baseShape);
|
||||
});
|
||||
|
||||
it("should handle empty ZodError", () => {
|
||||
const zodError = new z.ZodError([]);
|
||||
const error = new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Internal Server Error",
|
||||
cause: zodError,
|
||||
});
|
||||
|
||||
const result = errorFormatter({
|
||||
shape: baseShape,
|
||||
error,
|
||||
type: "query",
|
||||
path: "test",
|
||||
input: undefined,
|
||||
ctx: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
message: "Invalid input",
|
||||
code: 400,
|
||||
data: {
|
||||
code: "BAD_REQUEST",
|
||||
httpStatus: 400,
|
||||
path: "test",
|
||||
zodError: {
|
||||
formErrors: [],
|
||||
fieldErrors: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { TRPCError } from "@trpc/server";
|
||||
|
||||
type ErrorShape = {
|
||||
message: string;
|
||||
code: number;
|
||||
data: {
|
||||
code: string;
|
||||
httpStatus: number;
|
||||
path?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type ErrorFormatterOptions = {
|
||||
shape: ErrorShape;
|
||||
error: TRPCError;
|
||||
type: "query" | "mutation" | "subscription" | "unknown";
|
||||
path: string | undefined;
|
||||
input: unknown;
|
||||
ctx: unknown;
|
||||
};
|
||||
|
||||
export function errorFormatter({ shape, error }: ErrorFormatterOptions): ErrorShape {
|
||||
if (error.cause instanceof z.ZodError) {
|
||||
return {
|
||||
message: "Invalid input",
|
||||
code: 400,
|
||||
data: {
|
||||
code: "BAD_REQUEST",
|
||||
httpStatus: 400,
|
||||
path: shape.data.path,
|
||||
zodError: error.cause.flatten(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
|
||||
import { redactError } from "@calcom/lib/redactError";
|
||||
|
||||
import { middleware } from "../trpc";
|
||||
|
||||
const captureErrorsMiddleware = middleware(async ({ next }) => {
|
||||
try {
|
||||
const result = await next();
|
||||
if (result.ok) return result;
|
||||
if (!result.error.cause) return result;
|
||||
throw result.error.cause;
|
||||
} catch (error) {
|
||||
captureException(error);
|
||||
throw redactError(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default captureErrorsMiddleware;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown";
|
||||
|
||||
import type { TRPCError } from "@trpc/server";
|
||||
|
||||
type OnErrorOptions = {
|
||||
error: TRPCError;
|
||||
req?: NextApiRequest;
|
||||
res?: NextApiResponse;
|
||||
};
|
||||
|
||||
export function onErrorHandler({ error }: OnErrorOptions) {
|
||||
// Convert any error to a HttpError using our centralized error handling
|
||||
const httpError = getServerErrorFromUnknown(error);
|
||||
|
||||
// Log errors that aren't client errors (400s)
|
||||
if (httpError.statusCode >= 500) {
|
||||
captureException(error);
|
||||
console.error("Something went wrong", error);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import captureErrorsMiddleware from "../middlewares/captureErrorsMiddleware";
|
||||
import perfMiddleware from "../middlewares/perfMiddleware";
|
||||
import { isAdminMiddleware, isAuthed, isOrgAdminMiddleware } from "../middlewares/sessionMiddleware";
|
||||
import { procedure } from "../trpc";
|
||||
@@ -24,10 +23,10 @@ const isRateLimitedByUserIdMiddleware = ({ intervalInMs, limit }: IRateLimitOpti
|
||||
return next({ ctx: { user: ctx.user, session: ctx.session } });
|
||||
});
|
||||
*/
|
||||
const authedProcedure = procedure.use(captureErrorsMiddleware).use(perfMiddleware).use(isAuthed);
|
||||
const authedProcedure = procedure.use(perfMiddleware).use(isAuthed);
|
||||
/*export const authedRateLimitedProcedure = ({ intervalInMs, limit }: IRateLimitOptions) =>
|
||||
authedProcedure.use(isRateLimitedByUserIdMiddleware({ intervalInMs, limit }));*/
|
||||
export const authedAdminProcedure = publicProcedure.use(captureErrorsMiddleware).use(isAdminMiddleware);
|
||||
export const authedOrgAdminProcedure = publicProcedure.use(captureErrorsMiddleware).use(isOrgAdminMiddleware);
|
||||
export const authedAdminProcedure = publicProcedure.use(isAdminMiddleware);
|
||||
export const authedOrgAdminProcedure = publicProcedure.use(isOrgAdminMiddleware);
|
||||
|
||||
export default authedProcedure;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import captureErrorsMiddleware from "../middlewares/captureErrorsMiddleware";
|
||||
import perfMiddleware from "../middlewares/perfMiddleware";
|
||||
import { tRPCContext } from "../trpc";
|
||||
|
||||
const publicProcedure = tRPCContext.procedure.use(captureErrorsMiddleware).use(perfMiddleware);
|
||||
const publicProcedure = tRPCContext.procedure.use(perfMiddleware);
|
||||
|
||||
export default publicProcedure;
|
||||
|
||||
@@ -3,9 +3,11 @@ import superjson from "superjson";
|
||||
import { initTRPC } from "@trpc/server";
|
||||
|
||||
import type { createContextInner } from "./createContext";
|
||||
import { errorFormatter } from "./errorFormatter";
|
||||
|
||||
export const tRPCContext = initTRPC.context<typeof createContextInner>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter,
|
||||
});
|
||||
|
||||
export const router = tRPCContext.router;
|
||||
|
||||
Reference in New Issue
Block a user