counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
439 lines (438 loc) • 18.2 kB
JavaScript
import { mediaTypes } from "@hapi/accept";
import createDebugger from "debug";
import fetch, { Headers } from "node-fetch";
import { createResponseBuilder } from "./response-builder.js";
import { isExplodedObjectQueryParam, validateRequest, } from "./request-validator.js";
import { validateResponse } from "./response-validator.js";
import { Tools } from "./tools.js";
const debug = createDebugger("counterfact:server:dispatcher");
/**
* Merges path-item-level and operation-level parameter arrays.
*
* Operation-level parameters take precedence when both arrays define a
* parameter with the same `name` and `in` location, per the OpenAPI
* specification.
*/
function mergeParameters(pathItemParams, operationParams) {
const map = new Map();
for (const p of pathItemParams) {
map.set(`${p.in}:${p.name}`, p);
}
for (const p of operationParams) {
map.set(`${p.in}:${p.name}`, p);
}
return [...map.values()];
}
/**
* Parses the `Cookie` request header into a key/value map.
*
* Duplicate keys are silently dropped (first occurrence wins) and values are
* percent-decoded where possible.
*
* @param cookieHeader - The raw `Cookie` header string.
* @returns A record mapping cookie name to decoded value.
*/
function parseCookies(cookieHeader) {
const cookies = {};
for (const part of cookieHeader.split(";")) {
const eqIndex = part.indexOf("=");
if (eqIndex === -1) {
continue;
}
const key = part.slice(0, eqIndex).trim();
const value = part.slice(eqIndex + 1).trim();
if (key && !(key in cookies)) {
try {
cookies[key] = decodeURIComponent(value);
}
catch (error) {
debug("could not decode cookie value for key %s: %o", key, error);
cookies[key] = value;
}
}
}
return cookies;
}
/**
* Gathers exploded object query parameters back into a nested object under the
* parameter's own name.
*
* Per OpenAPI 3.x, a query parameter of type `object` with `style: form` and
* `explode: true` (both defaults for query params) is serialised as individual
* query parameters — one per object property. This function reconstructs the
* object so that the route handler can access `$.query.<paramName>` rather than
* only the individual flat parameters.
*
* Properties that are "claimed" by an object parameter are removed from the
* top-level map and placed under the parameter name. Unclaimed keys remain at
* the top level.
*
* @param query - The raw parsed query-string map from the HTTP request.
* @param parameters - The OpenAPI parameter definitions for the current operation.
* @returns A new map with exploded object parameters reconstructed as nested objects.
*/
export function collectExplodedObjectParams(query, parameters) {
const result = { ...query };
for (const parameter of parameters) {
if (!isExplodedObjectQueryParam(parameter))
continue;
const properties = parameter.schema?.properties;
if (!properties)
continue;
const obj = {};
for (const key of Object.keys(properties)) {
if (key in result) {
obj[key] = result[key];
delete result[key];
}
}
if (Object.keys(obj).length > 0) {
result[parameter.name] = obj;
}
}
return result;
}
/**
* Core HTTP request dispatcher.
*
* Receives incoming requests from the Koa middleware layer, matches them
* against the {@link Registry}, optionally validates the request and response
* against the OpenAPI spec, and invokes the matching route-handler function.
*
* Content-negotiation (Accept header handling) is performed before returning
* the response so the caller always receives the most appropriate content type.
*/
export class Dispatcher {
registry;
contextRegistry;
openApiDocument;
fetch;
config; // Add config property
/**
* The version label for this dispatcher's spec (e.g. `"v1"`, `"v2"`).
* Empty string when running without a version.
*/
version;
/**
* Ordered list of all version labels for the API group this dispatcher
* belongs to. The first entry is the oldest version. Used by
* `$.minVersion()` at runtime to determine if the current version is
* greater than or equal to a given minimum version.
*/
versions;
constructor(registry, contextRegistry, openApiDocument, config, version = "", versions = []) {
this.registry = registry;
this.contextRegistry = contextRegistry;
this.openApiDocument = openApiDocument;
this.fetch = fetch;
this.config = config;
this.version = version;
this.versions = versions;
}
parameterTypes(parameters) {
const types = {
body: new Map(),
cookie: new Map(),
formData: new Map(),
header: new Map(),
path: new Map(),
query: new Map(),
};
if (!parameters) {
return types;
}
for (const parameter of parameters) {
// querystring parameters represent the entire query string as a single
// typed object; they are not individually coerced by name.
if (parameter.in === "querystring") {
continue;
}
const type = parameter?.type ?? parameter?.schema?.type;
if (type !== undefined) {
types[parameter.in].set(parameter.name, type === "integer" ? "number" : type);
}
}
return types;
}
findPathItem(path) {
if (!this.openApiDocument) {
return undefined;
}
for (const key in this.openApiDocument.paths) {
if (key.toLowerCase() === path.toLowerCase()) {
return this.openApiDocument.paths[key];
}
}
return undefined;
}
apiKeySecurityParameters() {
const schemes = this.openApiDocument?.components?.securitySchemes;
return Object.values(schemes ?? {})
.filter(({ in: location, name, type }) => type === "apiKey" &&
typeof name === "string" &&
(location === "header" ||
location === "query" ||
location === "cookie"))
.map(({ in: location, name }) => ({
in: location,
name: name,
required: false,
schema: { type: "string" },
}));
}
authWithApiKey(auth, cookie, headers, query) {
const apiKeyScheme = this.apiKeySecurityParameters().find((parameter) => ["cookie", "header", "query"].includes(parameter.in));
if (!apiKeyScheme) {
return auth;
}
const apiKey = apiKeyScheme.in === "query"
? query[apiKeyScheme.name]
: apiKeyScheme.in === "cookie"
? cookie[apiKeyScheme.name]
: Object.entries(headers).find(([key]) => key.toLowerCase() === apiKeyScheme.name.toLowerCase())?.[1];
const normalizedApiKey = Array.isArray(apiKey)
? (apiKey[0] ?? "")
: (apiKey ?? "");
return { ...auth, apiKey: normalizedApiKey };
}
/**
* Resolves the OpenAPI operation for `path` and `method`, merging any
* top-level `produces` array from the document root and any path-item-level
* `parameters` into the operation.
*
* Per the OpenAPI specification, parameters defined at the path item level
* are shared across all operations on that path. Operation-level parameters
* take precedence when both define a parameter with the same `name` and `in`.
*
* @param path - The matched route path (e.g. `"/pets/{petId}"`).
* @param method - The HTTP method.
* @returns The {@link OpenApiOperation} if found, or `undefined`.
*/
operationForPathAndMethod(path, method) {
const pathItem = this.findPathItem(path);
if (pathItem === undefined) {
return undefined;
}
const normalizedMethod = method.toLowerCase();
const operation = pathItem[normalizedMethod] ??
pathItem.additionalOperations?.[method] ??
pathItem.additionalOperations?.[method.toUpperCase()] ??
pathItem.additionalOperations?.[normalizedMethod];
if (operation === undefined) {
return undefined;
}
// Merge path-item-level parameters with operation-level parameters.
// Operation-level parameters take precedence on same name+in collision.
const pathItemParams = pathItem.parameters ?? [];
const operationParams = operation.parameters ?? [];
const mergedParameters = pathItemParams.length > 0
? mergeParameters(pathItemParams, operationParams)
: operationParams.length > 0
? operationParams
: undefined;
const mergedOperation = mergedParameters !== undefined
? { ...operation, parameters: mergedParameters }
: operation;
const apiKeyParameters = this.apiKeySecurityParameters();
const operationWithSecurity = apiKeyParameters.length > 0
? {
...mergedOperation,
parameters: mergeParameters(mergedOperation.parameters ?? [], apiKeyParameters),
}
: mergedOperation;
if (this.openApiDocument?.produces) {
return {
produces: this.openApiDocument.produces,
...operationWithSecurity,
};
}
return operationWithSecurity;
}
normalizeResponse(response, acceptHeader) {
if (response.content !== undefined) {
const content = this.selectContent(acceptHeader, response.content);
if (content === undefined) {
return {
body: `Not Acceptable: could not produce a response matching any of the following content types: ${acceptHeader}`,
contentType: "text/plain",
status: 406,
};
}
const normalizedResponse = {
...response,
body: content.body,
contentType: content.type,
};
delete normalizedResponse.content;
return normalizedResponse;
}
return {
...response,
contentType: response.headers?.["content-type"]?.toString() ??
response.contentType ??
"unknown/unknown",
};
}
/**
* Picks the best matching content entry from a multi-type response using the
* request's `Accept` header preferences.
*
* @param acceptHeader - The value of the `Accept` request header.
* @param content - Array of `{ type, body }` objects representing all
* available content-type variants.
* @returns The first entry whose MIME type satisfies the accept preferences,
* or `undefined` when none match.
*/
selectContent(acceptHeader, content) {
const preferredMediaTypes = mediaTypes(acceptHeader);
for (const mediaType of preferredMediaTypes) {
const contentItem = content.find((item) => this.isMediaType(item.type, mediaType));
if (contentItem) {
return contentItem;
}
}
return undefined;
}
isMediaType(type, pattern) {
if (pattern === "*/*") {
return true;
}
const [baseType, subType] = type.split("/");
const [patternType, patternSubType] = pattern.split("/");
if (baseType === patternType) {
return subType === patternSubType || patternSubType === "*";
}
if (subType === patternSubType) {
return baseType === patternType || patternType === "*";
}
return false;
}
/**
* Main request handler.
*
* Orchestrates base-path stripping, route matching, request validation,
* handler invocation, content negotiation, and response validation.
*
* @param request - The incoming request descriptor.
* @returns A promise that resolves to a {@link CounterfactResponseObject}.
*/
async request({ auth, body, headers = {}, method, path, query, rawBody, req, }) {
debug(`request: ${method} ${path}`);
debug(`headers: ${JSON.stringify(headers)}`);
debug(`body: ${JSON.stringify(body)}`);
// If the incoming path includes the base path, remove it
if (this.openApiDocument?.basePath !== undefined &&
path.toLowerCase().startsWith(this.openApiDocument.basePath.toLowerCase())) {
path = path.slice(this.openApiDocument.basePath.length);
}
const { matchedPath } = this.registry.handler(path, method);
if (!this.registry.exists(method, path) &&
this.registry.pathExistsWithAnyMethod(path, method)) {
return {
body: `The ${method} method is not allowed for ${path}\n`,
contentType: "text/plain",
headers: { allow: this.registry.allowedMethods(path) },
status: 405,
};
}
const operation = this.operationForPathAndMethod(matchedPath, method);
const requestCookie = parseCookies(headers.cookie ?? headers.Cookie ?? "");
if (this.config?.validateRequests !== false) {
const validation = validateRequest(operation, {
body,
cookie: requestCookie,
headers,
query,
});
if (!validation.valid) {
return {
body: `Request validation failed:\n${validation.errors.join("\n")}`,
contentType: "text/plain",
status: 400,
};
}
}
// Reconstruct exploded object query parameters so that `$.query.<name>`
// contains the assembled object instead of only the individual flat keys.
const processedQuery = collectExplodedObjectParams(query, operation?.parameters ?? []);
const continuousDistribution = (min, max) => {
return min + Math.random() * (max - min);
};
const response = await this.registry.endpoint(method, path, this.parameterTypes(operation?.parameters))({
auth: this.authWithApiKey(auth, requestCookie, headers, query),
body,
context: this.contextRegistry.find(matchedPath),
async delay(milliseconds = 0, maxMilliseconds = 0) {
const delayInMs = maxMilliseconds - milliseconds <= 0
? milliseconds
: continuousDistribution(milliseconds, maxMilliseconds);
return new Promise((resolve) => setTimeout(resolve, delayInMs));
},
cookie: requestCookie,
headers,
proxy: async (url) => {
delete headers.host;
const fetchResponse = await this.fetch(`${url}${req.path ?? ""}`, {
body: body === undefined ? undefined : rawBody,
headers: new Headers(headers),
method,
});
const responseHeaders = Object.fromEntries(fetchResponse.headers.entries());
return {
body: await fetchResponse.text(),
contentType: responseHeaders["content-type"] ?? "unknown/unknown",
headers: responseHeaders,
status: fetchResponse.status,
};
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- object params are reconstructed and typed by generated route types
query: processedQuery,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- typed by generated route types; entire query string as a single object
querystring: processedQuery,
// @ts-expect-error - Might be pushing the limits of what TypeScript can do here
response: createResponseBuilder(operation ?? { responses: {} }, this.config), // Pass config
tools: new Tools({ headers }),
...(this.version !== "" && {
version: this.version,
minVersion: (min) => {
const currentIdx = this.versions.indexOf(this.version);
const minIdx = this.versions.indexOf(min);
if (currentIdx === -1 || minIdx === -1) {
return false;
}
return currentIdx >= minIdx;
},
}),
});
if (response === undefined) {
return {
body: `The ${method} function did not return anything. Did you forget a return statement?`,
status: 500,
};
}
const normalizedResponse = this.normalizeResponse(response, headers.accept ?? "*/*");
if (normalizedResponse.body !== undefined &&
!mediaTypes(headers.accept ?? "*/*").some((type) => this.isMediaType(normalizedResponse.contentType ?? "", type))) {
return {
body: JSON.stringify(mediaTypes(headers.accept ?? "*/*")),
status: 406,
};
}
if (this.config?.validateResponses !== false) {
const validation = validateResponse(operation, normalizedResponse);
if (!validation.valid) {
return {
...normalizedResponse,
appendedHeaders: [
...(normalizedResponse.appendedHeaders ?? []),
...validation.errors.map((error) => [
"response-type-error",
error,
]),
],
};
}
}
return normalizedResponse;
}
}