Replace eslint and prettier with Biome

This commit is contained in:
Jesse Wierzbinski 2024-04-06 19:30:49 -10:00
parent 4a5a2ea590
commit af0d627f19
No known key found for this signature in database
199 changed files with 16493 additions and 16361 deletions

View file

@ -1,23 +1,23 @@
export interface CliParameter {
name: string;
/* Like -v for --version */
shortName?: string;
/**
* If not positioned, the argument will need to be called with --name value instead of just value
* @default true
*/
positioned?: boolean;
/* Whether the argument needs a value (requires positioned to be false) */
needsValue?: boolean;
optional?: true;
type: CliParameterType;
description?: string;
name: string;
/* Like -v for --version */
shortName?: string;
/**
* If not positioned, the argument will need to be called with --name value instead of just value
* @default true
*/
positioned?: boolean;
/* Whether the argument needs a value (requires positioned to be false) */
needsValue?: boolean;
optional?: true;
type: CliParameterType;
description?: string;
}
export enum CliParameterType {
STRING = "string",
NUMBER = "number",
BOOLEAN = "boolean",
ARRAY = "array",
EMPTY = "empty",
STRING = "string",
NUMBER = "number",
BOOLEAN = "boolean",
ARRAY = "array",
EMPTY = "empty",
}

View file

