counterfact 2.1.0 → 2.2.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 +179 -51
- package/bin/README.md +43 -0
- package/bin/counterfact.js +16 -3
- package/dist/app.js +1 -1
- package/dist/client/README.md +14 -0
- package/dist/server/admin-api-middleware.js +259 -0
- package/dist/server/context-registry.js +10 -0
- package/dist/server/counterfact-types/index.ts +13 -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/dist/server/response-builder.js +18 -0
- package/dist/typescript-generator/response-type-coder.js +20 -0
- package/package.json +2 -2
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
|
|
22
|
+
|
|
23
|
+
---
|
|
8
24
|
|
|
9
|
-
|
|
10
|
-
|
|
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
|
+
---
|
|
58
|
+
|
|
59
|
+
## Examples
|
|
14
60
|
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
74
|
+
### Typed custom responses
|
|
33
75
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<details>
|
|
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,133 @@ export const DELETE: HTTP_DELETE = ($) => {
|
|
|
72
96
|
};
|
|
73
97
|
```
|
|
74
98
|
|
|
75
|
-
|
|
99
|
+
### Returning named examples
|
|
76
100
|
|
|
77
|
-
|
|
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:
|
|
78
102
|
|
|
79
|
-
|
|
103
|
+
```ts
|
|
104
|
+
export const GET: HTTP_GET = ($) => {
|
|
105
|
+
return $.response[200].example("successResponse");
|
|
106
|
+
};
|
|
107
|
+
```
|
|
80
108
|
|
|
81
|
-
|
|
109
|
+
### State management with plain old objects
|
|
82
110
|
|
|
83
|
-
|
|
111
|
+
Use a `_.context.ts` file to share in-memory state across routes. POST data and GET it back, just like a real API.
|
|
84
112
|
|
|
85
|
-
|
|
113
|
+
```ts
|
|
114
|
+
// mock-api/routes/_.context.ts
|
|
115
|
+
export class Context {
|
|
116
|
+
pets: Pet[] = [];
|
|
117
|
+
|
|
118
|
+
addPet(pet: Pet) {
|
|
119
|
+
const id = this.pets.length;
|
|
120
|
+
this.pets.push({ ...pet, id });
|
|
121
|
+
return this.pets[id];
|
|
122
|
+
}
|
|
86
123
|
|
|
87
|
-
|
|
124
|
+
getPetById(id: number) {
|
|
125
|
+
return this.pets[id];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
88
129
|
|
|
89
|
-
|
|
130
|
+
```ts
|
|
131
|
+
// mock-api/routes/pet.ts
|
|
132
|
+
export const POST: HTTP_POST = ($) => {
|
|
133
|
+
return $.response[200].json($.context.addPet($.body));
|
|
134
|
+
};
|
|
90
135
|
|
|
91
|
-
|
|
136
|
+
// mock-api/routes/pet/{petId}.ts
|
|
137
|
+
export const GET: HTTP_GET = ($) => {
|
|
138
|
+
const pet = $.context.getPetById($.path.petId);
|
|
139
|
+
if (!pet) return $.response[404].text(`Pet ${$.path.petId} not found.`);
|
|
140
|
+
return $.response[200].json(pet);
|
|
141
|
+
};
|
|
142
|
+
```
|
|
92
143
|
|
|
93
|
-
|
|
144
|
+
You can also interact with the context object using a REPL. It's like DevTools on the server side. (See "Live REPL" below.)
|
|
94
145
|
|
|
95
|
-
|
|
96
|
-
<div align="center" markdown="1">
|
|
146
|
+
---
|
|
97
147
|
|
|
98
|
-
|
|
148
|
+
## Key Capabilities
|
|
149
|
+
|
|
150
|
+
### 🔄 Hot Reload
|
|
151
|
+
|
|
152
|
+
Save a route file and the server picks it up instantly — no restart, no lost state. Your in-memory context survives every reload.
|
|
153
|
+
|
|
154
|
+
### 🖥 Live REPL
|
|
155
|
+
|
|
156
|
+
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.
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
⬣> context.pets.length
|
|
160
|
+
3
|
|
161
|
+
⬣> context.addPet({ name: "Fluffy", photoUrls: [] })
|
|
162
|
+
⬣> client.get("/pet/3")
|
|
163
|
+
⬣> .proxy on /payments # forward /payments to the real API
|
|
164
|
+
⬣> .proxy off # stop all proxying
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### 🔀 Hybrid Proxy
|
|
168
|
+
|
|
169
|
+
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.
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
npx counterfact@latest openapi.yaml mock-api --proxy-url https://api.example.com
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### 🔒 Type Safety
|
|
176
|
+
|
|
177
|
+
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.
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
export const GET: HTTP_GET = ($) => {
|
|
181
|
+
return $.response[200]
|
|
182
|
+
.header("x-request-id", $.headers["x-request-id"])
|
|
183
|
+
.json({
|
|
184
|
+
id: $.path.userId,
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## CLI Reference
|
|
192
|
+
|
|
193
|
+
```sh
|
|
194
|
+
npx counterfact@latest [openapi.yaml] [destination] [options]
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
| Option | Description |
|
|
198
|
+
| ------------------- | ------------------------------------------- |
|
|
199
|
+
| `--port <number>` | Server port (default: `3100`) |
|
|
200
|
+
| `-o, --open` | Open browser automatically |
|
|
201
|
+
| `-g, --generate` | Generate route and type files |
|
|
202
|
+
| `-w, --watch` | Generate and watch for spec changes |
|
|
203
|
+
| `-s, --serve` | Start the mock server |
|
|
204
|
+
| `-r, --repl` | Start the interactive REPL |
|
|
205
|
+
| `--proxy-url <url>` | Forward all requests to this URL by default |
|
|
206
|
+
| `--prefix <path>` | Base path prefix (e.g. `/api/v1`) |
|
|
207
|
+
|
|
208
|
+
Run `npx counterfact --help` for the full list of options.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## About the Author
|
|
213
|
+
|
|
214
|
+
Counterfact came out of a pattern I kept seeing: teams are slowed down more by coordination than by code.
|
|
215
|
+
|
|
216
|
+
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.
|
|
217
|
+
|
|
218
|
+
Counterfact is one way of removing that friction.
|
|
219
|
+
|
|
220
|
+
I’m currently available — not for long.
|
|
221
|
+
|
|
222
|
+
→ https://patrickmcelhaney.org
|
|
223
|
+
|
|
224
|
+
<div align="center" markdown="1">
|
|
225
|
+
|
|
226
|
+
[Documentation](./docs/usage.md) | [Changelog](./CHANGELOG.md) | [Contributing](./CONTRIBUTING.md)
|
|
99
227
|
|
|
100
228
|
</div>
|
package/bin/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
| `--proxy-url <url>` | Forward all unmatched requests to this upstream URL |
|
|
41
|
+
| `--prefix <path>` | Base path prefix for all routes (e.g. `/api/v1`) |
|
|
42
|
+
|
|
43
|
+
Run `npx counterfact --help` to see the full option list.
|
package/bin/counterfact.js
CHANGED
|
@@ -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");
|
|
@@ -218,7 +226,7 @@ async function main(source, destination) {
|
|
|
218
226
|
"",
|
|
219
227
|
"",
|
|
220
228
|
"🔔 PLEASE READ: Feedback, Telemetry, and Privacy Discussion (10 March 2026)",
|
|
221
|
-
" https://
|
|
229
|
+
" https://counterfact.dev/telemetry-discussion",
|
|
222
230
|
"",
|
|
223
231
|
"",
|
|
224
232
|
watchMessage,
|
|
@@ -302,8 +310,13 @@ program
|
|
|
302
310
|
.option("--watch-routes", "generate + watch routes for changes")
|
|
303
311
|
.option("-s, --serve", "start the server")
|
|
304
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/*")
|
|
305
314
|
.option("-r, --repl", "start the REPL")
|
|
306
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
|
+
)
|
|
307
320
|
.option(
|
|
308
321
|
"--prefix <string>",
|
|
309
322
|
"base path from which routes will be served (e.g. /api/v1)",
|
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,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.
|
|
@@ -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
|
}
|
|
@@ -37,6 +37,7 @@ type OmitValueWhenNever<Base> = Pick<
|
|
|
37
37
|
|
|
38
38
|
interface OpenApiResponse {
|
|
39
39
|
content: { [key: MediaType]: OpenApiContent };
|
|
40
|
+
examples?: { [key: string]: unknown };
|
|
40
41
|
headers: { [key: string]: OpenApiHeader };
|
|
41
42
|
requiredHeaders: string;
|
|
42
43
|
}
|
|
@@ -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
|
}>;
|
|
@@ -252,6 +263,7 @@ interface OpenApiOperation {
|
|
|
252
263
|
}
|
|
253
264
|
|
|
254
265
|
interface WideResponseBuilder {
|
|
266
|
+
example: (name: string) => WideResponseBuilder;
|
|
255
267
|
header: (body: unknown) => WideResponseBuilder;
|
|
256
268
|
html: (body: unknown) => WideResponseBuilder;
|
|
257
269
|
json: (body: unknown) => WideResponseBuilder;
|
|
@@ -274,6 +286,7 @@ interface WideOperationArgument {
|
|
|
274
286
|
export type { COUNTERFACT_RESPONSE };
|
|
275
287
|
|
|
276
288
|
export type {
|
|
289
|
+
ExampleNames,
|
|
277
290
|
HttpStatusCode,
|
|
278
291
|
MaybePromise,
|
|
279
292
|
MediaType,
|
|
@@ -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));
|
|
@@ -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);
|
|
@@ -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.1
|
|
3
|
+
"version": "2.2.1",
|
|
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",
|
|
@@ -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",
|