refactor: 🔥 Remove dead code

This commit is contained in:
Jesse Wierzbinski 2024-05-07 03:13:37 +00:00
parent 592f7c0ac2
commit 7b05a34cce
No known key found for this signature in database
36 changed files with 32 additions and 1692 deletions

View file

@ -1,219 +0,0 @@
import { parse } from "qs";
/**
* RequestParser
* Parses Request object into a JavaScript object
* based on the Content-Type header
* @param request Request object
* @returns JavaScript object of type T
*/
export class RequestParser {
constructor(public request: Request) {}
/**
* Parse request body into a JavaScript object
* @returns JavaScript object of type T
* @throws Error if body is invalid
*/
async toObject<T>() {
switch (await this.determineContentType()) {
case "application/json":
return {
...(await this.parseJson<T>()),
...this.parseQuery<T>(),
};
case "application/x-www-form-urlencoded":
return {
...(await this.parseFormUrlencoded<T>()),
...this.parseQuery<T>(),
};
case "multipart/form-data":
return {
...(await this.parseFormData<T>()),
...this.parseQuery<T>(),
};
default:
return { ...this.parseQuery() } as T;
}
}
/**
* Determine body content type
* If there is no Content-Type header, automatically
* guess content type. Cuts off after ";" character
* @returns Content-Type header value, or empty string if there is no body
* @throws Error if body is invalid
* @private
*/
private async determineContentType() {
const content_type = this.request.headers.get("Content-Type");
if (content_type?.startsWith("application/json")) {
return "application/json";
}
if (content_type?.startsWith("application/x-www-form-urlencoded")) {
return "application/x-www-form-urlencoded";
}
if (content_type?.startsWith("multipart/form-data")) {
return "multipart/form-data";
}
// Check if body is valid JSON
try {
await this.request.clone().json();
return "application/json";
} catch {
// This is not JSON
}
// Check if body is valid FormData
try {
await this.request.clone().formData();
return "multipart/form-data";
} catch {
// This is not FormData
}
if (content_type) {
return content_type.split(";")[0] ?? "";
}
if (this.request.body) {
throw new Error("Invalid body");
}
// If there is no body, return query parameters
return "";
}
/**
* Parse FormData body into a JavaScript object
* @returns JavaScript object of type T
* @private
* @throws Error if body is invalid
*/
private async parseFormData<T>(): Promise<Partial<T>> {
const formData = await this.request.clone().formData();
const result: Partial<T> = {};
// Extract the files from the FormData
for (const [key, value] of formData.entries()) {
if (value instanceof Blob) {
result[key as keyof T] = value as T[keyof T];
}
}
const formDataWithoutFiles = new FormData();
for (const [key, value] of formData.entries()) {
if (!(value instanceof Blob)) {
formDataWithoutFiles.append(key, value);
}
}
// Convert to URLSearchParams and parse as query
const searchParams = new URLSearchParams([
...formDataWithoutFiles.entries(),
] as [string, string][]);
const parsed = parse(searchParams.toString(), {
parseArrays: true,
interpretNumericEntities: true,
});
const casted = castBooleanObject(
parsed as PossiblyRecursiveObject,
) as Partial<T>;
return { ...result, ...casted };
}
/**
* Parse application/x-www-form-urlencoded body into a JavaScript object
* @returns JavaScript object of type T
* @private
* @throws Error if body is invalid
*/
private async parseFormUrlencoded<T>(): Promise<Partial<T>> {
const parsed = parse(await this.request.text(), {
parseArrays: true,
interpretNumericEntities: true,
});
return castBooleanObject(
parsed as PossiblyRecursiveObject,
) as Partial<T>;
}
/**
* Parse JSON body into a JavaScript object
* @returns JavaScript object of type T
* @private
* @throws Error if body is invalid
*/
private async parseJson<T>(): Promise<T> {
return (await this.request.json()) as T;
}
/**
* Parse query parameters into a JavaScript object
* @private
* @throws Error if body is invalid
* @returns JavaScript object of type T
*/
parseQuery<T>(): Partial<T> {
const parsed = parse(
new URL(this.request.url).searchParams.toString(),
{
parseArrays: true,
interpretNumericEntities: true,
},
);
return castBooleanObject(
parsed as PossiblyRecursiveObject,
) as Partial<T>;
}
}
interface PossiblyRecursiveObject {
[key: string]:
| PossiblyRecursiveObject[]
| PossiblyRecursiveObject
| string
| string[]
| boolean;
}
// Recursive
const castBooleanObject = (value: PossiblyRecursiveObject | string) => {
if (typeof value === "string") {
return castBoolean(value);
}
for (const key in value) {
const child = value[key];
if (Array.isArray(child)) {
value[key] = child.map((v) => castBooleanObject(v)) as string[];
} else if (typeof child === "object") {
value[key] = castBooleanObject(child);
} else {
value[key] = castBoolean(child as string);
}
}
return value;
};
const castBoolean = (value: string) => {
if (["true"].includes(value)) {
return true;
}
if (["false"].includes(value)) {
return false;
}
return value;
};

