counterfact 0.30.0 → 0.32.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.
@@ -57,29 +57,19 @@ async function main(source, destination) {
57
57
 
58
58
  debug("loaded counterfact", config);
59
59
 
60
- const waysToInteract = [
61
- `Call the REST APIs at ${url} (with your front end app, curl, Postman, etc.)`,
62
- `Change the implementation of the APIs by editing files under ${nodePath
63
- .join(basePath, "paths")
64
- .replaceAll("\\", "/")} (no need to restart)`,
65
- `Use the GUI at ${guiUrl}`,
66
- "Use the REPL below (type .counterfact for more information)",
67
- ];
68
-
69
60
  const introduction = [
61
+ "____ ____ _ _ _ _ ___ ____ ____ ____ ____ ____ ___",
62
+ "|___ [__] |__| |\\| | |=== |--< |--- |--| |___ | ",
63
+ " High code, low effort mock REST APIs",
70
64
  "",
71
- "Welcome to Counterfact!",
72
- "",
73
- "Counterfact is a mock server used to develop and test your front end app.",
74
- "There are several ways to poke and prod the server in order to make it behave the way you need for testing.",
65
+ `| API Base URL ==> ${url}`,
66
+ `| Admin Console ==> ${guiUrl}`,
67
+ "| Instructions ==> https://counterfact.dev/docs/usage.html",
75
68
  "",
69
+ "Starting REPL, type .help for more info",
76
70
  ];
77
71
 
78
- process.stdout.write(`${introduction.join("\n")}\n`);
79
-
80
- process.stdout.write(
81
- waysToInteract.map((text, index) => `${index + 1}. ${text}`).join("\n"),
82
- );
72
+ process.stdout.write(introduction.join("\n"));
83
73
 
84
74
  process.stdout.write("\n\n");
85
75
 
@@ -0,0 +1,130 @@
1
+ function isDirectory(test) {
2
+ return test !== undefined;
3
+ }
4
+ export class ModuleTree {
5
+ root = {
6
+ directories: {},
7
+ files: {},
8
+ isWildcard: false,
9
+ name: "",
10
+ rawName: "",
11
+ };
12
+ addModuleToDirectory(directory, segments, module) {
13
+ if (directory === undefined) {
14
+ return;
15
+ }
16
+ const [segment, ...remainingSegments] = segments;
17
+ if (segment === undefined) {
18
+ throw new Error("segments array is empty");
19
+ }
20
+ 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;
28
+ }
29
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
30
+ directory.directories[segment.toLowerCase()] ??= {
31
+ directories: {},
32
+ files: {},
33
+ isWildcard: segment.startsWith("{"),
34
+ name: segment.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
35
+ rawName: segment,
36
+ };
37
+ this.addModuleToDirectory(directory.directories[segment.toLocaleLowerCase()], remainingSegments, module);
38
+ }
39
+ add(url, module) {
40
+ this.addModuleToDirectory(this.root, url.split("/").slice(1), module);
41
+ }
42
+ removeModuleFromDirectory(directory, segments) {
43
+ if (!isDirectory(directory)) {
44
+ return;
45
+ }
46
+ const [segment, ...remainingSegments] = segments;
47
+ if (segment === undefined) {
48
+ return;
49
+ }
50
+ if (remainingSegments.length === 0) {
51
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
52
+ delete directory.files[segment.toLowerCase()];
53
+ return;
54
+ }
55
+ this.removeModuleFromDirectory(directory.directories[segment.toLowerCase()], remainingSegments);
56
+ }
57
+ remove(url) {
58
+ const segments = url.split("/").slice(1);
59
+ this.removeModuleFromDirectory(this.root, segments);
60
+ }
61
+ // eslint-disable-next-line max-params
62
+ buildMatch(directory, segment, pathVariables, matchedPath) {
63
+ const match = directory.files[segment.toLowerCase()] ??
64
+ Object.values(directory.files).find((file) => file.isWildcard);
65
+ if (match === undefined) {
66
+ return undefined;
67
+ }
68
+ if (match.isWildcard) {
69
+ return {
70
+ ...match,
71
+ matchedPath: `${matchedPath}/${match.rawName}`,
72
+ pathVariables: {
73
+ ...pathVariables,
74
+ [match.name]: segment,
75
+ },
76
+ };
77
+ }
78
+ return {
79
+ ...match,
80
+ matchedPath: `${matchedPath}/${match.rawName}`,
81
+ pathVariables,
82
+ };
83
+ }
84
+ // eslint-disable-next-line max-statements, max-params
85
+ matchWithinDirectory(directory, segments, pathVariables, matchedPath) {
86
+ if (segments.length === 0) {
87
+ return undefined;
88
+ }
89
+ const [segment, ...remainingSegments] = segments;
90
+ if (segment === undefined) {
91
+ throw new Error("segment cannot be undefined but TypeScript doesn't know that");
92
+ }
93
+ if (remainingSegments.length === 0) {
94
+ return this.buildMatch(directory, segment, pathVariables, matchedPath);
95
+ }
96
+ const exactMatch = directory.directories[segment.toLowerCase()];
97
+ if (isDirectory(exactMatch)) {
98
+ return this.matchWithinDirectory(exactMatch, remainingSegments, pathVariables, `${matchedPath}/${segment}`);
99
+ }
100
+ const wildcardDirectory = Object.values(directory.directories).find((subdirectory) => subdirectory.isWildcard);
101
+ if (wildcardDirectory) {
102
+ return this.matchWithinDirectory(wildcardDirectory, remainingSegments, {
103
+ ...pathVariables,
104
+ [wildcardDirectory.name]: segment,
105
+ }, `${matchedPath}/${wildcardDirectory.rawName}`);
106
+ }
107
+ return undefined;
108
+ }
109
+ match(url) {
110
+ return this.matchWithinDirectory(this.root, url.split("/").slice(1), {}, "");
111
+ }
112
+ get routes() {
113
+ const routes = [];
114
+ function traverse(directory, path) {
115
+ Object.values(directory.directories).forEach((subdirectory) => {
116
+ traverse(subdirectory, `${path}/${subdirectory.rawName}`);
117
+ });
118
+ Object.values(directory.files).forEach((file) => {
119
+ routes.push(`${path}/${file.rawName}`);
120
+ });
121
+ }
122
+ // eslint-disable-next-line unicorn/consistent-function-scoping
123
+ function stripBrackets(string) {
124
+ return string.replaceAll(/\{|\}/gu, "");
125
+ }
126
+ traverse(this.root, "");
127
+ // eslint-disable-next-line etc/no-assign-mutated-array
128
+ return routes.sort((first, second) => stripBrackets(first).localeCompare(stripBrackets(second)));
129
+ }
130
+ }
@@ -1,4 +1,5 @@
1
1
  import createDebugger from "debug";
