chore: write defaultResponder for App Router APIs (#19727)

* create defaultResponderForAppDir

* rename all usages

* finish

* fix
This commit is contained in:
Benny Joo
2025-03-05 09:12:07 +00:00
committed by GitHub
parent 75627cb097
commit 251121a76f
9 changed files with 159 additions and 51 deletions
-36
View File
@@ -1,36 +0,0 @@
import type { Params } from "app/_types";
import { ApiError } from "next/dist/server/api-utils";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown";
export const apiRouteMiddleware =
(handler: (req: NextRequest, { params }: { params: Params }) => Promise<NextResponse | Response>) =>
async (req: NextRequest, { params }: { params: Params }) => {
try {
return await handler(req, { params });
} catch (error) {
if (error instanceof ApiError) {
return NextResponse.json({ message: error.message }, { status: error.statusCode });
} else {
const serverError = getServerErrorFromUnknown(error);
// we don't want to report Bad Request errors to Sentry / console
if (!(serverError.statusCode >= 400 && serverError.statusCode < 500)) {
console.error(error);
const captureException = (await import("@sentry/nextjs")).captureException;
captureException(error);
}
return NextResponse.json(
{
message: serverError.message,
url: serverError.url,
method: serverError.method,
},
{
status: serverError.statusCode,
}
);
}
}
};
+2 -2
View File
@@ -1,5 +1,5 @@
import type { Params } from "app/_types";
import { apiRouteMiddleware } from "app/api/apiRouteMiddleware";
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
@@ -63,6 +63,6 @@ async function handler(req: NextRequest, { params }: { params: Params }) {
});
}
const getHandler = apiRouteMiddleware(handler);
const getHandler = defaultResponderForAppDir(handler);
export { getHandler as GET };
+3 -3
View File
@@ -1,4 +1,4 @@
import { apiRouteMiddleware } from "app/api/apiRouteMiddleware";
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { cookies, headers } from "next/headers";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
@@ -26,7 +26,7 @@ async function handler(req: NextRequest) {
return NextResponse.json(result, { status: statusCode });
}
const deleteHandler = apiRouteMiddleware((req: NextRequest) => handler(req));
const postHandler = apiRouteMiddleware((req: NextRequest) => handler(req));
const deleteHandler = defaultResponderForAppDir(handler);
const postHandler = defaultResponderForAppDir(handler);
export { deleteHandler as DELETE, postHandler as POST };
+2 -2
View File
@@ -1,4 +1,4 @@
import { apiRouteMiddleware } from "app/api/apiRouteMiddleware";
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { createHmac } from "crypto";
import { headers } from "next/headers";
import type { NextRequest } from "next/server";
@@ -540,4 +540,4 @@ async function handler(request: NextRequest) {
});
}
export const POST = apiRouteMiddleware(handler);
export const POST = defaultResponderForAppDir(handler);
@@ -0,0 +1,77 @@
import type { NextRequest } from "next/server";
import { describe, expect, it, vi } from "vitest";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { TRPCError } from "@trpc/server";
import { defaultResponderForAppDir } from "./defaultResponderForAppDir";
vi.mock("next/server", () => ({
NextRequest: class MockNextRequest {},
NextResponse: {
json: vi.fn((body, init) => ({
json: vi.fn().mockResolvedValue(body),
status: init?.status || 200,
})),
},
}));
describe("defaultResponderForAppDir", () => {
it("should return a JSON response when handler resolves with a result", async () => {
const f = vi.fn().mockResolvedValue({ success: true });
const req = { method: "GET", url: "/api/test" } as unknown as NextRequest;
const params = {};
const response = await defaultResponderForAppDir(f)(req, { params });
console.log(response);
const json = await response.json();
expect(response.status).toBe(200);
expect(json).toEqual({ success: true });
});
it("should return an empty JSON response when handler resolves with no result", async () => {
const f = vi.fn().mockResolvedValue(null);
const req = { method: "GET", url: "/api/test" } as unknown as NextRequest;
const params = {};
const response = await defaultResponderForAppDir(f)(req, { params });
const json = await response.json();
expect(response.status).toBe(200);
expect(json).toEqual({});
});
it("should respond with status code 409 for NoAvailableUsersFound", async () => {
const f = vi.fn().mockRejectedValue(new Error(ErrorCode.NoAvailableUsersFound));
const req = { method: "GET", url: "/api/test" } as unknown as NextRequest;
const params = {};
const response = await defaultResponderForAppDir(f)(req, { params });
const json = await response.json();
expect(response.status).toBe(409);
expect(json).toEqual({
message: ErrorCode.NoAvailableUsersFound,
url: undefined,
method: undefined,
});
});
it("should respond with a 429 status code for rate limit errors", async () => {
const f = vi.fn().mockRejectedValue(new TRPCError({ code: "TOO_MANY_REQUESTS" }));
const req = { method: "POST", url: "/api/test" } as unknown as NextRequest;
const params = {};
const response = await defaultResponderForAppDir(f)(req, { params });
const json = await response.json();
expect(response.status).toBe(429);
expect(json).toEqual({
message: "TOO_MANY_REQUESTS",
url: undefined,
method: undefined,
});
});
});
@@ -0,0 +1,70 @@
import { wrapApiHandlerWithSentry } from "@sentry/nextjs";
import type { Params } from "app/_types";
import { ApiError } from "next/dist/server/api-utils";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown";
import { performance } from "@calcom/lib/server/perfObserver";
type Handler<T extends NextResponse | Response = NextResponse> = (
req: NextRequest,
{ params }: { params: Params }
) => Promise<T>;
export const defaultResponderForAppDir = <T extends NextResponse | Response = NextResponse>(
handler: Handler<T>,
endpointRoute?: string
) => {
return async (req: NextRequest, { params }: { params: Params }) => {
let ok = false;
try {
performance.mark("Start");
const result = endpointRoute
? await wrapApiHandlerWithSentry(async () => await handler(req, { params }), endpointRoute)()
: await handler(req, { params });
ok = true;
if (result) {
return result instanceof Response ? result : NextResponse.json(result);
}
return NextResponse.json({});
} catch (error) {
let serverError;
if (error instanceof ApiError) {
serverError = {
message: error.message,
statusCode: error.statusCode,
url: req.url,
method: req.method,
};
} else {
serverError = getServerErrorFromUnknown(error);
}
// Don't report 400-499 errors to Sentry/console
if (!(serverError.statusCode >= 400 && serverError.statusCode < 500)) {
console.error(serverError);
const captureException = (await import("@sentry/nextjs")).captureException;
captureException(error);
}
return NextResponse.json(
{
message: serverError.message,
url: serverError.url,
method: serverError.method,
},
{
status: serverError.statusCode,
}
);
} finally {
performance.mark("End");
performance.measure(`[${ok ? "OK" : "ERROR"}][${req.method}] '${req.url}'`, "Start", "End");
}
};
};
+2 -2
View File
@@ -1,4 +1,4 @@
import { apiRouteMiddleware } from "app/api/apiRouteMiddleware";
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { createHmac } from "crypto";
import { cookies, headers } from "next/headers";
import { NextResponse } from "next/server";
@@ -79,4 +79,4 @@ async function handler() {
return NextResponse.json(response);
}
export const POST = apiRouteMiddleware(handler);
export const POST = defaultResponderForAppDir(handler);
+1 -4
View File
@@ -1,4 +1,3 @@
import { apiRouteMiddleware } from "app/api/apiRouteMiddleware";
import type { NextRequest } from "next/server";
import { ImageResponse } from "next/server";
import type { SatoriOptions } from "satori";
@@ -115,6 +114,4 @@ async function handler(req: NextRequest) {
}
}
const getHandler = apiRouteMiddleware(handler);
export { getHandler as GET };
export { handler as GET };
+2 -2
View File
@@ -35,8 +35,8 @@ vi.mock("@lib/buildLegacyCtx", () => ({
}),
}));
vi.mock("app/api/apiRouteMiddleware", () => ({
apiRouteMiddleware: (handler: () => Promise<Response>) => handler,
vi.mock("app/api/defaultResponderForAppDir", () => ({
defaultResponderForAppDir: (handler: () => Promise<Response>) => handler,
}));
vi.mock("@calcom/prisma", () => ({