Add new ServerHandler package which handles requests

This commit is contained in:
Jesse Wierzbinski 2024-04-13 21:51:00 -10:00
parent 3cdd685035
commit 327a716b12
No known key found for this signature in database
10 changed files with 616 additions and 110 deletions

View file

@ -136,12 +136,8 @@ export class RequestParser {
* @private
* @throws Error if body is invalid
*/
private async parseJson<T>(): Promise<Partial<T>> {
try {
return (await this.request.json()) as T;
} catch {
return {};
}
private async parseJson<T>(): Promise<T> {
return (await this.request.json()) as T;
}
/**

View file

@ -0,0 +1,178 @@
import { errorResponse, jsonResponse, response } from "@response";
import type { MatchedRoute } from "bun";
import { type Config, config } from "config-manager";
import { LogLevel, type LogManager, type MultiLogManager } from "log-manager";
import { RequestParser } from "request-parser";
import { type ZodType, z } from "zod";
import { fromZodError } from "zod-validation-error";
import {
type AuthData,
type UserWithRelations,
getFromRequest,
} from "~database/entities/User";
type MaybePromise<T> = T | Promise<T>;
type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
export type RouteHandler<
RouteMeta extends APIRouteMetadata,
ZodSchema extends ZodType,
> = (
req: Request,
matchedRoute: MatchedRoute,
extraData: {
auth: {
// If the route doesn't require authentication, set the type to UserWithRelations | null
// Otherwise set to UserWithRelations
user: RouteMeta["auth"]["required"] extends true
? UserWithRelations
: UserWithRelations | null;
token: RouteMeta["auth"]["required"] extends true
? string
: string | null;
};
parsedRequest: z.infer<ZodSchema>;
configManager: {
getConfig: () => Promise<Config>;
};
},
) => MaybePromise<Response> | MaybePromise<object>;
export interface APIRouteMetadata {
allowedMethods: HttpVerb[];
ratelimits: {
max: number;
duration: number;
};
route: string;
auth: {
required: boolean;
requiredOnMethods?: HttpVerb[];
oauthPermissions?: string[];
};
}
export interface APIRouteExports {
meta: APIRouteMetadata;
schema: z.AnyZodObject;
default: RouteHandler<APIRouteMetadata, z.AnyZodObject>;
}
const exampleZodSchema = z.object({
allowedMethods: z.array(z.string()),
ratelimits: z.object({
max: z.number(),
duration: z.number(),
}),
route: z.string(),
auth: z.object({
required: z.boolean(),
}),
});
export const processRoute = async (
matchedRoute: MatchedRoute,
request: Request,
logger: LogManager | MultiLogManager,
): Promise<Response> => {
if (request.method === "OPTIONS") {
return response();
}
const route: APIRouteExports | null = await import(
matchedRoute.filePath
).catch(() => null);
if (!route) {
return errorResponse("Route not found", 404);
}
// Check if the request method is allowed
if (!route.meta.allowedMethods.includes(request.method as HttpVerb)) {
return errorResponse("Method not allowed", 405);
}
let auth: AuthData | null = null;
if (
route.meta.auth.required ||
route.meta.auth.requiredOnMethods?.includes(request.method as HttpVerb)
) {
auth = await getFromRequest(request);
if (!auth.user) {
return errorResponse(
"Unauthorized: access to this method requires an authenticated user",
401,
);
}
}
// Check if Content-Type header is missing in POST, PUT and PATCH requests
if (["POST", "PUT", "PATCH"].includes(request.method)) {
if (!request.headers.has("Content-Type")) {
return errorResponse(
`Content-Type header is missing but required on method ${request.method}`,
400,
);
}
}
const parsedRequest = await new RequestParser(request)
.toObject()
.catch(async (err) => {
await logger.logError(
LogLevel.ERROR,
"Server.RouteRequestParser",
err as Error,
);
return null;
});
if (!parsedRequest) {
return errorResponse(
"The request could not be parsed, it may be malformed",
400,
);
}
const parsingResult = route.schema?.safeParse(parsedRequest);
if (parsingResult && !parsingResult.success) {
// Return a 422 error with the first error message
return errorResponse(fromZodError(parsingResult.error).toString(), 422);
}
try {
const output = await route.default(request, matchedRoute, {
auth: {
token: auth?.token ?? null,
user: auth?.user ?? null,
},
parsedRequest: parsingResult
? (parsingResult.data as z.infer<typeof route.schema>)
: parsedRequest,
configManager: {
getConfig: async () => config as Config,
},
});
// If the output is a normal JS object and not a Response, convert it to a jsonResponse
if (!(output instanceof Response)) {
return jsonResponse(output);
}
return output;
} catch (err) {
await logger.logError(
LogLevel.ERROR,
"Server.RouteHandler",
err as Error,
);
return errorResponse(
`A server error occured: ${(err as Error).message}`,
500,
);
}
};

View file

@ -0,0 +1,6 @@
{
"name": "server-handler",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { "zod": "^3.22.4", "zod-validation-error": "^3.1.0" }
}

View file

@ -0,0 +1,363 @@
import { afterAll, describe, expect, it, mock } from "bun:test";
import type { MatchedRoute } from "bun";
import { LogManager } from "log-manager";
import { z } from "zod";
import { getTestUsers } from "~tests/utils";
import { type APIRouteExports, processRoute } from ".";
describe("Route Processor", () => {
it("should return a Response", async () => {
mock.module(
"./route",
() =>
({
default: async () => new Response(),
meta: {
allowedMethods: ["GET"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "GET",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output).toBeInstanceOf(Response);
});
it("should return a 404 when the route does not exist", async () => {
const output = await processRoute(
{
filePath: "./nonexistent-route",
} as MatchedRoute,
new Request("https://test.com/nonexistent-route"),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(404);
});
it("should return a 405 when the request method is not allowed", async () => {
mock.module(
"./route",
() =>
({
default: async () => new Response(),
meta: {
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "GET",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(405);
});
it("should return a 401 when the route requires authentication but no user is authenticated", async () => {
mock.module(
"./route",
() =>
({
default: async () => new Response(),
meta: {
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: true,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "POST",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(401);
});
it("should return a 400 when the Content-Type header is missing in POST, PUT and PATCH requests", async () => {
mock.module(
"./route",
() =>
({
default: async () => new Response(),
meta: {
allowedMethods: ["POST", "PUT", "PATCH"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "POST",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(400);
const output2 = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "PUT",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output2.status).toBe(400);
const output3 = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "PATCH",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output3.status).toBe(400);
});
it("should return a 400 when the request could not be parsed", async () => {
mock.module(
"./route",
() =>
({
default: async () => new Response(),
meta: {
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: "invalid-json",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(400);
});
it("should return a 422 when the request does not match the schema", async () => {
mock.module(
"./route",
() =>
({
default: async () => new Response(),
meta: {
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({
foo: z.string(),
}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ bar: "baz" }),
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(422);
});
it("should convert any JS objects returned by the route to a Response", async () => {
mock.module(
"./route",
() =>
({
default: async () => ({ status: 200 }),
meta: {
allowedMethods: ["GET"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "GET",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(200);
});
it("should handle route errors", async () => {
mock.module(
"./route",
() =>
({
default: async () => {
throw new Error("Route error");
},
meta: {
allowedMethods: ["GET"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "GET",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(500);
});
it("should return the route output when everything is valid", async () => {
mock.module(
"./route",
() =>
({
default: async () => new Response("OK"),
meta: {
allowedMethods: ["GET"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "GET",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(200);
expect(await output.text()).toBe("OK");
});
});