@ -1,18 +1,18 @@
import { CliParameterType, type CliParameter } from "./cli-builder.type";
import chalk from "chalk";
import strip from "strip-ansi";
import { type CliParameter, CliParameterType } 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]);
export function startsWithArray(fullArray: string[], startArray: string[]) {
if (startArray.length > fullArray.length) {
return false;
}
return fullArray
.slice(0, startArray.length)
.every((value, index) => value === startArray[index]);
}
interface TreeType {
[key: string]: CliCommand | TreeType;
[key: string]: CliCommand | TreeType;
}
/**
@ -20,178 +20,186 @@ interface TreeType {
* @param commands Array of commands to register
*/
export class CliBuilder {
constructor(public commands: CliCommand[] = []) {}
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 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);
}
/**
* 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 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)
);
}
/**
* 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]
)
);
}
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;
}
}
/**
* 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);
}
if (args[0].includes("bun")) {
// Formatted like bun cli.ts [command]
return args.slice(2);
}
return args;
}
/**
* Turn raw system args into a CLI command and run it
* @param args Args directly from process.argv
*/
async processArgs(args: string[]) {
const revelantArgs = this.getRelevantArgs(args);
/**
* Turn raw system args into a CLI command and run it
* @param args Args directly from process.argv
*/
async processArgs(args: string[]) {
const revelantArgs = this.getRelevantArgs(args);
// Handle "-h", "--help" and "help" commands as special cases
if (revelantArgs.length === 1) {
if (["-h", "--help", "help"].includes(revelantArgs[0])) {
this.displayHelp();
return;
}
}
// Handle "-h", "--help" and "help" commands as special cases
if (revelantArgs.length === 1) {
if (["-h", "--help", "help"].includes(revelantArgs[0])) {
this.displayHelp();
return;
}
}
// 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)
);
// 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),
);
if (matchingCommands.length === 0) {
console.log(
`Invalid command "${revelantArgs.join(" ")}". Please use the ${chalk.bold("help")} command to see a list of commands`
);
return 0;
}
if (matchingCommands.length === 0) {
console.log(
`Invalid command "${revelantArgs.join(
" ",
)}". Please use the ${chalk.bold(
"help",
)} command to see a list of commands`,
);
return 0;
}
// Get command with largest category size
const command = matchingCommands.reduce((prev, current) =>
prev.categories.length > current.categories.length ? prev : current
);
// Get command with largest category size
const command = matchingCommands.reduce((prev, current) =>
prev.categories.length > current.categories.length ? prev : current,
);
const argsWithoutCategories = revelantArgs.slice(
command.categories.length
);
const argsWithoutCategories = revelantArgs.slice(
command.categories.length,
);
return await command.run(argsWithoutCategories);
}
return await command.run(argsWithoutCategories);
}
/**
* Recursively urns the commands into a tree where subcategories mark each sub-branch
* @example
* ```txt
* user verify
* user delete
* user new admin
* user new
* ->
* user
* verify
* delete
* new
* admin
* ""
* ```
*/
getCommandTree(commands: CliCommand[]): TreeType {
const tree: TreeType = {};
/**
* Recursively urns the commands into a tree where subcategories mark each sub-branch
* @example
* ```txt
* user verify
* user delete
* user new admin
* user new
* ->
* user
* verify
* delete
* new
* admin
* ""
* ```
*/
getCommandTree(commands: CliCommand[]): TreeType {
const tree: TreeType = {};
for (const command of commands) {
let currentLevel = tree; // Start at the root
for (const command of commands) {
let currentLevel = tree; // Start at the root
// Split the command into parts and iterate over them
for (const part of command.categories) {
// If this part doesn't exist in the current level of the tree, add it (__proto__ check to prevent prototype pollution)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!currentLevel[part] && part !== "__proto__") {
// If this is the last part of the command, add the command itself
if (
part ===
command.categories[command.categories.length - 1]
) {
currentLevel[part] = command;
break;
}
currentLevel[part] = {};
}
// Split the command into parts and iterate over them
for (const part of command.categories) {
// If this part doesn't exist in the current level of the tree, add it (__proto__ check to prevent prototype pollution)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!currentLevel[part] && part !== "__proto__") {
// If this is the last part of the command, add the command itself
if (
part ===
command.categories[command.categories.length - 1]
) {
currentLevel[part] = command;
break;
}
currentLevel[part] = {};
}
// Move down to the next level of the tree
currentLevel = currentLevel[part] as TreeType;
}
}
// Move down to the next level of the tree
currentLevel = currentLevel[part] as TreeType;
}
}
return tree;
}
return tree;
}
/**
* Display help for every command in a tree manner
*/
displayHelp() {
/*
/**
* Display help for every command in a tree manner
*/
displayHelp() {
/*
user
set
admin: List of admin commands
@ -204,217 +212,242 @@ export class CliBuilder {
verify
...
*/
const tree = this.getCommandTree(this.commands);
let writeBuffer = "";
const tree = this.getCommandTree(this.commands);
let writeBuffer = "";
const displayTree = (tree: TreeType, depth = 0) => {
for (const [key, value] of Object.entries(tree)) {
if (value instanceof CliCommand) {
writeBuffer += `${" ".repeat(depth)}${chalk.blue(key)}|${chalk.underline(value.description)}\n`;
const positionedArgs = value.argTypes.filter(
arg => arg.positioned ?? true
);
const unpositionedArgs = value.argTypes.filter(
arg => !(arg.positioned ?? true)
);
const displayTree = (tree: TreeType, depth = 0) => {
for (const [key, value] of Object.entries(tree)) {
if (value instanceof CliCommand) {
writeBuffer += `${" ".repeat(depth)}${chalk.blue(
key,
)}|${chalk.underline(value.description)}\n`;
const positionedArgs = value.argTypes.filter(
(arg) => arg.positioned ?? true,
);
const unpositionedArgs = value.argTypes.filter(
(arg) => !(arg.positioned ?? true),
);
for (const arg of positionedArgs) {
writeBuffer += `${" ".repeat(depth + 1)}${chalk.green(
arg.name
)}|${
arg.description ?? "(no description)"
} ${arg.optional ? chalk.gray("(optional)") : ""}\n`;
}
for (const arg of unpositionedArgs) {
writeBuffer += `${" ".repeat(depth + 1)}${chalk.yellow("--" + arg.name)}${arg.shortName ? ", " + chalk.yellow("-" + arg.shortName) : ""}|${
arg.description ?? "(no description)"
} ${arg.optional ? chalk.gray("(optional)") : ""}\n`;
}
for (const arg of positionedArgs) {
writeBuffer += `${" ".repeat(
depth + 1,
)}${chalk.green(arg.name)}|${
arg.description ?? "(no description)"
} ${arg.optional ? chalk.gray("(optional)") : ""}\n`;
}
for (const arg of unpositionedArgs) {
writeBuffer += `${" ".repeat(
depth + 1,
)}${chalk.yellow(`--${arg.name}`)}${
arg.shortName
? `, ${chalk.yellow(`-${arg.shortName}`)}`
: ""
}|${arg.description ?? "(no description)"} ${
arg.optional ? chalk.gray("(optional)") : ""
}\n`;
}
if (value.example) {
writeBuffer += `${" ".repeat(depth + 1)}${chalk.bold("Example:")} ${chalk.bgGray(
value.example
)}\n`;
}
} else {
writeBuffer += `${" ".repeat(depth)}${chalk.blue(key)}\n`;
displayTree(value, depth + 1);
}
}
};
if (value.example) {
writeBuffer += `${" ".repeat(depth + 1)}${chalk.bold(
"Example:",
)} ${chalk.bgGray(value.example)}\n`;
}
} else {
writeBuffer += `${" ".repeat(depth)}${chalk.blue(
key,
)}\n`;
displayTree(value, depth + 1);
}
}
};
displayTree(tree);
displayTree(tree);
// Replace all "|" with enough dots so that the text on the left + the dots = the same length
const optimal_length = Number(
// @ts-expect-error Slightly hacky but works
writeBuffer.split("\n").reduce((prev, current) => {
// If previousValue is empty
if (!prev)
return current.includes("|")
? current.split("|")[0].length
: 0;
if (!current.includes("|")) return prev;
const [left] = current.split("|");
// Strip ANSI color codes or they mess up the length
return Math.max(Number(prev), strip(left).length);
})
);
// Replace all "|" with enough dots so that the text on the left + the dots = the same length
const optimal_length = Number(
writeBuffer
.split("\n")
// @ts-expect-error I don't know how this works and I don't want to know
.reduce((prev, current) => {
// If previousValue is empty
if (!prev)
return current.includes("|")
? current.split("|")[0].length
: 0;
if (!current.includes("|")) return prev;
const [left] = current.split("|");
// Strip ANSI color codes or they mess up the length
return Math.max(Number(prev), Bun.stringWidth(left));
}),
);
for (const line of writeBuffer.split("\n")) {
const [left, right] = line.split("|");
if (!right) {
console.log(left);
continue;
}
// Strip ANSI color codes or they mess up the length
const dots = ".".repeat(optimal_length + 5 - strip(left).length);
console.log(`${left}${dots}${right}`);
}
}
for (const line of writeBuffer.split("\n")) {
const [left, right] = line.split("|");
if (!right) {
console.log(left);
continue;
}
// Strip ANSI color codes or they mess up the length
const dots = ".".repeat(optimal_length + 5 - Bun.stringWidth(left));
console.log(`${left}${dots}${right}`);
}
}
}
type ExecuteFunction<T> = (
instance: CliCommand,
args: Partial<T>
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
instance: CliCommand,
args: Partial<T>,
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
) => Promise<number> | Promise<void> | number | void;
/**
* 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<T = any> {
constructor(
public categories: string[],
public argTypes: CliParameter[],
private execute: ExecuteFunction<T>,
public description?: string,
public example?: string
) {}
/**
* Display help message for the command
* formatted with Chalk and with emojis
*/
displayHelp() {
const positionedArgs = this.argTypes.filter(
arg => arg.positioned ?? true
);
const unpositionedArgs = this.argTypes.filter(
arg => !(arg.positioned ?? true)
);
const helpMessage = `
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
export class CliCommand<T = any> {
constructor(
public categories: string[],
public argTypes: CliParameter[],
private execute: ExecuteFunction<T>,
public description?: string,
public example?: string,
) {}
/**
* Display help message for the command
* formatted with Chalk and with emojis
*/
displayHelp() {
const positionedArgs = this.argTypes.filter(
(arg) => arg.positioned ?? true,
);
const unpositionedArgs = this.argTypes.filter(
(arg) => !(arg.positioned ?? true),
);
const helpMessage = `
${chalk.green("📚 Command:")} ${chalk.yellow(this.categories.join(" "))}
${this.description ? `${chalk.cyan(this.description)}\n` : ""}
${chalk.magenta("🔧 Arguments:")}
${positionedArgs
.map(
arg =>
`${chalk.bold(arg.name)}: ${chalk.blue(arg.description ?? "(no description)")} ${
arg.optional ? chalk.gray("(optional)") : ""
}`
)
.join("\n")}
.map(
(arg) =>
`${chalk.bold(arg.name)}: ${chalk.blue(
arg.description ?? "(no description)",
)} ${arg.optional ? chalk.gray("(optional)") : ""}`,
)
.join("\n")}
${unpositionedArgs
.map(
arg =>
`--${chalk.bold(arg.name)}${arg.shortName ? `, -${arg.shortName}` : ""}: ${chalk.blue(arg.description ?? "(no description)")} ${
arg.optional ? chalk.gray("(optional)") : ""
}`
)
.join(
"\n"
)}${this.example ? `\n${chalk.magenta("🚀 Example:")}\n${chalk.bgGray(this.example)}` : ""}
.map(
(arg) =>
`--${chalk.bold(arg.name)}${
arg.shortName ? `, -${arg.shortName}` : ""
}: ${chalk.blue(arg.description ?? "(no description)")} ${
arg.optional ? chalk.gray("(optional)") : ""
}`,
)
.join("\n")}${
this.example
? `\n${chalk.magenta("🚀 Example:")}\n${chalk.bgGray(this.example)}`
: ""
}
`;
console.log(helpMessage);
}
console.log(helpMessage);
}
/**
* 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;
/**
* Parses string array arguments into a full JavaScript object
* @param argsWithoutCategories
* @returns
*/
private parseArgs(
argsWithoutCategories: string[],
): Record<string, string | number | boolean | string[]> {
const parsedArgs: Record<string, string | number | boolean | string[]> =
{};
let currentParameter: CliParameter | null = null;
for (let i = 0; i < argsWithoutCategories.length; i++) {
const arg = argsWithoutCategories[i];
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 (arg.startsWith("-")) {
const shortName = arg.substring(1);
const argType = this.argTypes.find(
argType => argType.shortName === shortName
);
if (argType && !argType.needsValue) {
parsedArgs[argType.name] = true;
} else if (argType && argType.needsValue) {
parsedArgs[argType.name] = this.castArgValue(
argsWithoutCategories[i + 1],
argType.type
);
i++;
}
} else if (currentParameter) {
parsedArgs[currentParameter.name] = this.castArgValue(
arg,
currentParameter.type
);
currentParameter = null;
} else {
const positionedArgType = this.argTypes.find(
argType => argType.positioned && !parsedArgs[argType.name]
);
if (positionedArgType) {
parsedArgs[positionedArgType.name] = this.castArgValue(
arg,
positionedArgType.type
);
}
}
}
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?.needsValue) {
parsedArgs[argName] = this.castArgValue(
argsWithoutCategories[i + 1],
currentParameter.type,
);
i++;
currentParameter = null;
}
} else if (arg.startsWith("-")) {
const shortName = arg.substring(1);
const argType = this.argTypes.find(
(argType) => argType.shortName === shortName,
);
if (argType && !argType.needsValue) {
parsedArgs[argType.name] = true;
} else if (argType?.needsValue) {
parsedArgs[argType.name] = this.castArgValue(
argsWithoutCategories[i + 1],
argType.type,
);
i++;
}
} else if (currentParameter) {
parsedArgs[currentParameter.name] = this.castArgValue(
arg,
currentParameter.type,
);
currentParameter = null;
} else {
const positionedArgType = this.argTypes.find(
(argType) =>
argType.positioned && !parsedArgs[argType.name],
);
if (positionedArgType) {
parsedArgs[positionedArgType.name] = this.castArgValue(
arg,
positionedArgType.type,
);
}
}
}
return parsedArgs;
}
return parsedArgs;
}
private castArgValue(value: string, type: CliParameter["type"]): any {
switch (type) {
case CliParameterType.STRING:
return value;
case CliParameterType.NUMBER:
return Number(value);
case CliParameterType.BOOLEAN:
return value === "true";
case CliParameterType.ARRAY:
return value.split(",");
default:
return value;
}
}
private castArgValue(
value: string,
type: CliParameter["type"],
): string | number | boolean | string[] {
switch (type) {
case CliParameterType.STRING:
return value;
case CliParameterType.NUMBER:
return Number(value);
case CliParameterType.BOOLEAN:
return value === "true";
case CliParameterType.ARRAY:
return value.split(",");
default:
return value;
}
}
/**
* Runs the execute function with the parsed parameters as an argument
*/
async run(argsWithoutCategories: string[]) {
const args = this.parseArgs(argsWithoutCategories);
return await this.execute(this, args as any);
}
/**
* Runs the execute function with the parsed parameters as an argument
*/
async run(argsWithoutCategories: string[]) {
const args = this.parseArgs(argsWithoutCategories);
return await this.execute(this, args as T);
}
}

View file

@ -1,6 +1,6 @@
{
"name": "cli-parser",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { "chalk": "^5.3.0", "strip-ansi": "^7.1.0" }
}
"name": "cli-parser",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { "chalk": "^5.3.0", "strip-ansi": "^7.1.0" }
}

View file

@ -1,485 +1,488 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts
import { CliCommand, CliBuilder, startsWithArray } from "..";
import { describe, beforeEach, it, expect, jest, spyOn } from "bun:test";
import { beforeEach, describe, expect, it, jest, spyOn } from "bun:test";
import stripAnsi from "strip-ansi";
// FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts
import { CliBuilder, CliCommand, startsWithArray } from "..";
import { CliParameterType } from "../cli-builder.type";
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 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 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 true when startArray is empty", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray: string[] = [];
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);
});
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;
let cliCommand: CliCommand;
beforeEach(() => {
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
},
{
name: "arg2",
shortName: "a",
type: CliParameterType.NUMBER,
needsValue: true,
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
],
() => {
// Do nothing
}
);
});
beforeEach(() => {
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
},
{
name: "arg2",
shortName: "a",
type: CliParameterType.NUMBER,
needsValue: true,
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.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 parse string arguments correctly", () => {
// @ts-expect-error Testing private method
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 parse short names for arguments too", () => {
const args = cliCommand["parseArgs"]([
"--arg1",
"value1",
"-a",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(args).toEqual({
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
it("should parse short names for arguments too", () => {
// @ts-expect-error Testing private method
const args = cliCommand.parseArgs([
"--arg1",
"value1",
"-a",
"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", CliParameterType.NUMBER)).toBe(
42
);
expect(
cliCommand["castArgValue"]("true", CliParameterType.BOOLEAN)
).toBe(true);
expect(
cliCommand["castArgValue"]("value1,value2", CliParameterType.ARRAY)
).toEqual(["value1", "value2"]);
});
it("should cast argument values correctly", () => {
// @ts-expect-error Testing private method
expect(cliCommand.castArgValue("42", CliParameterType.NUMBER)).toBe(42);
// @ts-expect-error Testing private method
expect(cliCommand.castArgValue("true", CliParameterType.BOOLEAN)).toBe(
true,
);
expect(
// @ts-expect-error Testing private method
cliCommand.castArgValue("value1,value2", CliParameterType.ARRAY),
).toEqual(["value1", "value2"]);
});
it("should run the execute function with the parsed parameters", async () => {
const mockExecute = jest.fn();
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
},
{
name: "arg2",
type: CliParameterType.NUMBER,
needsValue: true,
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
],
mockExecute
);
it("should run the execute function with the parsed parameters", async () => {
const mockExecute = jest.fn();
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
},
{
name: "arg2",
type: CliParameterType.NUMBER,
needsValue: true,
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
],
mockExecute,
);
await cliCommand.run([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(mockExecute).toHaveBeenCalledWith(cliCommand, {
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
await cliCommand.run([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(mockExecute).toHaveBeenCalledWith(cliCommand, {
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
it("should work with a mix of positioned and non-positioned arguments", async () => {
const mockExecute = jest.fn();
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
},
{
name: "arg2",
type: CliParameterType.NUMBER,
needsValue: true,
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
{
name: "arg5",
type: CliParameterType.STRING,
needsValue: true,
positioned: true,
},
],
mockExecute
);
it("should work with a mix of positioned and non-positioned arguments", async () => {
const mockExecute = jest.fn();
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
},
{
name: "arg2",
type: CliParameterType.NUMBER,
needsValue: true,
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
{
name: "arg5",
type: CliParameterType.STRING,
needsValue: true,
positioned: true,
},
],
mockExecute,
);
await cliCommand.run([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
"value5",
]);
await cliCommand.run([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
"value5",
]);
expect(mockExecute).toHaveBeenCalledWith(cliCommand, {
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
arg5: "value5",
});
});
expect(mockExecute).toHaveBeenCalledWith(cliCommand, {
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
arg5: "value5",
});
});
it("should display help message correctly", () => {
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
// Do nothing
});
it("should display help message correctly", () => {
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
// Do nothing
});
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
description: "Argument 1",
optional: true,
},
{
name: "arg2",
type: CliParameterType.NUMBER,
needsValue: true,
description: "Argument 2",
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
description: "Argument 3",
optional: true,
positioned: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
description: "Argument 4",
positioned: false,
},
],
() => {
// Do nothing
},
"This is a test command",
"category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2"
);
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
description: "Argument 1",
optional: true,
},
{
name: "arg2",
type: CliParameterType.NUMBER,
needsValue: true,
description: "Argument 2",
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
description: "Argument 3",
optional: true,
positioned: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
description: "Argument 4",
positioned: false,
},
],
() => {
// Do nothing
},
"This is a test command",
"category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2",
);
cliCommand.displayHelp();
cliCommand.displayHelp();
const loggedString = consoleLogSpy.mock.calls.map(call =>
stripAnsi(call[0])
)[0];
const loggedString = consoleLogSpy.mock.calls.map((call) =>
stripAnsi(call[0]),
)[0];
consoleLogSpy.mockRestore();
consoleLogSpy.mockRestore();
expect(loggedString).toContain("📚 Command: category1 category2");
expect(loggedString).toContain("🔧 Arguments:");
expect(loggedString).toContain("arg1: Argument 1 (optional)");
expect(loggedString).toContain("arg2: Argument 2");
expect(loggedString).toContain("--arg3: Argument 3 (optional)");
expect(loggedString).toContain("--arg4: Argument 4");
expect(loggedString).toContain("🚀 Example:");
expect(loggedString).toContain(
"category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2"
);
});
expect(loggedString).toContain("📚 Command: category1 category2");
expect(loggedString).toContain("🔧 Arguments:");
expect(loggedString).toContain("arg1: Argument 1 (optional)");
expect(loggedString).toContain("arg2: Argument 2");
expect(loggedString).toContain("--arg3: Argument 3 (optional)");
expect(loggedString).toContain("--arg4: Argument 4");
expect(loggedString).toContain("🚀 Example:");
expect(loggedString).toContain(
"category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2",
);
});
});
describe("CliBuilder", () => {
let cliBuilder: CliBuilder;
let mockCommand1: CliCommand;
let mockCommand2: CliCommand;
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]);
});
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 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 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();
it("should error when adding duplicates", () => {
expect(() => {
cliBuilder.registerCommand(mockCommand1);
}).toThrow();
expect(() => {
cliBuilder.registerCommands([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 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 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", async () => {
const mockExecute = jest.fn();
const mockCommand = new CliCommand(
["category1", "sub1"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
positioned: false,
},
],
mockExecute
);
cliBuilder.registerCommand(mockCommand);
await cliBuilder.processArgs([
"./cli.ts",
"category1",
"sub1",
"--arg1",
"value1",
]);
expect(mockExecute).toHaveBeenCalledWith(expect.anything(), {
arg1: "value1",
});
});
it("should process args correctly", async () => {
const mockExecute = jest.fn();
const mockCommand = new CliCommand(
["category1", "sub1"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
positioned: false,
},
],
mockExecute,
);
cliBuilder.registerCommand(mockCommand);
await cliBuilder.processArgs([
"./cli.ts",
"category1",
"sub1",
"--arg1",
"value1",
]);
expect(mockExecute).toHaveBeenCalledWith(expect.anything(), {
arg1: "value1",
});
});
describe("should build command tree", () => {
let cliBuilder: CliBuilder;
let mockCommand1: CliCommand;
let mockCommand2: CliCommand;
let mockCommand3: CliCommand;
let mockCommand4: CliCommand;
let mockCommand5: CliCommand;
describe("should build command tree", () => {
let cliBuilder: CliBuilder;
let mockCommand1: CliCommand;
let mockCommand2: CliCommand;
let mockCommand3: CliCommand;
let mockCommand4: CliCommand;
let mockCommand5: CliCommand;
beforeEach(() => {
mockCommand1 = new CliCommand(["user", "verify"], [], jest.fn());
mockCommand2 = new CliCommand(["user", "delete"], [], jest.fn());
mockCommand3 = new CliCommand(
["user", "new", "admin"],
[],
jest.fn()
);
mockCommand4 = new CliCommand(["user", "new"], [], jest.fn());
mockCommand5 = new CliCommand(["admin", "delete"], [], jest.fn());
cliBuilder = new CliBuilder([
mockCommand1,
mockCommand2,
mockCommand3,
mockCommand4,
mockCommand5,
]);
});
beforeEach(() => {
mockCommand1 = new CliCommand(["user", "verify"], [], jest.fn());
mockCommand2 = new CliCommand(["user", "delete"], [], jest.fn());
mockCommand3 = new CliCommand(
["user", "new", "admin"],
[],
jest.fn(),
);
mockCommand4 = new CliCommand(["user", "new"], [], jest.fn());
mockCommand5 = new CliCommand(["admin", "delete"], [], jest.fn());
cliBuilder = new CliBuilder([
mockCommand1,
mockCommand2,
mockCommand3,
mockCommand4,
mockCommand5,
]);
});
it("should build the command tree correctly", () => {
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({
user: {
verify: mockCommand1,
delete: mockCommand2,
new: {
admin: mockCommand3,
},
},
admin: {
delete: mockCommand5,
},
});
});
it("should build the command tree correctly", () => {
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({
user: {
verify: mockCommand1,
delete: mockCommand2,
new: {
admin: mockCommand3,
},
},
admin: {
delete: mockCommand5,
},
});
});
it("should build the command tree correctly when there are no commands", () => {
cliBuilder = new CliBuilder([]);
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({});
});
it("should build the command tree correctly when there are no commands", () => {
cliBuilder = new CliBuilder([]);
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({});
});
it("should build the command tree correctly when there is only one command", () => {
cliBuilder = new CliBuilder([mockCommand1]);
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({
user: {
verify: mockCommand1,
},
});
});
});
it("should build the command tree correctly when there is only one command", () => {
cliBuilder = new CliBuilder([mockCommand1]);
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({
user: {
verify: mockCommand1,
},
});
});
});
it("should show help menu", () => {
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
// Do nothing
});
it("should show help menu", () => {
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
// Do nothing
});
const cliBuilder = new CliBuilder();
const cliBuilder = new CliBuilder();
const cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "name",
type: CliParameterType.STRING,
needsValue: true,
description: "Name of new item",
},
{
name: "delete-previous",
type: CliParameterType.NUMBER,
needsValue: false,
positioned: false,
optional: true,
description: "Also delete the previous item",
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
],
() => {
// Do nothing
},
"I love sussy sauces",
"emoji add --url https://site.com/image.png"
);
const cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "name",
type: CliParameterType.STRING,
needsValue: true,
description: "Name of new item",
},
{
name: "delete-previous",
type: CliParameterType.NUMBER,
needsValue: false,
positioned: false,
optional: true,
description: "Also delete the previous item",
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
],
() => {
// Do nothing
},
"I love sussy sauces",
"emoji add --url https://site.com/image.png",
);
cliBuilder.registerCommand(cliCommand);
cliBuilder.displayHelp();
cliBuilder.registerCommand(cliCommand);
cliBuilder.displayHelp();
const loggedString = consoleLogSpy.mock.calls
.map(call => stripAnsi(call[0]))
.join("\n");
const loggedString = consoleLogSpy.mock.calls
.map((call) => stripAnsi(call[0]))
.join("\n");
consoleLogSpy.mockRestore();
consoleLogSpy.mockRestore();
expect(loggedString).toContain("category1");
expect(loggedString).toContain(
" category2.................I love sussy sauces"
);
expect(loggedString).toContain(
" name..................Name of new item"
);
expect(loggedString).toContain(
" arg3..................(no description)"
);
expect(loggedString).toContain(
" arg4..................(no description)"
);
expect(loggedString).toContain(
" --delete-previous.....Also delete the previous item (optional)"
);
expect(loggedString).toContain(
" Example: emoji add --url https://site.com/image.png"
);
});
expect(loggedString).toContain("category1");
expect(loggedString).toContain(
" category2.................I love sussy sauces",
);
expect(loggedString).toContain(
" name..................Name of new item",
);
expect(loggedString).toContain(
" arg3..................(no description)",
);
expect(loggedString).toContain(
" arg4..................(no description)",
);
expect(loggedString).toContain(
" --delete-previous.....Also delete the previous item (optional)",
);
expect(loggedString).toContain(
" Example: emoji add --url https://site.com/image.png",
);
});
});

File diff suppressed because it is too large Load diff

View file

@ -6,18 +6,18 @@
*/
import { watchConfig } from "c12";
import { defaultConfig, type Config } from "./config.type";
import { type Config, defaultConfig } from "./config.type";
const { config } = await watchConfig<Config>({
configFile: "./config/config.toml",
defaultConfig: defaultConfig,
overrides:
(
await watchConfig<Config>({
configFile: "./config/config.internal.toml",
defaultConfig: {} as Config,
})
).config ?? undefined,
configFile: "./config/config.toml",
defaultConfig: defaultConfig,
overrides:
(
await watchConfig<Config>({
configFile: "./config/config.internal.toml",
defaultConfig: {} as Config,
})
).config ?? undefined,
});
const exportedConfig = config ?? defaultConfig;

View file

@ -1,6 +1,6 @@
{
"name": "config-manager",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { "@iarna/toml": "^2.2.5", "merge-deep-ts": "^1.2.6" }
}
"name": "config-manager",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { "@iarna/toml": "^2.2.5", "merge-deep-ts": "^1.2.6" }
}

View file

@ -1,12 +1,12 @@
import { appendFile } from "node:fs/promises";
import type { BunFile } from "bun";
import { appendFile } from "fs/promises";
export enum LogLevel {
DEBUG = "debug",
INFO = "info",
WARNING = "warning",
ERROR = "error",
CRITICAL = "critical",
DEBUG = "debug",
INFO = "info",
WARNING = "warning",
ERROR = "error",
CRITICAL = "critical",
}
/**
@ -14,161 +14,165 @@ export enum LogLevel {
* @param output BunFile of output (can be a normal file or something like Bun.stdout)
*/
export class LogManager {
constructor(private output: BunFile) {
void this.write(
`--- INIT LogManager at ${new Date().toISOString()} ---`
);
}
constructor(private output: BunFile) {
void this.write(
`--- INIT LogManager at ${new Date().toISOString()} ---`,
);
}
/**
* Logs a message to the output
* @param level Importance of the log
* @param entity Emitter of the log
* @param message Message to log
* @param showTimestamp Whether to show the timestamp in the log
*/
async log(
level: LogLevel,
entity: string,
message: string,
showTimestamp = true
) {
await this.write(
`${showTimestamp ? new Date().toISOString() + " " : ""}[${level.toUpperCase()}] ${entity}: ${message}`
);
}
/**
* Logs a message to the output
* @param level Importance of the log
* @param entity Emitter of the log
* @param message Message to log
* @param showTimestamp Whether to show the timestamp in the log
*/
async log(
level: LogLevel,
entity: string,
message: string,
showTimestamp = true,
) {
await this.write(
`${
showTimestamp ? `${new Date().toISOString()} ` : ""
}[${level.toUpperCase()}] ${entity}: ${message}`,
);
}
private async write(text: string) {
if (this.output == Bun.stdout) {
await Bun.write(Bun.stdout, text + "\n");
} else {
if (!(await this.output.exists())) {
// Create file if it doesn't exist
await Bun.write(this.output, "", {
createPath: true,
});
}
await appendFile(this.output.name ?? "", text + "\n");
}
}
private async write(text: string) {
if (this.output === Bun.stdout) {
await Bun.write(Bun.stdout, `${text}\n`);
} else {
if (!(await this.output.exists())) {
// Create file if it doesn't exist
await Bun.write(this.output, "", {
createPath: true,
});
}
await appendFile(this.output.name ?? "", `${text}\n`);
}
}
/**
* Logs an error to the output, wrapper for log
* @param level Importance of the log
* @param entity Emitter of the log
* @param error Error to log
*/
async logError(level: LogLevel, entity: string, error: Error) {
await this.log(level, entity, error.message);
}
/**
* Logs an error to the output, wrapper for log
* @param level Importance of the log
* @param entity Emitter of the log
* @param error Error to log
*/
async logError(level: LogLevel, entity: string, error: Error) {
await this.log(level, entity, error.message);
}
/**
* Logs a request to the output
* @param req Request to log
* @param ip IP of the request
* @param logAllDetails Whether to log all details of the request
*/
async logRequest(req: Request, ip?: string, logAllDetails = false) {
let string = ip ? `${ip}: ` : "";
/**
* Logs a request to the output
* @param req Request to log
* @param ip IP of the request
* @param logAllDetails Whether to log all details of the request
*/
async logRequest(req: Request, ip?: string, logAllDetails = false) {
let string = ip ? `${ip}: ` : "";
string += `${req.method} ${req.url}`;
string += `${req.method} ${req.url}`;
if (logAllDetails) {
string += `\n`;
string += ` [Headers]\n`;
// Pretty print headers
for (const [key, value] of req.headers.entries()) {
string += ` ${key}: ${value}\n`;
}
if (logAllDetails) {
string += "\n";
string += " [Headers]\n";
// Pretty print headers
for (const [key, value] of req.headers.entries()) {
string += ` ${key}: ${value}\n`;
}
// Pretty print body
string += ` [Body]\n`;
const content_type = req.headers.get("Content-Type");
// Pretty print body
string += " [Body]\n";
const content_type = req.headers.get("Content-Type");
if (content_type && content_type.includes("application/json")) {
const json = await req.json();
const stringified = JSON.stringify(json, null, 4)
.split("\n")
.map(line => ` ${line}`)
.join("\n");
if (content_type?.includes("application/json")) {
const json = await req.json();
const stringified = JSON.stringify(json, null, 4)
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
string += `${stringified}\n`;
} else if (
content_type &&
(content_type.includes("application/x-www-form-urlencoded") ||
content_type.includes("multipart/form-data"))
) {
const formData = await req.formData();
for (const [key, value] of formData.entries()) {
if (value.toString().length < 300) {
string += ` ${key}: ${value.toString()}\n`;
} else {
string += ` ${key}: <${value.toString().length} bytes>\n`;
}
}
} else {
const text = await req.text();
string += ` ${text}\n`;
}
}
await this.log(LogLevel.INFO, "Request", string);
}
string += `${stringified}\n`;
} else if (
content_type &&
(content_type.includes("application/x-www-form-urlencoded") ||
content_type.includes("multipart/form-data"))
) {
const formData = await req.formData();
for (const [key, value] of formData.entries()) {
if (value.toString().length < 300) {
string += ` ${key}: ${value.toString()}\n`;
} else {
string += ` ${key}: <${
value.toString().length
} bytes>\n`;
}
}
} else {
const text = await req.text();
string += ` ${text}\n`;
}
}
await this.log(LogLevel.INFO, "Request", string);
}
}
/**
* Outputs to multiple LogManager instances at once
*/
export class MultiLogManager {
constructor(private logManagers: LogManager[]) {}
constructor(private logManagers: LogManager[]) {}
/**
* Logs a message to all logManagers
* @param level Importance of the log
* @param entity Emitter of the log
* @param message Message to log
* @param showTimestamp Whether to show the timestamp in the log
*/
async log(
level: LogLevel,
entity: string,
message: string,
showTimestamp = true
) {
for (const logManager of this.logManagers) {
await logManager.log(level, entity, message, showTimestamp);
}
}
/**
* Logs a message to all logManagers
* @param level Importance of the log
* @param entity Emitter of the log
* @param message Message to log
* @param showTimestamp Whether to show the timestamp in the log
*/
async log(
level: LogLevel,
entity: string,
message: string,
showTimestamp = true,
) {
for (const logManager of this.logManagers) {
await logManager.log(level, entity, message, showTimestamp);
}
}
/**
* Logs an error to all logManagers
* @param level Importance of the log
* @param entity Emitter of the log
* @param error Error to log
*/
async logError(level: LogLevel, entity: string, error: Error) {
for (const logManager of this.logManagers) {
await logManager.logError(level, entity, error);
}
}
/**
* Logs an error to all logManagers
* @param level Importance of the log
* @param entity Emitter of the log
* @param error Error to log
*/
async logError(level: LogLevel, entity: string, error: Error) {
for (const logManager of this.logManagers) {
await logManager.logError(level, entity, error);
}
}
/**
* Logs a request to all logManagers
* @param req Request to log
* @param ip IP of the request
* @param logAllDetails Whether to log all details of the request
*/
async logRequest(req: Request, ip?: string, logAllDetails = false) {
for (const logManager of this.logManagers) {
await logManager.logRequest(req, ip, logAllDetails);
}
}
/**
* Logs a request to all logManagers
* @param req Request to log
* @param ip IP of the request
* @param logAllDetails Whether to log all details of the request
*/
async logRequest(req: Request, ip?: string, logAllDetails = false) {
for (const logManager of this.logManagers) {
await logManager.logRequest(req, ip, logAllDetails);
}
}
/**
* Create a MultiLogManager from multiple LogManager instances
* @param logManagers LogManager instances to use
* @returns
*/
static fromLogManagers(...logManagers: LogManager[]) {
return new MultiLogManager(logManagers);
}
/**
* Create a MultiLogManager from multiple LogManager instances
* @param logManagers LogManager instances to use
* @returns
*/
static fromLogManagers(...logManagers: LogManager[]) {
return new MultiLogManager(logManagers);
}
}

View file

@ -2,5 +2,5 @@
"name": "log-manager",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { }
}
"dependencies": {}
}

View file

@ -1,117 +1,117 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/log-manager/log-manager.test.ts
import { LogManager, LogLevel, MultiLogManager } from "../index";
import type fs from "fs/promises";
import {
describe,
it,
beforeEach,
expect,
jest,
mock,
type Mock,
test,
type Mock,
beforeEach,
describe,
expect,
it,
jest,
mock,
test,
} from "bun:test";
import type fs from "node:fs/promises";
import type { BunFile } from "bun";
// FILEPATH: /home/jessew/Dev/lysand/packages/log-manager/log-manager.test.ts
import { LogLevel, LogManager, MultiLogManager } from "../index";
describe("LogManager", () => {
let logManager: LogManager;
let mockOutput: BunFile;
let mockAppend: Mock<typeof fs.appendFile>;
let logManager: LogManager;
let mockOutput: BunFile;
let mockAppend: Mock<typeof fs.appendFile>;
beforeEach(async () => {
mockOutput = Bun.file("test.log");
mockAppend = jest.fn();
await mock.module("fs/promises", () => ({
appendFile: mockAppend,
}));
logManager = new LogManager(mockOutput);
});
beforeEach(async () => {
mockOutput = Bun.file("test.log");
mockAppend = jest.fn();
await mock.module("fs/promises", () => ({
appendFile: mockAppend,
}));
logManager = new LogManager(mockOutput);
});
it("should initialize and write init log", () => {
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining("--- INIT LogManager at")
);
});
it("should initialize and write init log", () => {
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining("--- INIT LogManager at"),
);
});
it("should log message with timestamp", async () => {
await logManager.log(LogLevel.INFO, "TestEntity", "Test message");
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining("[INFO] TestEntity: Test message")
);
});
it("should log message with timestamp", async () => {
await logManager.log(LogLevel.INFO, "TestEntity", "Test message");
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining("[INFO] TestEntity: Test message"),
);
});
it("should log message without timestamp", async () => {
await logManager.log(
LogLevel.INFO,
"TestEntity",
"Test message",
false
);
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
"[INFO] TestEntity: Test message\n"
);
});
it("should log message without timestamp", async () => {
await logManager.log(
LogLevel.INFO,
"TestEntity",
"Test message",
false,
);
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
"[INFO] TestEntity: Test message\n",
);
});
test.skip("should write to stdout", async () => {
logManager = new LogManager(Bun.stdout);
await logManager.log(LogLevel.INFO, "TestEntity", "Test message");
test.skip("should write to stdout", async () => {
logManager = new LogManager(Bun.stdout);
await logManager.log(LogLevel.INFO, "TestEntity", "Test message");
const writeMock = jest.fn();
const writeMock = jest.fn();
await mock.module("Bun", () => ({
stdout: Bun.stdout,
write: writeMock,
}));
await mock.module("Bun", () => ({
stdout: Bun.stdout,
write: writeMock,
}));
expect(writeMock).toHaveBeenCalledWith(
Bun.stdout,
expect.stringContaining("[INFO] TestEntity: Test message")
);
});
expect(writeMock).toHaveBeenCalledWith(
Bun.stdout,
expect.stringContaining("[INFO] TestEntity: Test message"),
);
});
it("should throw error if output file does not exist", () => {
mockAppend.mockImplementationOnce(() => {
return Promise.reject(
new Error("Output file doesnt exist (and isnt stdout)")
);
});
expect(
logManager.log(LogLevel.INFO, "TestEntity", "Test message")
).rejects.toThrow(Error);
});
it("should throw error if output file does not exist", () => {
mockAppend.mockImplementationOnce(() => {
return Promise.reject(
new Error("Output file doesnt exist (and isnt stdout)"),
);
});
expect(
logManager.log(LogLevel.INFO, "TestEntity", "Test message"),
).rejects.toThrow(Error);
});
it("should log error message", async () => {
const error = new Error("Test error");
await logManager.logError(LogLevel.ERROR, "TestEntity", error);
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining("[ERROR] TestEntity: Test error")
);
});
it("should log error message", async () => {
const error = new Error("Test error");
await logManager.logError(LogLevel.ERROR, "TestEntity", error);
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining("[ERROR] TestEntity: Test error"),
);
});
it("should log basic request details", async () => {
const req = new Request("http://localhost/test", { method: "GET" });
await logManager.logRequest(req, "127.0.0.1");
it("should log basic request details", async () => {
const req = new Request("http://localhost/test", { method: "GET" });
await logManager.logRequest(req, "127.0.0.1");
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining("127.0.0.1: GET http://localhost/test")
);
});
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining("127.0.0.1: GET http://localhost/test"),
);
});
describe("Request logger", () => {
it("should log all request details for JSON content type", async () => {
const req = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ test: "value" }),
});
await logManager.logRequest(req, "127.0.0.1", true);
describe("Request logger", () => {
it("should log all request details for JSON content type", async () => {
const req = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ test: "value" }),
});
await logManager.logRequest(req, "127.0.0.1", true);
const expectedLog = `127.0.0.1: POST http://localhost/test
const expectedLog = `127.0.0.1: POST http://localhost/test
[Headers]
content-type: application/json
[Body]
@ -120,112 +120,112 @@ describe("LogManager", () => {
}
`;
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining(expectedLog)
);
});
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining(expectedLog),
);
});
it("should log all request details for text content type", async () => {
const req = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: "Test body",
});
await logManager.logRequest(req, "127.0.0.1", true);
it("should log all request details for text content type", async () => {
const req = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: "Test body",
});
await logManager.logRequest(req, "127.0.0.1", true);
const expectedLog = `127.0.0.1: POST http://localhost/test
const expectedLog = `127.0.0.1: POST http://localhost/test
[Headers]
content-type: text/plain
[Body]
Test body
`;
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining(expectedLog)
);
});
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining(expectedLog),
);
});
it("should log all request details for FormData content-type", async () => {
const formData = new FormData();
formData.append("test", "value");
const req = new Request("http://localhost/test", {
method: "POST",
body: formData,
});
await logManager.logRequest(req, "127.0.0.1", true);
it("should log all request details for FormData content-type", async () => {
const formData = new FormData();
formData.append("test", "value");
const req = new Request("http://localhost/test", {
method: "POST",
body: formData,
});
await logManager.logRequest(req, "127.0.0.1", true);
const expectedLog = `127.0.0.1: POST http://localhost/test
const expectedLog = `127.0.0.1: POST http://localhost/test
[Headers]
content-type: multipart/form-data; boundary=${
req.headers.get("Content-Type")?.split("boundary=")[1] ?? ""
}
req.headers.get("Content-Type")?.split("boundary=")[1] ?? ""
}
[Body]
test: value
`;
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining(
expectedLog.replace("----", expect.any(String))
)
);
});
});
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining(
expectedLog.replace("----", expect.any(String)),
),
);
});
});
});
describe("MultiLogManager", () => {
let multiLogManager: MultiLogManager;
let mockLogManagers: LogManager[];
let mockLog: jest.Mock;
let mockLogError: jest.Mock;
let mockLogRequest: jest.Mock;
let multiLogManager: MultiLogManager;
let mockLogManagers: LogManager[];
let mockLog: jest.Mock;
let mockLogError: jest.Mock;
let mockLogRequest: jest.Mock;
beforeEach(() => {
mockLog = jest.fn();
mockLogError = jest.fn();
mockLogRequest = jest.fn();
mockLogManagers = [
{
log: mockLog,
logError: mockLogError,
logRequest: mockLogRequest,
},
{
log: mockLog,
logError: mockLogError,
logRequest: mockLogRequest,
},
] as unknown as LogManager[];
multiLogManager = MultiLogManager.fromLogManagers(...mockLogManagers);
});
beforeEach(() => {
mockLog = jest.fn();
mockLogError = jest.fn();
mockLogRequest = jest.fn();
mockLogManagers = [
{
log: mockLog,
logError: mockLogError,
logRequest: mockLogRequest,
},
{
log: mockLog,
logError: mockLogError,
logRequest: mockLogRequest,
},
] as unknown as LogManager[];
multiLogManager = MultiLogManager.fromLogManagers(...mockLogManagers);
});
it("should log message to all logManagers", async () => {
await multiLogManager.log(LogLevel.INFO, "TestEntity", "Test message");
expect(mockLog).toHaveBeenCalledTimes(2);
expect(mockLog).toHaveBeenCalledWith(
LogLevel.INFO,
"TestEntity",
"Test message",
true
);
});
it("should log message to all logManagers", async () => {
await multiLogManager.log(LogLevel.INFO, "TestEntity", "Test message");
expect(mockLog).toHaveBeenCalledTimes(2);
expect(mockLog).toHaveBeenCalledWith(
LogLevel.INFO,
"TestEntity",
"Test message",
true,
);
});
it("should log error to all logManagers", async () => {
const error = new Error("Test error");
await multiLogManager.logError(LogLevel.ERROR, "TestEntity", error);
expect(mockLogError).toHaveBeenCalledTimes(2);
expect(mockLogError).toHaveBeenCalledWith(
LogLevel.ERROR,
"TestEntity",
error
);
});
it("should log error to all logManagers", async () => {
const error = new Error("Test error");
await multiLogManager.logError(LogLevel.ERROR, "TestEntity", error);
expect(mockLogError).toHaveBeenCalledTimes(2);
expect(mockLogError).toHaveBeenCalledWith(
LogLevel.ERROR,
"TestEntity",
error,
);
});
it("should log request to all logManagers", async () => {
const req = new Request("http://localhost/test", { method: "GET" });
await multiLogManager.logRequest(req, "127.0.0.1", true);
expect(mockLogRequest).toHaveBeenCalledTimes(2);
expect(mockLogRequest).toHaveBeenCalledWith(req, "127.0.0.1", true);
});
it("should log request to all logManagers", async () => {
const req = new Request("http://localhost/test", { method: "GET" });
await multiLogManager.logRequest(req, "127.0.0.1", true);
expect(mockLogRequest).toHaveBeenCalledTimes(2);
expect(mockLogRequest).toHaveBeenCalledWith(req, "127.0.0.1", true);
});
});