2
+ import { ModuleTree } from "./module-tree.js";
2
3
  const debug = createDebugger("counterfact:server:registry");
3
4
  function castParameters(parameters, parameterTypes) {
4
5
  const copy = { ...parameters };
@@ -11,87 +12,27 @@ function castParameters(parameters, parameterTypes) {
11
12
  });
12
13
  return copy;
13
14
  }
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
15
  export class Registry {
32
- modules = {};
33
- moduleTree = { children: {} };
16
+ moduleTree = new ModuleTree();
34
17
  get routes() {
35
- return routesForNode(this.moduleTree);
18
+ return this.moduleTree.routes;
36
19
  }
37
20
  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;
21
+ this.moduleTree.add(url, module);
47
22
  }
48
23
  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;
24
+ this.moduleTree.remove(url);
58
25
  }
59
26
  exists(method, url) {
60
27
  return Boolean(this.handler(url).module?.[method]);
61
28
  }
62
- // eslint-disable-next-line max-statements, sonarjs/cognitive-complexity
63
29
  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 };
30
+ const match = this.moduleTree.match(url);
31
+ return {
32
+ matchedPath: match?.matchedPath ?? "",
33
+ module: match?.module,
34
+ path: match?.pathVariables ?? {},
35
+ };
95
36
  }
