UNPKG

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
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); }; } }