View file

@ -1,64 +1,65 @@
import type { Config } from "config-manager";
import { MediaBackend, MediaBackendType, MediaHasher } from "..";
import type { ConvertableMediaFormats } from "../media-converter";
import { MediaConverter } from "../media-converter";
import { MediaBackend, MediaBackendType, MediaHasher } from "..";
import type { ConfigType } from "config-manager";
export class LocalMediaBackend extends MediaBackend {
constructor(config: ConfigType) {
super(config, MediaBackendType.LOCAL);
}
constructor(config: Config) {
super(config, MediaBackendType.LOCAL);
}
public async addFile(file: File) {
if (this.shouldConvertImages(this.config)) {
const fileExtension = file.name.split(".").pop();
const mediaConverter = new MediaConverter(
fileExtension as ConvertableMediaFormats,
this.config.media.conversion
.convert_to as ConvertableMediaFormats
);
file = await mediaConverter.convert(file);
}
public async addFile(file: File) {
let convertedFile = file;
if (this.shouldConvertImages(this.config)) {
const fileExtension = file.name.split(".").pop();
const mediaConverter = new MediaConverter(
fileExtension as ConvertableMediaFormats,
this.config.media.conversion
.convert_to as ConvertableMediaFormats,
);
convertedFile = await mediaConverter.convert(file);
}
const hash = await new MediaHasher().getMediaHash(file);
const hash = await new MediaHasher().getMediaHash(convertedFile);
const newFile = Bun.file(
`${this.config.media.local_uploads_folder}/${hash}`
);
const newFile = Bun.file(
`${this.config.media.local_uploads_folder}/${hash}`,
);
if (await newFile.exists()) {
throw new Error("File already exists");
}
if (await newFile.exists()) {
throw new Error("File already exists");
}
await Bun.write(newFile, file);
await Bun.write(newFile, convertedFile);
return {
uploadedFile: file,
path: `./uploads/${file.name}`,
hash: hash,
};
}
return {
uploadedFile: convertedFile,
path: `./uploads/${convertedFile.name}`,
hash: hash,
};
}
public async getFileByHash(
hash: string,
databaseHashFetcher: (sha256: string) => Promise<string | null>
): Promise<File | null> {
const filename = await databaseHashFetcher(hash);
public async getFileByHash(
hash: string,
databaseHashFetcher: (sha256: string) => Promise<string | null>,
): Promise<File | null> {
const filename = await databaseHashFetcher(hash);
if (!filename) return null;
if (!filename) return null;
return this.getFile(filename);
}
return this.getFile(filename);
}
public async getFile(filename: string): Promise<File | null> {
const file = Bun.file(
`${this.config.media.local_uploads_folder}/${filename}`
);
public async getFile(filename: string): Promise<File | null> {
const file = Bun.file(
`${this.config.media.local_uploads_folder}/${filename}`,
);
if (!(await file.exists())) return null;
if (!(await file.exists())) return null;
return new File([await file.arrayBuffer()], filename, {
type: file.type,
lastModified: file.lastModified,
});
}
return new File([await file.arrayBuffer()], filename, {
type: file.type,
lastModified: file.lastModified,
});
}
}

