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 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
 
@@ -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);
@@ -1,3 +1,4 @@
1
1
  export interface OpenApiHeader {
2
- schema: unknown;
2
+ required?: boolean;
3
+ schema: { [key: string]: unknown };
3
4
  }
@@ -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]: OpenApiHeader };
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
  }
@@ -37,7 +37,7 @@ export class Registry {
37
37
  moduleTree = new ModuleTree();
38
38
  middlewares = new Map();
39
39
  constructor() {
40
- this.middlewares.set("/", ($, respondTo) => respondTo($));
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 === "/" || 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.2.1",
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.1",
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",