counterfact 2.7.0 → 2.8.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/README.md +4 -159
- package/bin/counterfact.js +10 -2
- package/dist/app.js +74 -20
- package/dist/migrate/update-route-types.js +2 -3
- package/dist/repl/raw-http-client.js +19 -0
- package/dist/repl/repl.js +26 -7
- package/dist/repl/route-builder.js +68 -0
- package/dist/server/constants.js +8 -0
- package/dist/server/context-registry.js +54 -1
- package/dist/server/create-koa-app.js +27 -4
- package/dist/server/determine-module-kind.js +13 -0
- package/dist/server/dispatcher.js +46 -0
- package/dist/server/file-discovery.js +20 -9
- package/dist/server/is-proxy-enabled-for-path.js +12 -0
- package/dist/server/json-to-xml.js +10 -0
- package/dist/server/koa-middleware.js +18 -1
- package/dist/server/load-openapi-document.js +4 -11
- package/dist/server/module-dependency-graph.js +25 -0
- package/dist/server/module-loader.js +44 -21
- package/dist/server/module-tree.js +36 -0
- package/dist/server/openapi-document.js +69 -0
- package/dist/server/openapi-middleware.js +34 -5
- package/dist/server/registry.js +89 -0
- package/dist/server/response-builder.js +15 -0
- package/dist/server/scenario-registry.js +26 -0
- package/dist/server/tools.js +27 -0
- package/dist/server/transpiler.js +23 -9
- package/dist/typescript-generator/code-generator.js +117 -4
- package/dist/typescript-generator/coder.js +76 -0
- package/dist/typescript-generator/operation-coder.js +12 -4
- package/dist/typescript-generator/operation-type-coder.js +39 -4
- package/dist/typescript-generator/parameters-type-coder.js +2 -4
- package/dist/typescript-generator/prune.js +2 -1
- package/dist/typescript-generator/repository.js +76 -20
- package/dist/typescript-generator/requirement.js +69 -0
- package/dist/typescript-generator/{generate.js → scenario-file-generator.js} +98 -81
- package/dist/typescript-generator/script.js +70 -7
- package/dist/typescript-generator/specification.js +27 -0
- package/dist/util/ensure-directory-exists.js +7 -0
- package/dist/util/forward-slash-path.js +63 -0
- package/dist/util/read-file.js +11 -0
- package/dist/util/runtime-can-execute-erasable-ts.js +11 -0
- package/dist/util/windows-escape.js +18 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -4,174 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
<br>
|
|
6
6
|
|
|
7
|
-
**Your backend isn't ready. Your frontend can't wait.**
|
|
8
|
-
|
|
9
|
-
**Counterfact turns your OpenAPI spec into a live, stateful API you can program in TypeScript.**
|
|
10
|
-
|
|
11
|
-
<br>
|
|
12
|
-
|
|
13
7
|
 [](https://github.com/ellerbrock/typescript-badges/) [](https://coveralls.io/github/pmcelhaney/counterfact)
|
|
14
8
|
|
|
15
9
|
</div>
|
|
16
10
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
Built for frontend developers, test engineers, and AI agents that need a predictable API to work against.
|
|
11
|
+
You've used mock servers. You know where they stop being useful: static responses, no shared state, no way to inject a failure mid-run, no control without restarting. Counterfact picks up where they leave off.
|
|
20
12
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
## Minute 1 — Start the server
|
|
13
|
+
Point it at an OpenAPI spec and it generates TypeScript handlers for every endpoint—type-safe, hot-reloading, sharing state across routes. A built-in REPL gives you a live control surface: seed data, trigger error conditions, proxy individual routes to a real backend, all on a running server. Whether you're a frontend developer waiting on a backend, a test engineer who needs clean reproducible state, or an AI agent that needs a stable API to work against, Counterfact is the simulator that doesn't plateau.
|
|
24
14
|
|
|
25
15
|
```sh
|
|
26
16
|
npx counterfact@latest https://petstore3.swagger.io/api/v3/openapi.json api
|
|
27
17
|
```
|
|
28
18
|
|
|
29
|
-
>
|
|
30
|
-
|
|
31
|
-
That’s it.
|
|
32
|
-
|
|
33
|
-
Counterfact reads your spec, generates a TypeScript handler for every endpoint, and starts a server at `http://localhost:3100`.
|
|
34
|
-
|
|
35
|
-
Open `http://localhost:3100/counterfact/swagger/`.
|
|
36
|
-
|
|
37
|
-
Every endpoint is already live, returning random, schema-valid responses. No code written yet.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
## Minute 2 — Make a route return real data
|
|
42
|
-
|
|
43
|
-
Open the generated file for `GET /pet/{petId}`:
|
|
44
|
-
|
|
45
|
-
```ts
|
|
46
|
-
import type { HTTP_GET } from "../../types/paths/pet/{petId}.types.js";
|
|
47
|
-
|
|
48
|
-
export const GET: HTTP_GET = ($) => $.response[200].random();
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
Replace `.random()` with your own logic:
|
|
52
|
-
|
|
53
|
-
```ts
|
|
54
|
-
export const GET: HTTP_GET = ($) => {
|
|
55
|
-
if ($.path.petId === 99) {
|
|
56
|
-
return $.response[404].text("Pet not found");
|
|
57
|
-
}
|
|
58
|
-
return $.response[200].json({
|
|
59
|
-
id: $.path.petId,
|
|
60
|
-
name: "Fluffy",
|
|
61
|
-
status: "available",
|
|
62
|
-
photoUrls: []
|
|
63
|
-
});
|
|
64
|
-
};
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
Save the file. The server reloads instantly—no restart, no lost state.
|
|
68
|
-
|
|
69
|
-
TypeScript enforces the contract. If your response doesn’t match the spec, you’ll know before you make the request.
|
|
70
|
-
|
|
71
|
-
## Minute 3 — Add state that survives across requests
|
|
72
|
-
|
|
73
|
-
Real APIs have memory. Yours should too.
|
|
74
|
-
|
|
75
|
-
Create `api/routes/_.context.ts`:
|
|
76
|
-
|
|
77
|
-
```ts
|
|
78
|
-
import type { Pet } from "../types/components/pet.types.js";
|
|
79
|
-
|
|
80
|
-
export class Context {
|
|
81
|
-
private pets = new Map<number, Pet>();
|
|
82
|
-
private nextId = 1;
|
|
83
|
-
|
|
84
|
-
add(data: Omit<Pet, "id">): Pet {
|
|
85
|
-
const pet = { ...data, id: this.nextId++ };
|
|
86
|
-
this.pets.set(pet.id, pet);
|
|
87
|
-
return pet;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
get(id: number): Pet | undefined { return this.pets.get(id); }
|
|
91
|
-
list(): Pet[] { return [...this.pets.values()]; }
|
|
92
|
-
remove(id: number): void { this.pets.delete(id); }
|
|
93
|
-
}
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
Use it in your routes:
|
|
97
|
-
|
|
98
|
-
```ts
|
|
99
|
-
export const GET: HTTP_GET = ($) => $.response[200].json($.context.list());
|
|
100
|
-
export const POST: HTTP_POST = ($) => $.response[200].json($.context.add($.body));
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
Now your API behaves like a real system:
|
|
104
|
-
- POST creates data
|
|
105
|
-
- GET returns it
|
|
106
|
-
- DELETE removes it
|
|
107
|
-
|
|
108
|
-
State survives hot reloads. Restarting resets everything—perfect for clean test runs.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
## Minute 4 — Control the system at runtime (REPL)
|
|
113
|
-
|
|
114
|
-
This is where Counterfact becomes more than a mock.
|
|
115
|
-
|
|
116
|
-
The built-in REPL lets you inspect and control the system while it’s running.
|
|
117
|
-
|
|
118
|
-
Seed data:
|
|
119
|
-
|
|
120
|
-
```
|
|
121
|
-
⬣> context.add({ name: "Fluffy", status: "available", photoUrls: [] })
|
|
122
|
-
⬣> context.add({ name: "Rex", status: "pending", photoUrls: [] })
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
Make requests:
|
|
126
|
-
|
|
127
|
-
```
|
|
128
|
-
⬣> client.get("/pet/1")
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
Simulate failures instantly:
|
|
132
|
-
|
|
133
|
-
```
|
|
134
|
-
⬣> context.rateLimitExceeded = true
|
|
135
|
-
⬣> client.get("/pet/1")
|
|
136
|
-
{ status: 429, body: "Too Many Requests" }
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
No HTTP scripts. No restarts. Just direct control.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
## Minute 5 — Proxy to the real backend
|
|
144
|
-
|
|
145
|
-
When parts of your backend are ready, forward them through.
|
|
146
|
-
|
|
147
|
-
Everything else stays simulated.
|
|
148
|
-
|
|
149
|
-
```sh
|
|
150
|
-
npx counterfact@latest openapi.yaml api --proxy-url https://api.example.com
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
Toggle paths live:
|
|
154
|
-
|
|
155
|
-
```
|
|
156
|
-
⬣> .proxy on /payments
|
|
157
|
-
⬣> .proxy on /auth
|
|
158
|
-
⬣> .proxy off
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
## What you just built
|
|
164
|
-
|
|
165
|
-
In five minutes, you turned a static spec into a working system:
|
|
166
|
-
|
|
167
|
-
- **Schema-valid responses** from the moment it starts
|
|
168
|
-
- **Type-safe handlers** generated from your spec
|
|
169
|
-
- **Shared state** across all routes
|
|
170
|
-
- **Hot reloading** without losing that state
|
|
171
|
-
- A **live control surface (REPL)** for runtime behavior
|
|
172
|
-
- **Selective proxying** to real services
|
|
173
|
-
|
|
174
|
-
|
|
19
|
+
> Requires Node ≥ 22.0.0
|
|
175
20
|
|
|
176
21
|
## Go deeper
|
|
177
22
|
|
|
@@ -189,4 +34,4 @@ In five minutes, you turned a static spec into a working system:
|
|
|
189
34
|
|
|
190
35
|
[Changelog](./CHANGELOG.md) · [Contributing](./CONTRIBUTING.md)
|
|
191
36
|
|
|
192
|
-
</div>
|
|
37
|
+
</div>
|
package/bin/counterfact.js
CHANGED
|
@@ -162,6 +162,14 @@ const { loadConfigFile } = await import(
|
|
|
162
162
|
)
|
|
163
163
|
);
|
|
164
164
|
|
|
165
|
+
const { pathResolve } = await import(
|
|
166
|
+
resolve(
|
|
167
|
+
nativeTs
|
|
168
|
+
? "../src/util/forward-slash-path.js"
|
|
169
|
+
: "../dist/util/forward-slash-path.js",
|
|
170
|
+
)
|
|
171
|
+
);
|
|
172
|
+
|
|
165
173
|
const DEFAULT_PORT = 3100;
|
|
166
174
|
|
|
167
175
|
const debug = createDebug("counterfact:bin:counterfact");
|
|
@@ -314,9 +322,9 @@ async function main(source, destination) {
|
|
|
314
322
|
source = options.spec;
|
|
315
323
|
}
|
|
316
324
|
|
|
317
|
-
const destinationPath =
|
|
325
|
+
const destinationPath = pathResolve(destination);
|
|
318
326
|
|
|
319
|
-
const basePath =
|
|
327
|
+
const basePath = pathResolve(destinationPath);
|
|
320
328
|
|
|
321
329
|
// If no action-related option is provided, default to all options
|
|
322
330
|
|
package/dist/app.js
CHANGED
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
import fs, { rm } from "node:fs/promises";
|
|
2
|
-
import nodePath from "node:path";
|
|
3
2
|
import { createHttpTerminator } from "http-terminator";
|
|
4
3
|
import { startRepl as startReplServer } from "./repl/repl.js";
|
|
4
|
+
import { createRouteFunction } from "./repl/route-builder.js";
|
|
5
5
|
import { ContextRegistry } from "./server/context-registry.js";
|
|
6
6
|
import { createKoaApp } from "./server/create-koa-app.js";
|
|
7
7
|
import { Dispatcher } from "./server/dispatcher.js";
|
|
8
|
-
import { koaMiddleware } from "./server/koa-middleware.js";
|
|
9
8
|
import { loadOpenApiDocument } from "./server/load-openapi-document.js";
|
|
10
9
|
import { ModuleLoader } from "./server/module-loader.js";
|
|
11
|
-
import { OpenApiWatcher } from "./server/openapi-watcher.js";
|
|
12
10
|
import { Registry } from "./server/registry.js";
|
|
13
11
|
import { ScenarioRegistry } from "./server/scenario-registry.js";
|
|
14
12
|
import { Transpiler } from "./server/transpiler.js";
|
|
15
13
|
import { CodeGenerator } from "./typescript-generator/code-generator.js";
|
|
16
|
-
import {
|
|
14
|
+
import { ScenarioFileGenerator } from "./typescript-generator/scenario-file-generator.js";
|
|
17
15
|
import { runtimeCanExecuteErasableTs } from "./util/runtime-can-execute-erasable-ts.js";
|
|
16
|
+
import { pathJoin } from "./util/forward-slash-path.js";
|
|
18
17
|
export { loadOpenApiDocument } from "./server/load-openapi-document.js";
|
|
18
|
+
export async function runStartupScenario(scenarioRegistry, contextRegistry, config, openApiDocument) {
|
|
19
|
+
const indexModule = scenarioRegistry.getModule("index");
|
|
20
|
+
if (!indexModule || typeof indexModule["startup"] !== "function") {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const scenario$ = {
|
|
24
|
+
context: contextRegistry.find("/"),
|
|
25
|
+
loadContext: (path) => contextRegistry.find(path),
|
|
26
|
+
route: createRouteFunction(config.port, "localhost", openApiDocument),
|
|
27
|
+
routes: {},
|
|
28
|
+
};
|
|
29
|
+
await indexModule["startup"](scenario$);
|
|
30
|
+
}
|
|
19
31
|
const allowedMethods = [
|
|
20
32
|
"all",
|
|
21
33
|
"head",
|
|
@@ -27,6 +39,16 @@ const allowedMethods = [
|
|
|
27
39
|
"options",
|
|
28
40
|
];
|
|
29
41
|
const mswHandlers = {};
|
|
42
|
+
/**
|
|
43
|
+
* Dispatches a single MSW (Mock Service Worker) intercepted request to the
|
|
44
|
+
* matching Counterfact route handler registered via {@link createMswHandlers}.
|
|
45
|
+
*
|
|
46
|
+
* @param request - The intercepted request, including the HTTP method, path,
|
|
47
|
+
* headers, query, body, and a `rawPath` that preserves the original URL
|
|
48
|
+
* before base-path stripping.
|
|
49
|
+
* @returns The response produced by the matching handler, or a 404 object when
|
|
50
|
+
* no handler has been registered for the given method and path.
|
|
51
|
+
*/
|
|
30
52
|
export async function handleMswRequest(request) {
|
|
31
53
|
const { method, rawPath } = request;
|
|
32
54
|
const handler = mswHandlers[`${method}:${rawPath}`];
|
|
@@ -36,15 +58,25 @@ export async function handleMswRequest(request) {
|
|
|
36
58
|
console.warn(`No handler found for ${method} ${rawPath}`);
|
|
37
59
|
return { error: `No handler found for ${method} ${rawPath}`, status: 404 };
|
|
38
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Loads an OpenAPI document, registers all routes from it as MSW handlers, and
|
|
63
|
+
* returns the list of registered routes so callers (e.g. Vitest Browser mode)
|
|
64
|
+
* can mount them on their own request-interception layer.
|
|
65
|
+
*
|
|
66
|
+
* @param config - Counterfact configuration; `openApiPath` and `basePath` are
|
|
67
|
+
* the most important fields for this function.
|
|
68
|
+
* @param ModuleLoaderClass - Injectable module-loader constructor, primarily
|
|
69
|
+
* used in tests to substitute a test-friendly implementation.
|
|
70
|
+
* @returns An array of `{ method, path }` objects describing every registered
|
|
71
|
+
* MSW handler.
|
|
72
|
+
*/
|
|
39
73
|
export async function createMswHandlers(config, ModuleLoaderClass = ModuleLoader) {
|
|
40
74
|
// TODO: For some reason the Vitest Custom Commands needed by Vitest Browser mode fail on fs.readFile when they are called from the nested loadOpenApiDocument function.
|
|
41
75
|
// If we "pre-read" the file here it works. This is a workaround to avoid the issue.
|
|
42
76
|
await fs.readFile(config.openApiPath);
|
|
43
77
|
const openApiDocument = await loadOpenApiDocument(config.openApiPath);
|
|
44
78
|
const modulesPath = config.basePath;
|
|
45
|
-
const compiledPathsDirectory =
|
|
46
|
-
.join(modulesPath, ".cache")
|
|
47
|
-
.replaceAll("\\", "/");
|
|
79
|
+
const compiledPathsDirectory = pathJoin(modulesPath, ".cache");
|
|
48
80
|
const registry = new Registry();
|
|
49
81
|
const contextRegistry = new ContextRegistry();
|
|
50
82
|
const dispatcher = new Dispatcher(registry, contextRegistry, openApiDocument, config);
|
|
@@ -67,47 +99,69 @@ export async function createMswHandlers(config, ModuleLoaderClass = ModuleLoader
|
|
|
67
99
|
});
|
|
68
100
|
return handlers;
|
|
69
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Creates and configures a full Counterfact server instance.
|
|
104
|
+
*
|
|
105
|
+
* Sets up the route registry, context registry, scenario registry, code
|
|
106
|
+
* generator, transpiler, module loader, Koa application, and OpenAPI watcher.
|
|
107
|
+
* The returned object exposes handles for starting the server, stopping it, and
|
|
108
|
+
* launching the interactive REPL.
|
|
109
|
+
*
|
|
110
|
+
* @param config - Runtime configuration (port, paths, feature flags, etc.).
|
|
111
|
+
* @returns An object containing the configured sub-systems and two entry-point
|
|
112
|
+
* functions:
|
|
113
|
+
* - `start(options)` — generates/watches code and optionally starts the HTTP
|
|
114
|
+
* server; returns a `stop()` handle.
|
|
115
|
+
* - `startRepl()` — launches the interactive Node.js REPL connected to the
|
|
116
|
+
* live server state.
|
|
117
|
+
*/
|
|
70
118
|
export async function counterfact(config) {
|
|
71
119
|
const modulesPath = config.basePath;
|
|
72
120
|
const nativeTs = await runtimeCanExecuteErasableTs();
|
|
73
|
-
const compiledPathsDirectory =
|
|
74
|
-
.join(modulesPath, nativeTs ? "routes" : ".cache")
|
|
75
|
-
.replaceAll("\\", "/");
|
|
121
|
+
const compiledPathsDirectory = pathJoin(modulesPath, nativeTs ? "routes" : ".cache");
|
|
76
122
|
if (!nativeTs) {
|
|
77
123
|
await rm(compiledPathsDirectory, { force: true, recursive: true });
|
|
78
124
|
}
|
|
79
125
|
const registry = new Registry();
|
|
80
126
|
const contextRegistry = new ContextRegistry();
|
|
81
127
|
const scenarioRegistry = new ScenarioRegistry();
|
|
128
|
+
const scenarioFileGenerator = new ScenarioFileGenerator(modulesPath);
|
|
82
129
|
const codeGenerator = new CodeGenerator(config.openApiPath, config.basePath, config.generate);
|
|
83
130
|
const openApiDocument = config.openApiPath === "_"
|
|
84
131
|
? undefined
|
|
85
132
|
: await loadOpenApiDocument(config.openApiPath);
|
|
86
133
|
const dispatcher = new Dispatcher(registry, contextRegistry, openApiDocument, config);
|
|
87
|
-
const transpiler = new Transpiler(
|
|
88
|
-
const moduleLoader = new ModuleLoader(compiledPathsDirectory, registry, contextRegistry,
|
|
89
|
-
|
|
90
|
-
|
|
134
|
+
const transpiler = new Transpiler(pathJoin(modulesPath, "routes"), compiledPathsDirectory, "commonjs");
|
|
135
|
+
const moduleLoader = new ModuleLoader(compiledPathsDirectory, registry, contextRegistry, pathJoin(modulesPath, "scenarios"), scenarioRegistry);
|
|
136
|
+
const koaApp = createKoaApp({
|
|
137
|
+
config,
|
|
138
|
+
contextRegistry,
|
|
139
|
+
dispatcher,
|
|
140
|
+
registry,
|
|
91
141
|
});
|
|
92
|
-
const middleware = koaMiddleware(dispatcher, config);
|
|
93
|
-
const koaApp = createKoaApp(registry, middleware, config, contextRegistry);
|
|
94
|
-
const openApiWatcher = new OpenApiWatcher(config.openApiPath, dispatcher);
|
|
95
142
|
async function start(options) {
|
|
96
143
|
const { generate, startServer, watch, buildCache } = options;
|
|
97
144
|
if (config.openApiPath !== "_" && (generate.routes || generate.types)) {
|
|
98
145
|
await codeGenerator.generate();
|
|
99
146
|
}
|
|
147
|
+
if (generate.types) {
|
|
148
|
+
await scenarioFileGenerator.generate();
|
|
149
|
+
}
|
|
100
150
|
if (config.openApiPath !== "_" && (watch.routes || watch.types)) {
|
|
101
151
|
await codeGenerator.watch();
|
|
102
152
|
}
|
|
153
|
+
if (watch.types) {
|
|
154
|
+
await scenarioFileGenerator.watch();
|
|
155
|
+
}
|
|
103
156
|
let httpTerminator;
|
|
104
157
|
if (startServer) {
|
|
105
|
-
await
|
|
158
|
+
await openApiDocument?.watch();
|
|
106
159
|
if (!nativeTs) {
|
|
107
160
|
await transpiler.watch();
|
|
108
161
|
}
|
|
109
162
|
await moduleLoader.load();
|
|
110
163
|
await moduleLoader.watch();
|
|
164
|
+
await runStartupScenario(scenarioRegistry, contextRegistry, config, openApiDocument);
|
|
111
165
|
const server = koaApp.listen({
|
|
112
166
|
port: config.port,
|
|
113
167
|
});
|
|
@@ -123,9 +177,10 @@ export async function counterfact(config) {
|
|
|
123
177
|
return {
|
|
124
178
|
async stop() {
|
|
125
179
|
await codeGenerator.stopWatching();
|
|
180
|
+
await scenarioFileGenerator.stopWatching();
|
|
126
181
|
await transpiler.stopWatching();
|
|
127
182
|
await moduleLoader.stopWatching();
|
|
128
|
-
await
|
|
183
|
+
await openApiDocument?.stopWatching();
|
|
129
184
|
await httpTerminator?.terminate();
|
|
130
185
|
},
|
|
131
186
|
};
|
|
@@ -133,7 +188,6 @@ export async function counterfact(config) {
|
|
|
133
188
|
return {
|
|
134
189
|
contextRegistry,
|
|
135
190
|
koaApp,
|
|
136
|
-
koaMiddleware: middleware,
|
|
137
191
|
registry,
|
|
138
192
|
start,
|
|
139
193
|
startRepl: () => startReplServer(contextRegistry, registry, config, undefined, // use the default print function (stdout)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import createDebug from "debug";
|
|
4
|
+
import { toForwardSlashPath } from "../util/forward-slash-path.js";
|
|
4
5
|
import { OperationTypeCoder, } from "../typescript-generator/operation-type-coder.js";
|
|
5
6
|
import { Specification } from "../typescript-generator/specification.js";
|
|
6
7
|
const debug = createDebug("counterfact:migrate:update-route-types");
|
|
@@ -180,9 +181,7 @@ async function processRouteDirectory(routesDir, currentPath, mapping) {
|
|
|
180
181
|
}
|
|
181
182
|
else if (entry.name.endsWith(".ts") && entry.name !== "_.context.ts") {
|
|
182
183
|
// Process TypeScript route files (skip context files)
|
|
183
|
-
const routePath = relativePath
|
|
184
|
-
.replace(/\.ts$/, "")
|
|
185
|
-
.replaceAll("\\", "/");
|
|
184
|
+
const routePath = toForwardSlashPath(relativePath.replace(/\.ts$/, ""));
|
|
186
185
|
const methodMap = mapping.get(routePath);
|
|
187
186
|
if (methodMap) {
|
|
188
187
|
const wasUpdated = await updateRouteFile(absolutePath, methodMap);
|
|
@@ -51,6 +51,16 @@ function stringifyBody(body) {
|
|
|
51
51
|
}
|
|
52
52
|
return JSON.stringify(body);
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* A minimal HTTP/1.1 client that communicates over a raw TCP socket.
|
|
56
|
+
*
|
|
57
|
+
* Used in the Counterfact REPL (`client.*`) to send requests to the local mock
|
|
58
|
+
* server and pretty-print the request and response to `stdout` with ANSI
|
|
59
|
+
* colours.
|
|
60
|
+
*
|
|
61
|
+
* Unlike `fetch` or Axios, `RawHttpClient` does not buffer or parse the
|
|
62
|
+
* response — the raw HTTP response string is returned from every method.
|
|
63
|
+
*/
|
|
54
64
|
export class RawHttpClient {
|
|
55
65
|
host;
|
|
56
66
|
port;
|
|
@@ -59,30 +69,39 @@ export class RawHttpClient {
|
|
|
59
69
|
this.host = host;
|
|
60
70
|
this.port = port;
|
|
61
71
|
}
|
|
72
|
+
/** Sends a `GET` request and returns the raw HTTP response string. */
|
|
62
73
|
get(path, headers = {}) {
|
|
63
74
|
return this.#send("GET", path, "", headers);
|
|
64
75
|
}
|
|
76
|
+
/** Sends a `HEAD` request and returns the raw HTTP response string. */
|
|
65
77
|
head(path, headers = {}) {
|
|
66
78
|
return this.#send("HEAD", path, "", headers);
|
|
67
79
|
}
|
|
80
|
+
/** Sends a `POST` request with `body` and returns the raw HTTP response string. */
|
|
68
81
|
post(path, body = "", headers = {}) {
|
|
69
82
|
return this.#send("POST", path, body, headers);
|
|
70
83
|
}
|
|
84
|
+
/** Sends a `PUT` request with `body` and returns the raw HTTP response string. */
|
|
71
85
|
put(path, body = "", headers = {}) {
|
|
72
86
|
return this.#send("PUT", path, body, headers);
|
|
73
87
|
}
|
|
88
|
+
/** Sends a `DELETE` request and returns the raw HTTP response string. */
|
|
74
89
|
delete(path, headers = {}) {
|
|
75
90
|
return this.#send("DELETE", path, "", headers);
|
|
76
91
|
}
|
|
92
|
+
/** Sends a `CONNECT` request and returns the raw HTTP response string. */
|
|
77
93
|
connect(path, headers = {}) {
|
|
78
94
|
return this.#send("CONNECT", path, "", headers);
|
|
79
95
|
}
|
|
96
|
+
/** Sends an `OPTIONS` request and returns the raw HTTP response string. */
|
|
80
97
|
options(path, headers = {}) {
|
|
81
98
|
return this.#send("OPTIONS", path, "", headers);
|
|
82
99
|
}
|
|
100
|
+
/** Sends a `TRACE` request and returns the raw HTTP response string. */
|
|
83
101
|
trace(path, headers = {}) {
|
|
84
102
|
return this.#send("TRACE", path, "", headers);
|
|
85
103
|
}
|
|
104
|
+
/** Sends a `PATCH` request with `body` and returns the raw HTTP response string. */
|
|
86
105
|
patch(path, body = "", headers = {}) {
|
|
87
106
|
return this.#send("PATCH", path, body, headers);
|
|
88
107
|
}
|
package/dist/repl/repl.js
CHANGED
|
@@ -20,13 +20,13 @@ const ROUTE_BUILDER_METHODS = [
|
|
|
20
20
|
*
|
|
21
21
|
* @param registry - The route registry used to complete path arguments for `route()` and `client.*()` calls.
|
|
22
22
|
* @param fallback - Optional fallback completer (e.g. the Node.js built-in completer) invoked when no custom completion matches.
|
|
23
|
-
* @param scenarioRegistry - When provided, enables tab completion for `.
|
|
23
|
+
* @param scenarioRegistry - When provided, enables tab completion for `.scenario` commands by enumerating
|
|
24
24
|
* exported function names and file-key prefixes from the loaded scenario modules.
|
|
25
25
|
*/
|
|
26
26
|
export function createCompleter(registry, fallback, scenarioRegistry) {
|
|
27
27
|
return (line, callback) => {
|
|
28
|
-
// Check for .
|
|
29
|
-
const applyMatch = line.match(/^\.
|
|
28
|
+
// Check for .scenario completion: .scenario <partial>
|
|
29
|
+
const applyMatch = line.match(/^\.scenario\s+(?<partial>\S*)$/u);
|
|
30
30
|
if (applyMatch) {
|
|
31
31
|
const partial = applyMatch.groups?.["partial"] ?? "";
|
|
32
32
|
if (scenarioRegistry !== undefined) {
|
|
@@ -84,6 +84,25 @@ export function createCompleter(registry, fallback, scenarioRegistry) {
|
|
|
84
84
|
callback(null, [matches, partial]);
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Launches the interactive Counterfact REPL.
|
|
89
|
+
*
|
|
90
|
+
* The REPL is a standard Node.js REPL augmented with:
|
|
91
|
+
* - `context` / `loadContext(path)` globals wired to the {@link ContextRegistry}.
|
|
92
|
+
* - `client` — a {@link RawHttpClient} pre-configured for `localhost`.
|
|
93
|
+
* - `route(path)` — creates a {@link RouteBuilder} for the given path.
|
|
94
|
+
* - `.counterfact` — help command.
|
|
95
|
+
* - `.proxy` — proxy configuration command.
|
|
96
|
+
* - `.scenario` — runs a named scenario function from the scenarios directory.
|
|
97
|
+
*
|
|
98
|
+
* @param contextRegistry - The live context registry.
|
|
99
|
+
* @param registry - The route registry (used for tab completion).
|
|
100
|
+
* @param config - Server configuration.
|
|
101
|
+
* @param print - Output function; defaults to writing to `stdout`.
|
|
102
|
+
* @param openApiDocument - Optional OpenAPI document for tab completion.
|
|
103
|
+
* @param scenarioRegistry - Optional scenario registry for `.scenario` support.
|
|
104
|
+
* @returns The configured Node.js REPL server instance.
|
|
105
|
+
*/
|
|
87
106
|
export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument, scenarioRegistry) {
|
|
88
107
|
function printProxyStatus() {
|
|
89
108
|
if (config.proxyUrl === "") {
|
|
@@ -123,7 +142,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
123
142
|
}
|
|
124
143
|
}
|
|
125
144
|
const replServer = repl.start({
|
|
126
|
-
prompt: "⬣> ",
|
|
145
|
+
prompt: "\x1b[38;2;0;113;181m⬣> \x1b[0m",
|
|
127
146
|
});
|
|
128
147
|
const builtinCompleter = replServer.completer;
|
|
129
148
|
// completer is typed as readonly in @types/node but is writable at runtime
|
|
@@ -173,11 +192,11 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
173
192
|
replServer.context.RawHttpClient = RawHttpClient;
|
|
174
193
|
replServer.context.route = createRouteFunction(config.port, "localhost", openApiDocument);
|
|
175
194
|
replServer.context.routes = {};
|
|
176
|
-
replServer.defineCommand("
|
|
195
|
+
replServer.defineCommand("scenario", {
|
|
177
196
|
async action(text) {
|
|
178
197
|
const parts = text.trim().split("/").filter(Boolean);
|
|
179
198
|
if (parts.length === 0) {
|
|
180
|
-
print("usage: .
|
|
199
|
+
print("usage: .scenario <path>");
|
|
181
200
|
this.clearBufferedCommand();
|
|
182
201
|
this.displayPrompt();
|
|
183
202
|
return;
|
|
@@ -220,7 +239,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
220
239
|
this.clearBufferedCommand();
|
|
221
240
|
this.displayPrompt();
|
|
222
241
|
},
|
|
223
|
-
help: 'apply a scenario script (".
|
|
242
|
+
help: 'apply a scenario script (".scenario <path>" calls the named export from scenarios/)',
|
|
224
243
|
});
|
|
225
244
|
return replServer;
|
|
226
245
|
}
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { RawHttpClient } from "./raw-http-client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Immutable fluent builder for constructing and sending HTTP requests from the
|
|
4
|
+
* Counterfact REPL.
|
|
5
|
+
*
|
|
6
|
+
* Each builder method returns a **new** `RouteBuilder` instance with the
|
|
7
|
+
* updated field — the original is never mutated. When all required parameters
|
|
8
|
+
* are set, call {@link send} to execute the request.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* // Inside the REPL:
|
|
12
|
+
* route("/pets/{petId}").method("get").path({ petId: 1 }).send();
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
2
15
|
export class RouteBuilder {
|
|
3
16
|
routePath;
|
|
4
17
|
_body;
|
|
@@ -47,29 +60,63 @@ export class RouteBuilder {
|
|
|
47
60
|
queryParams: overrides.queryParams ?? this._queryParams,
|
|
48
61
|
});
|
|
49
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Returns a new builder with the HTTP method set.
|
|
65
|
+
*
|
|
66
|
+
* @param method - HTTP method name (case-insensitive, e.g. `"get"`, `"POST"`).
|
|
67
|
+
*/
|
|
50
68
|
method(method) {
|
|
51
69
|
return this.clone({ method: method.toUpperCase() });
|
|
52
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Returns a new builder with additional path parameters merged in.
|
|
73
|
+
*
|
|
74
|
+
* @param params - Key/value map of path variable names to values.
|
|
75
|
+
*/
|
|
53
76
|
path(params) {
|
|
54
77
|
return this.clone({ pathParams: { ...this._pathParams, ...params } });
|
|
55
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Returns a new builder with additional query parameters merged in.
|
|
81
|
+
*
|
|
82
|
+
* @param params - Key/value map of query parameter names to values.
|
|
83
|
+
*/
|
|
56
84
|
query(params) {
|
|
57
85
|
return this.clone({ queryParams: { ...this._queryParams, ...params } });
|
|
58
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Returns a new builder with additional request headers merged in.
|
|
89
|
+
*
|
|
90
|
+
* @param params - Key/value map of header names to values.
|
|
91
|
+
*/
|
|
59
92
|
headers(params) {
|
|
60
93
|
return this.clone({ headerParams: { ...this._headerParams, ...params } });
|
|
61
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Returns a new builder with the request body set.
|
|
97
|
+
*
|
|
98
|
+
* @param body - The request body (will be serialised to JSON or sent as-is).
|
|
99
|
+
*/
|
|
62
100
|
body(body) {
|
|
63
101
|
return this.clone({ body });
|
|
64
102
|
}
|
|
65
103
|
getOperation() {
|
|
66
104
|
return this._operation;
|
|
67
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Returns `true` when a method is set and no required parameters are
|
|
108
|
+
* missing.
|
|
109
|
+
*/
|
|
68
110
|
ready() {
|
|
69
111
|
if (!this._method)
|
|
70
112
|
return false;
|
|
71
113
|
return this.missing() === undefined;
|
|
72
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Returns a {@link MissingParams} object describing all required parameters
|
|
117
|
+
* that have not yet been set, or `undefined` when nothing is missing (or
|
|
118
|
+
* when the operation has no parameters).
|
|
119
|
+
*/
|
|
73
120
|
missing() {
|
|
74
121
|
const operation = this.getOperation();
|
|
75
122
|
if (!operation?.parameters)
|
|
@@ -98,6 +145,10 @@ export class RouteBuilder {
|
|
|
98
145
|
return undefined;
|
|
99
146
|
return missingParams;
|
|
100
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Returns a human-readable help string describing the operation, its
|
|
150
|
+
* parameters, and the expected responses.
|
|
151
|
+
*/
|
|
101
152
|
help() {
|
|
102
153
|
const method = this._method ?? "[no method set]";
|
|
103
154
|
const operation = this.getOperation();
|
|
@@ -168,6 +219,13 @@ export class RouteBuilder {
|
|
|
168
219
|
}
|
|
169
220
|
return lines.join("\n");
|
|
170
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Executes the HTTP request and returns the parsed response body.
|
|
224
|
+
*
|
|
225
|
+
* @throws When no HTTP method has been set.
|
|
226
|
+
* @throws When required parameters are missing.
|
|
227
|
+
* @throws When an unsupported HTTP method is used.
|
|
228
|
+
*/
|
|
171
229
|
async send() {
|
|
172
230
|
if (!this._method) {
|
|
173
231
|
throw new Error('No HTTP method set. Use .method("get") to set the method.');
|
|
@@ -265,6 +323,16 @@ export class RouteBuilder {
|
|
|
265
323
|
return lines.join("\n");
|
|
266
324
|
}
|
|
267
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* Creates a factory function that constructs a {@link RouteBuilder} for a
|
|
328
|
+
* given route path, pre-configured with the server's host, port, and OpenAPI
|
|
329
|
+
* document.
|
|
330
|
+
*
|
|
331
|
+
* @param port - The port the Counterfact server is listening on.
|
|
332
|
+
* @param host - The server hostname (default `"localhost"`).
|
|
333
|
+
* @param openApiDocument - Optional OpenAPI document for parameter introspection.
|
|
334
|
+
* @returns A function `(routePath: string) => RouteBuilder`.
|
|
335
|
+
*/
|
|
268
336
|
export function createRouteFunction(port, host, openApiDocument) {
|
|
269
337
|
return (routePath) => new RouteBuilder(routePath, { host, openApiDocument, port });
|
|
270
338
|
}
|
package/dist/server/constants.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default options passed to every chokidar watcher in Counterfact.
|
|
3
|
+
*
|
|
4
|
+
* - `ignoreInitial: true` — suppresses the initial `"add"` events emitted for
|
|
5
|
+
* files already present when the watcher starts.
|
|
6
|
+
* - `usePolling: true` on Windows — chokidar's native FSEvents are unreliable
|
|
7
|
+
* on Windows; polling is more reliable there.
|
|
8
|
+
*/
|
|
1
9
|
export const CHOKIDAR_OPTIONS = {
|
|
2
10
|
ignoreInitial: true,
|
|
3
11
|
usePolling: process.platform === "win32",
|