View file

@ -1,9 +0,0 @@
{
"name": "request-parser",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { "qs": "^6.12.1" },
"devDependencies": {
"@types/qs": "^6.9.15"
}
}

View file

@ -1,180 +0,0 @@
import { describe, expect, it, test } from "bun:test";
import { RequestParser } from "..";
describe("RequestParser", () => {
describe("Should parse query parameters correctly", () => {
test("With text parameters", async () => {
const request = new Request(
"http://localhost?param1=value1&param2=value2",
);
const result = await new RequestParser(request).parseQuery<{
param1: string;
param2: string;
}>();
expect(result).toEqual({ param1: "value1", param2: "value2" });
});
test("With Array", async () => {
const request = new Request(
"http://localhost?test[]=value1&test[]=value2",
);
const result = await new RequestParser(request).parseQuery<{
test: string[];
}>();
expect(result?.test).toEqual(["value1", "value2"]);
});
test("With Array of objects", async () => {
const request = new Request(
"http://localhost?test[][key]=value1&test[][value]=value2",
);
const result = await new RequestParser(request).parseQuery<{
test: { key: string; value: string }[];
}>();
expect(result?.test).toEqual([{ key: "value1", value: "value2" }]);
});
test("With Array of multiple objects", async () => {
const request = new Request(
"http://localhost?test[][key]=value1&test[][value]=value2&test[][key]=value3&test[][value]=value4",
);
const result = await new RequestParser(request).parseQuery<{
test: { key: string[]; value: string[] }[];
}>();
expect(result?.test).toEqual([
{ key: ["value1", "value3"], value: ["value2", "value4"] },
]);
});
test("With both at once", async () => {
const request = new Request(
"http://localhost?param1=value1&param2=value2&test[]=value1&test[]=value2",
);
const result = await new RequestParser(request).parseQuery<{
param1: string;
param2: string;
test: string[];
}>();
expect(result).toEqual({
param1: "value1",
param2: "value2",
test: ["value1", "value2"],
});
});
});
it("should parse JSON body correctly", async () => {
const request = new Request("http://localhost", {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ param1: "value1", param2: "value2" }),
});
const result = await new RequestParser(request).toObject<{
param1: string;
param2: string;
}>();
expect(result).toEqual({ param1: "value1", param2: "value2" });
});
it("should handle invalid JSON body", async () => {
const request = new Request("http://localhost", {
headers: { "Content-Type": "application/json" },
body: "invalid json",
});
const result = new RequestParser(request).toObject<{
param1: string;
param2: string;
}>();
expect(result).rejects.toThrow();
});
describe("should parse form data correctly", () => {
test("With basic text parameters", async () => {
const formData = new FormData();
formData.append("param1", "value1");
formData.append("param2", "value2");
const request = new Request("http://localhost", {
method: "POST",
body: formData,
});
const result = await new RequestParser(request).toObject<{
param1: string;
param2: string;
}>();
expect(result).toEqual({ param1: "value1", param2: "value2" });
});
test("With File object", async () => {
const file = new File(["content"], "filename.txt", {
type: "text/plain",
});
const formData = new FormData();
formData.append("file", file);
const request = new Request("http://localhost", {
method: "POST",
body: formData,
});
const result = await new RequestParser(request).toObject<{
file: File;
}>();
expect(result?.file).toBeInstanceOf(File);
expect(await result?.file?.text()).toEqual("content");
});
test("With Array", async () => {
const formData = new FormData();
formData.append("test[]", "value1");
formData.append("test[]", "value2");
const request = new Request("http://localhost", {
method: "POST",
body: formData,
});
const result = await new RequestParser(request).toObject<{
test: string[];
}>();
expect(result?.test).toEqual(["value1", "value2"]);
});
test("With all three at once", async () => {
const file = new File(["content"], "filename.txt", {
type: "text/plain",
});
const formData = new FormData();
formData.append("param1", "value1");
formData.append("param2", "value2");
formData.append("file", file);
formData.append("test[]", "value1");
formData.append("test[]", "value2");
const request = new Request("http://localhost", {
method: "POST",
body: formData,
});
const result = await new RequestParser(request).toObject<{
param1: string;
param2: string;
file: File;
test: string[];
}>();
expect(result).toEqual({
param1: "value1",
param2: "value2",
file: file,
test: ["value1", "value2"],
});
});
test("URL Encoded", async () => {
const request = new Request("http://localhost", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: "param1=value1&param2=value2",
});
const result = await new RequestParser(request).toObject<{
param1: string;
param2: string;
}>();
expect(result).toEqual({ param1: "value1", param2: "value2" });
});
});
});

