counterfact 0.25.5 → 0.26.1

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.
Files changed (123) hide show
  1. package/bin/counterfact.js +3 -3
  2. package/dist/src/server/constants.js +3 -0
  3. package/dist/src/server/context-registry.js +32 -0
  4. package/dist/src/server/counterfact.js +47 -0
  5. package/dist/src/server/dispatcher.js +150 -0
  6. package/dist/src/server/koa-middleware.js +42 -0
  7. package/dist/src/server/module-loader.js +109 -0
  8. package/dist/src/server/registry.js +116 -0
  9. package/dist/src/server/repl.js +33 -0
  10. package/dist/src/server/response-builder.js +97 -0
  11. package/dist/src/server/start.js +106 -0
  12. package/dist/src/server/tools.js +27 -0
  13. package/dist/src/server/transpiler.js +84 -0
  14. package/dist/src/typescript-generator/coder.js +43 -0
  15. package/dist/src/typescript-generator/context-coder.js +48 -0
  16. package/dist/src/typescript-generator/generate.js +45 -0
  17. package/dist/src/typescript-generator/operation-coder.js +36 -0
  18. package/dist/src/typescript-generator/operation-type-coder.js +76 -0
  19. package/dist/src/typescript-generator/parameters-type-coder.js +38 -0
  20. package/dist/src/typescript-generator/printers.js +10 -0
  21. package/dist/src/typescript-generator/repository.js +67 -0
  22. package/dist/src/typescript-generator/requirement.js +65 -0
  23. package/dist/src/typescript-generator/response-type-coder.js +85 -0
  24. package/dist/src/typescript-generator/schema-coder.js +52 -0
  25. package/dist/src/typescript-generator/schema-type-coder.js +88 -0
  26. package/dist/src/typescript-generator/script.js +140 -0
  27. package/dist/src/typescript-generator/specification.js +46 -0
  28. package/dist/src/util/ensure-directory-exists.js +13 -0
  29. package/dist/src/util/read-file.js +13 -0
  30. package/dist/templates/response-builder-factory.js +1 -0
  31. package/{templates → dist/templates}/response-builder-factory.ts +7 -16
  32. package/package.json +56 -40
  33. package/.changeset/README.md +0 -8
  34. package/.changeset/config.json +0 -11
  35. package/.devcontainer/Dockerfile +0 -14
  36. package/.devcontainer/base.Dockerfile +0 -17
  37. package/.devcontainer/devcontainer.json +0 -47
  38. package/.eslintrc.cjs +0 -208
  39. package/.github/workflows/ci.yaml +0 -42
  40. package/.github/workflows/codeql.yml +0 -76
  41. package/.github/workflows/coveralls.yaml +0 -27
  42. package/.github/workflows/debug-windows.yaml +0 -40
  43. package/.github/workflows/mutation-testing.yaml +0 -36
  44. package/.github/workflows/release.yaml +0 -42
  45. package/.husky/post-commit +0 -4
  46. package/.putout.json +0 -5
  47. package/.rtx.toml +0 -2
  48. package/.swcrc +0 -13
  49. package/.vscode/settings.json +0 -28
  50. package/CHANGELOG.md +0 -386
  51. package/CNAME +0 -1
  52. package/CODE_OF_CONDUCT.md +0 -128
  53. package/CONTRIBUTING.md +0 -30
  54. package/_config.yaml +0 -12
  55. package/_includes/head-custom-google-analytics.html +0 -13
  56. package/docs/quick-start.md +0 -19
  57. package/docs/usage.md +0 -262
  58. package/jest.config.js +0 -33
  59. package/openapi-example.yaml +0 -64
  60. package/petstore.yaml +0 -819
  61. package/renovate.json +0 -24
  62. package/src/server/constants.js +0 -3
  63. package/src/server/context-registry.js +0 -38
  64. package/src/server/counterfact.js +0 -67
  65. package/src/server/dispatcher.js +0 -200
  66. package/src/server/koa-middleware.js +0 -51
  67. package/src/server/module-loader.js +0 -155
  68. package/src/server/registry.js +0 -150
  69. package/src/server/repl.js +0 -56
  70. package/src/server/response-builder.js +0 -122
  71. package/src/server/start.js +0 -146
  72. package/src/server/tools.js +0 -36
  73. package/src/server/transpiler.js +0 -99
  74. package/src/typescript-generator/README.md +0 -70
  75. package/src/typescript-generator/coder.js +0 -58
  76. package/src/typescript-generator/context-coder.js +0 -58
  77. package/src/typescript-generator/generate.js +0 -41
  78. package/src/typescript-generator/operation-coder.js +0 -51
  79. package/src/typescript-generator/operation-type-coder.js +0 -116
  80. package/src/typescript-generator/parameters-type-coder.js +0 -53
  81. package/src/typescript-generator/printers.js +0 -11
  82. package/src/typescript-generator/repository.js +0 -120
  83. package/src/typescript-generator/requirement.js +0 -93
  84. package/src/typescript-generator/response-type-coder.js +0 -120
  85. package/src/typescript-generator/schema-coder.js +0 -71
  86. package/src/typescript-generator/schema-type-coder.js +0 -135
  87. package/src/typescript-generator/script.js +0 -210
  88. package/src/typescript-generator/specification.js +0 -69
  89. package/src/util/read-file.js +0 -41
  90. package/stryker.config.json +0 -13
  91. package/test/lib/with-temporary-files.js +0 -86
  92. package/test/server/context-registry.test.js +0 -85
  93. package/test/server/counterfact.test.js +0 -144
  94. package/test/server/dispatcher.proxy.test.js +0 -44
  95. package/test/server/dispatcher.test.js +0 -570
  96. package/test/server/koa-middleware.test.js +0 -199
  97. package/test/server/module-loader.test.js +0 -168
  98. package/test/server/registry.test.js +0 -149
  99. package/test/server/response-builder.test.js +0 -202
  100. package/test/server/tools.test.js +0 -51
  101. package/test/server/transpiler.test.js +0 -117
  102. package/test/typescript-generator/__snapshots__/end-to-end.test.js.snap +0 -6440
  103. package/test/typescript-generator/__snapshots__/operation-coder.test.js.snap +0 -15
  104. package/test/typescript-generator/__snapshots__/operation-type-coder.test.js.snap +0 -295
  105. package/test/typescript-generator/__snapshots__/parameters-type-coder.test.js.snap +0 -36
  106. package/test/typescript-generator/coder.test.js +0 -106
  107. package/test/typescript-generator/context-coder.test.js +0 -91
  108. package/test/typescript-generator/end-to-end.test.js +0 -20
  109. package/test/typescript-generator/integration.test.js +0 -56
  110. package/test/typescript-generator/operation-coder.test.js +0 -143
  111. package/test/typescript-generator/operation-type-coder.test.js +0 -278
  112. package/test/typescript-generator/parameters-type-coder.test.js +0 -94
  113. package/test/typescript-generator/petstore.yaml +0 -819
  114. package/test/typescript-generator/repository.test.js +0 -14
  115. package/test/typescript-generator/requirement.test.js +0 -132
  116. package/test/typescript-generator/response-type-coder.test.js +0 -42
  117. package/test/typescript-generator/schema-coder.test.js +0 -139
  118. package/test/typescript-generator/schema-type-coder.test.js +0 -328
  119. package/test/typescript-generator/script.test.js +0 -202
  120. package/test/typescript-generator/specification.test.js +0 -155
  121. package/tsconfig.json +0 -12
  122. /package/{src → dist/src}/client/index.html.hbs +0 -0
  123. /package/{src → dist/src}/client/rapi-doc.html.hbs +0 -0
