refactor: Rewrite functions into packages

This commit is contained in:
Jesse Wierzbinski 2024-03-07 19:34:50 -10:00
parent 847e679a10
commit 78f216092b
No known key found for this signature in database
21 changed files with 1426 additions and 70 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -162,8 +162,6 @@ const logRequest = async (req: Request) => {
);
// Add headers
// @ts-expect-error TypeScript is missing entries for some reason
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const headers = req.headers.entries();
for (const [key, value] of headers) {

View file

@ -109,6 +109,8 @@
"prisma": "^5.6.0",
"prisma-redis-middleware": "^4.8.0",
"semver": "^7.5.4",
"sharp": "^0.33.0-rc.2"
"sharp": "^0.33.0-rc.2",
"request-parser": "file:packages/request-parser",
"config-manager": "file:packages/config-manager"
}
}

View file

@ -0,0 +1,8 @@
export interface CliParameter {
name: string;
// If not positioned, the argument will need to be called with --name value instead of just value
positioned?: boolean;
// Whether the argument needs a value (requires positioned to be false)
needsValue?: boolean;
type: "string" | "number" | "boolean" | "array";
}

View file

@ -0,0 +1,203 @@
import type { CliParameter } from "./cli-builder.type";
export function startsWithArray(fullArray: any[], startArray: any[]) {
if (startArray.length > fullArray.length) {
return false;
}
return fullArray
.slice(0, startArray.length)
.every((value, index) => value === startArray[index]);
}
/**
* Builder for a CLI
* @param commands Array of commands to register
*/
export class CliBuilder {
constructor(public commands: CliCommand[] = []) {}
/**
* Add command to the CLI
* @throws Error if command already exists
* @param command Command to add
*/
registerCommand(command: CliCommand) {
if (this.checkIfCommandAlreadyExists(command)) {
throw new Error(
`Command category '${command.categories.join(" ")}' already exists`
);
}
this.commands.push(command);
}
/**
* Add multiple commands to the CLI
* @throws Error if command already exists
* @param commands Commands to add
*/
registerCommands(commands: CliCommand[]) {
const existingCommand = commands.find(command =>
this.checkIfCommandAlreadyExists(command)
);
if (existingCommand) {
throw new Error(
`Command category '${existingCommand.categories.join(" ")}' already exists`
);
}
this.commands.push(...commands);
}
/**
* Remove command from the CLI
* @param command Command to remove
*/
deregisterCommand(command: CliCommand) {
this.commands = this.commands.filter(
registeredCommand => registeredCommand !== command
);
}
/**
* Remove multiple commands from the CLI
* @param commands Commands to remove
*/
deregisterCommands(commands: CliCommand[]) {
this.commands = this.commands.filter(
registeredCommand => !commands.includes(registeredCommand)
);
}
checkIfCommandAlreadyExists(command: CliCommand) {
return this.commands.some(
registeredCommand =>
registeredCommand.categories.length ==
command.categories.length &&
registeredCommand.categories.every(
(category, index) => category === command.categories[index]
)
);
}
/**
* Get relevant args for the command (without executable or runtime)
* @param args Arguments passed to the CLI
*/
private getRelevantArgs(args: string[]) {
if (args[0].startsWith("./")) {
// Formatted like ./cli.ts [command]
return args.slice(1);
} else if (args[0].includes("bun")) {
// Formatted like bun cli.ts [command]
return args.slice(2);
} else {
return args;
}
}
/**
* Turn raw system args into a CLI command and run it
* @param args Args directly from process.argv
*/
processArgs(args: string[]) {
const revelantArgs = this.getRelevantArgs(args);
// Find revelant command
// Search for a command with as many categories matching args as possible
const matchingCommands = this.commands.filter(command =>
startsWithArray(revelantArgs, command.categories)
);
// Get command with largest category size
const command = matchingCommands.reduce((prev, current) =>
prev.categories.length > current.categories.length ? prev : current
);
const argsWithoutCategories = args.slice(command.categories.length - 1);
command.run(argsWithoutCategories);
}
}
/**
* A command that can be executed from the command line
* @param categories Example: `["user", "create"]` for the command `./cli user create --name John`
*/
export class CliCommand {
constructor(
public categories: string[],
public argTypes: CliParameter[],
private execute: (args: Record<string, any>) => void
) {}
/**
* Parses string array arguments into a full JavaScript object
* @param argsWithoutCategories
* @returns
*/
private parseArgs(argsWithoutCategories: string[]): Record<string, any> {
const parsedArgs: Record<string, any> = {};
let currentParameter: CliParameter | null = null;
for (let i = 0; i < argsWithoutCategories.length; i++) {
const arg = argsWithoutCategories[i];
if (arg.startsWith("--")) {
const argName = arg.substring(2);
currentParameter =
this.argTypes.find(argType => argType.name === argName) ||
null;
if (currentParameter && !currentParameter.needsValue) {
parsedArgs[argName] = true;
currentParameter = null;
} else if (currentParameter && currentParameter.needsValue) {
parsedArgs[argName] = this.castArgValue(
argsWithoutCategories[i + 1],
currentParameter.type
);
i++;
currentParameter = null;
}
} else if (currentParameter) {
parsedArgs[currentParameter.name] = this.castArgValue(
arg,
currentParameter.type
);
currentParameter = null;
} else {
const positionedArgType = this.argTypes.find(
argType => argType.positioned
);
if (positionedArgType) {
parsedArgs[positionedArgType.name] = this.castArgValue(
arg,
positionedArgType.type
);
}
}
}
return parsedArgs;
}
private castArgValue(value: string, type: CliParameter["type"]): any {
switch (type) {
case "string":
return value;
case "number":
return Number(value);
case "boolean":
return value === "true";
case "array":
return value.split(",");
default:
return value;
}
}
/**
* Runs the execute function with the parsed parameters as an argument
*/
run(argsWithoutCategories: string[]) {
const args = this.parseArgs(argsWithoutCategories);
this.execute(args);
}
}