View file

@ -1,179 +0,0 @@
import { dualLogger } from "@loggers";
import { errorResponse, jsonResponse, response } from "@response";
import type { MatchedRoute } from "bun";
import { type Config, config } from "config-manager";
import type { Hono } from "hono";
import type { RouterRoute } from "hono/types";
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 { Application } from "~database/entities/Application";
import { type AuthData, getFromRequest } from "~database/entities/User";
import type { User } from "~packages/database-interface/user";
type MaybePromise<T> = T | Promise<T>;
export 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 User | null
// Otherwise set to User
user: RouteMeta["auth"]["required"] extends true
? User
: User | null;
token: RouteMeta["auth"]["required"] extends true
? string
: string | null;
application: Application | 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;
schemas?: {
query?: z.AnyZodObject;
body?: z.AnyZodObject;
};
default: (app: Hono) => RouterRoute;
}
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((e) => {
dualLogger.logError(LogLevel.ERROR, "Server.RouteImport", e as Error);
return null;
});
if (!route?.meta) {
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);
}
const auth: AuthData = await getFromRequest(request);
if (
route.meta.auth.required ||
route.meta.auth.requiredOnMethods?.includes(request.method as HttpVerb)
) {
if (!auth.user) {
return errorResponse(
"Unauthorized: access to this method requires an authenticated user",
401,
);
}
}
// Check if Content-Type header is missing if there is a body
if (request.clone().body) {
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.clone())
.toObject()
.catch(async (err) => {
console.log(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,
application: auth?.application ?? 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.log(
LogLevel.DEBUG,
"Server.RouteHandler",
(err as Error).toString(),
);
await logger.logError(
LogLevel.ERROR,
"Server.RouteHandler",
err as Error,
);
return errorResponse(
`A server error occured: ${(err as Error).message}`,
500,
);
}
};

View file

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

View file

@ -1,340 +0,0 @@
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 but there is a body", 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",
body: "test",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.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");
});
});