counterfact
Version:
a library for building a fake REST API for testing
151 lines (150 loc) • 5.86 kB
JavaScript
function isDirectory(test) {
return test !== undefined;
}
export class ModuleTree {
root = {
directories: {},
files: {},
isWildcard: false,
name: "",
rawName: "",
};
putDirectory(directory, segments) {
const [segment, ...remainingSegments] = segments;
if (segment === undefined) {
throw new Error("segments array is empty");
}
if (remainingSegments.length === 0) {
return directory;
}
const nextDirectory = (directory.directories[segment.toLowerCase()] ??= {
directories: {},
files: {},
isWildcard: segment.startsWith("{"),
name: segment.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
rawName: segment,
});
return this.putDirectory(nextDirectory, remainingSegments);
}
addModuleToDirectory(directory, segments, module) {
if (directory === undefined) {
return;
}
const targetDirectory = this.putDirectory(directory, segments);
const filename = segments.at(-1);
if (filename === undefined) {
throw new Error("The file name (the last segment of the URL) is undefined. This is theoretically impossible but TypeScript can't enforce it.");
}
targetDirectory.files[filename] = {
isWildcard: filename.startsWith("{"),
module,
name: filename.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
rawName: filename,
};
}
add(url, module) {
this.addModuleToDirectory(this.root, url.split("/").slice(1), module);
}
removeModuleFromDirectory(directory, segments) {
if (!isDirectory(directory)) {
return;
}
const [segment, ...remainingSegments] = segments;
if (segment === undefined) {
return;
}
if (remainingSegments.length === 0) {
delete directory.files[segment.toLowerCase()];
return;
}
this.removeModuleFromDirectory(directory.directories[segment.toLowerCase()], remainingSegments);
}
remove(url) {
const segments = url.split("/").slice(1);
this.removeModuleFromDirectory(this.root, segments);
}
fileModuleDefined(file, method) {
return file.module[method] !== undefined;
}
buildMatch(directory, segment, pathVariables, matchedPath, method) {
function normalizedSegment(segment, directory) {
for (const file in directory.files) {
if (file.toLowerCase() === segment.toLowerCase()) {
return file;
}
}
return "";
}
const match = directory.files[normalizedSegment(segment, directory)] ??
Object.values(directory.files).find((file) => file.isWildcard && this.fileModuleDefined(file, method));
if (match === undefined) {
return undefined;
}
if (match.isWildcard) {
return {
...match,
matchedPath: `${matchedPath}/${match.rawName}`,
pathVariables: {
...pathVariables,
[match.name]: segment,
},
};
}
return {
...match,
matchedPath: `${matchedPath}/${match.rawName}`,
pathVariables,
};
}
matchWithinDirectory(directory, segments, pathVariables, matchedPath, method) {
if (segments.length === 0) {
return undefined;
}
const [segment, ...remainingSegments] = segments;
if (segment === undefined) {
throw new Error("segment cannot be undefined but TypeScript doesn't know that");
}
if (remainingSegments.length === 0 ||
(remainingSegments.length === 1 && remainingSegments[0] === "")) {
return this.buildMatch(directory, segment, pathVariables, matchedPath, method);
}
const exactMatch = directory.directories[segment.toLowerCase()];
if (isDirectory(exactMatch)) {
return this.matchWithinDirectory(exactMatch, remainingSegments, pathVariables, `${matchedPath}/${segment}`, method);
}
const wildcardDirectories = Object.values(directory.directories).filter((subdirectory) => subdirectory.isWildcard);
for (const wildcardDirectory of wildcardDirectories) {
const match = this.matchWithinDirectory(wildcardDirectory, remainingSegments, {
...pathVariables,
[wildcardDirectory.name]: segment,
}, `${matchedPath}/${wildcardDirectory.rawName}`, method);
if (match !== undefined) {
return match;
}
}
return undefined;
}
match(url, method) {
return this.matchWithinDirectory(this.root, url.split("/").slice(1), {}, "", method);
}
get routes() {
const routes = [];
function traverse(directory, path) {
Object.values(directory.directories).forEach((subdirectory) => {
traverse(subdirectory, `${path}/${subdirectory.rawName}`);
});
Object.values(directory.files).forEach((file) => {
const methods = Object.entries(file.module).map(([method, implementation]) => [method, String(implementation)]);
routes.push({
methods: Object.fromEntries(methods),
path: `${path}/${file.rawName}`,
});
});
}
function stripBrackets(string) {
return string.replaceAll(/\{|\}/gu, "");
}
traverse(this.root, "");
return routes.sort((first, second) => stripBrackets(first.path).localeCompare(stripBrackets(second.path)));
}
}