UNPKG

counterfact

Version:

a library for building a fake REST API for testing

166 lines (165 loc) 6.69 kB
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; } }