Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ The `defineRoute` function is used to define route handlers in a type-safe and s
| hasFormData | `boolean` | Is the request body a [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData) |
| action | (source: [ActionSource](#action-source)) => Promise<[Response](https://developer.mozilla.org/en-US/docs/Web/API/Response)> | Function handling the request, receiving pathParams, queryParams, and requestBody. |
| responses | Record<`number`, [ResponseDefinition](#response-definition)> | Object defining possible responses, each with a description and optional content schema. |
| handleErrors | (errorType: string, issues?: [ZodIssues](https://zod.dev/ERROR_HANDLING?id=zodissue)[]) => Response | `(Optional)` Custom error handler can be provided to replace the default behavior. [See below](#example) |

### Action Source

Expand Down Expand Up @@ -98,6 +99,21 @@ export const { GET } = defineRoute({
200: { description: "User details retrieved successfully", content: UserDTO },
404: { description: "User not found" },
},
// optional 👇👇👇
handleErrors: (errorType, issues) => {
console.log(issues);
switch (errorType) {
"PARSE_FORM_DATA":
"PARSE_REQUEST_BODY":
"PARSE_SEARCH_PARAMS":
return new Response(null, { status: 400 });
"PARSE_PATH_PARAMS":
return new Response(null, { status: 404 });
"UNNECESSARY_PATH_PARAMS":
"UNKNOWN_ERROR":
return new Response(null, { status: 500 });
}
},
});
```

Expand Down
8 changes: 6 additions & 2 deletions src/core/body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,20 @@ export async function parseRequestBody<I, O>(
// eslint-disable-next-line no-console
console.log((error as ZodError).issues);
}
throw new Error("PARSE_FORM_DATA");
throw new Error("PARSE_FORM_DATA", { cause: (error as ZodError).issues });
}
}
try {
return schema.parse(await request.json());
} catch (error) {
if (error instanceof Error && error.message === "Unexpected end of JSON input") {
const result = schema.safeParse({});
throw new Error("PARSE_REQUEST_BODY", { cause: result.error?.issues });
}
if (process.env.NODE_ENV !== "production") {
// eslint-disable-next-line no-console
console.log((error as ZodError).issues);
}
throw new Error("PARSE_REQUEST_BODY");
throw new Error("PARSE_REQUEST_BODY", { cause: (error as ZodError).issues });
}
}
65 changes: 65 additions & 0 deletions src/core/definer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,4 +244,69 @@ describe("defineRoute", () => {

console.log = originalLog;
});

it("should use custom error handler correctly for an expected error", async () => {
mockRequest.json.mockImplementation(() => {
throw new SyntaxError("Unexpected end of JSON input");
});

const route = defineRoute({
operationId: "postExample",
method: "POST",
summary: "Post Example",
description: "Posts example data",
tags: ["example"],
requestBody: z.object({ name: z.string() }),
action: mockAction,
responses: {
200: { description: "OK" },
418: { description: "I'm a teapot" },
},
handleErrors: (_errorType, _cause) => {
return new Response("I'm a teapot", { status: 418 });
},
});

const nextJsRouteHandler = route.POST;

const response = await nextJsRouteHandler(mockRequest as unknown as Request, {});
const bodyText = await response.text();

expect(response).toBeInstanceOf(Response);
expect(response.status).toBe(418);
expect(bodyText).toBe("I'm a teapot");
});

it("should use custom error handler correctly for an unexpected error", async () => {
const route = defineRoute({
operationId: "getExample",
method: "GET",
summary: "Get Example",
description: "Fetches example data",
tags: ["example"],
action: () => {
throw new Error("Critical error");
},
responses: {
200: { description: "OK" },
418: { description: "I'm a teapot" },
500: { description: "Backend developer is gonna get fired" },
},
handleErrors: (errorType, _cause) => {
if (errorType === "UNKNOWN_ERROR") {
return new Response("Backend developer is gonna get fired", { status: 500 });
}
return new Response(errorType, { status: 418 });
},
});

const nextJsRouteHandler = route.GET;

const response = await nextJsRouteHandler(mockRequest as unknown as Request, {});
const bodyText = await response.text();

expect(response).toBeInstanceOf(Response);
expect(response.status).toBe(500);
expect(bodyText).toBe("Backend developer is gonna get fired");
});
});
13 changes: 12 additions & 1 deletion src/core/definer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { customErrorTypes } from "~/types/error";
import type { HttpMethod } from "~/types/http";
import type { RouteHandler, RouteMethodHandler } from "~/types/next";
import type { ResponseDefinition } from "~/types/response";
Expand All @@ -6,7 +7,7 @@ import { resolveParams } from "./params";
import parsePathParams from "./path-params";
import { addBadRequest, bundleResponses } from "./responses";
import parseSearchParams from "./search-params";
import type { ZodType, ZodTypeDef } from "zod";
import type { ZodIssue, ZodType, ZodTypeDef } from "zod";

type ActionSource<PathParams, QueryParams, RequestBody> = {
pathParams: PathParams,
Expand Down Expand Up @@ -44,6 +45,7 @@ type RouteOptions<
queryParams?: ZodType<QueryParamsOutput, ZodTypeDef, QueryParamsInput>,
action: (source: ActionSource<PathParamsOutput, QueryParamsOutput, RequestBodyOutput>) => Response | Promise<Response>,
responses: Record<string, ResponseDefinition>,
handleErrors?: (errorType: typeof customErrorTypes[number] | "UNKNOWN_ERROR", issues?: ZodIssue[]) => Response,
} & (RouteWithBody<RequestBodyInput, RequestBodyOutput> | RouteWithoutBody);

function defineRoute<M extends HttpMethod, PPI, PPO, QPI, QPO, RBI, RBO>(input: RouteOptions<M, PPI, PPO, QPI, QPO, RBI, RBO>) {
Expand All @@ -55,6 +57,15 @@ function defineRoute<M extends HttpMethod, PPI, PPO, QPI, QPO, RBI, RBO>(input:
const body = await parseRequestBody(request, input.method, input.requestBody ?? undefined, input.hasFormData) as RBO;
return await input.action({ pathParams, queryParams, body });
} catch (error) {
if (input.handleErrors) {
if (error instanceof Error) {
const errorMessage = error.message as typeof customErrorTypes[number];
if (customErrorTypes.includes(errorMessage)) {
return input.handleErrors(errorMessage, error.cause as ZodIssue[]);
}
}
return input.handleErrors("UNKNOWN_ERROR");
}
if (error instanceof Error) {
switch (error.message) {
case "PARSE_FORM_DATA":
Expand Down
2 changes: 1 addition & 1 deletion src/core/search-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ export default function parseSearchParams<I, O>(source: URLSearchParams, schema?
// eslint-disable-next-line no-console
console.log((error as ZodError).issues);
}
throw new Error("PARSE_SEARCH_PARAMS");
throw new Error("PARSE_SEARCH_PARAMS", { cause: (error as ZodError).issues });
}
}
7 changes: 7 additions & 0 deletions src/types/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const customErrorTypes = [
"PARSE_FORM_DATA" as const,
"PARSE_REQUEST_BODY" as const,
"PARSE_SEARCH_PARAMS" as const,
"PARSE_PATH_PARAMS" as const,
"UNNECESSARY_PATH_PARAMS" as const,
];