View file

@ -1,69 +1,74 @@
import { S3Client } from "@jsr/bradenmacdonald__s3-lite-client";
import type { Config } from "config-manager";
import { MediaBackend, MediaBackendType, MediaHasher } from "..";
import type { ConvertableMediaFormats } from "../media-converter";
import { MediaConverter } from "../media-converter";
import { MediaBackend, MediaBackendType, MediaHasher } from "..";
import type { ConfigType } from "config-manager";
export class S3MediaBackend extends MediaBackend {
constructor(
config: ConfigType,
private s3Client = new S3Client({
endPoint: config.s3.endpoint,
useSSL: true,
region: config.s3.region || "auto",
bucket: config.s3.bucket_name,
accessKey: config.s3.access_key,
secretKey: config.s3.secret_access_key,
})
) {
super(config, MediaBackendType.S3);
}
constructor(
config: Config,
private s3Client = new S3Client({
endPoint: config.s3.endpoint,
useSSL: true,
region: config.s3.region || "auto",
bucket: config.s3.bucket_name,
accessKey: config.s3.access_key,
secretKey: config.s3.secret_access_key,
}),
) {
super(config, MediaBackendType.S3);
}
public async addFile(file: File) {
if (this.shouldConvertImages(this.config)) {
const fileExtension = file.name.split(".").pop();
const mediaConverter = new MediaConverter(
fileExtension as ConvertableMediaFormats,
this.config.media.conversion
.convert_to as ConvertableMediaFormats
);
file = await mediaConverter.convert(file);
}
public async addFile(file: File) {
let convertedFile = file;
if (this.shouldConvertImages(this.config)) {
const fileExtension = file.name.split(".").pop();
const mediaConverter = new MediaConverter(
fileExtension as ConvertableMediaFormats,
this.config.media.conversion
.convert_to as ConvertableMediaFormats,
);
convertedFile = await mediaConverter.convert(file);
}
const hash = await new MediaHasher().getMediaHash(file);
const hash = await new MediaHasher().getMediaHash(convertedFile);
await this.s3Client.putObject(file.name, file.stream(), {
size: file.size,
});
await this.s3Client.putObject(
convertedFile.name,
convertedFile.stream(),
{
size: convertedFile.size,
},
);
return {
uploadedFile: file,
hash: hash,
};
}
return {
uploadedFile: convertedFile,
hash: hash,
};
}
public async getFileByHash(
hash: string,
databaseHashFetcher: (sha256: string) => Promise<string | null>
): Promise<File | null> {
const filename = await databaseHashFetcher(hash);
public async getFileByHash(
hash: string,
databaseHashFetcher: (sha256: string) => Promise<string | null>,
): Promise<File | null> {
const filename = await databaseHashFetcher(hash);
if (!filename) return null;
if (!filename) return null;
return this.getFile(filename);
}
return this.getFile(filename);
}
public async getFile(filename: string): Promise<File | null> {
try {
await this.s3Client.statObject(filename);
} catch {
return null;
}
public async getFile(filename: string): Promise<File | null> {
try {
await this.s3Client.statObject(filename);
} catch {
return null;
}
const file = await this.s3Client.getObject(filename);
const file = await this.s3Client.getObject(filename);
return new File([await file.arrayBuffer()], filename, {
type: file.headers.get("Content-Type") || "undefined",
});
}
return new File([await file.arrayBuffer()], filename, {
type: file.headers.get("Content-Type") || "undefined",
});
}
}

