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.
- package/bin/counterfact.js +3 -3
- package/dist/src/server/constants.js +3 -0
- package/dist/src/server/context-registry.js +32 -0
- package/dist/src/server/counterfact.js +47 -0
- package/dist/src/server/dispatcher.js +150 -0
- package/dist/src/server/koa-middleware.js +42 -0
- package/dist/src/server/module-loader.js +109 -0
- package/dist/src/server/registry.js +116 -0
- package/dist/src/server/repl.js +33 -0
- package/dist/src/server/response-builder.js +97 -0
- package/dist/src/server/start.js +106 -0
- package/dist/src/server/tools.js +27 -0
- package/dist/src/server/transpiler.js +84 -0
- package/dist/src/typescript-generator/coder.js +43 -0
- package/dist/src/typescript-generator/context-coder.js +48 -0
- package/dist/src/typescript-generator/generate.js +45 -0
- package/dist/src/typescript-generator/operation-coder.js +36 -0
- package/dist/src/typescript-generator/operation-type-coder.js +76 -0
- package/dist/src/typescript-generator/parameters-type-coder.js +38 -0
- package/dist/src/typescript-generator/printers.js +10 -0
- package/dist/src/typescript-generator/repository.js +67 -0
- package/dist/src/typescript-generator/requirement.js +65 -0
- package/dist/src/typescript-generator/response-type-coder.js +85 -0
- package/dist/src/typescript-generator/schema-coder.js +52 -0
- package/dist/src/typescript-generator/schema-type-coder.js +88 -0
- package/dist/src/typescript-generator/script.js +140 -0
- package/dist/src/typescript-generator/specification.js +46 -0
- package/dist/src/util/ensure-directory-exists.js +13 -0
- package/dist/src/util/read-file.js +13 -0
- package/dist/templates/response-builder-factory.js +1 -0
- package/{templates → dist/templates}/response-builder-factory.ts +7 -16
- package/package.json +56 -40
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -11
- package/.devcontainer/Dockerfile +0 -14
- package/.devcontainer/base.Dockerfile +0 -17
- package/.devcontainer/devcontainer.json +0 -47
- package/.eslintrc.cjs +0 -208
- package/.github/workflows/ci.yaml +0 -42
- package/.github/workflows/codeql.yml +0 -76
- package/.github/workflows/coveralls.yaml +0 -27
- package/.github/workflows/debug-windows.yaml +0 -40
- package/.github/workflows/mutation-testing.yaml +0 -36
- package/.github/workflows/release.yaml +0 -42
- package/.husky/post-commit +0 -4
- package/.putout.json +0 -5
- package/.rtx.toml +0 -2
- package/.swcrc +0 -13
- package/.vscode/settings.json +0 -28
- package/CHANGELOG.md +0 -386
- package/CNAME +0 -1
- package/CODE_OF_CONDUCT.md +0 -128
- package/CONTRIBUTING.md +0 -30
- package/_config.yaml +0 -12
- package/_includes/head-custom-google-analytics.html +0 -13
- package/docs/quick-start.md +0 -19
- package/docs/usage.md +0 -262
- package/jest.config.js +0 -33
- package/openapi-example.yaml +0 -64
- package/petstore.yaml +0 -819
- package/renovate.json +0 -24
- package/src/server/constants.js +0 -3
- package/src/server/context-registry.js +0 -38
- package/src/server/counterfact.js +0 -67
- package/src/server/dispatcher.js +0 -200
- package/src/server/koa-middleware.js +0 -51
- package/src/server/module-loader.js +0 -155
- package/src/server/registry.js +0 -150
- package/src/server/repl.js +0 -56
- package/src/server/response-builder.js +0 -122
- package/src/server/start.js +0 -146
- package/src/server/tools.js +0 -36
- package/src/server/transpiler.js +0 -99
- package/src/typescript-generator/README.md +0 -70
- package/src/typescript-generator/coder.js +0 -58
- package/src/typescript-generator/context-coder.js +0 -58
- package/src/typescript-generator/generate.js +0 -41
- package/src/typescript-generator/operation-coder.js +0 -51
- package/src/typescript-generator/operation-type-coder.js +0 -116
- package/src/typescript-generator/parameters-type-coder.js +0 -53
- package/src/typescript-generator/printers.js +0 -11
- package/src/typescript-generator/repository.js +0 -120
- package/src/typescript-generator/requirement.js +0 -93
- package/src/typescript-generator/response-type-coder.js +0 -120
- package/src/typescript-generator/schema-coder.js +0 -71
- package/src/typescript-generator/schema-type-coder.js +0 -135
- package/src/typescript-generator/script.js +0 -210
- package/src/typescript-generator/specification.js +0 -69
- package/src/util/read-file.js +0 -41
- package/stryker.config.json +0 -13
- package/test/lib/with-temporary-files.js +0 -86
- package/test/server/context-registry.test.js +0 -85
- package/test/server/counterfact.test.js +0 -144
- package/test/server/dispatcher.proxy.test.js +0 -44
- package/test/server/dispatcher.test.js +0 -570
- package/test/server/koa-middleware.test.js +0 -199
- package/test/server/module-loader.test.js +0 -168
- package/test/server/registry.test.js +0 -149
- package/test/server/response-builder.test.js +0 -202
- package/test/server/tools.test.js +0 -51
- package/test/server/transpiler.test.js +0 -117
- package/test/typescript-generator/__snapshots__/end-to-end.test.js.snap +0 -6440
- package/test/typescript-generator/__snapshots__/operation-coder.test.js.snap +0 -15
- package/test/typescript-generator/__snapshots__/operation-type-coder.test.js.snap +0 -295
- package/test/typescript-generator/__snapshots__/parameters-type-coder.test.js.snap +0 -36
- package/test/typescript-generator/coder.test.js +0 -106
- package/test/typescript-generator/context-coder.test.js +0 -91
- package/test/typescript-generator/end-to-end.test.js +0 -20
- package/test/typescript-generator/integration.test.js +0 -56
- package/test/typescript-generator/operation-coder.test.js +0 -143
- package/test/typescript-generator/operation-type-coder.test.js +0 -278
- package/test/typescript-generator/parameters-type-coder.test.js +0 -94
- package/test/typescript-generator/petstore.yaml +0 -819
- package/test/typescript-generator/repository.test.js +0 -14
- package/test/typescript-generator/requirement.test.js +0 -132
- package/test/typescript-generator/response-type-coder.test.js +0 -42
- package/test/typescript-generator/schema-coder.test.js +0 -139
- package/test/typescript-generator/schema-type-coder.test.js +0 -328
- package/test/typescript-generator/script.test.js +0 -202
- package/test/typescript-generator/specification.test.js +0 -155
- package/tsconfig.json +0 -12
- /package/{src → dist/src}/client/index.html.hbs +0 -0
- /package/{src → dist/src}/client/rapi-doc.html.hbs +0 -0
package/bin/counterfact.js
CHANGED
|
@@ -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,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
|
+
}
|