counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
243 lines (242 loc) • 9.18 kB
JavaScript
import createDebugger from "debug";
import { ModuleTree } from "./module-tree.js";
const debug = createDebugger("counterfact:server:registry");
const DEFAULT_HTTP_METHODS = [
"DELETE",
"GET",
"HEAD",
"OPTIONS",
"PATCH",
"POST",
"PUT",
"QUERY",
"TRACE",
];
/**
* Casts a string URL/header/query parameter value to the type declared in the
* OpenAPI spec.
*
* @param value - The raw parameter value (may already be the correct type when
* the HTTP framework has pre-parsed it).
* @param type - The OpenAPI primitive type string (`"integer"`, `"number"`,
* `"boolean"`, or anything else to leave as a string).
* @returns The value coerced to the appropriate JavaScript type.
*/
function castParameter(value, type) {
if (typeof value !== "string") {
return value;
}
if (type === "integer") {
return Number.parseInt(value);
}
if (type === "number") {
return Number.parseFloat(value);
}
if (type === "boolean") {
return value === "true";
}
return value;
}
/**
* Applies {@link castParameter} to every value in a parameters map.
*
* @param parameters - Key/value map of raw parameter values.
* @param parameterTypes - Map from parameter name to its OpenAPI type string.
* @returns A new object with the same keys and cast values.
*/
function castParameters(parameters = {}, parameterTypes = new Map()) {
const copy = {};
Object.entries(parameters).forEach(([key, value]) => {
copy[key] = castParameter(value, parameterTypes.get(key) ?? "string");
});
return copy;
}
/**
* Central route registry that maps URL patterns to route-handler modules.
*
* Routes are stored in a {@link ModuleTree} that supports wildcard path
* segments (e.g. `{petId}`). The registry also maintains an ordered chain of
* middleware functions that wrap every route handler execution.
*/
export class Registry {
moduleTree = new ModuleTree();
middlewares = new Map();
methodNames = new Set(DEFAULT_HTTP_METHODS);
constructor() {
this.middlewares.set("", ($, respondTo) => respondTo($));
}
/** Returns all registered routes as a flat array of `{ path, methods }` objects. */
get routes() {
return this.moduleTree.routes;
}
/**
* Registers (or replaces) the module for a URL pattern.
*
* @param url - The URL pattern (e.g. `/pets/{petId}`).
* @param module - The route-handler module exposing HTTP-method functions.
*/
add(url, module) {
this.moduleTree.add(url, module);
for (const methodName of Object.keys(module)) {
this.methodNames.add(methodName.toUpperCase());
}
}
/**
* Registers a middleware function that wraps every handler under `url`.
*
* Middleware receives `($, respondTo)` where `respondTo` is the next handler
* in the chain. Setting `url` to `"/"` makes the middleware global.
*
* @param url - The path prefix at which this middleware applies.
* @param callback - The middleware function.
*/
addMiddleware(url, callback) {
this.middlewares.set(url === "/" ? "" : url, callback);
}
/**
* Removes the module registered at `url`.
*
* @param url - The URL pattern to deregister.
*/
remove(url) {
this.moduleTree.remove(url);
}
/**
* Returns `true` when a handler for `method` is registered at `url`.
*
* @param method - HTTP method (e.g. `"GET"`).
* @param url - The request URL.
*/
methodFromModule(module, method) {
if (module === undefined) {
return undefined;
}
return (module[method] ??
module[method.toUpperCase()] ??
module[method.toLowerCase()]);
}
exists(method, url) {
return (this.methodFromModule(this.handler(url, method).module, method) !==
undefined);
}
methodsForPath(url) {
return [...this.methodNames].filter((method) => this.methodFromModule(this.moduleTree.match(url, method)?.module, method) !== undefined);
}
/**
* Finds the best-matching module and extracts path-variable bindings for a
* given URL and HTTP method.
*
* @param url - The incoming request URL.
* @param method - The HTTP method.
* @returns An object with `module`, `path` (variable bindings),
* `matchedPath`, and `ambiguous` flag.
*/
handler(url, method) {
const match = this.moduleTree.match(url, method);
return {
ambiguous: match?.ambiguous ?? false,
matchedPath: match?.matchedPath ?? "",
module: match?.module,
path: match?.pathVariables ?? {},
};
}
/**
* Returns `true` when the URL matches a registered module for at least one
* HTTP method other than `excludeMethod`.
*
* Used to decide whether to respond with 405 Method Not Allowed.
*
* @param url - The request URL.
* @param excludeMethod - The method to exclude from the check.
*/
pathExistsWithAnyMethod(url, excludeMethod) {
return this.methodsForPath(url).some((method) => method.toUpperCase() !== excludeMethod.toUpperCase());
}
/**
* Returns a comma-separated list of HTTP methods that have a registered
* handler at `url`. Used to populate the `Allow` response header for 405
* responses.
*
* @param url - The request URL.
*/
allowedMethods(url) {
return this.methodsForPath(url).join(", ");
}
/**
* Returns an async function that executes the registered handler for
* `httpRequestMethod` at `url`, wrapped by all applicable middleware.
*
* Path, query, and header parameter values are cast to their declared types
* before being forwarded to the handler. The returned function always
* resolves to a {@link CounterfactResponseObject}.
*
* @param httpRequestMethod - The HTTP method to look up.
* @param url - The incoming request URL (before path-variable substitution).
* @param parameterTypes - Optional maps from parameter name to OpenAPI type
* for each of `header`, `path`, and `query`.
*/
endpoint(httpRequestMethod, url, parameterTypes = {}) {
const handler = this.handler(url, httpRequestMethod);
debug("handler for %s: %o", url, handler);
if (handler.ambiguous) {
return () => ({
body: `Ambiguous wildcard paths: the request to ${url} matches multiple routes. Please resolve the ambiguity in your API spec or route handlers.`,
contentType: "text/plain",
headers: {},
status: 500,
});
}
const execute = this.methodFromModule(handler.module, httpRequestMethod);
if (!execute) {
debug(`Could not find a ${httpRequestMethod} method matching ${url}\n`);
return () => ({
body: `Could not find a ${httpRequestMethod} method matching ${url}\n`,
contentType: "text/plain",
headers: {},
status: 404,
});
}
return async ({ ...requestData }) => {
const operationArgument = {
...requestData,
headers: castParameters(requestData.headers, parameterTypes.header),
matchedPath: handler.matchedPath,
path: castParameters(handler.path, parameterTypes.path),
query: castParameters(requestData.query, parameterTypes.query),
};
operationArgument.x = operationArgument;
const executeAndNormalizeResponse = async (requestData) => {
const result = await execute(requestData);
if (typeof result === "string") {
return {
headers: {},
status: 200,
body: result,
contentType: "text/plain",
};
}
if (typeof result === "undefined") {
return {
headers: {},
body: `The ${httpRequestMethod} function did not return anything. Did you forget a return statement?`,
status: 500,
};
}
return result;
};
const middlewares = this.middlewares;
function recurse(path, respondTo) {
debug("recursing path", path);
if (path === null)
return respondTo;
const nextPath = path === "" ? null : path.slice(0, path.lastIndexOf("/"));
const middleware = middlewares.get(path);
if (middleware !== undefined) {
return recurse(nextPath, ($) => middleware($, respondTo));
}
return recurse(nextPath, respondTo);
}
return recurse(operationArgument.matchedPath ?? "/", executeAndNormalizeResponse)(operationArgument);
};
}
}