View file

@ -1,101 +1,101 @@
import type { ConfigType } from "config-manager";
import type { Config } from "config-manager";
export enum MediaBackendType {
LOCAL = "local",
S3 = "s3",
LOCAL = "local",
S3 = "s3",
}
interface UploadedFileMetadata {
uploadedFile: File;
path?: string;
hash: string;
uploadedFile: File;
path?: string;
hash: string;
}
export class MediaHasher {
/**
* Returns the SHA-256 hash of a file in hex format
* @param media The file to hash
* @returns The SHA-256 hash of the file in hex format
*/
public async getMediaHash(media: File) {
const hash = new Bun.SHA256()
.update(await media.arrayBuffer())
.digest("hex");
/**
* Returns the SHA-256 hash of a file in hex format
* @param media The file to hash
* @returns The SHA-256 hash of the file in hex format
*/
public async getMediaHash(media: File) {
const hash = new Bun.SHA256()
.update(await media.arrayBuffer())
.digest("hex");
return hash;
}
return hash;
}
}
export class MediaBackend {
constructor(
public config: ConfigType,
public backend: MediaBackendType
) {}
constructor(
public config: Config,
public backend: MediaBackendType,
) {}
static async fromBackendType(
backend: MediaBackendType,
config: ConfigType
): Promise<MediaBackend> {
switch (backend) {
case MediaBackendType.LOCAL:
return new (await import("./backends/local")).LocalMediaBackend(
config
);
case MediaBackendType.S3:
return new (await import("./backends/s3")).S3MediaBackend(
config
);
default:
throw new Error(`Unknown backend type: ${backend as any}`);
}
}
static async fromBackendType(
backend: MediaBackendType,
config: Config,
): Promise<MediaBackend> {
switch (backend) {
case MediaBackendType.LOCAL:
return new (await import("./backends/local")).LocalMediaBackend(
config,
);
case MediaBackendType.S3:
return new (await import("./backends/s3")).S3MediaBackend(
config,
);
default:
throw new Error(`Unknown backend type: ${backend as string}`);
}
}
public getBackendType() {
return this.backend;
}
public getBackendType() {
return this.backend;
}
public shouldConvertImages(config: ConfigType) {
return config.media.conversion.convert_images;
}
public shouldConvertImages(config: Config) {
return config.media.conversion.convert_images;
}
/**
* Fetches file from backend from SHA-256 hash
* @param file SHA-256 hash of wanted file
* @param databaseHashFetcher Function that takes in a sha256 hash as input and outputs the filename of that file in the database
* @returns The file as a File object
*/
public getFileByHash(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
file: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
databaseHashFetcher: (sha256: string) => Promise<string>
): Promise<File | null> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass")
);
}
/**
* Fetches file from backend from SHA-256 hash
* @param file SHA-256 hash of wanted file
* @param databaseHashFetcher Function that takes in a sha256 hash as input and outputs the filename of that file in the database
* @returns The file as a File object
*/
public getFileByHash(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
file: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
databaseHashFetcher: (sha256: string) => Promise<string>,
): Promise<File | null> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass"),
);
}
/**
* Fetches file from backend from filename
* @param filename File name
* @returns The file as a File object
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public getFile(filename: string): Promise<File | null> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass")
);
}
/**
* Fetches file from backend from filename
* @param filename File name
* @returns The file as a File object
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public getFile(filename: string): Promise<File | null> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass"),
);
}
/**
* Adds file to backend
* @param file File to add
* @returns Metadata about the uploaded file
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public addFile(file: File): Promise<UploadedFileMetadata> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass")
);
}
/**
* Adds file to backend
* @param file File to add
* @returns Metadata about the uploaded file
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public addFile(file: File): Promise<UploadedFileMetadata> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass"),
);
}
}

View file

@ -6,89 +6,89 @@
import sharp from "sharp";
export enum ConvertableMediaFormats {
PNG = "png",
WEBP = "webp",
JPEG = "jpeg",
JPG = "jpg",
AVIF = "avif",
JXL = "jxl",
HEIF = "heif",
PNG = "png",
WEBP = "webp",
JPEG = "jpeg",
JPG = "jpg",
AVIF = "avif",
JXL = "jxl",
HEIF = "heif",
}
/**
* Handles media conversion between formats
*/
export class MediaConverter {
constructor(
public fromFormat: ConvertableMediaFormats,
public toFormat: ConvertableMediaFormats
) {}
constructor(
public fromFormat: ConvertableMediaFormats,
public toFormat: ConvertableMediaFormats,
) {}
/**
* Returns whether the media is convertable
* @returns Whether the media is convertable
*/
public isConvertable() {
return (
this.fromFormat !== this.toFormat &&
Object.values(ConvertableMediaFormats).includes(this.fromFormat)
);
}
/**
* Returns whether the media is convertable
* @returns Whether the media is convertable
*/
public isConvertable() {
return (
this.fromFormat !== this.toFormat &&
Object.values(ConvertableMediaFormats).includes(this.fromFormat)
);
}
/**
* Returns the file name with the extension replaced
* @param fileName File name to replace
* @returns File name with extension replaced
*/
private getReplacedFileName(fileName: string) {
return this.extractFilenameFromPath(fileName).replace(
new RegExp(`\\.${this.fromFormat}$`),
`.${this.toFormat}`
);
}
/**
* Returns the file name with the extension replaced
* @param fileName File name to replace
* @returns File name with extension replaced
*/
private getReplacedFileName(fileName: string) {
return this.extractFilenameFromPath(fileName).replace(
new RegExp(`\\.${this.fromFormat}$`),
`.${this.toFormat}`,
);
}
/**
* Extracts the filename from a path
* @param path Path to extract filename from
* @returns Extracted filename
*/
private extractFilenameFromPath(path: string) {
// Don't count escaped slashes as path separators
const pathParts = path.split(/(?<!\\)\//);
return pathParts[pathParts.length - 1];
}
/**
* Extracts the filename from a path
* @param path Path to extract filename from
* @returns Extracted filename
*/
private extractFilenameFromPath(path: string) {
// Don't count escaped slashes as path separators
const pathParts = path.split(/(?<!\\)\//);
return pathParts[pathParts.length - 1];
}
/**
* Converts media to the specified format
* @param media Media to convert
* @returns Converted media
*/
public async convert(media: File) {
if (!this.isConvertable()) {
return media;
}
/**
* Converts media to the specified format
* @param media Media to convert
* @returns Converted media
*/
public async convert(media: File) {
if (!this.isConvertable()) {
return media;
}
const sharpCommand = sharp(await media.arrayBuffer());
const sharpCommand = sharp(await media.arrayBuffer());
// Calculate newFilename before changing formats to prevent errors with jpg files
const newFilename = this.getReplacedFileName(media.name);
// Calculate newFilename before changing formats to prevent errors with jpg files
const newFilename = this.getReplacedFileName(media.name);
if (this.fromFormat === ConvertableMediaFormats.JPG) {
this.fromFormat = ConvertableMediaFormats.JPEG;
}
if (this.fromFormat === ConvertableMediaFormats.JPG) {
this.fromFormat = ConvertableMediaFormats.JPEG;
}
if (this.toFormat === ConvertableMediaFormats.JPG) {
this.toFormat = ConvertableMediaFormats.JPEG;
}
if (this.toFormat === ConvertableMediaFormats.JPG) {
this.toFormat = ConvertableMediaFormats.JPEG;
}
const convertedBuffer = await sharpCommand[this.toFormat]().toBuffer();
const convertedBuffer = await sharpCommand[this.toFormat]().toBuffer();
// Convert the buffer to a BlobPart
const buffer = new Blob([convertedBuffer]);
// Convert the buffer to a BlobPart
const buffer = new Blob([convertedBuffer]);
return new File([buffer], newFilename, {
type: `image/${this.toFormat}`,
lastModified: Date.now(),
});
}
return new File([buffer], newFilename, {
type: `image/${this.toFormat}`,
lastModified: Date.now(),
});
}
}

View file

@ -1,9 +1,9 @@
{
"name": "media-manager",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {
"@jsr/bradenmacdonald__s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client",
"config-manager": "workspace:*"
}
}
"name": "media-manager",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {
"@jsr/bradenmacdonald__s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client",
"config-manager": "workspace:*"
}
}

View file

@ -1,276 +1,277 @@
import { beforeEach, describe, expect, it, jest, spyOn } from "bun:test";
import type { S3Client } from "@jsr/bradenmacdonald__s3-lite-client";
import type { Config } from "config-manager";
// FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/backends/s3.test.ts
import { MediaBackend, MediaBackendType, MediaHasher } from "..";
import type { S3Client } from "@bradenmacdonald/s3-lite-client";
import { beforeEach, describe, jest, it, expect, spyOn } from "bun:test";
import { S3MediaBackend } from "../backends/s3";
import type { ConfigType } from "config-manager";
import { ConvertableMediaFormats, MediaConverter } from "../media-converter";
import { LocalMediaBackend } from "../backends/local";
import { S3MediaBackend } from "../backends/s3";
import { ConvertableMediaFormats, MediaConverter } from "../media-converter";
type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
[P in keyof T]?: DeepPartial<T[P]>;
};
describe("MediaBackend", () => {
let mediaBackend: MediaBackend;
let mockConfig: ConfigType;
let mediaBackend: MediaBackend;
let mockConfig: Config;
beforeEach(() => {
mockConfig = {
media: {
conversion: {
convert_images: true,
},
},
} as ConfigType;
mediaBackend = new MediaBackend(mockConfig, MediaBackendType.S3);
});
beforeEach(() => {
mockConfig = {
media: {
conversion: {
convert_images: true,
},
},
} as Config;
mediaBackend = new MediaBackend(mockConfig, MediaBackendType.S3);
});
it("should initialize with correct backend type", () => {
expect(mediaBackend.getBackendType()).toEqual(MediaBackendType.S3);
});
it("should initialize with correct backend type", () => {
expect(mediaBackend.getBackendType()).toEqual(MediaBackendType.S3);
});
describe("fromBackendType", () => {
it("should return a LocalMediaBackend instance for LOCAL backend type", async () => {
const backend = await MediaBackend.fromBackendType(
MediaBackendType.LOCAL,
mockConfig
);
expect(backend).toBeInstanceOf(LocalMediaBackend);
});
describe("fromBackendType", () => {
it("should return a LocalMediaBackend instance for LOCAL backend type", async () => {
const backend = await MediaBackend.fromBackendType(
MediaBackendType.LOCAL,
mockConfig,
);
expect(backend).toBeInstanceOf(LocalMediaBackend);
});
it("should return a S3MediaBackend instance for S3 backend type", async () => {
const backend = await MediaBackend.fromBackendType(
MediaBackendType.S3,
{
s3: {
endpoint: "localhost:4566",
region: "us-east-1",
bucket_name: "test-bucket",
access_key: "test-access",
public_url: "test",
secret_access_key: "test-secret",
},
} as ConfigType
);
expect(backend).toBeInstanceOf(S3MediaBackend);
});
it("should return a S3MediaBackend instance for S3 backend type", async () => {
const backend = await MediaBackend.fromBackendType(
MediaBackendType.S3,
{
s3: {
endpoint: "localhost:4566",
region: "us-east-1",
bucket_name: "test-bucket",
access_key: "test-access",
public_url: "test",
secret_access_key: "test-secret",
},
} as Config,
);
expect(backend).toBeInstanceOf(S3MediaBackend);
});
it("should throw an error for unknown backend type", () => {
expect(
MediaBackend.fromBackendType("unknown" as any, mockConfig)
).rejects.toThrow("Unknown backend type: unknown");
});
});
it("should throw an error for unknown backend type", () => {
expect(
// @ts-expect-error This is a test
MediaBackend.fromBackendType("unknown", mockConfig),
).rejects.toThrow("Unknown backend type: unknown");
});
});
it("should check if images should be converted", () => {
expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(true);
mockConfig.media.conversion.convert_images = false;
expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(false);
});
it("should check if images should be converted", () => {
expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(true);
mockConfig.media.conversion.convert_images = false;
expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(false);
});
it("should throw error when calling getFileByHash", () => {
const mockHash = "test-hash";
const databaseHashFetcher = jest.fn().mockResolvedValue("test.jpg");
it("should throw error when calling getFileByHash", () => {
const mockHash = "test-hash";
const databaseHashFetcher = jest.fn().mockResolvedValue("test.jpg");
expect(
mediaBackend.getFileByHash(mockHash, databaseHashFetcher)
).rejects.toThrow(Error);
});
expect(
mediaBackend.getFileByHash(mockHash, databaseHashFetcher),
).rejects.toThrow(Error);
});
it("should throw error when calling getFile", () => {
const mockFilename = "test.jpg";
it("should throw error when calling getFile", () => {
const mockFilename = "test.jpg";
expect(mediaBackend.getFile(mockFilename)).rejects.toThrow(Error);
});
expect(mediaBackend.getFile(mockFilename)).rejects.toThrow(Error);
});
it("should throw error when calling addFile", () => {
const mockFile = new File([""], "test.jpg");
it("should throw error when calling addFile", () => {
const mockFile = new File([""], "test.jpg");
expect(mediaBackend.addFile(mockFile)).rejects.toThrow();
});
expect(mediaBackend.addFile(mockFile)).rejects.toThrow();
});
});
describe("S3MediaBackend", () => {
let s3MediaBackend: S3MediaBackend;
let mockS3Client: Partial<S3Client>;
let mockConfig: DeepPartial<ConfigType>;
let mockFile: File;
let mockMediaHasher: MediaHasher;
let s3MediaBackend: S3MediaBackend;
let mockS3Client: Partial<S3Client>;
let mockConfig: DeepPartial<Config>;
let mockFile: File;
let mockMediaHasher: MediaHasher;
beforeEach(() => {
mockConfig = {
s3: {
endpoint: "http://localhost:4566",
region: "us-east-1",
bucket_name: "test-bucket",
access_key: "test-access-key",
secret_access_key: "test-secret-access-key",
public_url: "test",
},
media: {
conversion: {
convert_to: ConvertableMediaFormats.PNG,
},
},
};
mockFile = new File([new TextEncoder().encode("test")], "test.jpg");
mockMediaHasher = new MediaHasher();
mockS3Client = {
putObject: jest.fn().mockResolvedValue({}),
statObject: jest.fn().mockResolvedValue({}),
getObject: jest.fn().mockResolvedValue({
blob: jest.fn().mockResolvedValue(new Blob()),
headers: new Headers({ "Content-Type": "image/jpeg" }),
}),
} as Partial<S3Client>;
s3MediaBackend = new S3MediaBackend(
mockConfig as ConfigType,
mockS3Client as S3Client
);
});
beforeEach(() => {
mockConfig = {
s3: {
endpoint: "http://localhost:4566",
region: "us-east-1",
bucket_name: "test-bucket",
access_key: "test-access-key",
secret_access_key: "test-secret-access-key",
public_url: "test",
},
media: {
conversion: {
convert_to: ConvertableMediaFormats.PNG,
},
},
};
mockFile = new File([new TextEncoder().encode("test")], "test.jpg");
mockMediaHasher = new MediaHasher();
mockS3Client = {
putObject: jest.fn().mockResolvedValue({}),
statObject: jest.fn().mockResolvedValue({}),
getObject: jest.fn().mockResolvedValue({
blob: jest.fn().mockResolvedValue(new Blob()),
headers: new Headers({ "Content-Type": "image/jpeg" }),
}),
} as Partial<S3Client>;
s3MediaBackend = new S3MediaBackend(
mockConfig as Config,
mockS3Client as S3Client,
);
});
it("should initialize with correct type", () => {
expect(s3MediaBackend.getBackendType()).toEqual(MediaBackendType.S3);
});
it("should initialize with correct type", () => {
expect(s3MediaBackend.getBackendType()).toEqual(MediaBackendType.S3);
});
it("should add file", async () => {
const mockHash = "test-hash";
spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash);
it("should add file", async () => {
const mockHash = "test-hash";
spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash);
const result = await s3MediaBackend.addFile(mockFile);
const result = await s3MediaBackend.addFile(mockFile);
expect(result.uploadedFile).toEqual(mockFile);
expect(result.hash).toHaveLength(64);
expect(mockS3Client.putObject).toHaveBeenCalledWith(
mockFile.name,
expect.any(ReadableStream),
{ size: mockFile.size }
);
});
expect(result.uploadedFile).toEqual(mockFile);
expect(result.hash).toHaveLength(64);
expect(mockS3Client.putObject).toHaveBeenCalledWith(
mockFile.name,
expect.any(ReadableStream),
{ size: mockFile.size },
);
});
it("should get file by hash", async () => {
const mockHash = "test-hash";
const mockFilename = "test.jpg";
const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename);
mockS3Client.statObject = jest.fn().mockResolvedValue({});
mockS3Client.getObject = jest.fn().mockResolvedValue({
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)),
headers: new Headers({ "Content-Type": "image/jpeg" }),
});
it("should get file by hash", async () => {
const mockHash = "test-hash";
const mockFilename = "test.jpg";
const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename);
mockS3Client.statObject = jest.fn().mockResolvedValue({});
mockS3Client.getObject = jest.fn().mockResolvedValue({
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)),
headers: new Headers({ "Content-Type": "image/jpeg" }),
});
const file = await s3MediaBackend.getFileByHash(
mockHash,
databaseHashFetcher
);
const file = await s3MediaBackend.getFileByHash(
mockHash,
databaseHashFetcher,
);
expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg");
});
expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg");
});
it("should get file", async () => {
const mockFilename = "test.jpg";
mockS3Client.statObject = jest.fn().mockResolvedValue({});
mockS3Client.getObject = jest.fn().mockResolvedValue({
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)),
headers: new Headers({ "Content-Type": "image/jpeg" }),
});
it("should get file", async () => {
const mockFilename = "test.jpg";
mockS3Client.statObject = jest.fn().mockResolvedValue({});
mockS3Client.getObject = jest.fn().mockResolvedValue({
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)),
headers: new Headers({ "Content-Type": "image/jpeg" }),
});
const file = await s3MediaBackend.getFile(mockFilename);
const file = await s3MediaBackend.getFile(mockFilename);
expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg");
});
expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg");
});
});
describe("LocalMediaBackend", () => {
let localMediaBackend: LocalMediaBackend;
let mockConfig: ConfigType;
let mockFile: File;
let mockMediaHasher: MediaHasher;
let localMediaBackend: LocalMediaBackend;
let mockConfig: Config;
let mockFile: File;
let mockMediaHasher: MediaHasher;
beforeEach(() => {
mockConfig = {
media: {
conversion: {
convert_images: true,
convert_to: ConvertableMediaFormats.PNG,
},
local_uploads_folder: "./uploads",
},
} as ConfigType;
mockFile = Bun.file(__dirname + "/megamind.jpg") as unknown as File;
mockMediaHasher = new MediaHasher();
localMediaBackend = new LocalMediaBackend(mockConfig);
});
beforeEach(() => {
mockConfig = {
media: {
conversion: {
convert_images: true,
convert_to: ConvertableMediaFormats.PNG,
},
local_uploads_folder: "./uploads",
},
} as Config;
mockFile = Bun.file(`${__dirname}/megamind.jpg`) as unknown as File;
mockMediaHasher = new MediaHasher();
localMediaBackend = new LocalMediaBackend(mockConfig);
});
it("should initialize with correct type", () => {
expect(localMediaBackend.getBackendType()).toEqual(
MediaBackendType.LOCAL
);
});
it("should initialize with correct type", () => {
expect(localMediaBackend.getBackendType()).toEqual(
MediaBackendType.LOCAL,
);
});
it("should add file", async () => {
const mockHash = "test-hash";
spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash);
const mockMediaConverter = new MediaConverter(
ConvertableMediaFormats.JPG,
ConvertableMediaFormats.PNG
);
spyOn(mockMediaConverter, "convert").mockResolvedValue(mockFile);
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () => Promise.resolve(false),
}));
spyOn(Bun, "write").mockImplementationOnce(() =>
Promise.resolve(mockFile.size)
);
it("should add file", async () => {
const mockHash = "test-hash";
spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash);
const mockMediaConverter = new MediaConverter(
ConvertableMediaFormats.JPG,
ConvertableMediaFormats.PNG,
);
spyOn(mockMediaConverter, "convert").mockResolvedValue(mockFile);
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () => Promise.resolve(false),
}));
spyOn(Bun, "write").mockImplementationOnce(() =>
Promise.resolve(mockFile.size),
);
const result = await localMediaBackend.addFile(mockFile);
const result = await localMediaBackend.addFile(mockFile);
expect(result.uploadedFile).toEqual(mockFile);
expect(result.path).toEqual(`./uploads/megamind.png`);
expect(result.hash).toHaveLength(64);
});
expect(result.uploadedFile).toEqual(mockFile);
expect(result.path).toEqual("./uploads/megamind.png");
expect(result.hash).toHaveLength(64);
});
it("should get file by hash", async () => {
const mockHash = "test-hash";
const mockFilename = "test.jpg";
const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename);
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () => Promise.resolve(true),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
type: "image/jpeg",
lastModified: 123456789,
}));
it("should get file by hash", async () => {
const mockHash = "test-hash";
const mockFilename = "test.jpg";
const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename);
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () => Promise.resolve(true),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
type: "image/jpeg",
lastModified: 123456789,
}));
const file = await localMediaBackend.getFileByHash(
mockHash,
databaseHashFetcher
);
const file = await localMediaBackend.getFileByHash(
mockHash,
databaseHashFetcher,
);
expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg");
});
expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg");
});
it("should get file", async () => {
const mockFilename = "test.jpg";
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () => Promise.resolve(true),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
type: "image/jpeg",
lastModified: 123456789,
}));
it("should get file", async () => {
const mockFilename = "test.jpg";
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () => Promise.resolve(true),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
type: "image/jpeg",
lastModified: 123456789,
}));
const file = await localMediaBackend.getFile(mockFilename);
const file = await localMediaBackend.getFile(mockFilename);
expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg");
});
expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg");
});
});