96
37
  endpoint(httpRequestMethod, url, parameterTypes = {}) {
97
38
  const handler = this.handler(url);
@@ -0,0 +1,201 @@
1
+ interface OpenApiHeader {
2
+ schema: unknown;
3
+ }
4
+
5
+ interface OpenApiContent {
6
+ schema: unknown;
7
+ }
8
+
9
+ interface Example {
10
+ description: string;
11
+ summary: string;
12
+ value: unknown;
13
+ }
14
+
15
+ type MediaType = `${string}/${string}`;
16
+
17
+ type OmitValueWhenNever<Base> = Pick<
18
+ Base,
19
+ {
20
+ [Key in keyof Base]: [Base[Key]] extends [never] ? never : Key;
21
+ }[keyof Base]
22
+ >;
23
+
24
+ interface OpenApiResponse {
25
+ content: { [key: MediaType]: OpenApiContent };
26
+ headers: { [key: string]: OpenApiHeader };
27
+ }
28
+
29
+ interface OpenApiResponses {
30
+ [key: string]: OpenApiResponse;
31
+ }
32
+
33
+ type IfHasKey<SomeObject, Key, Yes, No> = Key extends keyof SomeObject
34
+ ? Yes
35
+ : No;
36
+
37
+ type MaybeShortcut<
38
+ ContentType extends MediaType,
39
+ Response extends OpenApiResponse,
40
+ > = IfHasKey<
41
+ Response["content"],
42
+ ContentType,
43
+ (body: Response["content"][ContentType]["schema"]) => GenericResponseBuilder<{
44
+ content: Omit<Response["content"], ContentType>;
45
+ headers: Response["headers"];
46
+ }>,
47
+ never
48
+ >;
49
+
50
+ type MatchFunction<Response extends OpenApiResponse> = <
51
+ ContentType extends MediaType & keyof Response["content"],
52
+ >(
53
+ contentType: ContentType,
54
+ body: Response["content"][ContentType]["schema"],
55
+ ) => GenericResponseBuilder<{
56
+ content: Omit<Response["content"], ContentType>;
57
+ headers: Response["headers"];
58
+ }>;
59
+
60
+ type HeaderFunction<Response extends OpenApiResponse> = <
61
+ Header extends string & keyof Response["headers"],
62
+ >(
63
+ header: Header,
64
+ value: Response["headers"][Header]["schema"],
65
+ ) => GenericResponseBuilder<{
66
+ content: Response["content"];
67
+ headers: Omit<Response["headers"], Header>;
68
+ }>;
69
+
70
+ interface ResponseBuilder {
71
+ [status: number | `${number} ${string}`]: ResponseBuilder;
72
+ content?: { body: unknown; type: string }[];
73
+ header: (name: string, value: string) => ResponseBuilder;
74
+ headers: { [name: string]: string };
75
+ html: (body: unknown) => ResponseBuilder;
76
+ json: (body: unknown) => ResponseBuilder;
77
+ match: (contentType: string, body: unknown) => ResponseBuilder;
78
+ random: () => ResponseBuilder;
79
+ randomLegacy: () => ResponseBuilder;
80
+ status?: number;
81
+ text: (body: unknown) => ResponseBuilder;
82
+ }
83
+
84
+ type GenericResponseBuilder<
85
+ Response extends OpenApiResponse = OpenApiResponse,
86
+ > = [keyof Response["content"]] extends [never]
87
+ ? // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
88
+ void
89
+ : OmitValueWhenNever<{
90
+ header: [keyof Response["headers"]] extends [never]
91
+ ? never
92
+ : HeaderFunction<Response>;
93
+ html: MaybeShortcut<"text/html", Response>;
94
+ json: MaybeShortcut<"application/json", Response>;
95
+ match: [keyof Response["content"]] extends [never]
96
+ ? never
97
+ : MatchFunction<Response>;
98
+ random: [keyof Response["content"]] extends [never] ? never : () => void;
99
+ text: MaybeShortcut<"text/plain", Response>;
100
+ }>;
101
+
102
+ type ResponseBuilderFactory<
103
+ Responses extends OpenApiResponses = OpenApiResponses,
104
+ > = {
105
+ [StatusCode in keyof Responses]: GenericResponseBuilder<
106
+ Responses[StatusCode]
107
+ >;
108
+ } & { [key: string]: GenericResponseBuilder<Responses["default"]> };
109
+
110
+ type HttpStatusCode =
111
+ | 100
112
+ | 101
113
+ | 102
114
+ | 200
115
+ | 201
116
+ | 202
117
+ | 203
118
+ | 204
119
+ | 205
120
+ | 206
121
+ | 207
122
+ | 226
123
+ | 300
124
+ | 301
125
+ | 302
126
+ | 303
127
+ | 304
128
+ | 305
129
+ | 307
130
+ | 308
131
+ | 400
132
+ | 401
133
+ | 402
134
+ | 403
135
+ | 404
136
+ | 405
137
+ | 406
138
+ | 407
139
+ | 408
140
+ | 409
141
+ | 410
142
+ | 411
143
+ | 412
144
+ | 413
145
+ | 414
146
+ | 415
147
+ | 416
148
+ | 417
149
+ | 418
150
+ | 422
151
+ | 423
152
+ | 424
153
+ | 426
154
+ | 428
155
+ | 429
156
+ | 431
157
+ | 451
158
+ | 500
159
+ | 501
160
+ | 502
161
+ | 503
162
+ | 504
163
+ | 505
164
+ | 506
165
+ | 507
166
+ | 511;
167
+
168
+ interface OpenApiParameters {
169
+ in: "body" | "cookie" | "formData" | "header" | "path" | "query";
170
+ name: string;
171
+ schema?: {
172
+ type: string;
173
+ };
174
+ }
175
+
176
+ interface OpenApiOperation {
177
+ parameters?: OpenApiParameters[];
178
+ produces?: string[];
179
+ responses: {
180
+ [status: string]: {
181
+ content?: {
182
+ [type: number | string]: {
183
+ examples?: { [key: string]: Example };
184
+ schema: unknown;
185
+ };
186
+ };
187
+ examples?: { [key: string]: unknown };
188
+ schema?: unknown;
189
+ };
190
+ };
191
+ }
192
+
193
+ export type {
194
+ HttpStatusCode,
195
+ MediaType,
196
+ OpenApiOperation,
197
+ OpenApiParameters,
198
+ OpenApiResponse,
199
+ ResponseBuilder,
200
+ ResponseBuilderFactory,
201
+ };
@@ -32,7 +32,7 @@ export class Repository {
32
32
  }
33
33
  copyCoreFiles(destination) {
34
34
  return fs.copyFile(nodePath
35
- .join(__dirname, "../../src/server/types.d.ts")
35
+ .join(__dirname, "../../dist/server/types.d.ts")
36
36
  .replaceAll("\\", "/"), nodePath.join(destination, "types.d.ts").replaceAll("\\", "/"));
37
37
  }
38
38
  async writeFiles(destination) {
@@ -48,13 +48,12 @@ export class Repository {
48
48
  .stat(fullPath)
49
49
  .then((stat) => stat.isFile())
50
50
  .catch(() => false))) {
51
- process.stdout.write(`not overwriting ${fullPath}\n`);
51
+ debug(`not overwriting ${fullPath}\n`);
52
52
  return;
53
53
  }
54
54
  debug("about to write", fullPath);
55
55
  await fs.writeFile(fullPath, contents);
56
56
  debug("did write", fullPath);
57
- process.stdout.write(`writing ${fullPath}\n`);
58
57
  });