View file

@ -0,0 +1,6 @@
{
"name": "arg-parser",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {}
}

View file

@ -0,0 +1,217 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts
import { CliCommand, CliBuilder, startsWithArray } from "..";
import { describe, beforeEach, it, expect, jest } from "bun:test";
describe("startsWithArray", () => {
it("should return true when fullArray starts with startArray", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray = ["a", "b", "c"];
expect(startsWithArray(fullArray, startArray)).toBe(true);
});
it("should return false when fullArray does not start with startArray", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray = ["b", "c", "d"];
expect(startsWithArray(fullArray, startArray)).toBe(false);
});
it("should return true when startArray is empty", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray: any[] = [];
expect(startsWithArray(fullArray, startArray)).toBe(true);
});
it("should return false when fullArray is shorter than startArray", () => {
const fullArray = ["a", "b", "c"];
const startArray = ["a", "b", "c", "d", "e"];
expect(startsWithArray(fullArray, startArray)).toBe(false);
});
});
describe("CliCommand", () => {
let cliCommand: CliCommand;
beforeEach(() => {
cliCommand = new CliCommand(
["category1", "category2"],
[
{ name: "arg1", type: "string", needsValue: true },
{ name: "arg2", type: "number", needsValue: true },
{ name: "arg3", type: "boolean", needsValue: false },
{ name: "arg4", type: "array", needsValue: true },
],
() => {
// Do nothing
}
);
});
it("should parse string arguments correctly", () => {
const args = cliCommand["parseArgs"]([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(args).toEqual({
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
it("should cast argument values correctly", () => {
expect(cliCommand["castArgValue"]("42", "number")).toBe(42);
expect(cliCommand["castArgValue"]("true", "boolean")).toBe(true);
expect(cliCommand["castArgValue"]("value1,value2", "array")).toEqual([
"value1",
"value2",
]);
});
it("should run the execute function with the parsed parameters", () => {
const mockExecute = jest.fn();
cliCommand = new CliCommand(
["category1", "category2"],
[
{ name: "arg1", type: "string", needsValue: true },
{ name: "arg2", type: "number", needsValue: true },
{ name: "arg3", type: "boolean", needsValue: false },
{ name: "arg4", type: "array", needsValue: true },
],
mockExecute
);
cliCommand.run([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(mockExecute).toHaveBeenCalledWith({
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
it("should work with a mix of positioned and non-positioned arguments", () => {
const mockExecute = jest.fn();
cliCommand = new CliCommand(
["category1", "category2"],
[
{ name: "arg1", type: "string", needsValue: true },
{ name: "arg2", type: "number", needsValue: true },
{ name: "arg3", type: "boolean", needsValue: false },
{ name: "arg4", type: "array", needsValue: true },
{
name: "arg5",
type: "string",
needsValue: true,
positioned: true,
},
],
mockExecute
);
cliCommand.run([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
"value5",
]);
expect(mockExecute).toHaveBeenCalledWith({
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
arg5: "value5",
});
});
});
describe("CliBuilder", () => {
let cliBuilder: CliBuilder;
let mockCommand1: CliCommand;
let mockCommand2: CliCommand;
beforeEach(() => {
mockCommand1 = new CliCommand(["category1"], [], jest.fn());
mockCommand2 = new CliCommand(["category2"], [], jest.fn());
cliBuilder = new CliBuilder([mockCommand1]);
});
it("should register a command correctly", () => {
cliBuilder.registerCommand(mockCommand2);
expect(cliBuilder.commands).toContain(mockCommand2);
});
it("should register multiple commands correctly", () => {
const mockCommand3 = new CliCommand(["category3"], [], jest.fn());
cliBuilder.registerCommands([mockCommand2, mockCommand3]);
expect(cliBuilder.commands).toContain(mockCommand2);
expect(cliBuilder.commands).toContain(mockCommand3);
});
it("should error when adding duplicates", () => {
expect(() => {
cliBuilder.registerCommand(mockCommand1);
}).toThrow();
expect(() => {
cliBuilder.registerCommands([mockCommand1]);
}).toThrow();
});
it("should deregister a command correctly", () => {
cliBuilder.deregisterCommand(mockCommand1);
expect(cliBuilder.commands).not.toContain(mockCommand1);
});
it("should deregister multiple commands correctly", () => {
cliBuilder.registerCommand(mockCommand2);
cliBuilder.deregisterCommands([mockCommand1, mockCommand2]);
expect(cliBuilder.commands).not.toContain(mockCommand1);
expect(cliBuilder.commands).not.toContain(mockCommand2);
});
it("should process args correctly", () => {
const mockExecute = jest.fn();
const mockCommand = new CliCommand(
["category1", "sub1"],
[
{
name: "arg1",
type: "string",
needsValue: true,
positioned: false,
},
],
mockExecute
);
cliBuilder.registerCommand(mockCommand);
cliBuilder.processArgs([
"./cli.ts",
"category1",
"sub1",
"--arg1",
"value1",
]);
expect(mockExecute).toHaveBeenCalledWith({
arg1: "value1",
});
});
});

View file

@ -0,0 +1,359 @@
export interface ConfigType {
database: {
host: string;
port: number;
username: string;
password: string;
database: string;
};
redis: {
queue: {
host: string;
port: number;
password: string;
database: number | null;
};
cache: {
host: string;
port: number;
password: string;
database: number | null;
enabled: boolean;
};
};
meilisearch: {
host: string;
port: number;
api_key: string;
enabled: boolean;
};
signups: {
tos_url: string;
rules: string[];
registration: boolean;
};
oidc: {
providers: {
name: string;
id: string;
url: string;
client_id: string;
client_secret: string;
icon: string;
}[];
};
http: {
base_url: string;
bind: string;
bind_port: string;
banned_ips: string[];
banned_user_agents: string[];
};
instance: {
name: string;
description: string;
banner: string;
logo: string;
};
smtp: {
server: string;
port: number;
username: string;
password: string;
tls: boolean;
};
validation: {
max_displayname_size: number;
max_bio_size: number;
max_username_size: number;
max_note_size: number;
max_avatar_size: number;
max_header_size: number;
max_media_size: number;
max_media_attachments: number;
max_media_description_size: number;
max_poll_options: number;
max_poll_option_size: number;
min_poll_duration: number;
max_poll_duration: number;
username_blacklist: string[];
blacklist_tempmail: boolean;
email_blacklist: string[];
url_scheme_whitelist: string[];
enforce_mime_types: boolean;
allowed_mime_types: string[];
};
media: {
backend: string;
deduplicate_media: boolean;
conversion: {
convert_images: boolean;
convert_to: string;
};
};
s3: {
endpoint: string;
access_key: string;
secret_access_key: string;
region: string;
bucket_name: string;
public_url: string;
};
defaults: {
visibility: string;
language: string;
avatar: string;
header: string;
};
email: {
send_on_report: boolean;
send_on_suspend: boolean;
send_on_unsuspend: boolean;
};
activitypub: {
use_tombstones: boolean;
reject_activities: string[];
force_followers_only: string[];
discard_reports: string[];
discard_deletes: string[];
discard_banners: string[];
discard_avatars: string[];
discard_updates: string[];
discard_follows: string[];
force_sensitive: string[];
remove_media: string[];
fetch_all_collection_members: boolean;
authorized_fetch: boolean;
};
filters: {
note_filters: string[];
username_filters: string[];
displayname_filters: string[];
bio_filters: string[];
emoji_filters: string[];
};
logging: {
log_requests: boolean;
log_requests_verbose: boolean;
log_filters: boolean;
};
ratelimits: {
duration_coeff: number;
max_coeff: number;
};
custom_ratelimits: Record<
string,
{
duration: number;
max: number;
}
>;
[key: string]: unknown;
}
export const configDefaults: ConfigType = {
http: {
bind: "http://0.0.0.0",
bind_port: "8000",
base_url: "http://lysand.localhost:8000",
banned_ips: [],
banned_user_agents: [],
},
database: {
host: "localhost",
port: 5432,
username: "postgres",
password: "postgres",
database: "lysand",
},
redis: {
queue: {
host: "localhost",
port: 6379,
password: "",
database: 0,
},
cache: {
host: "localhost",
port: 6379,
password: "",
database: 1,
enabled: false,
},
},
meilisearch: {
host: "localhost",
port: 1491,
api_key: "",
enabled: false,
},
signups: {
tos_url: "",
rules: [],
registration: false,
},
oidc: {
providers: [],
},
instance: {
banner: "",
description: "",
logo: "",
name: "",
},
smtp: {
password: "",
port: 465,
server: "",
tls: true,
username: "",
},
media: {
backend: "local",
deduplicate_media: true,
conversion: {
convert_images: false,
convert_to: "webp",
},
},
email: {
send_on_report: false,
send_on_suspend: false,
send_on_unsuspend: false,
},
s3: {
access_key: "",
bucket_name: "",
endpoint: "",
public_url: "",
region: "",
secret_access_key: "",
},
validation: {
max_displayname_size: 50,
max_bio_size: 6000,
max_note_size: 5000,
max_avatar_size: 5_000_000,
max_header_size: 5_000_000,
max_media_size: 40_000_000,
max_media_attachments: 10,
max_media_description_size: 1000,
max_poll_options: 20,
max_poll_option_size: 500,
min_poll_duration: 60,
max_poll_duration: 1893456000,
max_username_size: 30,
username_blacklist: [
".well-known",
"~",
"about",
"activities",
"api",
"auth",
"dev",
"inbox",
"internal",
"main",
"media",
"nodeinfo",
"notice",
"oauth",
"objects",
"proxy",
"push",
"registration",
"relay",
"settings",
"status",
"tag",
"users",
"web",
"search",
"mfa",
],
blacklist_tempmail: false,
email_blacklist: [],
url_scheme_whitelist: [
"http",
"https",
"ftp",
"dat",
"dweb",
"gopher",
"hyper",
"ipfs",
"ipns",
"irc",
"xmpp",
"ircs",
"magnet",
"mailto",
"mumble",
"ssb",
],
enforce_mime_types: false,
allowed_mime_types: [],
},
defaults: {
visibility: "public",
language: "en",
avatar: "",
header: "",
},
activitypub: {
use_tombstones: true,
reject_activities: [],
force_followers_only: [],
discard_reports: [],
discard_deletes: [],
discard_banners: [],
discard_avatars: [],
force_sensitive: [],
discard_updates: [],
discard_follows: [],
remove_media: [],
fetch_all_collection_members: false,
authorized_fetch: false,
},
filters: {
note_filters: [],
username_filters: [],
displayname_filters: [],
bio_filters: [],
emoji_filters: [],
},
logging: {
log_requests: false,
log_requests_verbose: false,
log_filters: true,
},
ratelimits: {
duration_coeff: 1,
max_coeff: 1,
},
custom_ratelimits: {},
};

View file

@ -0,0 +1,118 @@
/**
* @file index.ts
* @summary ConfigManager system to retrieve and modify system configuration
* @description Can read from a hand-written file, config.toml, or from a machine-saved file, config.internal.toml
* Fuses both and provides a way to retrieve individual values
*/
import { parse, stringify, type JsonMap } from "@iarna/toml";
import type { ConfigType } from "./config-type.type";
import merge from "merge-deep-ts";
export class ConfigManager {
constructor(
public config: {
configPathOverride?: string;
internalConfigPathOverride?: string;
}
) {}
/**
* @summary Reads the config files and returns the merge as a JSON object
* @returns {Promise<T = ConfigType>} The merged config file as a JSON object
*/
async getConfig<T = ConfigType>() {
const config = await this.readConfig<T>();
const internalConfig = await this.readInternalConfig<T>();
return this.mergeConfigs<T>(config, internalConfig);
}
getConfigPath() {
return (
this.config.configPathOverride ||
process.cwd() + "/config/config.toml"
);
}
getInternalConfigPath() {
return (
this.config.internalConfigPathOverride ||
process.cwd() + "/config/config.internal.toml"
);
}
/**
* @summary Reads the internal config file and returns it as a JSON object
* @returns {Promise<T = ConfigType>} The internal config file as a JSON object
*/
private async readInternalConfig<T = ConfigType>() {
const config = Bun.file(this.getInternalConfigPath());
if (!(await config.exists())) {
await Bun.write(config, "");
}
return this.parseConfig<T>(await config.text());
}
/**
* @summary Reads the config file and returns it as a JSON object
* @returns {Promise<T = ConfigType>} The config file as a JSON object
*/
private async readConfig<T = ConfigType>() {
const config = Bun.file(this.getConfigPath());
if (!(await config.exists())) {
throw new Error(
`Error while reading config at path ${this.getConfigPath()}: Config file not found`
);
}
return this.parseConfig<T>(await config.text());
}
/**
* @summary Parses a TOML string and returns it as a JSON object
* @param text The TOML string to parse
* @returns {T = ConfigType} The parsed TOML string as a JSON object
* @throws {Error} If the TOML string is invalid
* @private
*/
private parseConfig<T = ConfigType>(text: string) {
try {
// To all [Symbol] keys from the object
return JSON.parse(JSON.stringify(parse(text))) as T;
} catch (e: any) {
throw new Error(
`Error while parsing config at path ${this.getConfigPath()}: ${e}`
);
}
}
/**
* Writes changed values to the internal config
* @param config The new config object
*/
async writeConfig<T = ConfigType>(config: T) {
const path = this.getInternalConfigPath();
const file = Bun.file(path);
await Bun.write(
file,
`# THIS FILE IS AUTOMATICALLY GENERATED. DO NOT EDIT IT MANUALLY, EDIT THE STANDARD CONFIG.TOML INSTEAD.\n${stringify(
config as JsonMap
)}`
);
}
/**
* @summary Merges two config objects together, with
* the latter configs' values taking precedence
* @param configs
* @returns
*/
private mergeConfigs<T = ConfigType>(...configs: T[]) {
return merge(configs) as T;
}
}

View file

@ -0,0 +1,6 @@
{
"name": "config-manager",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {}
}

View file

@ -0,0 +1,96 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/config-manager/config-manager.test.ts
import { stringify } from "@iarna/toml";
import { ConfigManager } from "..";
import { describe, beforeEach, spyOn, it, expect } from "bun:test";
describe("ConfigManager", () => {
let configManager: ConfigManager;
beforeEach(() => {
configManager = new ConfigManager({
configPathOverride: "./config/config.toml",
internalConfigPathOverride: "./config/config.internal.toml",
});
});
it("should get the correct config path", () => {
expect(configManager.getConfigPath()).toEqual("./config/config.toml");
});
it("should get the correct internal config path", () => {
expect(configManager.getInternalConfigPath()).toEqual(
"./config/config.internal.toml"
);
});
it("should read the config file correctly", async () => {
const mockConfig = { key: "value" };
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () =>
new Promise(resolve => {
resolve(true);
}),
text: () =>
new Promise(resolve => {
resolve(stringify(mockConfig));
}),
}));
const config = await configManager.getConfig<typeof mockConfig>();
expect(config).toEqual(mockConfig);
});
it("should read the internal config file correctly", async () => {
const mockConfig = { key: "value" };
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () =>
new Promise(resolve => {
resolve(true);
}),
text: () =>
new Promise(resolve => {
resolve(stringify(mockConfig));
}),
}));
const config =
// @ts-expect-error Force call private function for testing
await configManager.readInternalConfig<typeof mockConfig>();
expect(config).toEqual(mockConfig);
});
it("should write to the internal config file correctly", async () => {
const mockConfig = { key: "value" };
spyOn(Bun, "write").mockImplementationOnce(
() =>
new Promise(resolve => {
resolve(10);
})
);
await configManager.writeConfig(mockConfig);
});
it("should merge configs correctly", () => {
const config1 = { key1: "value1", key2: "value2" };
const config2 = { key2: "newValue2", key3: "value3" };
// @ts-expect-error Force call private function for testing
const mergedConfig = configManager.mergeConfigs<Record<string, string>>(
config1,
config2
);
expect(mergedConfig).toEqual({
key1: "value1",
key2: "newValue2",
key3: "value3",
});
});
});

View file

@ -0,0 +1,170 @@
/**
* RequestParser
* @file index.ts
* @module request-parser
* @description Parses Request object into a JavaScript object based on the content type
*/
/**
* 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>() {
try {
switch (await this.determineContentType()) {
case "application/json":
return this.parseJson<T>();
case "application/x-www-form-urlencoded":
return this.parseFormUrlencoded<T>();
case "multipart/form-data":
return this.parseFormData<T>();
default:
return this.parseQuery<T>();
}
} catch {
return {} 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() {
if (this.request.headers.get("Content-Type")) {
return (
this.request.headers.get("Content-Type")?.split(";")[0] ?? ""
);
}
// Check if body is valid JSON
try {
await this.request.json();
return "application/json";
} catch {
// This is not JSON
}
// Check if body is valid FormData
try {
await this.request.formData();
return "multipart/form-data";
} catch {
// This is not FormData
}
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.formData();
const result: Partial<T> = {};
for (const [key, value] of formData.entries()) {
if (value instanceof File) {
result[key as keyof T] = value as any;
} else if (key.endsWith("[]")) {
const arrayKey = key.slice(0, -2) as keyof T;
if (!result[arrayKey]) {
result[arrayKey] = [] as T[keyof T];
}
(result[arrayKey] as any[]).push(value);
} else {
result[key as keyof T] = value as any;
}
}
return result;
}
/**
* 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 formData = await this.request.formData();
const result: Partial<T> = {};
for (const [key, value] of formData.entries()) {
if (key.endsWith("[]")) {
const arrayKey = key.slice(0, -2) as keyof T;
if (!result[arrayKey]) {
result[arrayKey] = [] as T[keyof T];
}
(result[arrayKey] as any[]).push(value);
} else {
result[key as keyof T] = value as any;
}
}
return result;
}
/**
* 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<Partial<T>> {
try {
return (await this.request.json()) as T;
} catch {
return {};
}
}
/**
* Parse query parameters into a JavaScript object
* @private
* @throws Error if body is invalid
* @returns JavaScript object of type T
*/
private parseQuery<T>(): Partial<T> {
const result: Partial<T> = {};
const url = new URL(this.request.url);
for (const [key, value] of url.searchParams.entries()) {
if (key.endsWith("[]")) {
const arrayKey = key.slice(0, -2) as keyof T;
if (!result[arrayKey]) {
result[arrayKey] = [] as T[keyof T];
}
(result[arrayKey] as string[]).push(value);
} else {
result[key as keyof T] = value as any;
}
}
return result;
}
}

View file

@ -0,0 +1,6 @@
{
"name": "request-parser",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {}
}

View file

@ -0,0 +1,158 @@
import { describe, it, expect, 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).toObject<{
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).toObject<{
test: string[];
}>();
expect(result.test).toEqual(["value1", "value2"]);
});
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).toObject<{
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 = await new RequestParser(request).toObject<{
param1: string;
param2: string;
}>();
expect(result).toEqual({});
});
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,11 +1,11 @@
import { getConfig } from "~classes/configmanager";
import { parseRequest } from "@request";
import { jsonResponse } from "@response";
import { tempmailDomains } from "@tempmail";
import { applyConfig } from "@api";
import { client } from "~database/datasource";
import { createNewLocalUser } from "~database/entities/User";
import ISO6391 from "iso-639-1";
import type { RouteHandler } from "~server/api/routes.type";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -19,20 +19,17 @@ export const meta = applyConfig({
},
});
/**
* Creates a new user
*/
export default async (req: Request): Promise<Response> => {
// TODO: Add Authorization check
const body = await parseRequest<{
const handler: RouteHandler<{
username: string;
email: string;
password: string;
agreement: boolean;
locale: string;
reason: string;
}>(req);
}> = async (req, matchedRoute, extraData) => {
// TODO: Add Authorization check
const body = extraData.parsedRequest;
const config = getConfig();
@ -94,8 +91,8 @@ export default async (req: Request): Promise<Response> => {
// Check if username doesnt match filters
if (
config.filters.username_filters.some(
filter => body.username?.match(filter)
config.filters.username_filters.some(filter =>
body.username?.match(filter)
)
) {
errors.details.username.push({
@ -204,3 +201,8 @@ export default async (req: Request): Promise<Response> => {
status: 200,
});
};
/**
* Creates a new user
*/
export default handler;

View file

@ -1,12 +1,8 @@
import { errorResponse, jsonResponse } from "@response";
import {
getFromRequest,
userRelations,
userToAPI,
} from "~database/entities/User";
import { userRelations, userToAPI } from "~database/entities/User";
import { applyConfig } from "@api";
import { parseRequest } from "@request";
import { client } from "~database/datasource";
import type { RouteHandler } from "~server/api/routes.type";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -20,10 +16,16 @@ export const meta = applyConfig({
},
});
export default async (req: Request): Promise<Response> => {
const handler: RouteHandler<{
q?: string;
limit?: number;
offset?: number;
resolve?: boolean;
following?: boolean;
}> = async (req, matchedRoute, extraData) => {
// TODO: Add checks for disabled or not email verified accounts
const { user } = await getFromRequest(req);
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
@ -32,13 +34,7 @@ export default async (req: Request): Promise<Response> => {
limit = 40,
offset,
q,
} = await parseRequest<{
q?: string;
limit?: number;
offset?: number;
resolve?: boolean;
following?: boolean;
}>(req);
} = extraData.parsedRequest;
if (limit < 1 || limit > 80) {
return errorResponse("Limit must be between 1 and 80", 400);
@ -76,3 +72,5 @@ export default async (req: Request): Promise<Response> => {
return jsonResponse(accounts.map(acct => userToAPI(acct)));
};
export default handler;

View file

@ -1,11 +1,6 @@
import { getConfig } from "~classes/configmanager";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import {
userRelations,
userToAPI,
type AuthData,
} from "~database/entities/User";
import { userRelations, userToAPI } from "~database/entities/User";
import { applyConfig } from "@api";
import { sanitize } from "isomorphic-dompurify";
import { sanitizeHtml } from "@sanitization";
@ -15,7 +10,7 @@ import { parseEmojis } from "~database/entities/Emoji";
import { client } from "~database/datasource";
import type { APISource } from "~types/entities/source";
import { convertTextToHtml } from "@formatting";
import type { MatchedRoute } from "bun";
import type { RouteHandler } from "~server/api/routes.type";
export const meta = applyConfig({
allowedMethods: ["PATCH"],
@ -29,15 +24,19 @@ export const meta = applyConfig({
},
});
/**
* Patches a user
*/
export default async (
req: Request,
matchedRoute: MatchedRoute,
auth: AuthData
): Promise<Response> => {
const { user } = auth;
const handler: RouteHandler<{
display_name: string;
note: string;
avatar: File;
header: File;
locked: string;
bot: string;
discoverable: string;
"source[privacy]": string;
"source[sensitive]": string;
"source[language]": string;
}> = async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
@ -54,18 +53,7 @@ export default async (
"source[privacy]": source_privacy,
"source[sensitive]": source_sensitive,
"source[language]": source_language,
} = await parseRequest<{
display_name: string;
note: string;
avatar: File;
header: File;
locked: string;
bot: string;
discoverable: string;
"source[privacy]": string;
"source[sensitive]": string;
"source[language]": string;
}>(req);
} = extraData.parsedRequest;
const sanitizedNote = await sanitizeHtml(note ?? "");
@ -147,7 +135,7 @@ export default async (
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(user.source as any).privacy = source_privacy;
user.source.privacy = source_privacy;
}
if (source_sensitive && user.source) {
@ -157,7 +145,7 @@ export default async (
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(user.source as any).sensitive = source_sensitive === "true";
user.source.sensitive = source_sensitive === "true";
}
if (source_language && user.source) {
@ -169,7 +157,7 @@ export default async (
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(user.source as any).language = source_language;
user.source.language = source_language;
}
if (avatar) {
@ -264,3 +252,5 @@ export default async (
return jsonResponse(userToAPI(output));
};
export default handler;

View file

@ -1,6 +1,7 @@
import { errorResponse, jsonResponse } from "@response";
import { getFromRequest, userToAPI } from "~database/entities/User";
import { userToAPI } from "~database/entities/User";
import { applyConfig } from "@api";
import type { RouteHandler } from "~server/api/routes.type";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -14,10 +15,12 @@ export const meta = applyConfig({
},
});
export default async (req: Request): Promise<Response> => {
const handler: RouteHandler<> = (req, matchedRoute, extraData) => {};
const handler: RouteHandler<""> = (req, matchedRoute, extraData) => {
// TODO: Add checks for disabled or not email verified accounts
const { user } = await getFromRequest(req);
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401);
@ -25,3 +28,5 @@ export default async (req: Request): Promise<Response> => {
...userToAPI(user, true),
});
};
export default handler;

13
server/api/routes.type.ts Normal file
View file

@ -0,0 +1,13 @@
import type { MatchedRoute } from "bun";
import type { ConfigManager } from "config-manager";
import type { AuthData } from "~database/entities/User";
export type RouteHandler<T> = (
req: Request,
matchedRoute: MatchedRoute,
extraData: {
auth: AuthData;
parsedRequest: Partial<T>;
configManager: ConfigManager;
}
) => Response | Promise<Response>;

View file

@ -1,6 +1,6 @@
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",

View file

@ -5,7 +5,7 @@
* either FormData, query parameters, or JSON in the request
* @param request The request to parse
*/
export async function parseRequest<T>(request: Request): Promise<Partial<T>> {
/* export async function parseRequest<T>(request: Request): Promise<Partial<T>> {
const query = new URL(request.url).searchParams;
let output: Partial<T> = {};
@ -93,3 +93,4 @@ export async function parseRequest<T>(request: Request): Promise<Partial<T>> {
return output;
}
*/