counterfact 2.2.1 → 2.3.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/README.md +1 -0
- package/bin/README.md +1 -0
- package/bin/counterfact.js +22 -3
- package/dist/server/counterfact-types/OpenApiHeader.ts +2 -1
- package/dist/server/counterfact-types/index.ts +4 -1
- package/dist/server/module-loader.js +18 -0
- package/dist/server/registry.js +4 -5
- package/dist/server/response-builder.js +10 -0
- package/dist/typescript-generator/generate.js +6 -0
- package/dist/typescript-generator/prune.js +101 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -202,6 +202,7 @@ npx counterfact@latest [openapi.yaml] [destination] [options]
|
|
|
202
202
|
| `-w, --watch` | Generate and watch for spec changes |
|
|
203
203
|
| `-s, --serve` | Start the mock server |
|
|
204
204
|
| `-r, --repl` | Start the interactive REPL |
|
|
205
|
+
| `--spec <path>` | Path or URL to the OpenAPI document |
|
|
205
206
|
| `--proxy-url <url>` | Forward all requests to this URL by default |
|
|
206
207
|
| `--prefix <path>` | Base path prefix (e.g. `/api/v1`) |
|
|
207
208
|
|
package/bin/README.md
CHANGED
|
@@ -37,6 +37,7 @@ npx counterfact openapi.yaml ./api [options]
|
|
|
37
37
|
| `-w, --watch` | Re-generate whenever the spec changes |
|
|
38
38
|
| `-s, --serve` | Start the HTTP server |
|
|
39
39
|
| `-r, --repl` | Start the interactive REPL |
|
|
40
|
+
| `--spec <path>` | Path or URL to the OpenAPI document (alternative to positional argument) |
|
|
40
41
|
| `--proxy-url <url>` | Forward all unmatched requests to this upstream URL |
|
|
41
42
|
| `--prefix <path>` | Base path prefix for all routes (e.g. `/api/v1`) |
|
|
42
43
|
|
package/bin/counterfact.js
CHANGED
|
@@ -102,11 +102,20 @@ async function main(source, destination) {
|
|
|
102
102
|
|
|
103
103
|
const options = program.opts();
|
|
104
104
|
|
|
105
|
+
// --spec takes precedence over the positional [openapi.yaml] argument.
|
|
106
|
+
// When --spec is provided, the [openapi.yaml] positional slot shifts to
|
|
107
|
+
// become the [destination] argument (so `counterfact --spec api.yaml ./api`
|
|
108
|
+
// works the same as `counterfact api.yaml ./api`).
|
|
109
|
+
if (options.spec) {
|
|
110
|
+
if (source !== "_") {
|
|
111
|
+
destination = source;
|
|
112
|
+
}
|
|
113
|
+
source = options.spec;
|
|
114
|
+
}
|
|
115
|
+
|
|
105
116
|
const args = process.argv;
|
|
106
117
|
|
|
107
|
-
const destinationPath = nodePath
|
|
108
|
-
.join(process.cwd(), destination)
|
|
109
|
-
.replaceAll("\\", "/");
|
|
118
|
+
const destinationPath = nodePath.resolve(destination).replaceAll("\\", "/");
|
|
110
119
|
|
|
111
120
|
const basePath = nodePath.resolve(destinationPath).replaceAll("\\", "/");
|
|
112
121
|
|
|
@@ -155,6 +164,8 @@ async function main(source, destination) {
|
|
|
155
164
|
options.watch ||
|
|
156
165
|
options.watchTypes ||
|
|
157
166
|
options.buildCache,
|
|
167
|
+
|
|
168
|
+
prune: Boolean(options.prune),
|
|
158
169
|
},
|
|
159
170
|
|
|
160
171
|
openApiPath: source,
|
|
@@ -326,5 +337,13 @@ program
|
|
|
326
337
|
"--always-fake-optionals",
|
|
327
338
|
"random responses will include optional fields",
|
|
328
339
|
)
|
|
340
|
+
.option(
|
|
341
|
+
"--prune",
|
|
342
|
+
"remove route files that no longer exist in the OpenAPI spec",
|
|
343
|
+
)
|
|
344
|
+
.option(
|
|
345
|
+
"--spec <string>",
|
|
346
|
+
"path or URL to OpenAPI document (alternative to the positional [openapi.yaml] argument)",
|
|
347
|
+
)
|
|
329
348
|
.action(main)
|
|
330
349
|
.parse(process.argv);
|
|
@@ -38,7 +38,7 @@ type OmitValueWhenNever<Base> = Pick<
|
|
|
38
38
|
interface OpenApiResponse {
|
|
39
39
|
content: { [key: MediaType]: OpenApiContent };
|
|
40
40
|
examples?: { [key: string]: unknown };
|
|
41
|
-
headers: { [key: string]:
|
|
41
|
+
headers: { [key: string]: { schema: unknown } };
|
|
42
42
|
requiredHeaders: string;
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -257,6 +257,9 @@ interface OpenApiOperation {
|
|
|
257
257
|
};
|
|
258
258
|
};
|
|
259
259
|
examples?: { [key: string]: unknown };
|
|
260
|
+
headers?: {
|
|
261
|
+
[name: string]: OpenApiHeader;
|
|
262
|
+
};
|
|
260
263
|
schema?: { [key: string]: unknown };
|
|
261
264
|
};
|
|
262
265
|
};
|
|
@@ -110,10 +110,28 @@ export class ModuleLoader extends EventTarget {
|
|
|
110
110
|
if (basename(pathName).startsWith("_.context.") &&
|
|
111
111
|
isContextModule(endpoint)) {
|
|
112
112
|
const loadContext = (path) => this.contextRegistry.find(path);
|
|
113
|
+
const contextDir = nodePath.dirname(unescapePathForWindows(pathName));
|
|
114
|
+
const readJson = async (relativePath) => {
|
|
115
|
+
const absolutePath = nodePath.resolve(contextDir, relativePath);
|
|
116
|
+
let content;
|
|
117
|
+
try {
|
|
118
|
+
content = await fs.readFile(absolutePath, "utf8");
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new Error(`readJson: could not read file at "${absolutePath}" (resolved from "${relativePath}" relative to "${contextDir}")`);
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(content);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw new Error(`readJson: file at "${absolutePath}" does not contain valid JSON`);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
113
130
|
this.contextRegistry.update(directory,
|
|
114
131
|
// @ts-expect-error TS says Context has no constructable signatures but that's not true?
|
|
115
132
|
new endpoint.Context({
|
|
116
133
|
loadContext,
|
|
134
|
+
readJson,
|
|
117
135
|
}));
|
|
118
136
|
return;
|
|
119
137
|
}
|
package/dist/server/registry.js
CHANGED
|
@@ -37,7 +37,7 @@ export class Registry {
|
|
|
37
37
|
moduleTree = new ModuleTree();
|
|
38
38
|
middlewares = new Map();
|
|
39
39
|
constructor() {
|
|
40
|
-
this.middlewares.set("
|
|
40
|
+
this.middlewares.set("", ($, respondTo) => respondTo($));
|
|
41
41
|
}
|
|
42
42
|
get routes() {
|
|
43
43
|
return this.moduleTree.routes;
|
|
@@ -46,7 +46,7 @@ export class Registry {
|
|
|
46
46
|
this.moduleTree.add(url, module);
|
|
47
47
|
}
|
|
48
48
|
addMiddleware(url, callback) {
|
|
49
|
-
this.middlewares.set(url, callback);
|
|
49
|
+
this.middlewares.set(url === "/" ? "" : url, callback);
|
|
50
50
|
}
|
|
51
51
|
remove(url) {
|
|
52
52
|
this.moduleTree.remove(url);
|
|
@@ -111,11 +111,10 @@ export class Registry {
|
|
|
111
111
|
};
|
|
112
112
|
const middlewares = this.middlewares;
|
|
113
113
|
function recurse(path, respondTo) {
|
|
114
|
+
debug("recursing path", path);
|
|
114
115
|
if (path === null)
|
|
115
116
|
return respondTo;
|
|
116
|
-
const nextPath = path === "
|
|
117
|
-
? null
|
|
118
|
-
: path.slice(0, path.lastIndexOf("/")) || "/";
|
|
117
|
+
const nextPath = path === "" ? null : path.slice(0, path.lastIndexOf("/"));
|
|
119
118
|
const middleware = middlewares.get(path);
|
|
120
119
|
if (middleware !== undefined) {
|
|
121
120
|
return recurse(nextPath, ($) => middleware($, respondTo));
|
|
@@ -96,6 +96,12 @@ export function createResponseBuilder(operation, config) {
|
|
|
96
96
|
return unknownStatusCodeResponse(this.status);
|
|
97
97
|
}
|
|
98
98
|
const { content } = response;
|
|
99
|
+
const generatedHeaders = {};
|
|
100
|
+
for (const [name, header] of Object.entries(response.headers ?? {})) {
|
|
101
|
+
if (header.required && !(name in (this.headers ?? {}))) {
|
|
102
|
+
generatedHeaders[name] = JSONSchemaFaker.generate(header.schema ?? { type: "string" });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
99
105
|
return {
|
|
100
106
|
...this,
|
|
101
107
|
content: Object.keys(content).map((type) => ({
|
|
@@ -104,6 +110,10 @@ export function createResponseBuilder(operation, config) {
|
|
|
104
110
|
: JSONSchemaFaker.generate(content[type]?.schema ?? { type: "object" }), content[type]?.schema),
|
|
105
111
|
type,
|
|
106
112
|
})),
|
|
113
|
+
headers: {
|
|
114
|
+
...generatedHeaders,
|
|
115
|
+
...this.headers,
|
|
116
|
+
},
|
|
107
117
|
};
|
|
108
118
|
},
|
|
109
119
|
randomLegacy() {
|
|
@@ -5,6 +5,7 @@ import nodePath from "node:path";
|
|
|
5
5
|
import createDebug from "debug";
|
|
6
6
|
import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
|
|
7
7
|
import { OperationCoder } from "./operation-coder.js";
|
|
8
|
+
import { pruneRoutes } from "./prune.js";
|
|
8
9
|
import { Repository } from "./repository.js";
|
|
9
10
|
import { Specification } from "./specification.js";
|
|
10
11
|
const debug = createDebug("counterfact:typescript-generator:generate");
|
|
@@ -41,6 +42,11 @@ export async function generate(source, destination, generateOptions, repository
|
|
|
41
42
|
debug("reading the #/paths from the specification");
|
|
42
43
|
const paths = await getPathsFromSpecification(specification);
|
|
43
44
|
debug("got %i paths", paths.size);
|
|
45
|
+
if (generateOptions.prune && generateOptions.routes) {
|
|
46
|
+
debug("pruning defunct route files");
|
|
47
|
+
await pruneRoutes(destination, paths.keys());
|
|
48
|
+
debug("done pruning");
|
|
49
|
+
}
|
|
44
50
|
const securityRequirement = specification.getRequirement("#/components/securitySchemes");
|
|
45
51
|
const securitySchemes = Object.values(securityRequirement?.data ?? {});
|
|
46
52
|
paths.forEach((pathDefinition, key) => {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import nodePath from "node:path";
|
|
3
|
+
import createDebug from "debug";
|
|
4
|
+
const debug = createDebug("counterfact:typescript-generator:prune");
|
|
5
|
+
/**
|
|
6
|
+
* Collects all .ts route files in a directory recursively.
|
|
7
|
+
* Context files (_.context.ts) are excluded.
|
|
8
|
+
* @param {string} routesDir - Path to routes directory
|
|
9
|
+
* @param {string} currentPath - Current subdirectory being processed (relative to routesDir)
|
|
10
|
+
* @returns {Promise<string[]>} - Array of relative paths (using forward slashes)
|
|
11
|
+
*/
|
|
12
|
+
async function collectRouteFiles(routesDir, currentPath = "") {
|
|
13
|
+
const files = [];
|
|
14
|
+
try {
|
|
15
|
+
const fullDir = currentPath
|
|
16
|
+
? nodePath.join(routesDir, currentPath)
|
|
17
|
+
: routesDir;
|
|
18
|
+
const entries = await fs.readdir(fullDir, { withFileTypes: true });
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
const relativePath = currentPath
|
|
21
|
+
? `${currentPath}/${entry.name}`
|
|
22
|
+
: entry.name;
|
|
23
|
+
if (entry.isDirectory()) {
|
|
24
|
+
files.push(...(await collectRouteFiles(routesDir, relativePath)));
|
|
25
|
+
}
|
|
26
|
+
else if (entry.name.endsWith(".ts") && entry.name !== "_.context.ts") {
|
|
27
|
+
files.push(relativePath);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error.code !== "ENOENT") {
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return files;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Recursively removes empty directories under rootDir, but not rootDir itself.
|
|
40
|
+
* @param {string} dir - Directory to check
|
|
41
|
+
* @param {string} rootDir - Root directory that should never be removed
|
|
42
|
+
*/
|
|
43
|
+
async function removeEmptyDirectories(dir, rootDir) {
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
await removeEmptyDirectories(nodePath.join(dir, entry.name), rootDir);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (nodePath.resolve(dir) === nodePath.resolve(rootDir)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const remaining = await fs.readdir(dir);
|
|
60
|
+
if (remaining.length === 0) {
|
|
61
|
+
await fs.rmdir(dir);
|
|
62
|
+
debug("removed empty directory: %s", dir);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Converts an OpenAPI path to the expected route file path (relative to routesDir).
|
|
67
|
+
* e.g. "/pet/{id}" -> "pet/{id}.ts", "/" -> "index.ts"
|
|
68
|
+
* @param {string} openApiPath
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
function openApiPathToRouteFile(openApiPath) {
|
|
72
|
+
const filePath = openApiPath === "/" ? "index" : openApiPath.slice(1);
|
|
73
|
+
return `${filePath}.ts`;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Prunes route files that no longer correspond to any path in the OpenAPI spec.
|
|
77
|
+
* Context files (_.context.ts) are never pruned.
|
|
78
|
+
* @param {string} destination - Base destination directory (contains the routes/ sub-directory)
|
|
79
|
+
* @param {Iterable<string>} openApiPaths - Iterable of OpenAPI path strings (e.g. "/pet/{id}")
|
|
80
|
+
* @returns {Promise<number>} - Number of files removed
|
|
81
|
+
*/
|
|
82
|
+
export async function pruneRoutes(destination, openApiPaths) {
|
|
83
|
+
const routesDir = nodePath.join(destination, "routes");
|
|
84
|
+
const expectedFiles = new Set(Array.from(openApiPaths).map(openApiPathToRouteFile));
|
|
85
|
+
debug("expected route files: %o", Array.from(expectedFiles));
|
|
86
|
+
const actualFiles = await collectRouteFiles(routesDir);
|
|
87
|
+
debug("actual route files: %o", actualFiles);
|
|
88
|
+
let prunedCount = 0;
|
|
89
|
+
for (const file of actualFiles) {
|
|
90
|
+
const normalizedFile = file.replaceAll("\\", "/");
|
|
91
|
+
if (!expectedFiles.has(normalizedFile)) {
|
|
92
|
+
const fullPath = nodePath.join(routesDir, file);
|
|
93
|
+
debug("pruning %s", fullPath);
|
|
94
|
+
await fs.rm(fullPath);
|
|
95
|
+
prunedCount++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
await removeEmptyDirectories(routesDir, routesDir);
|
|
99
|
+
debug("pruned %d files", prunedCount);
|
|
100
|
+
return prunedCount;
|
|
101
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "counterfact",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/app.js",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"@types/debug": "^4.1.12",
|
|
88
88
|
"@types/jest": "30.0.0",
|
|
89
89
|
"@types/js-yaml": "4.0.9",
|
|
90
|
-
"@types/koa": "3.0.
|
|
90
|
+
"@types/koa": "3.0.2",
|
|
91
91
|
"@types/koa-bodyparser": "4.3.13",
|
|
92
92
|
"@types/koa-proxy": "1.0.8",
|
|
93
93
|
"@types/koa-static": "4.0.4",
|