View file

@ -1,65 +1,65 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/media-converter.test.ts
import { describe, it, expect, beforeEach } from "bun:test";
import { MediaConverter, ConvertableMediaFormats } from "../media-converter";
import { beforeEach, describe, expect, it } from "bun:test";
import { ConvertableMediaFormats, MediaConverter } from "../media-converter";
describe("MediaConverter", () => {
let mediaConverter: MediaConverter;
let mediaConverter: MediaConverter;
beforeEach(() => {
mediaConverter = new MediaConverter(
ConvertableMediaFormats.JPG,
ConvertableMediaFormats.PNG
);
});
beforeEach(() => {
mediaConverter = new MediaConverter(
ConvertableMediaFormats.JPG,
ConvertableMediaFormats.PNG,
);
});
it("should initialize with correct formats", () => {
expect(mediaConverter.fromFormat).toEqual(ConvertableMediaFormats.JPG);
expect(mediaConverter.toFormat).toEqual(ConvertableMediaFormats.PNG);
});
it("should initialize with correct formats", () => {
expect(mediaConverter.fromFormat).toEqual(ConvertableMediaFormats.JPG);
expect(mediaConverter.toFormat).toEqual(ConvertableMediaFormats.PNG);
});
it("should check if media is convertable", () => {
expect(mediaConverter.isConvertable()).toBe(true);
mediaConverter.toFormat = ConvertableMediaFormats.JPG;
expect(mediaConverter.isConvertable()).toBe(false);
});
it("should check if media is convertable", () => {
expect(mediaConverter.isConvertable()).toBe(true);
mediaConverter.toFormat = ConvertableMediaFormats.JPG;
expect(mediaConverter.isConvertable()).toBe(false);
});
it("should replace file name extension", () => {
const fileName = "test.jpg";
const expectedFileName = "test.png";
// Written like this because it's a private function
expect(mediaConverter["getReplacedFileName"](fileName)).toEqual(
expectedFileName
);
});
it("should replace file name extension", () => {
const fileName = "test.jpg";
const expectedFileName = "test.png";
// Written like this because it's a private function
expect(mediaConverter.getReplacedFileName(fileName)).toEqual(
expectedFileName,
);
});
describe("Filename extractor", () => {
it("should extract filename from path", () => {
const path = "path/to/test.jpg";
const expectedFileName = "test.jpg";
expect(mediaConverter["extractFilenameFromPath"](path)).toEqual(
expectedFileName
);
});
describe("Filename extractor", () => {
it("should extract filename from path", () => {
const path = "path/to/test.jpg";
const expectedFileName = "test.jpg";
expect(mediaConverter.extractFilenameFromPath(path)).toEqual(
expectedFileName,
);
});
it("should handle escaped slashes", () => {
const path = "path/to/test\\/test.jpg";
const expectedFileName = "test\\/test.jpg";
expect(mediaConverter["extractFilenameFromPath"](path)).toEqual(
expectedFileName
);
});
});
it("should handle escaped slashes", () => {
const path = "path/to/test\\/test.jpg";
const expectedFileName = "test\\/test.jpg";
expect(mediaConverter.extractFilenameFromPath(path)).toEqual(
expectedFileName,
);
});
});
it("should convert media", async () => {
const file = Bun.file(__dirname + "/megamind.jpg");
it("should convert media", async () => {
const file = Bun.file(`${__dirname}/megamind.jpg`);
const convertedFile = await mediaConverter.convert(
file as unknown as File
);
const convertedFile = await mediaConverter.convert(
file as unknown as File,
);
expect(convertedFile.name).toEqual("megamind.png");
expect(convertedFile.type).toEqual(
`image/${ConvertableMediaFormats.PNG}`
);
});
expect(convertedFile.name).toEqual("megamind.png");
expect(convertedFile.type).toEqual(
`image/${ConvertableMediaFormats.PNG}`,
);
});
});

