counterfact 2.2.0 → 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
@@ -96,6 +96,16 @@ export const DELETE: HTTP_DELETE = ($) => {
96
96
  };
97
97
  ```
98
98
 
99
+ ### Returning named examples
100
+
101
+ If your OpenAPI spec defines named examples, use `.example(name)` to return a specific one. The name is autocompleted and type-checked from your spec:
102
+
103
+ ```ts
104
+ export const GET: HTTP_GET = ($) => {
105
+ return $.response[200].example("successResponse");
106
+ };
107
+ ```
108
+
99
109
  ### State management with plain old objects
100
110
 
101
111
  Use a `_.context.ts` file to share in-memory state across routes. POST data and GET it back, just like a real API.
@@ -192,6 +202,7 @@ npx counterfact@latest [openapi.yaml] [destination] [options]
192
202
  | `-w, --watch` | Generate and watch for spec changes |
193
203
  | `-s, --serve` | Start the mock server |
194
204
  | `-r, --repl` | Start the interactive REPL |
205
+ | `--spec <path>` | Path or URL to the OpenAPI document |
195
206
  | `--proxy-url <url>` | Forward all requests to this URL by default |
196
207
  | `--prefix <path>` | Base path prefix (e.g. `/api/v1`) |
197
208
 
package/bin/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # `bin/` — CLI Entry Point
2
+
3
+ This directory contains the executable script that is run when a developer invokes `npx counterfact` (or `counterfact` after a global install).
4
+
5
+ ## Files
6
+
7
+ | File | Description |
8
+ |---|---|
9
+ | `counterfact.js` | Parses command-line arguments with [Commander](https://github.com/tj/commander.js), validates inputs, and calls `counterfact()` from `src/app.ts` to start the server, code generator, file watcher, and/or REPL |
10
+
11
+ ## How It Works
12
+
13
+ ```
14
+ npx counterfact openapi.yaml ./api [options]
15
+
16
+
17
+ ┌────────────────────────────┐
18
+ │ counterfact.js │
19
+ │ │
20
+ │ 1. Parse args (Commander) │
21
+ │ 2. Resolve paths │
22
+ │ 3. Build Config object │
23
+ │ 4. Run migrations if │
24
+ │ old layout detected │
25
+ │ 5. Call start(config) │
26
+ │ from src/app.ts │
27
+ └────────────────────────────┘
28
+ ```
29
+
30
+ ### Key CLI Options
31
+
32
+ | Option | Description |
33
+ |---|---|
34
+ | `--port <number>` | HTTP server port (default: `3100`) |
35
+ | `-o, --open` | Open the dashboard in a browser after startup |
36
+ | `-g, --generate` | Generate route and type files from the OpenAPI spec |
37
+ | `-w, --watch` | Re-generate whenever the spec changes |
38
+ | `-s, --serve` | Start the HTTP server |
39
+ | `-r, --repl` | Start the interactive REPL |
40
+ | `--spec <path>` | Path or URL to the OpenAPI document (alternative to positional argument) |
41
+ | `--proxy-url <url>` | Forward all unmatched requests to this upstream URL |
42
+ | `--prefix <path>` | Base path prefix for all routes (e.g. `/api/v1`) |
43
+
44
+ Run `npx counterfact --help` to see the full option list.
@@ -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);
@@ -0,0 +1,14 @@
1
+ # `src/client/` — Built-in UI Templates
2
+
3
+ This directory contains [Handlebars](https://handlebarsjs.com/) (`.hbs`) templates that are rendered by `page-middleware.ts` to produce the browser-facing pages bundled with Counterfact.
4
+
5
+ ## Files
6
+
7
+ | File | Description |
8
+ |---|---|
9
+ | `index.html.hbs` | Template for the Counterfact dashboard (`/counterfact/`); lists registered routes and shows server status |
10
+ | `rapi-doc.html.hbs` | Template for the interactive API documentation page (`/counterfact/swagger/`); embeds the [RapiDoc](https://rapidocweb.com/) viewer and adds VSCode "open file" links |
11
+
12
+ ## How It Works
13
+
14
+ When a request arrives for `/counterfact/` or `/counterfact/swagger/`, `page-middleware.ts` compiles the appropriate template with runtime data (routes, port, base path, etc.) and sends the resulting HTML to the browser. No build step is required; templates are rendered on the fly.
@@ -1,3 +1,4 @@
1
1
  export interface OpenApiHeader {
2
- schema: unknown;
2
+ required?: boolean;
3
+ schema: { [key: string]: unknown };
3
4
  }
@@ -37,7 +37,8 @@ type OmitValueWhenNever<Base> = Pick<
37
37
 
38
38
  interface OpenApiResponse {
39
39
  content: { [key: MediaType]: OpenApiContent };
40
- headers: { [key: string]: OpenApiHeader };
40
+ examples?: { [key: string]: unknown };
41
+ headers: { [key: string]: { schema: unknown } };
41
42
  requiredHeaders: string;
42
43
  }
43
44
 
@@ -105,9 +106,16 @@ type RandomFunction<Response extends OpenApiResponse> = <
105
106
  Header extends string & keyof Response["headers"],
106
107
  >() => COUNTERFACT_RESPONSE;
107
108
 
109
+ type ExampleNames<Response extends OpenApiResponse> = Response extends {
110
+ examples: infer E;
111
+ }
112
+ ? keyof E & string
113
+ : never;
114
+
108
115
  interface ResponseBuilder {
109
116
  [status: number | `${number} ${string}`]: ResponseBuilder;
110
117
  content?: { body: unknown; type: string }[];
118
+ example: (name: string) => ResponseBuilder;
111
119
  header: (name: string, value: string) => ResponseBuilder;
112
120
  headers: { [name: string]: string };
113
121
  html: (body: unknown) => ResponseBuilder;
@@ -143,6 +151,9 @@ export type GenericResponseBuilderInner<
143
151
  random: [keyof Response["content"]] extends [never]
144
152
  ? never
145
153
  : RandomFunction<Response>;
154
+ example: [ExampleNames<Response>] extends [never]
155
+ ? never
156
+ : (name: ExampleNames<Response>) => COUNTERFACT_RESPONSE;
146
157
  text: MaybeShortcut<["text/plain"], Response>;
147
158
  xml: MaybeShortcut<["application/xml", "text/xml"], Response>;
148
159
  }>;
@@ -246,12 +257,16 @@ interface OpenApiOperation {
246
257
  };
247
258
  };
248
259
  examples?: { [key: string]: unknown };
260
+ headers?: {
261
+ [name: string]: OpenApiHeader;
262
+ };
249
263
  schema?: { [key: string]: unknown };
250
264
  };
251
265
  };
252
266
  }
253
267
 
254
268
  interface WideResponseBuilder {
269
+ example: (name: string) => WideResponseBuilder;
255
270
  header: (body: unknown) => WideResponseBuilder;
256
271
  html: (body: unknown) => WideResponseBuilder;
257
272
  json: (body: unknown) => WideResponseBuilder;
@@ -274,6 +289,7 @@ interface WideOperationArgument {
274
289
  export type { COUNTERFACT_RESPONSE };
275
290
 
276
291
  export type {
292
+ ExampleNames,
277
293
  HttpStatusCode,
278
294
  MaybePromise,
279
295
  MediaType,
@@ -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));
@@ -63,6 +63,24 @@ export function createResponseBuilder(operation, config) {
63
63
  ],
64
64
  };
65
65
  },
66
+ example(name) {
67
+ if (operation.produces) {
68
+ return unknownStatusCodeResponse(this.status);
69
+ }
70
+ const response = operation.responses[this.status ?? "default"] ??
71
+ operation.responses.default;
72
+ if (response?.content === undefined) {
73
+ return unknownStatusCodeResponse(this.status);
74
+ }
75
+ const { content } = response;
76
+ return {
77
+ ...this,
78
+ content: Object.keys(content).map((type) => ({
79
+ body: convertToXmlIfNecessary(type, content[type]?.examples?.[name]?.value, content[type]?.schema),
80
+ type,
81
+ })),
82
+ };
83
+ },
66
84
  random() {
67
85
  if (config?.alwaysFakeOptionals) {
68
86
  JSONSchemaFaker.option("alwaysFakeOptionals", true);
@@ -78,6 +96,12 @@ export function createResponseBuilder(operation, config) {
78
96
  return unknownStatusCodeResponse(this.status);
79
97
  }
80
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
+ }
81
105
  return {
82
106
  ...this,
83
107
  content: Object.keys(content).map((type) => ({
@@ -86,6 +110,10 @@ export function createResponseBuilder(operation, config) {
86
110
  : JSONSchemaFaker.generate(content[type]?.schema ?? { type: "object" }), content[type]?.schema),
87
111
  type,
88
112
  })),
113
+ headers: {
114
+ ...generatedHeaders,
115
+ ...this.headers,
116
+ },
89
117
  };
90
118
  },
91
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
+ }
@@ -52,6 +52,25 @@ export class ResponseTypeCoder extends TypeCoder {
52
52
  .map(({ name }) => `"${name}"`);
53
53
  return requiredHeaders.length === 0 ? "never" : requiredHeaders.join(" | ");
54
54
  }
55
+ buildExamplesObjectType(response) {
56
+ if (!response.has("content")) {
57
+ return "{}";
58
+ }
59
+ const exampleNames = [];
60
+ response.get("content").forEach((content) => {
61
+ if (content.has("examples")) {
62
+ content.get("examples").forEach((_, name) => {
63
+ if (!exampleNames.includes(name)) {
64
+ exampleNames.push(name);
65
+ }
66
+ });
67
+ }
68
+ });
69
+ if (exampleNames.length === 0) {
70
+ return "{}";
71
+ }
72
+ return printObject(exampleNames.map((name) => [name, "unknown"]));
73
+ }
55
74
  modulePath() {
56
75
  return `types/${this.requirement.data.$ref}.ts`;
57
76
  }
@@ -60,6 +79,7 @@ export class ResponseTypeCoder extends TypeCoder {
60
79
  headers: ${this.printHeaders(script, this.requirement)};
61
80
  requiredHeaders: ${this.printRequiredHeaders(this.requirement)};
62
81
  content: ${this.printContentObjectType(script, this.requirement)};
82
+ examples: ${this.buildExamplesObjectType(this.requirement)};
63
83
  }`;
64
84
  }
65
85
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "counterfact",
3
- "version": "2.2.0",
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",