59
58
  await Promise.all(writeFiles);
60
59
  await this.copyCoreFiles(destination);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "counterfact",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "a library for building a fake REST API for testing",
5
5
  "type": "module",
6
6
  "main": "./src/server/counterfact.js",
@@ -31,7 +31,7 @@
31
31
  "test": "yarn node --experimental-vm-modules ./node_modules/jest-cli/bin/jest --testPathIgnorePatterns=black-box --forceExit",
32
32
  "test:black-box": "rimraf dist && rimraf out && yarn build && yarn node --experimental-vm-modules ./node_modules/jest-cli/bin/jest black-box --forceExit --coverage=false",
33
33
  "test:mutants": "stryker run stryker.config.json",
34
- "build": "tsc && copyfiles -f \"src/client/**\" dist/client",
34
+ "build": "tsc && copyfiles -f \"src/client/**\" dist/client && copyfiles -f \"src/server/*.d.ts\" dist/server",
35
35
  "prepack": "yarn build",
36
36
  "release": "npx changeset publish",
37
37
  "prepare": "husky install",
@@ -43,21 +43,21 @@
43
43
  "counterfact": "./bin/counterfact.js"
44
44
  },
45
45
  "devDependencies": {
46
- "@changesets/cli": "2.26.2",
47
- "@stryker-mutator/core": "7.3.0",
48
- "@stryker-mutator/jest-runner": "7.3.0",
49
- "@stryker-mutator/typescript-checker": "7.3.0",
50
- "@swc/core": "1.3.96",
46
+ "@changesets/cli": "2.27.1",
47
+ "@stryker-mutator/core": "8.0.0",
48
+ "@stryker-mutator/jest-runner": "8.0.0",
49
+ "@stryker-mutator/typescript-checker": "8.0.0",
50
+ "@swc/core": "1.3.100",
51
51
  "@swc/jest": "0.2.29",
52
52
  "@testing-library/dom": "9.3.3",
53
- "@types/jest": "29.5.8",
53
+ "@types/jest": "29.5.10",
54
54
  "@types/js-yaml": "4.0.9",
55
- "@types/koa": "2.13.11",
55
+ "@types/koa": "2.13.12",
56
56
  "@types/koa-bodyparser": "4.3.12",
57
57
  "@types/koa-proxy": "1.0.7",
58
58
  "@types/koa-static": "4.0.4",
59
59
  "copyfiles": "2.4.1",
60
- "eslint": "8.53.0",
60
+ "eslint": "8.55.0",
61
61
  "eslint-config-hardcore": "41.3.0",
62
62
  "eslint-formatter-github-annotations": "0.1.0",
63
63
  "eslint-import-resolver-typescript": "3.6.1",
@@ -71,7 +71,7 @@
71
71
  "husky": "8.0.3",
72
72
  "jest": "29.7.0",
73
73
  "node-mocks-http": "1.13.0",
74
- "nodemon": "3.0.1",
74
+ "nodemon": "3.0.2",
75
75
  "patch-package": "8.0.0",
76
76
  "rimraf": "5.0.5",
77
77
  "stryker-cli": "1.0.2",
@@ -84,20 +84,20 @@
84
84
  "commander": "11.1.0",
85
85
  "debug": "4.3.4",
86
86
  "fetch": "1.1.0",
87
- "fs-extra": "11.1.1",
87
+ "fs-extra": "11.2.0",
88
88
  "handlebars": "4.7.8",
89
89
  "http-terminator": "3.2.0",
90
90
  "js-yaml": "4.1.0",
91
- "json-schema-faker": "0.5.3",
91
+ "json-schema-faker": "0.5.4",
92
92
  "json-schema-ref-parser": "9.0.9",
93
93
  "jsonwebtoken": "9.0.2",
94
94
  "koa": "2.14.2",
95
95
  "koa-bodyparser": "4.4.1",
96
96
  "koa-proxy": "1.0.0-alpha.3",
97
- "koa2-swagger-ui": "5.9.1",
97
+ "koa2-swagger-ui": "5.10.0",
98
98
  "node-fetch": "3.3.2",
99
99
  "open": "9.1.0",
100
- "prettier": "3.0.3",
101
- "typescript": "5.2.2"
100
+ "prettier": "3.1.0",
101
+ "typescript": "5.3.2"
102
102
  }
103
103
  }