counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
231 lines (230 loc) • 9.84 kB
JavaScript
function isDirectory(test) {
return test !== undefined;
}
/**
* Trie-based tree that maps URL path segments to route-handler modules.
*
* Each node in the tree represents one URL segment. Segments whose names are
* wrapped in curly braces (e.g. `{petId}`) are treated as wildcards and can
* match any value in that position.
*
* The tree supports:
* - **Exact matches** — literal URL segments take precedence over wildcards.
* - **Wildcard matches** — `{param}` segments capture the matched value as a
* path variable.
* - **Ambiguous-wildcard detection** — when multiple wildcards exist at the
* same level the match is flagged as `ambiguous` and an error is logged.
*/
export class ModuleTree {
root = {
directories: new Map(),
files: new Map(),
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 isNewDirectory = !directory.directories.has(segment.toLowerCase());
if (isNewDirectory) {
directory.directories.set(segment.toLowerCase(), {
directories: new Map(),
files: new Map(),
isWildcard: segment.startsWith("{"),
name: segment.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
rawName: segment,
});
}
const nextDirectory = directory.directories.get(segment.toLowerCase());
if (isNewDirectory && segment.startsWith("{")) {
const ambiguousWildcardDirectories = Array.from(directory.directories.values()).filter((subdirectory) => subdirectory.isWildcard);
if (ambiguousWildcardDirectories.length > 1) {
process.stderr.write(`[counterfact] ERROR: Ambiguous wildcard paths detected. Multiple wildcard directories exist at the same level: ${ambiguousWildcardDirectories.map((d) => d.rawName).join(", ")}. Requests may be routed unpredictably.\n`);
}
}
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.set(filename, {
isWildcard: filename.startsWith("{"),
module,
name: filename.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
rawName: filename,
});
if (filename.startsWith("{")) {
const ambiguousWildcardFiles = Array.from(targetDirectory.files.values()).filter((file) => file.isWildcard);
if (ambiguousWildcardFiles.length > 1) {
process.stderr.write(`[counterfact] ERROR: Ambiguous wildcard paths detected. Multiple wildcard files exist at the same path level: ${ambiguousWildcardFiles.map((f) => f.rawName).join(", ")}. Requests may be routed unpredictably.\n`);
}
}
}
/**
* Registers a module at the given URL pattern.
*
* @param url - The route URL pattern (e.g. `"/pets/{petId}"`).
* @param module - The route-handler module to associate with the URL.
*/
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) {
directory.files.delete(segment.toLowerCase());
return;
}
this.removeModuleFromDirectory(directory.directories.get(segment.toLowerCase()), remainingSegments);
}
/**
* Removes the module registered at `url`.
*
* @param url - The route URL pattern to deregister.
*/
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 of directory.files.keys()) {
if (file.toLowerCase() === segment.toLowerCase()) {
return file;
}
}
return "";
}
const exactMatchFile = directory.files.get(normalizedSegment(segment, directory));
// If the URL segment literally matches a file key (e.g., requesting "/{x}"
// as a literal URL value), exactMatchFile may be a wildcard file. In that
// case, fall through to wildcard matching below.
if (exactMatchFile !== undefined && !exactMatchFile.isWildcard) {
return {
...exactMatchFile,
matchedPath: `${matchedPath}/${exactMatchFile.rawName}`,
pathVariables,
};
}
const wildcardFiles = Array.from(directory.files.values()).filter((file) => file.isWildcard && this.fileModuleDefined(file, method));
if (wildcardFiles.length > 1) {
const firstWildcard = wildcardFiles[0];
return {
...firstWildcard,
ambiguous: true,
matchedPath: `${matchedPath}/${firstWildcard.rawName}`,
pathVariables: {
...pathVariables,
[firstWildcard.name]: segment,
},
};
}
const match = exactMatchFile ?? wildcardFiles[0];
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.get(segment.toLowerCase());
if (isDirectory(exactMatch)) {
return this.matchWithinDirectory(exactMatch, remainingSegments, pathVariables, `${matchedPath}/${segment}`, method);
}
const wildcardDirectories = Array.from(directory.directories.values()).filter((subdirectory) => subdirectory.isWildcard);
const wildcardMatches = [];
for (const wildcardDirectory of wildcardDirectories) {
const wildcardMatch = this.matchWithinDirectory(wildcardDirectory, remainingSegments, {
...pathVariables,
[wildcardDirectory.name]: segment,
}, `${matchedPath}/${wildcardDirectory.rawName}`, method);
if (wildcardMatch !== undefined) {
wildcardMatches.push(wildcardMatch);
}
}
if (wildcardMatches.length > 1) {
const firstMatch = wildcardMatches[0];
return { ...firstMatch, ambiguous: true };
}
return wildcardMatches[0];
}
/**
* Finds the best-matching module for `url` and `method`.
*
* Traverses the trie, preferring exact matches over wildcards at each
* segment. Returns `undefined` when no match is found.
*
* @param url - The incoming request URL.
* @param method - The HTTP method (used to validate wildcard matches).
* @returns A {@link Match} object, or `undefined` when nothing matches.
*/
match(url, method) {
return this.matchWithinDirectory(this.root, url.split("/").slice(1), {}, "", method);
}
/** Returns all registered routes sorted alphabetically by path. */
get routes() {
const routes = [];
function traverse(directory, path) {
directory.directories.forEach((subdirectory) => {
traverse(subdirectory, `${path}/${subdirectory.rawName}`);
});
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)));
}
}