View file

@ -2,7 +2,7 @@ import type { APActor, APNote } from "activitypub-types";
import { ActivityPubTranslator } from "./protocols/activitypub";
export enum SupportedProtocols {
ACTIVITYPUB = "activitypub",
ACTIVITYPUB = "activitypub",
}
/**
@ -12,37 +12,40 @@ export enum SupportedProtocols {
* This class is not meant to be instantiated directly, but rather for its children to be used.
*/
export class ProtocolTranslator {
static auto(object: any) {
const protocol = this.recognizeProtocol(object);
switch (protocol) {
case SupportedProtocols.ACTIVITYPUB:
return new ActivityPubTranslator();
default:
throw new Error("Unknown protocol");
}
}
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
static auto(object: any) {
const protocol = ProtocolTranslator.recognizeProtocol(object);
switch (protocol) {
case SupportedProtocols.ACTIVITYPUB:
return new ActivityPubTranslator();
default:
throw new Error("Unknown protocol");
}
}
/**
* Translates an ActivityPub actor to a Lysand user
* @param data Raw JSON-LD data from an ActivityPub actor
*/
user(data: APActor) {
//
}
/**
* Translates an ActivityPub actor to a Lysand user
* @param data Raw JSON-LD data from an ActivityPub actor
*/
user(data: APActor) {
//
}
/**
* Translates an ActivityPub note to a Lysand status
* @param data Raw JSON-LD data from an ActivityPub note
*/
status(data: APNote) {
//
}
/**
* Translates an ActivityPub note to a Lysand status
* @param data Raw JSON-LD data from an ActivityPub note
*/
status(data: APNote) {
//
}
/**
* Automatically recognizes the protocol of a given object
*/
private static recognizeProtocol(object: any) {
// Temporary stub
return SupportedProtocols.ACTIVITYPUB;
}
/**
* Automatically recognizes the protocol of a given object
*/
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
private static recognizeProtocol(object: any) {
// Temporary stub
return SupportedProtocols.ACTIVITYPUB;
}
}

