counterfact 2.0.1 → 2.2.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 +169 -51
- package/bin/counterfact.js +34 -14
- package/bin/taglines.txt +2 -0
- package/dist/app.js +1 -1
- package/dist/server/admin-api-middleware.js +259 -0
- package/dist/server/context-registry.js +10 -0
- package/dist/server/create-koa-app.js +15 -1
- package/dist/server/dispatcher.js +9 -0
- package/dist/server/registry.js +19 -1
- package/package.json +9 -9
package/README.md
CHANGED
|
@@ -1,69 +1,93 @@
|
|
|
1
1
|
<div align="center" markdown="1">
|
|
2
2
|
|
|
3
3
|
<img src="./counterfact.svg" alt="Counterfact" border=0>
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
<br>
|
|
6
|
+
|
|
7
|
+
 [](https://github.com/ellerbrock/typescript-badges/) [](https://coveralls.io/github/pmcelhaney/counterfact)
|
|
8
|
+
|
|
5
9
|
</div>
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
**Counterfact instantly turns an [OpenAPI/Swagge](https://www.openapis.org) spec into a live, working API you can run locally.**
|
|
14
|
+
|
|
15
|
+
Instead of waiting for a backend—or wiring up brittle mocks—it generates a server where every endpoint is backed by TypeScript code. Responses are valid by default, but fully customizable, and the system is stateful, interactive, and hot-reloading.
|
|
16
|
+
|
|
17
|
+
It’s not just a mock server.
|
|
18
|
+
|
|
19
|
+
It’s a controllable API environment you can shape in real time.
|
|
20
|
+
|
|
21
|
+
> Built by Patrick McElhaney · Currently available for the right opportunity → https://patrickmcelhaney.org
|
|
8
22
|
|
|
9
|
-
|
|
10
|
-
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npx counterfact@latest https://petstore3.swagger.io/api/v3/openapi.json api
|
|
11
29
|
```
|
|
12
30
|
|
|
13
|
-
|
|
31
|
+
That's it. Counterfact reads your OpenAPI spec, generates TypeScript route files in `api/`, and starts a mock server — all in one command. Point it at your own spec instead of the Petstore whenever you're ready.
|
|
32
|
+
|
|
33
|
+
> **Requires Node ≥ 17.0.0**
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- ⚡ **Zero config** — one command to generate and start a simulated api
|
|
40
|
+
- 🔒 **Type-safe by default** — route handlers are typed directly from your OpenAPI spec
|
|
41
|
+
- 🔄 **Hot reload** — edit route files while the server is running; state is preserved
|
|
42
|
+
- 🧠 **State management** — POST data and GET it back; share state across routes with context objects
|
|
43
|
+
- 🖥 **Live REPL** — inspect and modify server state from your terminal without touching files
|
|
44
|
+
- 🔀 **Hybrid proxy** — route some paths to the real API while mocking others
|
|
45
|
+
- 🎲 **Smart random data** — uses OpenAPI examples and schema metadata to generate realistic responses
|
|
46
|
+
- 📖 **Built-in Swagger UI** — browse and test your mock API in a browser automatically
|
|
47
|
+
- 🔌 **Middleware support** — add custom middleware with `_.middleware.ts` files
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## How It Works
|
|
52
|
+
|
|
53
|
+
1. **Generate** — Counterfact reads your OpenAPI spec and creates a `routes/` directory with a `.ts` file for each path, plus a `types/` directory with fully typed request/response interfaces.
|
|
54
|
+
2. **Customize** — Edit the route files to return exactly the data your frontend needs. The full power of TypeScript is at your disposal.
|
|
55
|
+
3. **Run** — The server hot-reloads on every save. No restart, no lost state.
|
|
56
|
+
|
|
57
|
+
---
|
|
14
58
|
|
|
15
|
-
|
|
16
|
-
|
|
59
|
+
## Examples
|
|
60
|
+
|
|
61
|
+
### Zero effort: random responses out of the box
|
|
62
|
+
|
|
63
|
+
Generated route files return random, schema-valid responses immediately — no editing required.
|
|
17
64
|
|
|
18
65
|
```ts
|
|
19
|
-
//
|
|
66
|
+
// mock-api/routes/store/order/{orderID}.ts
|
|
20
67
|
import type { HTTP_GET } from "../../../types/paths/store/order/{orderId}.types.js";
|
|
21
|
-
import type { HTTP_DELETE } from "../../../types/paths/store/order/{orderId}.types.js";
|
|
22
68
|
|
|
23
69
|
export const GET: HTTP_GET = ($) => {
|
|
24
70
|
return $.response[200].random();
|
|
25
71
|
};
|
|
26
|
-
|
|
27
|
-
export const DELETE: HTTP_DELETE = ($) => {
|
|
28
|
-
return $.response[200];
|
|
29
|
-
};
|
|
30
72
|
```
|
|
31
73
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
Want control? Edit the generated route files (e.g. `./mock-api/routes/store/order/{orderID}.ts`) and define responses directly. A type-safe API from your spec speeds up prototyping.
|
|
74
|
+
### Typed custom responses
|
|
35
75
|
|
|
36
|
-
|
|
37
|
-
<summary>Edit the code to define custom behavior and responses</summary>
|
|
76
|
+
Replace `.random()` with `.json()` to return specific data. TypeScript (via your IDE's autocomplete) guides you to a valid response.
|
|
38
77
|
|
|
39
78
|
```ts
|
|
40
|
-
// ./mock-api/routes/store/order/{orderID}.ts
|
|
41
|
-
import { Order } from "../../../types/components/schemas/Order.js";
|
|
42
79
|
import type { HTTP_GET } from "../../../types/paths/store/order/{orderId}.types.js";
|
|
43
80
|
import type { HTTP_DELETE } from "../../../types/paths/store/order/{orderId}.types.js";
|
|
44
81
|
|
|
45
82
|
export const GET: HTTP_GET = ($) => {
|
|
46
83
|
const orders: Record<number, Order> = {
|
|
47
|
-
1: {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
},
|
|
51
|
-
2: {
|
|
52
|
-
petId: 999,
|
|
53
|
-
status: "approved",
|
|
54
|
-
},
|
|
55
|
-
3: {
|
|
56
|
-
petId: 1234,
|
|
57
|
-
status: "delivered",
|
|
58
|
-
},
|
|
84
|
+
1: { petId: 100, status: "placed" },
|
|
85
|
+
2: { petId: 999, status: "approved" },
|
|
86
|
+
3: { petId: 1234, status: "delivered" },
|
|
59
87
|
};
|
|
60
88
|
|
|
61
|
-
const order = orders[$.
|
|
62
|
-
|
|
63
|
-
if (order === undefined) {
|
|
64
|
-
return $.response[404];
|
|
65
|
-
}
|
|
66
|
-
|
|
89
|
+
const order = orders[$.path.orderID];
|
|
90
|
+
if (order === undefined) return $.response[404];
|
|
67
91
|
return $.response[200].json(order);
|
|
68
92
|
};
|
|
69
93
|
|
|
@@ -72,29 +96,123 @@ export const DELETE: HTTP_DELETE = ($) => {
|
|
|
72
96
|
};
|
|
73
97
|
```
|
|
74
98
|
|
|
75
|
-
|
|
99
|
+
### State management with plain old objects
|
|
76
100
|
|
|
77
|
-
|
|
101
|
+
Use a `_.context.ts` file to share in-memory state across routes. POST data and GET it back, just like a real API.
|
|
78
102
|
|
|
79
|
-
|
|
103
|
+
```ts
|
|
104
|
+
// mock-api/routes/_.context.ts
|
|
105
|
+
export class Context {
|
|
106
|
+
pets: Pet[] = [];
|
|
107
|
+
|
|
108
|
+
addPet(pet: Pet) {
|
|
109
|
+
const id = this.pets.length;
|
|
110
|
+
this.pets.push({ ...pet, id });
|
|
111
|
+
return this.pets[id];
|
|
112
|
+
}
|
|
80
113
|
|
|
81
|
-
|
|
114
|
+
getPetById(id: number) {
|
|
115
|
+
return this.pets[id];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
82
119
|
|
|
83
|
-
|
|
120
|
+
```ts
|
|
121
|
+
// mock-api/routes/pet.ts
|
|
122
|
+
export const POST: HTTP_POST = ($) => {
|
|
123
|
+
return $.response[200].json($.context.addPet($.body));
|
|
124
|
+
};
|
|
84
125
|
|
|
85
|
-
|
|
126
|
+
// mock-api/routes/pet/{petId}.ts
|
|
127
|
+
export const GET: HTTP_GET = ($) => {
|
|
128
|
+
const pet = $.context.getPetById($.path.petId);
|
|
129
|
+
if (!pet) return $.response[404].text(`Pet ${$.path.petId} not found.`);
|
|
130
|
+
return $.response[200].json(pet);
|
|
131
|
+
};
|
|
132
|
+
```
|
|
86
133
|
|
|
87
|
-
|
|
134
|
+
You can also interact with the context object using a REPL. It's like DevTools on the server side. (See "Live REPL" below.)
|
|
88
135
|
|
|
89
|
-
|
|
136
|
+
---
|
|
90
137
|
|
|
91
|
-
|
|
138
|
+
## Key Capabilities
|
|
92
139
|
|
|
93
|
-
|
|
140
|
+
### 🔄 Hot Reload
|
|
94
141
|
|
|
95
|
-
|
|
96
|
-
<div align="center" markdown="1">
|
|
142
|
+
Save a route file and the server picks it up instantly — no restart, no lost state. Your in-memory context survives every reload.
|
|
97
143
|
|
|
98
|
-
|
|
144
|
+
### 🖥 Live REPL
|
|
145
|
+
|
|
146
|
+
The REPL gives you a JavaScript prompt connected directly to your running server. Inspect state, trigger edge cases, or adjust proxy settings without touching a file.
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
⬣> context.pets.length
|
|
150
|
+
3
|
|
151
|
+
⬣> context.addPet({ name: "Fluffy", photoUrls: [] })
|
|
152
|
+
⬣> client.get("/pet/3")
|
|
153
|
+
⬣> .proxy on /payments # forward /payments to the real API
|
|
154
|
+
⬣> .proxy off # stop all proxying
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### 🔀 Hybrid Proxy
|
|
158
|
+
|
|
159
|
+
Mock the paths that aren't ready yet while forwarding everything else to the real backend. See [Proxying](./docs/usage.md#proxy-peek-a-boo-) for details.
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
npx counterfact@latest openapi.yaml mock-api --proxy-url https://api.example.com
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### 🔒 Type Safety
|
|
166
|
+
|
|
167
|
+
Every route handler is typed to match your OpenAPI spec. When the spec changes, regenerating the types surfaces any mismatches at compile time — before they become bugs.
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
export const GET: HTTP_GET = ($) => {
|
|
171
|
+
return $.response[200]
|
|
172
|
+
.header("x-request-id", $.headers["x-request-id"])
|
|
173
|
+
.json({
|
|
174
|
+
id: $.path.userId,
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## CLI Reference
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
npx counterfact@latest [openapi.yaml] [destination] [options]
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
| Option | Description |
|
|
188
|
+
| ------------------- | ------------------------------------------- |
|
|
189
|
+
| `--port <number>` | Server port (default: `3100`) |
|
|
190
|
+
| `-o, --open` | Open browser automatically |
|
|
191
|
+
| `-g, --generate` | Generate route and type files |
|
|
192
|
+
| `-w, --watch` | Generate and watch for spec changes |
|
|
193
|
+
| `-s, --serve` | Start the mock server |
|
|
194
|
+
| `-r, --repl` | Start the interactive REPL |
|
|
195
|
+
| `--proxy-url <url>` | Forward all requests to this URL by default |
|
|
196
|
+
| `--prefix <path>` | Base path prefix (e.g. `/api/v1`) |
|
|
197
|
+
|
|
198
|
+
Run `npx counterfact --help` for the full list of options.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## About the Author
|
|
203
|
+
|
|
204
|
+
Counterfact came out of a pattern I kept seeing: teams are slowed down more by coordination than by code.
|
|
205
|
+
|
|
206
|
+
I’ve spent 25+ years building software and improving how engineering organizations operate across large enterprises, regulated industries, and complex systems. Most of that time, the real constraint wasn’t technology—it was dependency and coordination.
|
|
207
|
+
|
|
208
|
+
Counterfact is one way of removing that friction.
|
|
209
|
+
|
|
210
|
+
I’m currently available — not for long.
|
|
211
|
+
|
|
212
|
+
→ https://patrickmcelhaney.org
|
|
213
|
+
|
|
214
|
+
<div align="center" markdown="1">
|
|
215
|
+
|
|
216
|
+
[Documentation](./docs/usage.md) | [Changelog](./CHANGELOG.md) | [Contributing](./CONTRIBUTING.md)
|
|
99
217
|
|
|
100
218
|
</div>
|
package/bin/counterfact.js
CHANGED
|
@@ -51,17 +51,17 @@ function createWatchMessage(config) {
|
|
|
51
51
|
|
|
52
52
|
switch (true) {
|
|
53
53
|
case config.watch.routes && config.watch.types: {
|
|
54
|
-
watchMessage = "Watching for changes";
|
|
54
|
+
watchMessage = " Watching for changes";
|
|
55
55
|
|
|
56
56
|
break;
|
|
57
57
|
}
|
|
58
58
|
case config.watch.routes: {
|
|
59
|
-
watchMessage = "Watching routes for changes";
|
|
59
|
+
watchMessage = " Watching routes for changes";
|
|
60
60
|
|
|
61
61
|
break;
|
|
62
62
|
}
|
|
63
63
|
case config.watch.types: {
|
|
64
|
-
watchMessage = "Watching types for changes";
|
|
64
|
+
watchMessage = " Watching types for changes";
|
|
65
65
|
|
|
66
66
|
break;
|
|
67
67
|
}
|
|
@@ -136,6 +136,8 @@ async function main(source, destination) {
|
|
|
136
136
|
const swaggerUrl = `${url}/counterfact/swagger/`;
|
|
137
137
|
|
|
138
138
|
const config = {
|
|
139
|
+
adminApiToken:
|
|
140
|
+
options.adminApiToken ?? process.env.COUNTERFACT_ADMIN_API_TOKEN ?? "",
|
|
139
141
|
alwaysFakeOptionals: options.alwaysFakeOptionals,
|
|
140
142
|
basePath,
|
|
141
143
|
|
|
@@ -160,6 +162,7 @@ async function main(source, destination) {
|
|
|
160
162
|
proxyPaths: new Map([["", Boolean(options.proxyUrl)]]),
|
|
161
163
|
proxyUrl: options.proxyUrl ?? "",
|
|
162
164
|
routePrefix: options.prefix,
|
|
165
|
+
startAdminApi: options.adminApi,
|
|
163
166
|
startRepl: options.repl,
|
|
164
167
|
startServer: options.serve,
|
|
165
168
|
buildCache: options.buildCache || false,
|
|
@@ -170,7 +173,12 @@ async function main(source, destination) {
|
|
|
170
173
|
},
|
|
171
174
|
};
|
|
172
175
|
|
|
173
|
-
|
|
176
|
+
const configForLogging = {
|
|
177
|
+
...config,
|
|
178
|
+
adminApiToken: config.adminApiToken ? "[REDACTED]" : "",
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
debug("loading counterfact (%o)", configForLogging);
|
|
174
182
|
|
|
175
183
|
let didMigrate = false;
|
|
176
184
|
let didMigrateRouteTypes = false;
|
|
@@ -193,7 +201,7 @@ async function main(source, destination) {
|
|
|
193
201
|
|
|
194
202
|
const { start } = await counterfact(config);
|
|
195
203
|
|
|
196
|
-
debug("loaded counterfact",
|
|
204
|
+
debug("loaded counterfact", configForLogging);
|
|
197
205
|
|
|
198
206
|
// Migrate route type imports if needed
|
|
199
207
|
debug("checking if route type migration is needed");
|
|
@@ -206,19 +214,26 @@ async function main(source, destination) {
|
|
|
206
214
|
const watchMessage = createWatchMessage(config);
|
|
207
215
|
|
|
208
216
|
const introduction = [
|
|
209
|
-
"____ ____ _ _ _ _ ___ ____ ____ ____ ____ ____ ___",
|
|
210
|
-
String.raw
|
|
211
|
-
padTagLine(taglines[Math.floor(Math.random() * taglines.length)]),
|
|
217
|
+
" ____ ____ _ _ _ _ ___ ____ ____ ____ ____ ____ ___",
|
|
218
|
+
String.raw` |___ [__] |__| |\| | |=== |--< |--- |--| |___ | `,
|
|
219
|
+
" " + padTagLine(taglines[Math.floor(Math.random() * taglines.length)]),
|
|
220
|
+
"",
|
|
221
|
+
` API Base URL ${url}`,
|
|
222
|
+
source === "_" ? undefined : ` Swagger UI ${swaggerUrl}`,
|
|
223
|
+
"",
|
|
224
|
+
" Instructions https://counterfact.dev/docs/usage.html",
|
|
225
|
+
" Help/feedback https://github.com/pmcelhaney/counterfact/issues",
|
|
212
226
|
"",
|
|
213
|
-
`| API Base URL ==> ${url}`,
|
|
214
|
-
source === "_" ? undefined : `| Swagger UI ==> ${swaggerUrl}`,
|
|
215
227
|
"",
|
|
216
|
-
"
|
|
217
|
-
"
|
|
228
|
+
"🔔 PLEASE READ: Feedback, Telemetry, and Privacy Discussion (10 March 2026)",
|
|
229
|
+
" https://counterfact.dev/telemetry-discussion",
|
|
230
|
+
"",
|
|
218
231
|
"",
|
|
219
232
|
watchMessage,
|
|
220
|
-
config.startServer ? "Starting server" : undefined,
|
|
221
|
-
config.startRepl
|
|
233
|
+
config.startServer ? " Starting server" : undefined,
|
|
234
|
+
config.startRepl
|
|
235
|
+
? " Starting REPL (type .help for more info)"
|
|
236
|
+
: undefined,
|
|
222
237
|
];
|
|
223
238
|
|
|
224
239
|
process.stdout.write(
|
|
@@ -295,8 +310,13 @@ program
|
|
|
295
310
|
.option("--watch-routes", "generate + watch routes for changes")
|
|
296
311
|
.option("-s, --serve", "start the server")
|
|
297
312
|
.option("-b, --build-cache", "builds the cache of compiled routes and types")
|
|
313
|
+
.option("--no-admin-api", "disable the admin API at /_counterfact/api/*")
|
|
298
314
|
.option("-r, --repl", "start the REPL")
|
|
299
315
|
.option("--proxy-url <string>", "proxy URL")
|
|
316
|
+
.option(
|
|
317
|
+
"--admin-api-token <string>",
|
|
318
|
+
"bearer token required for /_counterfact/api/* endpoints (defaults to COUNTERFACT_ADMIN_API_TOKEN)",
|
|
319
|
+
)
|
|
300
320
|
.option(
|
|
301
321
|
"--prefix <string>",
|
|
302
322
|
"base path from which routes will be served (e.g. /api/v1)",
|
package/bin/taglines.txt
CHANGED
package/dist/app.js
CHANGED
|
@@ -90,7 +90,7 @@ export async function counterfact(config) {
|
|
|
90
90
|
const transpiler = new Transpiler(nodePath.join(modulesPath, "routes").replaceAll("\\", "/"), compiledPathsDirectory, "commonjs");
|
|
91
91
|
const moduleLoader = new ModuleLoader(compiledPathsDirectory, registry, contextRegistry);
|
|
92
92
|
const middleware = koaMiddleware(dispatcher, config);
|
|
93
|
-
const koaApp = createKoaApp(registry, middleware, config);
|
|
93
|
+
const koaApp = createKoaApp(registry, middleware, config, contextRegistry);
|
|
94
94
|
async function start(options) {
|
|
95
95
|
const { generate, startRepl: shouldStartRepl, startServer, watch, buildCache, } = options;
|
|
96
96
|
if (generate.routes || generate.types) {
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import createDebug from "debug";
|
|
2
|
+
const debug = createDebug("counterfact:server:admin-api-middleware");
|
|
3
|
+
function extractBearerToken(authorization) {
|
|
4
|
+
if (!authorization)
|
|
5
|
+
return undefined;
|
|
6
|
+
const [scheme, token] = authorization.trim().split(/\s+/, 2);
|
|
7
|
+
if (!scheme || !token || scheme.toLowerCase() !== "bearer")
|
|
8
|
+
return undefined;
|
|
9
|
+
return token;
|
|
10
|
+
}
|
|
11
|
+
function normalizeIp(ip) {
|
|
12
|
+
if (!ip)
|
|
13
|
+
return "";
|
|
14
|
+
if (ip.startsWith("::ffff:"))
|
|
15
|
+
return ip.slice("::ffff:".length);
|
|
16
|
+
return ip;
|
|
17
|
+
}
|
|
18
|
+
function isLoopbackIp(ip) {
|
|
19
|
+
const normalized = normalizeIp(ip);
|
|
20
|
+
return (normalized === "127.0.0.1" ||
|
|
21
|
+
normalized === "::1" ||
|
|
22
|
+
normalized === "0:0:0:0:0:0:0:1");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Admin API middleware for programmatic access to Counterfact internals.
|
|
26
|
+
* Exposes context management, proxy configuration, and route discovery
|
|
27
|
+
* through HTTP endpoints at /_counterfact/api/*
|
|
28
|
+
*
|
|
29
|
+
* This enables AI agents and external tools to interact with the mock server
|
|
30
|
+
* in the same way the REPL does, but via HTTP requests.
|
|
31
|
+
*/
|
|
32
|
+
export function adminApiMiddleware(registry, contextRegistry, config) {
|
|
33
|
+
return async (ctx, next) => {
|
|
34
|
+
const { pathname } = ctx.URL;
|
|
35
|
+
// Only handle admin API routes
|
|
36
|
+
if (!pathname.startsWith("/_counterfact/api/")) {
|
|
37
|
+
return await next();
|
|
38
|
+
}
|
|
39
|
+
// ===== Admin API Access Guard =====
|
|
40
|
+
// If an admin API token is configured, require Authorization: Bearer <token>.
|
|
41
|
+
// If not set, only allow loopback access.
|
|
42
|
+
const configuredToken = (config.adminApiToken ??
|
|
43
|
+
process.env.COUNTERFACT_ADMIN_API_TOKEN ??
|
|
44
|
+
"").trim();
|
|
45
|
+
const authHeader = typeof ctx.get === "function"
|
|
46
|
+
? ctx.get("authorization")
|
|
47
|
+
: ctx.request.headers.authorization;
|
|
48
|
+
const providedToken = extractBearerToken(authHeader);
|
|
49
|
+
if (configuredToken) {
|
|
50
|
+
if (!providedToken || providedToken !== configuredToken) {
|
|
51
|
+
debug("Admin API unauthorized request: missing/invalid bearer token");
|
|
52
|
+
ctx.status = 401;
|
|
53
|
+
ctx.body = {
|
|
54
|
+
success: false,
|
|
55
|
+
error: "Unauthorized",
|
|
56
|
+
message: "Admin API requires a valid bearer token.",
|
|
57
|
+
};
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const requestIp = normalizeIp(ctx.ip || ctx.request.ip || ctx.req.socket.remoteAddress);
|
|
63
|
+
if (!isLoopbackIp(requestIp)) {
|
|
64
|
+
debug("Admin API forbidden request from non-loopback IP: %s", requestIp);
|
|
65
|
+
ctx.status = 403;
|
|
66
|
+
ctx.body = {
|
|
67
|
+
success: false,
|
|
68
|
+
error: "Forbidden",
|
|
69
|
+
message: "Admin API is restricted to localhost unless an admin token is configured.",
|
|
70
|
+
};
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
debug("Admin API request: %s %s", ctx.method, pathname);
|
|
75
|
+
// Extract route components: ["_counterfact", "api", "resource", ...rest]
|
|
76
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
77
|
+
const [, , resource, ...rest] = parts;
|
|
78
|
+
try {
|
|
79
|
+
// ===== Health Check =====
|
|
80
|
+
if (resource === "health" && ctx.method === "GET") {
|
|
81
|
+
ctx.body = {
|
|
82
|
+
status: "ok",
|
|
83
|
+
port: config.port,
|
|
84
|
+
uptime: process.uptime(),
|
|
85
|
+
basePath: config.basePath,
|
|
86
|
+
routePrefix: config.routePrefix,
|
|
87
|
+
};
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// ===== List All Contexts =====
|
|
91
|
+
if (resource === "contexts" &&
|
|
92
|
+
rest.length === 0 &&
|
|
93
|
+
ctx.method === "GET") {
|
|
94
|
+
const paths = contextRegistry.getAllPaths();
|
|
95
|
+
const contexts = contextRegistry.getAllContexts();
|
|
96
|
+
ctx.body = {
|
|
97
|
+
success: true,
|
|
98
|
+
data: {
|
|
99
|
+
paths,
|
|
100
|
+
contexts,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// ===== Get Specific Context =====
|
|
106
|
+
if (resource === "contexts" && rest.length > 0 && ctx.method === "GET") {
|
|
107
|
+
const path = "/" + rest.join("/");
|
|
108
|
+
const context = contextRegistry.find(path);
|
|
109
|
+
ctx.body = {
|
|
110
|
+
success: true,
|
|
111
|
+
data: {
|
|
112
|
+
path,
|
|
113
|
+
context,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// ===== Update Context =====
|
|
119
|
+
if (resource === "contexts" && rest.length > 0 && ctx.method === "POST") {
|
|
120
|
+
const path = "/" + rest.join("/");
|
|
121
|
+
const newContext = ctx.request.body;
|
|
122
|
+
if (!newContext ||
|
|
123
|
+
typeof newContext !== "object" ||
|
|
124
|
+
Array.isArray(newContext)) {
|
|
125
|
+
ctx.status = 400;
|
|
126
|
+
ctx.body = {
|
|
127
|
+
success: false,
|
|
128
|
+
error: "Request body must be a valid, non-array JSON object",
|
|
129
|
+
};
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Update the context using the registry's smart diffing
|
|
133
|
+
contextRegistry.update(path, newContext);
|
|
134
|
+
ctx.body = {
|
|
135
|
+
success: true,
|
|
136
|
+
message: `Context updated for path: ${path}`,
|
|
137
|
+
data: {
|
|
138
|
+
path,
|
|
139
|
+
context: contextRegistry.find(path),
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// ===== Get Full Config =====
|
|
145
|
+
if (resource === "config" && rest.length === 0 && ctx.method === "GET") {
|
|
146
|
+
ctx.body = {
|
|
147
|
+
success: true,
|
|
148
|
+
data: {
|
|
149
|
+
alwaysFakeOptionals: config.alwaysFakeOptionals,
|
|
150
|
+
adminApiTokenConfigured: Boolean(configuredToken),
|
|
151
|
+
basePath: config.basePath,
|
|
152
|
+
buildCache: config.buildCache,
|
|
153
|
+
generate: config.generate,
|
|
154
|
+
openApiPath: config.openApiPath,
|
|
155
|
+
port: config.port,
|
|
156
|
+
proxyUrl: config.proxyUrl,
|
|
157
|
+
routePrefix: config.routePrefix,
|
|
158
|
+
startAdminApi: config.startAdminApi,
|
|
159
|
+
startRepl: config.startRepl,
|
|
160
|
+
startServer: config.startServer,
|
|
161
|
+
watch: config.watch,
|
|
162
|
+
// Don't expose proxyPaths Map directly, convert to array
|
|
163
|
+
proxyPaths: Array.from(config.proxyPaths.entries()),
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// ===== Get Proxy Configuration =====
|
|
169
|
+
if (resource === "config" &&
|
|
170
|
+
rest[0] === "proxy" &&
|
|
171
|
+
ctx.method === "GET") {
|
|
172
|
+
ctx.body = {
|
|
173
|
+
success: true,
|
|
174
|
+
data: {
|
|
175
|
+
proxyUrl: config.proxyUrl,
|
|
176
|
+
proxyPaths: Array.from(config.proxyPaths.entries()),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// ===== Update Proxy Configuration =====
|
|
182
|
+
if (resource === "config" &&
|
|
183
|
+
rest[0] === "proxy" &&
|
|
184
|
+
ctx.method === "PATCH") {
|
|
185
|
+
const body = ctx.request.body;
|
|
186
|
+
if (!body || typeof body !== "object") {
|
|
187
|
+
ctx.status = 400;
|
|
188
|
+
ctx.body = {
|
|
189
|
+
success: false,
|
|
190
|
+
error: "Request body must be a valid JSON object",
|
|
191
|
+
};
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Update proxy URL if provided
|
|
195
|
+
if (body.proxyUrl !== undefined) {
|
|
196
|
+
if (typeof body.proxyUrl !== "string") {
|
|
197
|
+
ctx.status = 400;
|
|
198
|
+
ctx.body = {
|
|
199
|
+
success: false,
|
|
200
|
+
error: "proxyUrl must be a string",
|
|
201
|
+
};
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const proxyUrl = body.proxyUrl.trim();
|
|
205
|
+
config.proxyUrl = proxyUrl;
|
|
206
|
+
debug("Updated proxy URL to: %s", config.proxyUrl);
|
|
207
|
+
}
|
|
208
|
+
// Update proxy paths if provided
|
|
209
|
+
if (Array.isArray(body.proxyPaths)) {
|
|
210
|
+
for (const [path, enabled] of body.proxyPaths) {
|
|
211
|
+
if (typeof path === "string" && typeof enabled === "boolean") {
|
|
212
|
+
config.proxyPaths.set(path, enabled);
|
|
213
|
+
debug("Set proxy for %s to %s", path, enabled);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
ctx.body = {
|
|
218
|
+
success: true,
|
|
219
|
+
message: "Proxy configuration updated",
|
|
220
|
+
data: {
|
|
221
|
+
proxyUrl: config.proxyUrl,
|
|
222
|
+
proxyPaths: Array.from(config.proxyPaths.entries()),
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// ===== List All Routes =====
|
|
228
|
+
if (resource === "routes" && ctx.method === "GET") {
|
|
229
|
+
ctx.body = {
|
|
230
|
+
success: true,
|
|
231
|
+
data: {
|
|
232
|
+
routes: registry.routes,
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// ===== 404 for Unknown Endpoints =====
|
|
238
|
+
ctx.status = 404;
|
|
239
|
+
ctx.body = {
|
|
240
|
+
success: false,
|
|
241
|
+
error: "Not found",
|
|
242
|
+
path: pathname,
|
|
243
|
+
message: `Unknown admin API endpoint: ${pathname}`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
// ===== 500 for Server Errors =====
|
|
248
|
+
debug("Admin API error: %O", error);
|
|
249
|
+
ctx.status = 500;
|
|
250
|
+
ctx.body = {
|
|
251
|
+
success: false,
|
|
252
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
253
|
+
stack: error instanceof Error && process.env.NODE_ENV !== "production"
|
|
254
|
+
? error.stack
|
|
255
|
+
: undefined,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -47,4 +47,14 @@ export class ContextRegistry {
|
|
|
47
47
|
Object.setPrototypeOf(context, Object.getPrototypeOf(updatedContext));
|
|
48
48
|
this.cache.set(path, cloneDeep(updatedContext));
|
|
49
49
|
}
|
|
50
|
+
getAllPaths() {
|
|
51
|
+
return Array.from(this.entries.keys());
|
|
52
|
+
}
|
|
53
|
+
getAllContexts() {
|
|
54
|
+
const result = {};
|
|
55
|
+
for (const [path, context] of this.entries.entries()) {
|
|
56
|
+
result[path] = context;
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
50
60
|
}
|
|
@@ -3,10 +3,11 @@ import createDebug from "debug";
|
|
|
3
3
|
import Koa from "koa";
|
|
4
4
|
import bodyParser from "koa-bodyparser";
|
|
5
5
|
import { koaSwagger } from "koa2-swagger-ui";
|
|
6
|
+
import { adminApiMiddleware } from "./admin-api-middleware.js";
|
|
6
7
|
import { openapiMiddleware } from "./openapi-middleware.js";
|
|
7
8
|
import { pageMiddleware } from "./page-middleware.js";
|
|
8
9
|
const debug = createDebug("counterfact:server:create-koa-app");
|
|
9
|
-
export function createKoaApp(registry, koaMiddleware, config) {
|
|
10
|
+
export function createKoaApp(registry, koaMiddleware, config, contextRegistry) {
|
|
10
11
|
const app = new Koa();
|
|
11
12
|
app.use(openapiMiddleware(config.openApiPath, `//localhost:${config.port}${config.routePrefix}`));
|
|
12
13
|
app.use(koaSwagger({
|
|
@@ -15,6 +16,9 @@ export function createKoaApp(registry, koaMiddleware, config) {
|
|
|
15
16
|
url: "/counterfact/openapi",
|
|
16
17
|
},
|
|
17
18
|
}));
|
|
19
|
+
if (config.startAdminApi) {
|
|
20
|
+
app.use(adminApiMiddleware(registry, contextRegistry, config));
|
|
21
|
+
}
|
|
18
22
|
debug("basePath: %s", config.basePath);
|
|
19
23
|
debug("routes", registry.routes);
|
|
20
24
|
app.use(pageMiddleware("/counterfact/", "index", {
|
|
@@ -42,6 +46,16 @@ export function createKoaApp(registry, koaMiddleware, config) {
|
|
|
42
46
|
},
|
|
43
47
|
}));
|
|
44
48
|
app.use(bodyParser());
|
|
49
|
+
app.use(async (ctx, next) => {
|
|
50
|
+
await next();
|
|
51
|
+
if (ctx.body !== null &&
|
|
52
|
+
ctx.body !== undefined &&
|
|
53
|
+
typeof ctx.body === "object" &&
|
|
54
|
+
!Buffer.isBuffer(ctx.body)) {
|
|
55
|
+
ctx.body = JSON.stringify(ctx.body, null, 2);
|
|
56
|
+
ctx.type = "application/json";
|
|
57
|
+
}
|
|
58
|
+
});
|
|
45
59
|
app.use(koaMiddleware);
|
|
46
60
|
return app;
|
|
47
61
|
}
|
|
@@ -120,6 +120,15 @@ export class Dispatcher {
|
|
|
120
120
|
path = path.replace(new RegExp(this.openApiDocument.basePath, "iu"), "");
|
|
121
121
|
}
|
|
122
122
|
const { matchedPath } = this.registry.handler(path, method);
|
|
123
|
+
if (!this.registry.exists(method, path) &&
|
|
124
|
+
this.registry.pathExistsWithAnyMethod(path, method)) {
|
|
125
|
+
return {
|
|
126
|
+
body: `The ${method} method is not allowed for ${path}\n`,
|
|
127
|
+
contentType: "text/plain",
|
|
128
|
+
headers: { allow: this.registry.allowedMethods(path) },
|
|
129
|
+
status: 405,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
123
132
|
const operation = this.operationForPathAndMethod(matchedPath, method);
|
|
124
133
|
const continuousDistribution = (min, max) => {
|
|
125
134
|
return min + Math.random() * (max - min);
|
package/dist/server/registry.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import createDebugger from "debug";
|
|
2
2
|
import { ModuleTree } from "./module-tree.js";
|
|
3
3
|
const debug = createDebugger("counterfact:server:registry");
|
|
4
|
+
const ALL_HTTP_METHODS = [
|
|
5
|
+
"DELETE",
|
|
6
|
+
"GET",
|
|
7
|
+
"HEAD",
|
|
8
|
+
"OPTIONS",
|
|
9
|
+
"PATCH",
|
|
10
|
+
"POST",
|
|
11
|
+
"PUT",
|
|
12
|
+
"TRACE",
|
|
13
|
+
];
|
|
4
14
|
function castParameter(value, type) {
|
|
5
15
|
if (typeof value !== "string") {
|
|
6
16
|
return value;
|
|
@@ -52,6 +62,12 @@ export class Registry {
|
|
|
52
62
|
path: match?.pathVariables ?? {},
|
|
53
63
|
};
|
|
54
64
|
}
|
|
65
|
+
pathExistsWithAnyMethod(url, excludeMethod) {
|
|
66
|
+
return ALL_HTTP_METHODS.filter((method) => method !== excludeMethod).some((method) => this.moduleTree.match(url, method) !== undefined);
|
|
67
|
+
}
|
|
68
|
+
allowedMethods(url) {
|
|
69
|
+
return ALL_HTTP_METHODS.filter((method) => Boolean(this.moduleTree.match(url, method)?.module?.[method])).join(", ");
|
|
70
|
+
}
|
|
55
71
|
endpoint(httpRequestMethod, url, parameterTypes = {}) {
|
|
56
72
|
const handler = this.handler(url, httpRequestMethod);
|
|
57
73
|
debug("handler for %s: %o", url, handler);
|
|
@@ -97,7 +113,9 @@ export class Registry {
|
|
|
97
113
|
function recurse(path, respondTo) {
|
|
98
114
|
if (path === null)
|
|
99
115
|
return respondTo;
|
|
100
|
-
const nextPath = path === ""
|
|
116
|
+
const nextPath = path === "/" || path === ""
|
|
117
|
+
? null
|
|
118
|
+
: path.slice(0, path.lastIndexOf("/")) || "/";
|
|
101
119
|
const middleware = middlewares.get(path);
|
|
102
120
|
if (middleware !== undefined) {
|
|
103
121
|
return recurse(nextPath, ($) => middleware($, respondTo));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "counterfact",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.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",
|
|
@@ -77,11 +77,11 @@
|
|
|
77
77
|
"postinstall": "patch-package"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
|
-
"@changesets/cli": "2.
|
|
81
|
-
"@stryker-mutator/core": "9.
|
|
82
|
-
"@stryker-mutator/jest-runner": "9.
|
|
83
|
-
"@stryker-mutator/typescript-checker": "9.
|
|
84
|
-
"@swc/core": "1.15.
|
|
80
|
+
"@changesets/cli": "2.30.0",
|
|
81
|
+
"@stryker-mutator/core": "9.6.0",
|
|
82
|
+
"@stryker-mutator/jest-runner": "9.6.0",
|
|
83
|
+
"@stryker-mutator/typescript-checker": "9.6.0",
|
|
84
|
+
"@swc/core": "1.15.18",
|
|
85
85
|
"@swc/jest": "0.2.39",
|
|
86
86
|
"@testing-library/dom": "10.4.1",
|
|
87
87
|
"@types/debug": "^4.1.12",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
|
96
96
|
"@typescript-eslint/parser": "^8.53.0",
|
|
97
97
|
"copyfiles": "2.4.1",
|
|
98
|
-
"eslint": "9.39.
|
|
98
|
+
"eslint": "9.39.4",
|
|
99
99
|
"eslint-formatter-github-annotations": "0.1.0",
|
|
100
100
|
"eslint-import-resolver-typescript": "4.4.4",
|
|
101
101
|
"eslint-plugin-etc": "2.0.3",
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
"eslint-plugin-security": "^4.0.0",
|
|
112
112
|
"eslint-plugin-unused-imports": "4.4.1",
|
|
113
113
|
"husky": "9.1.7",
|
|
114
|
-
"jest": "30.
|
|
114
|
+
"jest": "30.3.0",
|
|
115
115
|
"jest-retries": "1.0.1",
|
|
116
116
|
"node-mocks-http": "1.17.2",
|
|
117
117
|
"rimraf": "6.1.3",
|
|
@@ -128,7 +128,7 @@
|
|
|
128
128
|
"commander": "14.0.3",
|
|
129
129
|
"debug": "4.4.3",
|
|
130
130
|
"fetch": "1.1.0",
|
|
131
|
-
"fs-extra": "11.3.
|
|
131
|
+
"fs-extra": "11.3.4",
|
|
132
132
|
"handlebars": "4.7.8",
|
|
133
133
|
"http-terminator": "3.2.0",
|
|
134
134
|
"js-yaml": "4.1.1",
|