counterfact 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.js CHANGED
@@ -1,4 +1,4 @@
1
- import { rm } from "node:fs/promises";
1
+ import fs, { rm } from "node:fs/promises";
2
2
  import nodePath from "node:path";
3
3
  import { dereference } from "@apidevtools/json-schema-ref-parser";
4
4
  import { createHttpTerminator } from "http-terminator";
@@ -6,14 +6,24 @@ import yaml from "js-yaml";
6
6
  import { startRepl } from "./repl/repl.js";
7
7
  import { ContextRegistry } from "./server/context-registry.js";
8
8
  import { createKoaApp } from "./server/create-koa-app.js";
9
- import { Dispatcher } from "./server/dispatcher.js";
9
+ import { Dispatcher, } from "./server/dispatcher.js";
10
10
  import { koaMiddleware } from "./server/koa-middleware.js";
11
11
  import { ModuleLoader } from "./server/module-loader.js";
12
12
  import { Registry } from "./server/registry.js";
13
13
  import { Transpiler } from "./server/transpiler.js";
14
14
  import { CodeGenerator } from "./typescript-generator/code-generator.js";
15
15
  import { readFile } from "./util/read-file.js";
16
- async function loadOpenApiDocument(source) {
16
+ const allowedMethods = [
17
+ "all",
18
+ "head",
19
+ "get",
20
+ "post",
21
+ "put",
22
+ "delete",
23
+ "patch",
24
+ "options",
25
+ ];
26
+ export async function loadOpenApiDocument(source) {
17
27
  try {
18
28
  const text = await readFile(source);
19
29
  const openApiDocument = await yaml.load(text);
@@ -23,6 +33,50 @@ async function loadOpenApiDocument(source) {
23
33
  return undefined;
24
34
  }
25
35
  }
36
+ const mswHandlers = {};
37
+ export async function handleMswRequest(request) {
38
+ const { method, rawPath } = request;
39
+ const handler = mswHandlers[`${method}:${rawPath}`];
40
+ if (handler) {
41
+ return handler(request);
42
+ }
43
+ console.warn(`No handler found for ${method} ${rawPath}`);
44
+ return { error: `No handler found for ${method} ${rawPath}`, status: 404 };
45
+ }
46
+ export async function createMswHandlers(config, ModuleLoaderClass = ModuleLoader) {
47
+ // TODO: For some reason the Vitest Custom Commands needed by Vitest Browser mode fail on fs.readFile when they are called from the nested loadOpenApiDocument function.
48
+ // If we "pre-read" the file here it works. This is a workaround to avoid the issue.
49
+ const _ = await fs.readFile(config.openApiPath);
50
+ const openApiDocument = await loadOpenApiDocument(config.openApiPath);
51
+ if (openApiDocument === undefined) {
52
+ throw new Error(`Could not load OpenAPI document from ${config.openApiPath}`);
53
+ }
54
+ const modulesPath = config.basePath;
55
+ const compiledPathsDirectory = nodePath
56
+ .join(modulesPath, ".cache")
57
+ .replaceAll("\\", "/");
58
+ const registry = new Registry();
59
+ const contextRegistry = new ContextRegistry();
60
+ const dispatcher = new Dispatcher(registry, contextRegistry, openApiDocument, config);
61
+ const moduleLoader = new ModuleLoaderClass(compiledPathsDirectory, registry, contextRegistry);
62
+ await moduleLoader.load();
63
+ const routes = registry.routes;
64
+ const handlers = routes.flatMap((route) => {
65
+ const { methods, path } = route;
66
+ return Object.keys(methods)
67
+ .filter((method) => allowedMethods.includes(method.toLowerCase()))
68
+ .map((method) => {
69
+ const lowerMethod = method.toLowerCase();
70
+ const apiPath = `${openApiDocument.basePath ?? ""}${path.replaceAll("{", ":").replaceAll("}", "")}`;
71
+ const handler = async (request) => {
72
+ return await dispatcher.request(request);
73
+ };
74
+ mswHandlers[`${method}:${apiPath}`] = handler;
75
+ return { method: lowerMethod, path: apiPath };
76
+ });
77
+ });
78
+ return handlers;
79
+ }
26
80
  export async function counterfact(config) {
27
81
  const modulesPath = config.basePath;
28
82
  const compiledPathsDirectory = nodePath
@@ -62,14 +62,6 @@ export class Dispatcher {
62
62
  return operation;
63
63
  }
64
64
  normalizeResponse(response, acceptHeader) {
65
- if (typeof response === "string") {
66
- return {
67
- body: response,
68
- contentType: "text/plain",
69
- headers: {},
70
- status: 200,
71
- };
72
- }
73
65
  if (response.content !== undefined) {
74
66
  const content = this.selectContent(acceptHeader, response.content);
75
67
  if (content === undefined) {
@@ -89,7 +81,9 @@ export class Dispatcher {
89
81
  }
90
82
  return {
91
83
  ...response,
92
- contentType: response.headers?.["content-type"]?.toString() ?? "unknown/unknown",
84
+ contentType: response.headers?.["content-type"]?.toString() ??
85
+ response.contentType ??
86
+ "unknown/unknown",
93
87
  };
94
88
  }
95
89
  selectContent(acceptHeader, content) {
@@ -15,6 +15,11 @@ const debug = createDebug("counterfact:server:module-loader");
15
15
  function isContextModule(module) {
16
16
  return "Context" in module && typeof module.Context === "function";
17
17
  }
18
+ function isMiddlewareModule(module) {
19
+ return ("middleware" in module &&
20
+ typeof Object.getOwnPropertyDescriptor(module, "middleware")?.value ===
21
+ "function");
22
+ }
18
23
  export class ModuleLoader extends EventTarget {
19
24
  basePath;
20
25
  registry;
@@ -96,23 +101,27 @@ export class ModuleLoader extends EventTarget {
96
101
  const doImport = (await determineModuleKind(pathName)) === "commonjs"
97
102
  ? uncachedRequire
98
103
  : uncachedImport;
99
- const endpoint = (await doImport(pathName));
104
+ const endpoint = (await doImport(pathName).catch((err) => {
105
+ console.log("ERROR");
106
+ }));
100
107
  this.dispatchEvent(new Event("add"));
101
- if (basename(pathName).startsWith("_.context")) {
102
- if (isContextModule(endpoint)) {
103
- const loadContext = (path) => this.contextRegistry.find(path);
104
- this.contextRegistry.update(directory,
105
- // @ts-expect-error TS says Context has no constructable signatures but that's not true?
106
- new endpoint.Context({
107
- loadContext,
108
- }));
109
- }
108
+ if (basename(pathName).startsWith("_.context.") &&
109
+ isContextModule(endpoint)) {
110
+ const loadContext = (path) => this.contextRegistry.find(path);
111
+ this.contextRegistry.update(directory,
112
+ // @ts-expect-error TS says Context has no constructable signatures but that's not true?
113
+ new endpoint.Context({
114
+ loadContext,
115
+ }));
116
+ return;
110
117
  }
111
- else {
112
- if (url === "/index")
113
- this.registry.add("/", endpoint);
114
- this.registry.add(url, endpoint);
118
+ if (basename(pathName).startsWith("_.middleware.") &&
119
+ isMiddlewareModule(endpoint)) {
120
+ this.registry.addMiddleware(url.slice(0, url.lastIndexOf("/")) || "/", endpoint.middleware);
115
121
  }
122
+ if (url === "/index")
123
+ this.registry.add("/", endpoint);
124
+ this.registry.add(url, endpoint);
116
125
  }
117
126
  catch (error) {
118
127
  if (String(error) ===
@@ -120,8 +129,8 @@ export class ModuleLoader extends EventTarget {
120
129
  // Not sure why Node throws this error. It doesn't seem to matter.
121
130
  return;
122
131
  }
123
- throw error;
124
132
  process.stdout.write(`\nError loading ${pathName}:\n${String(error)}\n`);
133
+ throw error;
125
134
  }
126
135
  }
127
136
  }
@@ -9,31 +9,38 @@ export class ModuleTree {
9
9
  name: "",
10
10
  rawName: "",
11
11
  };
12
- addModuleToDirectory(directory, segments, module) {
13
- if (directory === undefined) {
14
- return;
15
- }
12
+ putDirectory(directory, segments) {
16
13
  const [segment, ...remainingSegments] = segments;
17
14
  if (segment === undefined) {
18
15
  throw new Error("segments array is empty");
19
16
  }
20
17
  if (remainingSegments.length === 0) {
21
- directory.files[segment.toLowerCase()] = {
22
- isWildcard: segment.startsWith("{"),
23
- module,
24
- name: segment.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
25
- rawName: segment,
26
- };
27
- return;
18
+ return directory;
28
19
  }
29
- directory.directories[segment.toLowerCase()] ??= {
20
+ const nextDirectory = (directory.directories[segment.toLowerCase()] ??= {
30
21
  directories: {},
31
22
  files: {},
32
23
  isWildcard: segment.startsWith("{"),
33
24
  name: segment.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
34
25
  rawName: segment,
26
+ });
27
+ return this.putDirectory(nextDirectory, remainingSegments);
28
+ }
29
+ addModuleToDirectory(directory, segments, module) {
30
+ if (directory === undefined) {
31
+ return;
32
+ }
33
+ const targetDirectory = this.putDirectory(directory, segments);
34
+ const filename = segments.at(-1);
35
+ if (filename === undefined) {
36
+ throw new Error("The file name (the last segment of the URL) is undefined. This is theoretically impossible but TypeScript can't enforce it.");
37
+ }
38
+ targetDirectory.files[filename] = {
39
+ isWildcard: filename.startsWith("{"),
40
+ module,
41
+ name: filename.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
42
+ rawName: filename,
35
43
  };
36
- this.addModuleToDirectory(directory.directories[segment.toLocaleLowerCase()], remainingSegments, module);
37
44
  }
38
45
  add(url, module) {
39
46
  this.addModuleToDirectory(this.root, url.split("/").slice(1), module);
@@ -60,7 +67,15 @@ export class ModuleTree {
60
67
  return file.module[method] !== undefined;
61
68
  }
62
69
  buildMatch(directory, segment, pathVariables, matchedPath, method) {
63
- const match = directory.files[segment.toLowerCase()] ??
70
+ function normalizedSegment(segment, directory) {
71
+ for (const file in directory.files) {
72
+ if (file.toLowerCase() === segment.toLowerCase()) {
73
+ return file;
74
+ }
75
+ }
76
+ return "";
77
+ }
78
+ const match = directory.files[normalizedSegment(segment, directory)] ??
64
79
  Object.values(directory.files).find((file) => file.isWildcard && this.fileModuleDefined(file, method));
65
80
  if (match === undefined) {
66
81
  return undefined;
@@ -25,12 +25,19 @@ function castParameters(parameters = {}, parameterTypes = {}) {
25
25
  }
26
26
  export class Registry {
27
27
  moduleTree = new ModuleTree();
28
+ middlewares = new Map();
29
+ constructor() {
30
+ this.middlewares.set("/", ($, respondTo) => respondTo($));
31
+ }
28
32
  get routes() {
29
33
  return this.moduleTree.routes;
30
34
  }
31
35
  add(url, module) {
32
36
  this.moduleTree.add(url, module);
33
37
  }
38
+ addMiddleware(url, callback) {
39
+ this.middlewares.set(url, callback);
40
+ }
34
41
  remove(url) {
35
42
  this.moduleTree.remove(url);
36
43
  }
@@ -66,7 +73,37 @@ export class Registry {
66
73
  query: castParameters(requestData.query, parameterTypes.query),
67
74
  };
68
75
  operationArgument.x = operationArgument;
69
- return await execute(operationArgument);
76
+ const executeAndNormalizeResponse = async (requestData) => {
77
+ const result = await execute(requestData);
78
+ if (typeof result === "string") {
79
+ return {
80
+ headers: {},
81
+ status: 200,
82
+ body: result,
83
+ contentType: "text/plain",
84
+ };
85
+ }
86
+ if (typeof result === "undefined") {
87
+ return {
88
+ headers: {},
89
+ body: `The ${httpRequestMethod} function did not return anything. Did you forget a return statement?`,
90
+ status: 500,
91
+ };
92
+ }
93
+ return result;
94
+ };
95
+ const middlewares = this.middlewares;
96
+ function recurse(path, respondTo) {
97
+ if (path === null)
98
+ return respondTo;
99
+ const nextPath = path === "" ? null : path.slice(0, path.lastIndexOf("/"));
100
+ const middleware = middlewares.get(path);
101
+ if (middleware !== undefined) {
102
+ return recurse(nextPath, ($) => middleware($, respondTo));
103
+ }
104
+ return recurse(nextPath, respondTo);
105
+ }
106
+ return recurse(operationArgument.matchedPath ?? "/", executeAndNormalizeResponse)(operationArgument);
70
107
  };
71
108
  }
72
109
  }
@@ -55,7 +55,7 @@ export function createResponseBuilder(operation, config) {
55
55
  return {
56
56
  ...this,
57
57
  content: [
58
- ...(this.content ?? []),
58
+ ...(this.content ?? []).filter((response) => response.type !== contentType),
59
59
  {
60
60
  body: convertToXmlIfNecessary(contentType, body, operation.responses[this.status ?? "default"]?.content?.[contentType]?.schema),
61
61
  type: contentType,
@@ -278,4 +278,5 @@ export type {
278
278
  ResponseBuilder,
279
279
  ResponseBuilderFactory,
280
280
  WideOperationArgument,
281
+ WideResponseBuilder,
281
282
  };
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "counterfact",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "a library for building a fake REST API for testing",
5
5
  "type": "module",
6
- "main": "./src/server/counterfact.js",
7
- "exports": "./src/server/counterfact.js",
8
- "types": "./src/counterfact.d.ts",
6
+ "main": "./dist/app.js",
7
+ "exports": "./dist/app.js",
8
+ "types": "./dist/server/types.d.ts",
9
9
  "author": "Patrick McElhaney <pmcelhaney@gmail.com> (https://patrickmcelhaney.com)",
10
10
  "license": "MIT",
11
11
  "repository": "github:pmcelhaney/counterfact",
@@ -44,12 +44,12 @@
44
44
  "postinstall": "patch-package"
45
45
  },
46
46
  "devDependencies": {
47
- "@changesets/cli": "2.27.12",
48
- "@stryker-mutator/core": "8.7.1",
49
- "@stryker-mutator/jest-runner": "8.7.1",
50
- "@stryker-mutator/typescript-checker": "8.7.1",
51
- "@swc/core": "1.10.14",
52
- "@swc/jest": "0.2.37",
47
+ "@changesets/cli": "2.29.4",
48
+ "@stryker-mutator/core": "9.0.1",
49
+ "@stryker-mutator/jest-runner": "9.0.1",
50
+ "@stryker-mutator/typescript-checker": "9.0.1",
51
+ "@swc/core": "1.11.29",
52
+ "@swc/jest": "0.2.38",
53
53
  "@testing-library/dom": "10.4.0",
54
54
  "@types/jest": "29.5.14",
55
55
  "@types/js-yaml": "4.0.9",
@@ -57,55 +57,56 @@
57
57
  "@types/koa-bodyparser": "4.3.12",
58
58
  "@types/koa-proxy": "1.0.7",
59
59
  "@types/koa-static": "4.0.4",
60
- "@types/lodash": "4.17.15",
60
+ "@types/lodash": "4.17.17",
61
61
  "copyfiles": "2.4.1",
62
- "eslint": "9.19.0",
62
+ "eslint": "9.28.0",
63
63
  "eslint-config-hardcore": "47.0.1",
64
64
  "eslint-formatter-github-annotations": "0.1.0",
65
- "eslint-import-resolver-typescript": "3.7.0",
65
+ "eslint-import-resolver-typescript": "4.4.2",
66
66
  "eslint-plugin-etc": "2.0.3",
67
- "eslint-plugin-file-progress": "3.0.1",
67
+ "eslint-plugin-file-progress": "3.0.2",
68
68
  "eslint-plugin-import": "2.31.0",
69
- "eslint-plugin-jest": "28.11.0",
69
+ "eslint-plugin-jest": "28.12.0",
70
70
  "eslint-plugin-jest-dom": "5.5.0",
71
71
  "eslint-plugin-no-explicit-type-exports": "0.12.1",
72
- "eslint-plugin-prettier": "5.2.3",
72
+ "eslint-plugin-prettier": "5.4.1",
73
73
  "eslint-plugin-unused-imports": "4.1.4",
74
74
  "husky": "9.1.7",
75
75
  "jest": "29.7.0",
76
76
  "jest-retries": "1.0.1",
77
- "node-mocks-http": "1.16.2",
77
+ "node-mocks-http": "1.17.2",
78
78
  "rimraf": "6.0.1",
79
79
  "stryker-cli": "1.0.2",
80
- "supertest": "7.0.0",
80
+ "supertest": "7.1.1",
81
81
  "using-temporary-files": "2.2.1"
82
82
  },
83
83
  "dependencies": {
84
- "@apidevtools/json-schema-ref-parser": "11.9.0",
84
+ "@apidevtools/json-schema-ref-parser": "12.0.2",
85
85
  "@hapi/accept": "6.0.3",
86
86
  "@types/json-schema": "7.0.15",
87
87
  "ast-types": "0.14.2",
88
88
  "chokidar": "4.0.3",
89
- "commander": "13.1.0",
90
- "debug": "4.4.0",
89
+ "commander": "14.0.0",
90
+ "debug": "4.4.1",
91
91
  "fetch": "1.1.0",
92
92
  "fs-extra": "11.3.0",
93
93
  "handlebars": "4.7.8",
94
94
  "http-terminator": "3.2.0",
95
95
  "js-yaml": "4.1.0",
96
- "json-schema-faker": "0.5.8",
96
+ "json-schema-faker": "0.5.9",
97
97
  "jsonwebtoken": "9.0.2",
98
- "koa": "2.15.3",
98
+ "koa": "3.0.0",
99
99
  "koa-bodyparser": "4.4.1",
100
100
  "koa-proxies": "0.12.4",
101
101
  "koa2-swagger-ui": "5.11.0",
102
102
  "lodash": "4.17.21",
103
103
  "node-fetch": "3.3.2",
104
- "open": "10.1.0",
104
+ "open": "10.1.2",
105
105
  "patch-package": "8.0.0",
106
- "precinct": "12.1.3",
107
- "prettier": "3.4.2",
108
- "recast": "0.23.9",
109
- "typescript": "5.7.3"
110
- }
106
+ "precinct": "12.2.0",
107
+ "prettier": "3.5.3",
108
+ "recast": "0.23.11",
109
+ "typescript": "5.8.3"
110
+ },
111
+ "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
111
112
  }