View file

@ -1,9 +1,9 @@
{
"name": "protocol-translator",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {},
"devDependencies": {
"activitypub-types": "^1.1.0"
}
}
"name": "protocol-translator",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {},
"devDependencies": {
"activitypub-types": "^1.1.0"
}
}

View file

@ -1,11 +1,5 @@
import { ProtocolTranslator } from "..";
export class ActivityPubTranslator extends ProtocolTranslator {
constructor() {
super();
}
user() {
}
}
user() {}
}

View file

@ -13,158 +13,158 @@
* @returns JavaScript object of type T
*/
export class RequestParser {
constructor(public request: Request) {}
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;
}
}
/**
* 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] ?? ""
);
}
/**
* 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 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
}
// 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 (this.request.body) {
throw new Error("Invalid body");
}
// If there is no body, return query parameters
return "";
}
// 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> = {};
/**
* 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];
}
for (const [key, value] of formData.entries()) {
if (value instanceof File) {
result[key as keyof T] = value as T[keyof T];
} 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;
}
}
(result[arrayKey] as FormDataEntryValue[]).push(value);
} else {
result[key as keyof T] = value as T[keyof T];
}
}
return result;
}
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> = {};
/**
* 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];
}
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;
}
}
(result[arrayKey] as FormDataEntryValue[]).push(value);
} else {
result[key as keyof T] = value as T[keyof T];
}
}
return result;
}
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 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);
/**
* 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;
}
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 T[keyof T];
}
}
return result;
}
}

View file

@ -3,4 +3,4 @@
"version": "0.0.0",
"main": "index.ts",
"dependencies": {}
}
}

View file

@ -1,158 +1,158 @@
import { describe, it, expect, test } from "bun:test";
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).toObject<{
param1: string;
param2: string;
}>();
expect(result).toEqual({ param1: "value1", param2: "value2" });
});
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 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"],
});
});
});
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 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({});
});
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" });
});
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 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 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("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" });
});
});
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" });
});
});
});