counterfact
Version:
a library for building a fake REST API for testing
166 lines (165 loc) • 6.69 kB
JavaScript
import { mediaTypes } from "@hapi/accept";
import createDebugger from "debug";
import fetch, { Headers } from "node-fetch";
import { createResponseBuilder } from "./response-builder.js";
import { Tools } from "./tools.js";
const debug = createDebugger("counterfact:server:dispatcher");
export class Dispatcher {
registry;
contextRegistry;
openApiDocument;
fetch;
config; // Add config property
constructor(registry, contextRegistry, openApiDocument, config) {
this.registry = registry;
this.contextRegistry = contextRegistry;
this.openApiDocument = openApiDocument;
this.fetch = fetch;
this.config = config;
}
parameterTypes(parameters) {
const types = {
body: {},
cookie: {},
formData: {},
header: {},
path: {},
query: {},
};
if (!parameters) {
return types;
}
for (const parameter of parameters) {
const type = parameter?.type;
if (type !== undefined) {
types[parameter.in][parameter.name] =
type === "integer" ? "number" : type;
}
}
return types;
}
findOperation(path, method) {
if (this.openApiDocument) {
for (const key in this.openApiDocument.paths) {
if (key.toLowerCase() === path.toLowerCase()) {
return this.openApiDocument.paths[key]?.[method.toLowerCase()];
}
}
}
return undefined;
}
operationForPathAndMethod(path, method) {
const operation = this.findOperation(path, method);
if (operation === undefined) {
return undefined;
}
if (this.openApiDocument?.produces) {
return {
produces: this.openApiDocument.produces,
...operation,
};
}
return operation;
}
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",
};
}
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;
}
async request({ auth, body, headers = {}, method, path, query, req, }) {
debug(`request: ${method} ${path}`);
// If the incoming path includes the base path, remove it
if (this.openApiDocument?.basePath !== undefined &&
path.toLowerCase().startsWith(this.openApiDocument.basePath.toLowerCase())) {
path = path.replace(new RegExp(this.openApiDocument.basePath, "iu"), "");
}
const { matchedPath } = this.registry.handler(path, method);
const operation = this.operationForPathAndMethod(matchedPath, method);
const response = await this.registry.endpoint(method, path, this.parameterTypes(operation?.parameters))({
auth,
body,
context: this.contextRegistry.find(matchedPath),
headers,
proxy: async (url) => {
if (body !== undefined && headers.contentType !== "application/json") {
throw new Error(`$.proxy() is currently limited to application/json requests. You tried to proxy to ${url} with a Content-Type of ${headers.contentType ?? "[unknown]"}. Please open an issue at https://github.com/pmcelhaney/counterfact/issues and prod me to fix this limitation.`);
}
const fetchResponse = await this.fetch(`${url}${req.path ?? ""}`, {
body: body === undefined ? undefined : JSON.stringify(body),
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,
};
},
query,
// @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 }),
});
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,
};
}
return normalizedResponse;
}
}