@@ -6,9 +6,9 @@ import { program } from "commander";
6
6
  import createDebug from "debug";
7
7
  import open from "open";
8
8
 
9
- import { startRepl } from "../src/server/repl.js";
10
- import { start } from "../src/server/start.js";
11
- import { generate } from "../src/typescript-generator/generate.js";
9
+ import { startRepl } from "../dist/src/server/repl.js";
10
+ import { start } from "../dist/src/server/start.js";
11
+ import { generate } from "../dist/src/typescript-generator/generate.js";
12
12
 
13
13
  const DEFAULT_PORT = 3100;
14
14
 
@@ -0,0 +1,3 @@
1
+ export const CHOKIDAR_OPTIONS = {
2
+ usePolling: process.platform === "win32",
3
+ };
@@ -0,0 +1,32 @@
1
+ export function parentPath(path) {
2
+ return String(path.split("/").slice(0, -1).join("/")) || "/";
3
+ }
4
+ export class ContextRegistry {
5
+ entries = new Map();
6
+ constructor() {
7
+ this.add("/", {});
8
+ }
9
+ add(path, context) {
10
+ if (context === undefined) {
11
+ throw new Error("context cannot be undefined");
12
+ }
13
+ this.entries.set(path, context);
14
+ }
15
+ find(path) {
16
+ return this.entries.get(path) ?? this.find(parentPath(path));
17
+ }
18
+ update(path, updatedContext) {
19
+ if (updatedContext === undefined) {
20
+ return;
21
+ }
22
+ const context = this.find(path);
23
+ for (const property in updatedContext) {
24
+ if (Object.prototype.hasOwnProperty.call(updatedContext, property) &&
25
+ !Object.prototype.hasOwnProperty.call(context, property)) {
26
+ context[property] = updatedContext[property];
27
+ }
28
+ }
29
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
30
+ Object.setPrototypeOf(context, Object.getPrototypeOf(updatedContext));
31
+ }
32
+ }
@@ -0,0 +1,47 @@
1
+ import nodePath from "node:path";
2
+ import yaml from "js-yaml";
3
+ import $RefParser from "json-schema-ref-parser";
4
+ import { readFile } from "../util/read-file.js";
5
+ import { ContextRegistry } from "./context-registry.js";
6
+ import { Dispatcher } from "./dispatcher.js";
7
+ import { koaMiddleware } from "./koa-middleware.js";
8
+ import { ModuleLoader } from "./module-loader.js";
9
+ import { Registry } from "./registry.js";
10
+ import { Transpiler } from "./transpiler.js";
11
+ async function loadOpenApiDocument(source) {
12
+ try {
13
+ const text = await readFile(source);
14
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
15
+ const openApiDocument = (await yaml.load(text));
16
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
17
+ return (await $RefParser.dereference(openApiDocument));
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ // eslint-disable-next-line max-statements
24
+ export async function counterfact(basePath, openApiPath = nodePath
25
+ .join(basePath, "../openapi.yaml")
26
+ .replaceAll("\\", "/"), options = {}) {
27
+ const openApiDocument = await loadOpenApiDocument(openApiPath);
28
+ const registry = new Registry();
29
+ const modulesPath = basePath;
30
+ const contextRegistry = new ContextRegistry();
31
+ const dispatcher = new Dispatcher(registry, contextRegistry, openApiDocument);
32
+ const compiledPathsDirectory = nodePath
33
+ .join(modulesPath, ".cache")
34
+ .replaceAll("\\", "/");
35
+ const transpiler = new Transpiler(nodePath.join(modulesPath, "paths").replaceAll("\\", "/"), compiledPathsDirectory);
36
+ await transpiler.watch();
37
+ const moduleLoader = new ModuleLoader(compiledPathsDirectory, registry, contextRegistry);
38
+ await moduleLoader.load();
39
+ await moduleLoader.watch();
40
+ return {
41
+ contextRegistry,
42
+ // eslint-disable-next-line total-functions/no-unsafe-readonly-mutable-assignment
43
+ koaMiddleware: koaMiddleware(dispatcher, options),
44
+ moduleLoader,
45
+ registry,
46
+ };
47
+ }
@@ -0,0 +1,150 @@
1
+ /* eslint-disable import/newline-after-import */
2
+ /* eslint-disable max-lines */
3
+ import { mediaTypes } from "@hapi/accept";
4
+ import createDebugger from "debug";
5
+ // eslint-disable-next-line @typescript-eslint/no-shadow
6
+ import fetch, { Headers } from "node-fetch";
7
+ import { createResponseBuilder, } from "./response-builder.js";
8
+ import { Tools } from "./tools.js";
9
+ const debug = createDebugger("counterfact:server:dispatcher");
10
+ export class Dispatcher {
11
+ registry;
12
+ contextRegistry;
13
+ openApiDocument;
14
+ fetch;
15
+ constructor(registry, contextRegistry, openApiDocument) {
16
+ this.registry = registry;
17
+ this.contextRegistry = contextRegistry;
18
+ this.openApiDocument = openApiDocument;
19
+ this.fetch = fetch;
20
+ }
21
+ parameterTypes(parameters) {
22
+ const types = {
23
+ body: {},
24
+ cookie: {},
25
+ formData: {},
26
+ header: {},
27
+ path: {},
28
+ query: {},
29
+ };
30
+ if (!parameters) {
31
+ return types;
32
+ }
33
+ for (const parameter of parameters) {
34
+ if (parameter.schema !== undefined) {
35
+ types[parameter.in][parameter.name] =
36
+ parameter.schema.type === "integer"
37
+ ? "number"
38
+ : parameter.schema.type;
39
+ }
40
+ }
41
+ return types;
42
+ }
43
+ operationForPathAndMethod(path, method) {
44
+ const operation = this.openApiDocument?.paths[path]?.[
45
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
46
+ method.toLowerCase()];
47
+ if (operation === undefined) {
48
+ return undefined;
49
+ }
50
+ if (this.openApiDocument?.produces) {
51
+ return {
52
+ produces: this.openApiDocument.produces,
53
+ ...operation,
54
+ };
55
+ }
56
+ return operation;
57
+ }
58
+ normalizeResponse(response, acceptHeader) {
59
+ if (typeof response === "string") {
60
+ return {
61
+ body: response,
62
+ contentType: "text/plain",
63
+ headers: {},
64
+ status: 200,
65
+ };
66
+ }
67
+ if (response.content !== undefined) {
68
+ const content = this.selectContent(acceptHeader, response.content);
69
+ if (content === undefined) {
70
+ return {
71
+ body: `Not Acceptable: could not produce a response matching any of the following content types: ${acceptHeader}`,
72
+ contentType: "text/plain",
73
+ status: 406,
74
+ };
75
+ }
76
+ const normalizedResponse = {
77
+ ...response,
78
+ body: content.body,
79
+ contentType: content.type,
80
+ };
81
+ delete normalizedResponse.content;
82
+ return normalizedResponse;
83
+ }
84
+ return {
85
+ ...response,
86
+ contentType: response.headers?.["content-type"]?.toString() ?? "unknown/unknown",
87
+ };
88
+ }
89
+ selectContent(acceptHeader, content) {
90
+ const preferredMediaTypes = mediaTypes(acceptHeader);
91
+ for (const mediaType of preferredMediaTypes) {
92
+ const contentItem = content.find((item) => this.isMediaType(item.type, mediaType));
93
+ if (contentItem) {
94
+ return contentItem;
95
+ }
96
+ }
97
+ return undefined;
98
+ }
99
+ isMediaType(type, pattern) {
100
+ if (pattern === "*/*") {
101
+ return true;
102
+ }
103
+ const [baseType, subType] = type.split("/");
104
+ const [patternType, patternSubType] = pattern.split("/");
105
+ if (baseType === patternType) {
106
+ return subType === patternSubType || patternSubType === "*";
107
+ }
108
+ if (subType === patternSubType) {
109
+ return baseType === patternType || patternType === "*";
110
+ }
111
+ return false;
112
+ }
113
+ // eslint-disable-next-line sonarjs/cognitive-complexity
114
+ async request({ body, headers = {}, method, path, query, req, }) {
115
+ debug(`request: ${method} ${path}`);
116
+ const { matchedPath } = this.registry.handler(path);
117
+ const operation = this.operationForPathAndMethod(matchedPath, method);
118
+ const response = await this.registry.endpoint(method, path, this.parameterTypes(operation?.parameters))({
119
+ body,
120
+ context: this.contextRegistry.find(path),
121
+ headers,
122
+ proxy: async (url) => {
123
+ if (body !== undefined && headers.contentType !== "application/json") {
124
+ 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.`);
125
+ }
126
+ const fetchResponse = await this.fetch(`${url}${req.path ?? ""}`, {
127
+ body: body === undefined ? undefined : JSON.stringify(body),
128
+ headers: new Headers(headers),
129
+ method,
130
+ });
131
+ const responseHeaders = Object.fromEntries(fetchResponse.headers.entries());
132
+ return {
133
+ body: await fetchResponse.text(),
134
+ contentType: responseHeaders["content-type"] ?? "unknown/unknown",
135
+ headers: responseHeaders,
136
+ status: fetchResponse.status,
137
+ };
138
+ },
139
+ query,
140
+ // @ts-expect-error - Might be pushing the limits of what TypeScript can do here
141
+ response: createResponseBuilder(operation ?? { responses: {} }),
142
+ tools: new Tools({ headers }),
143
+ });
144
+ const normalizedResponse = this.normalizeResponse(response, headers.accept ?? "*/*");
145
+ if (!mediaTypes(headers.accept ?? "*/*").some((type) => this.isMediaType(normalizedResponse.contentType, type))) {
146
+ return { body: mediaTypes(headers.accept ?? "*/*"), status: 406 };
147
+ }
148
+ return normalizedResponse;
149
+ }
150
+ }
@@ -0,0 +1,42 @@
1
+ import koaProxy from "koa-proxy";
2
+ const HTTP_STATUS_CODE_OK = 200;
3
+ function addCors(ctx, headers) {
4
+ // Always append CORS headers, reflecting back the headers requested if any
5
+ ctx.set("Access-Control-Allow-Origin", headers?.origin ?? "*");
6
+ ctx.set("Access-Control-Allow-Methods", "GET,HEAD,PUT,POST,DELETE,PATCH");
7
+ ctx.set("Access-Control-Allow-Headers", headers?.["access-control-request-headers"] ?? []);
8
+ ctx.set("Access-Control-Expose-Headers", headers?.["access-control-request-headers"] ?? []);
9
+ ctx.set("Access-Control-Allow-Credentials", "true");
10
+ }
11
+ export function koaMiddleware(dispatcher, { proxyEnabled = false, proxyUrl = "" } = {}, proxy = koaProxy) {
12
+ // eslint-disable-next-line max-statements
13
+ return async function middleware(ctx, next) {
14
+ const { body, headers, path, query } = ctx.request;
15
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
16
+ const method = ctx.request.method;
17
+ if (proxyEnabled && proxyUrl) {
18
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
19
+ return proxy({ host: proxyUrl })(ctx, next);
20
+ }
21
+ addCors(ctx, headers);
22
+ if (method === "OPTIONS") {
23
+ ctx.status = HTTP_STATUS_CODE_OK;
24
+ return undefined;
25
+ }
26
+ const response = await dispatcher.request({
27
+ body,
28
+ /* @ts-expect-error the value of a header can be an array and we don't have a solution for that yet */
29
+ headers,
30
+ method,
31
+ path,
32
+ /* @ts-expect-error the value of a querystring item can be an array and we don't have a solution for that yet */
33
+ query,
34
+ req: { path: "", ...ctx.req },
35
+ });
36
+ /* eslint-disable require-atomic-updates */
37
+ ctx.body = response.body;
38
+ ctx.status = response.status ?? HTTP_STATUS_CODE_OK;
39
+ /* eslint-enable require-atomic-updates */
40
+ return undefined;
41
+ };
42
+ }
@@ -0,0 +1,109 @@
1
+ import { once } from "node:events";
2
+ import { existsSync } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import nodePath from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { watch } from "chokidar";
7
+ import createDebug from "debug";
8
+ import { ContextRegistry } from "./context-registry.js";
9
+ const debug = createDebug("counterfact:typescript-generator:module-loader");
10
+ export class ModuleLoader extends EventTarget {
11
+ basePath;
12
+ registry;
13
+ watcher;
14
+ contextRegistry;
15
+ constructor(basePath, registry, contextRegistry = new ContextRegistry()) {
16
+ super();
17
+ this.basePath = basePath.replaceAll("\\", "/");
18
+ this.registry = registry;
19
+ this.contextRegistry = contextRegistry;
20
+ }
21
+ async watch() {
22
+ this.watcher = watch(`${this.basePath}/**/*.{js,mjs,ts,mts}`).on("all",
23
+ // eslint-disable-next-line max-statements
24
+ (eventName, pathNameOriginal) => {
25
+ const pathName = pathNameOriginal.replaceAll("\\", "/");
26
+ if (!["add", "change", "unlink"].includes(eventName)) {
27
+ return;
28
+ }
29
+ const parts = nodePath.parse(pathName.replace(this.basePath, ""));
30
+ const url = `/${parts.dir}/${parts.name}`
31
+ .replaceAll("\\", "/")
32
+ .replaceAll(/\/+/gu, "/");
33
+ if (eventName === "unlink") {
34
+ this.registry.remove(url);
35
+ this.dispatchEvent(new Event("remove"));
36
+ }
37
+ const fileUrl = `${pathToFileURL(pathName).toString()}?cacheBust=${Date.now()}`;
38
+ debug("importing module: %s", fileUrl);
39
+ // eslint-disable-next-line import/no-dynamic-require, no-unsanitized/method
40
+ import(fileUrl)
41
+ // eslint-disable-next-line promise/prefer-await-to-then
42
+ .then((endpoint) => {
43
+ this.dispatchEvent(new Event(eventName));
44
+ if (pathName.includes("$.context")) {
45
+ this.contextRegistry.update(parts.dir,
46
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
47
+ endpoint.default);
48
+ return "context";
49
+ }
50
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
51
+ this.registry.add(url, endpoint);
52
+ return "path";
53
+ })
54
+ // eslint-disable-next-line promise/prefer-await-to-then
55
+ .catch((error) => {
56
+ process.stdout.write(`\nError loading ${fileUrl}:\n${String(error)}\n`);
57
+ });
58
+ });
59
+ await once(this.watcher, "ready");
60
+ }
61
+ async stopWatching() {
62
+ await this.watcher?.close();
63
+ }
64
+ async load(directory = "") {
65
+ if (!existsSync(nodePath.join(this.basePath, directory).replaceAll("\\", "/"))) {
66
+ throw new Error(`Directory does not exist ${this.basePath}`);
67
+ }
68
+ const files = await fs.readdir(nodePath.join(this.basePath, directory).replaceAll("\\", "/"), {
69
+ withFileTypes: true,
70
+ });
71
+ const imports = files.flatMap(async (file) => {
72
+ const extension = file.name.split(".").at(-1);
73
+ if (file.isDirectory()) {
74
+ await this.load(nodePath.join(directory, file.name).replaceAll("\\", "/"));
75
+ return;
76
+ }
77
+ if (!["js", "mjs", "mts", "ts"].includes(extension ?? "")) {
78
+ return;
79
+ }
80
+ const fullPath = nodePath
81
+ .join(this.basePath, directory, file.name)
82
+ .replaceAll("\\", "/");
83
+ await this.loadEndpoint(fullPath, directory, file);
84
+ });
85
+ await Promise.all(imports);
86
+ }
87
+ async loadEndpoint(fullPath, directory, file) {
88
+ const fileUrl = `${pathToFileURL(fullPath).toString()}?cacheBust=${Date.now()}`;
89
+ try {
90
+ // eslint-disable-next-line import/no-dynamic-require, no-unsanitized/method, @typescript-eslint/consistent-type-assertions
91
+ const endpoint = (await import(fileUrl));
92
+ if (file.name.includes("$.context")) {
93
+ this.contextRegistry.add(`/${directory.replaceAll("\\", "/")}`,
94
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
95
+ endpoint.default);
96
+ }
97
+ else {
98
+ const url = `/${nodePath.join(directory, nodePath.parse(file.name).name)}`
99
+ .replaceAll("\\", "/")
100
+ .replaceAll(/\/+/gu, "/");
101
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
102
+ this.registry.add(url, endpoint);
103
+ }
104
+ }
105
+ catch (error) {
106
+ process.stdout.write(`\nError loading ${fileUrl}:\n${String(error)}\n`);
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,116 @@
1
+ import createDebugger from "debug";
2
+ const debug = createDebugger("counterfact:server:registry");
3
+ function castParameters(parameters, parameterTypes) {
4
+ const copy = { ...parameters };
5
+ Object.entries(copy).forEach(([key, value]) => {
6
+ copy[key] =
7
+ parameterTypes?.[key] === "number"
8
+ ? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
9
+ Number.parseInt(value, 10)
10
+ : value;
11
+ });
12
+ return copy;
13
+ }
14
+ function maybe(flag, value) {
15
+ return flag ? [value] : [];
16
+ }
17
+ function stripBrackets(string) {
18
+ return string.replaceAll(/\{|\}/gu, "");
19
+ }
20
+ function routesForNode(node) {
21
+ if (!node.children) {
22
+ return [];
23
+ }
24
+ return Object.entries(node.children)
25
+ .flatMap(([segment, child]) => [
26
+ ...maybe(child.module, `/${segment}`),
27
+ ...routesForNode(child).map((route) => `/${segment}${route}`),
28
+ ])
29
+ .sort((segment1, segment2) => stripBrackets(segment1).localeCompare(stripBrackets(segment2)));
30
+ }
31
+ export class Registry {
32
+ modules = {};
33
+ moduleTree = { children: {} };
34
+ get routes() {
35
+ return routesForNode(this.moduleTree);
36
+ }
37
+ add(url, module) {
38
+ let node = this.moduleTree;
39
+ for (const segment of url.split("/").slice(1)) {
40
+ node.children ??= {};
41
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
42
+ node.children[segment] ??= {};
43
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
44
+ node = node.children[segment];
45
+ }
46
+ node.module = module;
47
+ }
48
+ remove(url) {
49
+ let node = this.moduleTree;
50
+ for (const segment of url.split("/").slice(1)) {
51
+ node = node.children?.[segment];
52
+ if (!node) {
53
+ return false;
54
+ }
55
+ }
56
+ delete node.module;
57
+ return true;
58
+ }
59
+ exists(method, url) {
60
+ return Boolean(this.handler(url).module?.[method]);
61
+ }
62
+ // eslint-disable-next-line max-statements, sonarjs/cognitive-complexity
63
+ handler(url) {
64
+ let node = this.moduleTree;
65
+ const path = {};
66
+ const matchedParts = [""];
67
+ for (const segment of url.split("/").slice(1)) {
68
+ if (node === undefined) {
69
+ throw new Error("node or node node.children cannot be undefined");
70
+ }
71
+ if (node.children === undefined) {
72
+ throw new Error("node or node node.children cannot be undefined");
73
+ }
74
+ const matchingChild = Object.keys(node.children).find((candidate) => candidate.toLowerCase() === segment.toLowerCase());
75
+ debug("segment: %s", segment);
76
+ debug("matching child: %s", matchingChild);
77
+ if (matchingChild === undefined) {
78
+ const dynamicSegment = Object.keys(node.children).find((ds) => ds.startsWith("{") && ds.endsWith("}"));
79
+ if (dynamicSegment !== undefined) {
80
+ const variableName = dynamicSegment.slice(1, -1);
81
+ path[variableName] = segment;
82
+ node = node.children[dynamicSegment];
83
+ matchedParts.push(dynamicSegment);
84
+ }
85
+ }
86
+ else {
87
+ node = node.children[matchingChild];
88
+ matchedParts.push(matchingChild);
89
+ }
90
+ }
91
+ if (node === undefined) {
92
+ throw new Error("node cannot be undefined");
93
+ }
94
+ return { matchedPath: matchedParts.join("/"), module: node.module, path };
95
+ }
96
+ endpoint(httpRequestMethod, url, parameterTypes = {}) {
97
+ const handler = this.handler(url);
98
+ debug("handler for %s: %o", url, handler);
99
+ const execute = handler.module?.[httpRequestMethod];
100
+ if (!execute) {
101
+ return () => ({
102
+ body: `Could not find a ${httpRequestMethod} method matching ${url}\n`,
103
+ contentType: "text/plain",
104
+ headers: {},
105
+ status: 404,
106
+ });
107
+ }
108
+ return async ({ ...requestData }) => await execute({
109
+ ...requestData,
110
+ headers: castParameters(requestData.headers, parameterTypes.header),
111
+ matchedPath: handler.matchedPath,
112
+ path: castParameters(handler.path, parameterTypes.path),
113
+ query: castParameters(requestData.query, parameterTypes.query),
114
+ });
115
+ }
116
+ }
@@ -0,0 +1,33 @@
1
+ import repl from "node:repl";
2
+ export function startRepl(contextRegistry, config) {
3
+ const replServer = repl.start("> ");
4
+ replServer.defineCommand("counterfact", {
5
+ action() {
6
+ process.stdout.write("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.\n");
7
+ process.stdout.write("Except that it's connected to the running server, which you can access with the following globals:\n\n");
8
+ process.stdout.write("- loadContext('/some/path'): to access the context object for a given path\n");
9
+ process.stdout.write("- context: the root context ( same as loadContext('/') )\n");
10
+ process.stdout.write("\nFor more information, see https://counterfact.dev/docs/usage.html\n\n");
11
+ this.clearBufferedCommand();
12
+ this.displayPrompt();
13
+ },
14
+ help: "Get help with Counterfact",
15
+ });
16
+ replServer.defineCommand("proxy", {
17
+ action(state) {
18
+ if (state === "on") {
19
+ config.proxyEnabled = true;
20
+ }
21
+ if (state === "off") {
22
+ config.proxyEnabled = false;
23
+ }
24
+ process.stdout.write(`Proxy is ${config.proxyEnabled ? "on" : "off"}: ${config.proxyUrl}\n`);
25
+ this.clearBufferedCommand();
26
+ this.displayPrompt();
27
+ },
28
+ help: "proxy [on|off] - turn the proxy on or off; proxy - print proxy info",
29
+ });
30
+ replServer.context.loadContext = (path) => contextRegistry.find(path);
31
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
32
+ replServer.context.context = replServer.context.loadContext("/");
33
+ }
@@ -0,0 +1,97 @@
1
+ import { JSONSchemaFaker } from "json-schema-faker";
2
+ JSONSchemaFaker.option("useExamplesValue", true);
3
+ function oneOf(items) {
4
+ if (Array.isArray(items)) {
5
+ return items[Math.floor(Math.random() * items.length)];
6
+ }
7
+ return oneOf(Object.values(items));
8
+ }
9
+ function unknownStatusCodeResponse(statusCode) {
10
+ return {
11
+ content: [
12
+ {
13
+ body: `The Open API document does not specify a response for status code ${statusCode ?? '""'}`,
14
+ type: "text/plain",
15
+ },
16
+ ],
17
+ status: 500,
18
+ };
19
+ }
20
+ export function createResponseBuilder(operation) {
21
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
22
+ return new Proxy({}, {
23
+ // eslint-disable-next-line sonarjs/cognitive-complexity
24
+ get: (target, statusCode) => ({
25
+ header(name, value) {
26
+ return {
27
+ ...this,
28
+ headers: {
29
+ ...this.headers,
30
+ [name]: value,
31
+ },
32
+ };
33
+ },
34
+ html(body) {
35
+ return this.match("text/html", body);
36
+ },
37
+ json(body) {
38
+ return this.match("application/json", body);
39
+ },
40
+ match(contentType, body) {
41
+ return {
42
+ ...this,
43
+ content: [
44
+ ...(this.content ?? []),
45
+ {
46
+ body,
47
+ type: contentType,
48
+ },
49
+ ],
50
+ };
51
+ },
52
+ random() {
53
+ if (operation.produces) {
54
+ return this.randomLegacy();
55
+ }
56
+ const response = operation.responses[this.status ?? "default"] ??
57
+ operation.responses.default;
58
+ if (response?.content === undefined) {
59
+ return unknownStatusCodeResponse(this.status);
60
+ }
61
+ const { content } = response;
62
+ return {
63
+ ...this,
64
+ content: Object.keys(content).map((type) => ({
65
+ body: content[type]?.examples
66
+ ? oneOf(Object.values(content[type]?.examples ?? []).map((example) => example.value))
67
+ : JSONSchemaFaker.generate(
68
+ // eslint-disable-next-line total-functions/no-unsafe-readonly-mutable-assignment
69
+ content[type]?.schema ?? { type: "object" }),
70
+ type,
71
+ })),
72
+ };
73
+ },
74
+ randomLegacy() {
75
+ const response = operation.responses[this.status ?? "default"] ??
76
+ operation.responses.default;
77
+ if (response === undefined) {
78
+ return unknownStatusCodeResponse(this.status);
79
+ }
80
+ const body = response.examples
81
+ ? oneOf(response.examples)
82
+ : JSONSchemaFaker.generate(response.schema ?? { type: "object" });
83
+ return {
84
+ ...this,
85
+ content: operation.produces?.map((type) => ({
86
+ body,
87
+ type,
88
+ })),
89
+ };
90
+ },
91
+ status: Number.parseInt(statusCode, 10),
92
+ text(body) {
93
+ return this.match("text/plain", body);
94
+ },
95
+ }),
96
+ });
97
+ }