counterfact 2.14.0 → 2.14.2
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 +53 -37
- package/dist/api-runner.js +7 -5
- package/dist/app.js +51 -24
- package/dist/cli/run.js +1 -2
- package/dist/server/web-server/create-koa-app.js +10 -3
- package/dist/server/web-server/routes-middleware.js +51 -2
- package/dist/typescript-generator/code-generator.js +17 -3
- package/dist/typescript-generator/openapi-path.js +23 -0
- package/dist/typescript-generator/operation-coder.js +2 -4
- package/dist/typescript-generator/operation-type-coder.js +2 -4
- package/dist/typescript-generator/prune.js +52 -9
- package/dist/util/load-config-file.js +3 -0
- package/package.json +24 -25
package/README.md
CHANGED
|
@@ -1,61 +1,77 @@
|
|
|
1
1
|
<div align="center" markdown="1">
|
|
2
2
|
|
|
3
|
-
<h1><img src="./counterfact.svg" alt="Counterfact" border=0></h1>
|
|
3
|
+
<h1><img src="./counterfact.svg" alt="Counterfact" border="0"></h1>
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
 [](https://coveralls.io/github/counterfact/api-simulator)   [](https://deepwiki.com/counterfact/api-simulator)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Turn an OpenAPI document into an editable, stateful local API.
|
|
8
8
|
|
|
9
9
|
</div>
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
<h2>Mock servers work—until you need state, failures, or control mid-run.</h2>
|
|
13
|
-
</div
|
|
11
|
+
Counterfact generates TypeScript handlers and request/response types for supported OpenAPI operations, then starts a local server. Begin with schema-derived sample responses; add shared state, failures, latency, middleware, or selective proxying as your workflow needs them. Route and context files hot-reload while their in-memory state stays available.
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
Mock servers make it easy to get started, but hard to keep going.<br>
|
|
17
|
-
Counterfact is an API simulator without those limits.
|
|
13
|
+
Counterfact checks contract shape while giving you a path to shape the behavior. Start with schema-derived responses, then turn them into deterministic, stateful scenarios by editing the generated typed handlers and adding shared context. You can explore in seconds and keep the behavior you author as a repeatable local or CI fixture.
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
Supports Swagger 2.0 and OpenAPI 3.0, 3.1, and 3.2.
|
|
21
|
-
- Type-safe TypeScript handlers for every endpoint
|
|
22
|
-
- Hot reloading as you edit
|
|
23
|
-
- Shared state across routes
|
|
24
|
-
- **A built-in REPL to control behavior at runtime**
|
|
25
|
-
- Optional proxying to real backends
|
|
15
|
+
## Run a live API in 60 seconds
|
|
26
16
|
|
|
27
|
-
|
|
17
|
+
```sh
|
|
18
|
+
npx counterfact@latest https://petstore3.swagger.io/api/v3/openapi.json api
|
|
19
|
+
```
|
|
28
20
|
|
|
29
|
-
|
|
21
|
+
This generates editable files in `api/`, starts the server at `http://localhost:3100`, serves Swagger UI at `http://localhost:3100/counterfact/swagger/`, and opens the live REPL. Exit with `.exit`, <kbd>Ctrl</kbd>+<kbd>D</kbd>, or <kbd>Ctrl</kbd>+<kbd>C</kbd> twice.
|
|
30
22
|
|
|
31
|
-
For a
|
|
32
|
-
a *test engineer* who needs clean, reproducible state,<br>
|
|
33
|
-
or an *AI agent* that needs a stable API
|
|
23
|
+
Requires Node.js 22 or newer. For a repeatable project or CI workflow, install Counterfact as a dev dependency and commit the lockfile:
|
|
34
24
|
|
|
35
|
-
|
|
25
|
+
```sh
|
|
26
|
+
npm install --save-dev counterfact
|
|
27
|
+
npx counterfact ./openapi.yaml api
|
|
28
|
+
```
|
|
36
29
|
|
|
30
|
+
## Start with the workflow you need
|
|
37
31
|
|
|
38
|
-
|
|
32
|
+
| When you need to… | You can… | Start here |
|
|
33
|
+
| -------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
34
|
+
| Build ahead of your backend | Create a record, read it later, and shape the failure paths | [Build a stateful flow](./docs/first-10-minutes.md#build-a-stateful-flow) |
|
|
35
|
+
| Make failures repeatable | Reset, fail, recover, and tear down the same way every run | [Automate a deterministic test](./docs/first-10-minutes.md#automate-a-deterministic-test) |
|
|
36
|
+
| Give a coding agent a reliable sandbox | Leave a resettable context and an HTTP test for review | [Give an agent a verifiable task](./docs/first-10-minutes.md#give-an-agent-a-verifiable-task) |
|
|
37
|
+
| Bring endpoints online gradually | Keep one client base URL while paths move from local to upstream | [Run the checked hybrid path](./examples/first-10-minutes/#hybrid-path-ownership) |
|
|
38
|
+
| Keep the contract in the loop | Regenerate types and catch drift in your normal type check | [See a contract change reach a client](./examples/first-10-minutes/#see-a-contract-change-reach-the-client) |
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
The [first-10-minutes guide](./docs/first-10-minutes.md) includes the shared setup, reset and isolation rules, CI lifecycle, and the boundary between contract checks and behavior realism.
|
|
41
|
+
|
|
42
|
+
Prefer a complete artifact to copy? The [CI-checked first-10-minutes example](./examples/first-10-minutes/) contains a minimal OpenAPI contract, resettable context, typed handlers, real-HTTP test, lockfile, and type-check configuration.
|
|
43
|
+
|
|
44
|
+
## Confidence you can build on
|
|
45
|
+
|
|
46
|
+
| Where Counterfact helps | What you get | What remains yours |
|
|
47
|
+
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
48
|
+
| Generated TypeScript types | Supported status codes, media types, declared headers, and request/response shapes when your project runs a type check | The business rules that make the API truly yours |
|
|
49
|
+
| Request validation | Required query, header, and cookie parameters plus supported JSON/form bodies; detected mismatches return `400` | Authentication, authorization, path-parameter schemas, and business rules |
|
|
50
|
+
| Response checks | Required response headers and their schema types, with advisory `response-type-error` details when something is off | Response-body enforcement and production correctness |
|
|
51
|
+
| Your handlers, context, and scenarios | The state, rules, failures, reset behavior, and deterministic fixtures your workflow needs | Targeted real-backend and end-to-end coverage for the real service |
|
|
52
|
+
|
|
53
|
+
Counterfact gives you a fast, contract-shaped world to build against. Keep targeted real-backend and end-to-end coverage for the parts only the real service can prove. See [how to make the workflow yours](./docs/first-10-minutes.md#make-it-yours-with-confidence) for the practical checklist.
|
|
54
|
+
|
|
55
|
+
## Project lifecycle
|
|
43
56
|
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
- Commit your OpenAPI document, editable `routes/`, scenarios, and usually the generated `types/` so collaborators share a working contract snapshot.
|
|
58
|
+
- Regeneration overwrites generated types and may append scaffolding for newly added operations; it preserves existing handler bodies unless you explicitly use a destructive option such as `--prune`.
|
|
59
|
+
- In-memory context survives hot reload. A new process creates fresh context, and its constructor or `startup` scenario defines the initial state.
|
|
60
|
+
- Counterfact executes TypeScript route files, but it does not run a TypeScript type-check step for you. Include generated files in your project and run your normal type check in CI.
|
|
61
|
+
- Use a user-defined `reset()` in test setup, and use separate server instances and ports for parallel workers.
|
|
46
62
|
|
|
47
|
-
##
|
|
63
|
+
## Documentation
|
|
48
64
|
|
|
49
|
-
- [Getting started](./docs/getting-started.md) –
|
|
50
|
-
- [
|
|
51
|
-
- [
|
|
52
|
-
- [
|
|
53
|
-
- [
|
|
54
|
-
- [
|
|
55
|
-
- [
|
|
65
|
+
- [Getting started](./docs/getting-started.md) – Generated files, state, REPL, proxying, and spec changes
|
|
66
|
+
- [Usage](./docs/usage.md) – Feature map
|
|
67
|
+
- [Patterns](./docs/patterns/index.md) – Reusable development and testing workflows
|
|
68
|
+
- [Reference](./docs/reference.md) – `$` API, CLI flags, and architecture
|
|
69
|
+
- [FAQ](./docs/faq.md) – State, types, validation, and regeneration
|
|
70
|
+
- [How it compares](./docs/comparison.md) – json-server, WireMock, Prism, Microcks, and MSW
|
|
71
|
+
- [Example repository](https://github.com/counterfact/example-petstore) – A larger Petstore implementation
|
|
56
72
|
|
|
57
73
|
<div align="center" markdown="1">
|
|
58
74
|
|
|
59
|
-
[Changelog](./CHANGELOG.md) · [Contributing](./CONTRIBUTING.md)
|
|
75
|
+
[Changelog](./CHANGELOG.md) · [Contributing](./CONTRIBUTING.md) · [Security](./SECURITY.md)
|
|
60
76
|
|
|
61
77
|
</div>
|
package/dist/api-runner.js
CHANGED
|
@@ -82,7 +82,7 @@ export class ApiRunner {
|
|
|
82
82
|
return this.group ? `/${this.group}` : "";
|
|
83
83
|
}
|
|
84
84
|
config;
|
|
85
|
-
constructor(config, nativeTs, openApiDocument, group, version = "", versions = []) {
|
|
85
|
+
constructor(config, nativeTs, openApiDocument, group, version = "", versions = [], groupState) {
|
|
86
86
|
this.group = group;
|
|
87
87
|
this.version = version;
|
|
88
88
|
const modulesPath = this.group
|
|
@@ -96,8 +96,9 @@ export class ApiRunner {
|
|
|
96
96
|
this.prefix = config.prefix;
|
|
97
97
|
this.overlays = config.overlays ?? [];
|
|
98
98
|
this.registry = new Registry();
|
|
99
|
-
this.contextRegistry = new ContextRegistry();
|
|
100
|
-
this.scenarioRegistry =
|
|
99
|
+
this.contextRegistry = groupState?.contextRegistry ?? new ContextRegistry();
|
|
100
|
+
this.scenarioRegistry =
|
|
101
|
+
groupState?.scenarioRegistry ?? new ScenarioRegistry();
|
|
101
102
|
this.scenarioFileGenerator = new ScenarioFileGenerator(modulesPath);
|
|
102
103
|
this.codeGenerator = new CodeGenerator(this.openApiPath, config.basePath + this.subdirectory, config.generate, version, config.overlays ?? []);
|
|
103
104
|
this.dispatcher = new Dispatcher(this.registry, this.contextRegistry, openApiDocument, config, version, versions);
|
|
@@ -115,8 +116,9 @@ export class ApiRunner {
|
|
|
115
116
|
* @param group - Optional group name placing generated code in a subdirectory (default `""`).
|
|
116
117
|
* @param version - Optional version label for this spec (e.g. `"v1"`, `"v2"`).
|
|
117
118
|
* @param versions - Optional ordered list of all version labels in this group (oldest first).
|
|
119
|
+
* @param groupState - Optional context/scenario registries shared by the group's runners.
|
|
118
120
|
*/
|
|
119
|
-
static async create(config, group = "", version = "", versions = []) {
|
|
121
|
+
static async create(config, group = "", version = "", versions = [], groupState) {
|
|
120
122
|
const nativeTs = await runtimeCanExecuteErasableTs();
|
|
121
123
|
const modulesPath = group
|
|
122
124
|
? pathJoin(config.basePath, group)
|
|
@@ -128,7 +130,7 @@ export class ApiRunner {
|
|
|
128
130
|
const openApiDocument = config.openApiPath === "_"
|
|
129
131
|
? undefined
|
|
130
132
|
: await loadOpenApiDocument(config.openApiPath, config.overlays ?? []);
|
|
131
|
-
return new ApiRunner(config, nativeTs, openApiDocument, group, version, versions);
|
|
133
|
+
return new ApiRunner(config, nativeTs, openApiDocument, group, version, versions, groupState);
|
|
132
134
|
}
|
|
133
135
|
/**
|
|
134
136
|
* Generates TypeScript route stubs and type files from the OpenAPI spec.
|
package/dist/app.js
CHANGED
|
@@ -4,7 +4,9 @@ import { createHttpTerminator } from "http-terminator";
|
|
|
4
4
|
import { ApiRunner } from "./api-runner.js";
|
|
5
5
|
import { startRepl as startReplServer } from "./repl/repl.js";
|
|
6
6
|
import { createRouteFunction } from "./repl/route-builder.js";
|
|
7
|
+
import { ContextRegistry } from "./server/context-registry.js";
|
|
7
8
|
import { createKoaApp } from "./server/web-server/create-koa-app.js";
|
|
9
|
+
import { ScenarioRegistry } from "./server/scenario-registry.js";
|
|
8
10
|
import { Repository } from "./typescript-generator/repository.js";
|
|
9
11
|
import { ensureDirectoryExists } from "./util/ensure-directory-exists.js";
|
|
10
12
|
import { generateVersionsTsContent } from "./typescript-generator/versions-ts-generator.js";
|
|
@@ -24,37 +26,42 @@ export async function runStartupScenario(scenarioRegistry, contextRegistry, conf
|
|
|
24
26
|
await indexModule["startup"](scenario$);
|
|
25
27
|
}
|
|
26
28
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* 1. Explicit `prefix` (even `""`) → returned as-is.
|
|
31
|
-
* 2. `group` + `version` both present → `/<group>/<version>`.
|
|
32
|
-
* 3. `group` present (no `version`) → `/<group>`.
|
|
33
|
-
* 4. Neither → `""` (root).
|
|
29
|
+
* Runs one startup scenario per API group, in specification declaration order.
|
|
30
|
+
* Versioned runners in one group share the same scenario and context registries,
|
|
31
|
+
* so only the group's first runner participates in startup.
|
|
34
32
|
*/
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
33
|
+
async function runGroupStartupScenarios(runners, config) {
|
|
34
|
+
const startedGroups = new Set();
|
|
35
|
+
for (const runner of runners) {
|
|
36
|
+
if (startedGroups.has(runner.group)) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
startedGroups.add(runner.group);
|
|
40
|
+
try {
|
|
41
|
+
await runStartupScenario(runner.scenarioRegistry, runner.contextRegistry, config, runner.openApiDocument);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const groupLabel = runner.group
|
|
45
|
+
? `group "${runner.group}"${runner.version ? ` (version "${runner.version}")` : ""}`
|
|
46
|
+
: "the primary API";
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
throw new Error(`Startup scenario failed for ${groupLabel}: ${message}`, {
|
|
49
|
+
cause: error,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
44
52
|
}
|
|
45
|
-
return "";
|
|
46
53
|
}
|
|
47
54
|
/**
|
|
48
55
|
* Normalises the spec configuration to an array.
|
|
49
56
|
*
|
|
50
|
-
* When `specs` is provided, each
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
57
|
+
* When `specs` is provided, each omitted `prefix` defaults to `""` so the rest
|
|
58
|
+
* of the code can assume it is always a string. When `specs` is omitted, a
|
|
59
|
+
* single-entry array is constructed from `config.openApiPath`, `config.prefix`,
|
|
60
|
+
* and `group = ""`.
|
|
54
61
|
*/
|
|
55
62
|
function normalizeSpecs(config, specs) {
|
|
56
63
|
if (specs !== undefined) {
|
|
57
|
-
return specs.map((spec) => ({ ...spec, prefix:
|
|
64
|
+
return specs.map((spec) => ({ ...spec, prefix: spec.prefix ?? "" }));
|
|
58
65
|
}
|
|
59
66
|
return [
|
|
60
67
|
{
|
|
@@ -131,6 +138,18 @@ export async function counterfact(config, specs) {
|
|
|
131
138
|
versionsByGroup.set(spec.group, [...existing, version]);
|
|
132
139
|
}
|
|
133
140
|
}
|
|
141
|
+
// A group is one state boundary even when it contains several versioned
|
|
142
|
+
// specification runners. Preserve separate registries between groups while
|
|
143
|
+
// sharing context and scenarios between every runner in the same group.
|
|
144
|
+
const stateByGroup = new Map();
|
|
145
|
+
for (const spec of normalizedSpecs) {
|
|
146
|
+
if (!stateByGroup.has(spec.group)) {
|
|
147
|
+
stateByGroup.set(spec.group, {
|
|
148
|
+
contextRegistry: new ContextRegistry(),
|
|
149
|
+
scenarioRegistry: new ScenarioRegistry(),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
134
153
|
const runners = await Promise.all(normalizedSpecs.map((spec) => ApiRunner.create({
|
|
135
154
|
...config,
|
|
136
155
|
openApiPath: spec.source,
|
|
@@ -138,7 +157,7 @@ export async function counterfact(config, specs) {
|
|
|
138
157
|
// so that the --overlay CLI flag works in single-spec mode.
|
|
139
158
|
overlays: spec.overlays ?? config.overlays ?? [],
|
|
140
159
|
prefix: spec.prefix,
|
|
141
|
-
}, spec.group, spec.version ?? "", versionsByGroup.get(spec.group) ?? [])));
|
|
160
|
+
}, spec.group, spec.version ?? "", versionsByGroup.get(spec.group) ?? [], stateByGroup.get(spec.group))));
|
|
142
161
|
const koaApp = createKoaApp({
|
|
143
162
|
runners,
|
|
144
163
|
config,
|
|
@@ -205,7 +224,15 @@ export async function counterfact(config, specs) {
|
|
|
205
224
|
await Promise.all(runners.map((runner) => runner.start(options)));
|
|
206
225
|
let httpTerminator;
|
|
207
226
|
if (options.startServer) {
|
|
208
|
-
|
|
227
|
+
try {
|
|
228
|
+
await runGroupStartupScenarios(runners, { port: config.port });
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
// Startup happens after the runner watchers are active. If a scenario
|
|
232
|
+
// fails, release those resources and leave the HTTP port untouched.
|
|
233
|
+
await Promise.allSettled(runners.map((runner) => runner.stopWatching()));
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
209
236
|
const server = koaApp.listen({
|
|
210
237
|
port: config.port,
|
|
211
238
|
});
|
package/dist/cli/run.js
CHANGED
|
@@ -29,8 +29,7 @@ const DEFAULT_PORT = 3100;
|
|
|
29
29
|
* (single spec derived from config).
|
|
30
30
|
*
|
|
31
31
|
* Note: `prefix` is intentionally left `undefined` when not supplied so that
|
|
32
|
-
* `normalizeSpecs` (in `app.ts`) can
|
|
33
|
-
* `group`/`version`.
|
|
32
|
+
* `normalizeSpecs` (in `app.ts`) can apply the root-prefix default.
|
|
34
33
|
*/
|
|
35
34
|
export function normalizeSpecOption(specOption) {
|
|
36
35
|
if (Array.isArray(specOption)) {
|
|
@@ -4,7 +4,7 @@ import Koa from "koa";
|
|
|
4
4
|
import bodyParser from "koa-bodyparser";
|
|
5
5
|
import { koaSwagger } from "koa2-swagger-ui";
|
|
6
6
|
import { adminApiMiddleware } from "./admin-api-middleware.js";
|
|
7
|
-
import { routesMiddleware } from "./routes-middleware.js";
|
|
7
|
+
import { routesMiddleware, routesMiddlewareForRunners, } from "./routes-middleware.js";
|
|
8
8
|
import { openapiMiddleware } from "./openapi-middleware.js";
|
|
9
9
|
const debug = createDebug("counterfact:server:create-koa-app");
|
|
10
10
|
/**
|
|
@@ -61,8 +61,15 @@ export function createKoaApp({ runners, config, }) {
|
|
|
61
61
|
ctx.type = "application/json";
|
|
62
62
|
}
|
|
63
63
|
});
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
const [onlyRunner] = runners;
|
|
65
|
+
if (runners.length === 1 && onlyRunner !== undefined) {
|
|
66
|
+
app.use(routesMiddleware(onlyRunner.prefix, onlyRunner.dispatcher, {
|
|
67
|
+
proxyPaths: config.proxyPaths,
|
|
68
|
+
proxyUrl: config.proxyUrl,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
app.use(routesMiddlewareForRunners(runners, {
|
|
66
73
|
proxyPaths: config.proxyPaths,
|
|
67
74
|
proxyUrl: config.proxyUrl,
|
|
68
75
|
}));
|
|
@@ -91,7 +91,7 @@ function getAuthObject(ctx) {
|
|
|
91
91
|
* @param proxy - Proxy factory; injectable for testing.
|
|
92
92
|
* @returns A Koa middleware function.
|
|
93
93
|
*/
|
|
94
|
-
export function routesMiddleware(prefix, dispatcher, config, proxy = koaProxy) {
|
|
94
|
+
export function routesMiddleware(prefix, dispatcher, config, proxy = koaProxy, allowedMethodsOverride) {
|
|
95
95
|
return async function middleware(ctx, next) {
|
|
96
96
|
const { proxyUrl } = config;
|
|
97
97
|
debug("middleware running for path: %s", ctx.request.path);
|
|
@@ -106,7 +106,7 @@ export function routesMiddleware(prefix, dispatcher, config, proxy = koaProxy) {
|
|
|
106
106
|
if (isProxyEnabledForPath(path, config) && proxyUrl) {
|
|
107
107
|
return proxy("/", { changeOrigin: true, target: proxyUrl })(ctx, next);
|
|
108
108
|
}
|
|
109
|
-
addCors(ctx, dispatcher.registry.allowedMethods(path), headers);
|
|
109
|
+
addCors(ctx, allowedMethodsOverride ?? dispatcher.registry.allowedMethods(path), headers);
|
|
110
110
|
if (method === "OPTIONS") {
|
|
111
111
|
ctx.status = HTTP_STATUS_CODE_OK;
|
|
112
112
|
return undefined;
|
|
@@ -156,7 +156,56 @@ export function routesMiddleware(prefix, dispatcher, config, proxy = koaProxy) {
|
|
|
156
156
|
ctx.res.appendHeader(key, value);
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
|
+
if (response.status === 405 && allowedMethodsOverride !== undefined) {
|
|
160
|
+
ctx.set("Allow", allowedMethodsOverride);
|
|
161
|
+
}
|
|
159
162
|
ctx.status = response.status ?? HTTP_STATUS_CODE_OK;
|
|
160
163
|
return undefined;
|
|
161
164
|
};
|
|
162
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Selects the first runner that can handle a request, allowing runners with
|
|
168
|
+
* overlapping prefixes to share one public URL space.
|
|
169
|
+
*/
|
|
170
|
+
export function routesMiddlewareForRunners(runners, config, proxy = koaProxy) {
|
|
171
|
+
return async function multiRunnerRoutesMiddleware(ctx, next) {
|
|
172
|
+
const candidates = runners
|
|
173
|
+
.filter(({ prefix }) => ctx.request.path.startsWith(prefix))
|
|
174
|
+
.map((runner) => ({
|
|
175
|
+
path: ctx.request.path.slice(runner.prefix.length),
|
|
176
|
+
runner,
|
|
177
|
+
}));
|
|
178
|
+
if (candidates.length === 0) {
|
|
179
|
+
return await next();
|
|
180
|
+
}
|
|
181
|
+
const method = ctx.request.method;
|
|
182
|
+
for (const candidate of candidates) {
|
|
183
|
+
if ((config.proxyUrl && isProxyEnabledForPath(candidate.path, config)) ||
|
|
184
|
+
candidate.runner.dispatcher.registry.exists(method, candidate.path)) {
|
|
185
|
+
return routesMiddleware(candidate.runner.prefix, candidate.runner.dispatcher, config, proxy)(ctx, next);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const allowedMethods = new Set();
|
|
189
|
+
let pathOwner;
|
|
190
|
+
for (const candidate of candidates) {
|
|
191
|
+
const methods = candidate.runner.dispatcher.registry.allowedMethods(candidate.path);
|
|
192
|
+
if (methods === "")
|
|
193
|
+
continue;
|
|
194
|
+
pathOwner ??= candidate;
|
|
195
|
+
for (const allowedMethod of methods.split(", ")) {
|
|
196
|
+
allowedMethods.add(allowedMethod);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (method === "OPTIONS") {
|
|
200
|
+
const selected = pathOwner ?? candidates[0];
|
|
201
|
+
if (selected === undefined) {
|
|
202
|
+
return await next();
|
|
203
|
+
}
|
|
204
|
+
return routesMiddleware(selected.runner.prefix, selected.runner.dispatcher, config, proxy, [...allowedMethods].join(", "))(ctx, next);
|
|
205
|
+
}
|
|
206
|
+
if (pathOwner === undefined) {
|
|
207
|
+
return await next();
|
|
208
|
+
}
|
|
209
|
+
return routesMiddleware(pathOwner.runner.prefix, pathOwner.runner.dispatcher, config, proxy, [...allowedMethods].join(", "))(ctx, next);
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -8,7 +8,8 @@ import { CHOKIDAR_OPTIONS } from "../server/constants.js";
|
|
|
8
8
|
import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
|
|
9
9
|
import { waitForEvent } from "../util/wait-for-event.js";
|
|
10
10
|
import { OperationCoder } from "./operation-coder.js";
|
|
11
|
-
import {
|
|
11
|
+
import { assertNoNormalizedPathCollisions, normalizeOpenApiPath, } from "./openapi-path.js";
|
|
12
|
+
import { pruneRoutes, pruneTypes } from "./prune.js";
|
|
12
13
|
import { Repository } from "./repository.js";
|
|
13
14
|
import { Specification } from "./specification.js";
|
|
14
15
|
const debug = createDebug("counterfact:typescript-generator:generate");
|
|
@@ -94,9 +95,11 @@ export class CodeGenerator extends EventTarget {
|
|
|
94
95
|
debug("reading the #/paths from the specification");
|
|
95
96
|
const paths = await this.getPathsFromSpecification(specification);
|
|
96
97
|
debug("got %i paths", paths?.map?.length ?? 0);
|
|
98
|
+
const openApiPaths = paths.map((_pathDefinition, key) => key);
|
|
99
|
+
assertNoNormalizedPathCollisions(openApiPaths);
|
|
97
100
|
if (this.generateOptions.prune && this.generateOptions.routes) {
|
|
98
101
|
debug("pruning defunct route files");
|
|
99
|
-
await pruneRoutes(destination,
|
|
102
|
+
await pruneRoutes(destination, openApiPaths);
|
|
100
103
|
debug("done pruning");
|
|
101
104
|
}
|
|
102
105
|
const securityRequirement = specification.getRequirement("#/components/securitySchemes");
|
|
@@ -125,13 +128,24 @@ export class CodeGenerator extends EventTarget {
|
|
|
125
128
|
});
|
|
126
129
|
paths.forEach((pathDefinition, key) => {
|
|
127
130
|
debug("processing path %s", key);
|
|
128
|
-
const
|
|
131
|
+
const normalizedPath = normalizeOpenApiPath(key);
|
|
132
|
+
const path = normalizedPath === "/" ? "/index" : normalizedPath;
|
|
129
133
|
operationMethodsForPath(pathDefinition).forEach(([operation, requestMethod]) => {
|
|
130
134
|
repository
|
|
131
135
|
.get(`routes${path}.ts`)
|
|
132
136
|
.export(new OperationCoder(operation, this.version, requestMethod, securitySchemes));
|
|
133
137
|
});
|
|
134
138
|
});
|
|
139
|
+
if (this.generateOptions.prune && this.generateOptions.types) {
|
|
140
|
+
debug("resolving the expected generated type files");
|
|
141
|
+
await repository.finished();
|
|
142
|
+
debug("pruning defunct generated type files");
|
|
143
|
+
await pruneTypes(destination, [
|
|
144
|
+
...repository.scripts.keys(),
|
|
145
|
+
...(this.version === "" ? [] : ["types/versions.ts"]),
|
|
146
|
+
]);
|
|
147
|
+
debug("done pruning generated type files");
|
|
148
|
+
}
|
|
135
149
|
debug("telling the repository to write the files to %s", destination);
|
|
136
150
|
await repository.writeFiles(destination, this.generateOptions);
|
|
137
151
|
debug("finished writing the files");
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes an OpenAPI path for Counterfact's route and type module trees.
|
|
3
|
+
*
|
|
4
|
+
* Incoming requests already match route modules with or without a terminal
|
|
5
|
+
* slash, so terminal slashes must not create an extra empty file segment.
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeOpenApiPath(openApiPath) {
|
|
8
|
+
return openApiPath.replace(/\/+$/u, "") || "/";
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Rejects path keys that would target the same generated route module.
|
|
12
|
+
*/
|
|
13
|
+
export function assertNoNormalizedPathCollisions(openApiPaths) {
|
|
14
|
+
const originalPathByNormalizedPath = new Map();
|
|
15
|
+
for (const openApiPath of openApiPaths) {
|
|
16
|
+
const normalizedPath = normalizeOpenApiPath(openApiPath);
|
|
17
|
+
const existingPath = originalPathByNormalizedPath.get(normalizedPath);
|
|
18
|
+
if (existingPath !== undefined && existingPath !== openApiPath) {
|
|
19
|
+
throw new Error(`OpenAPI paths "${existingPath}" and "${openApiPath}" normalize to the same path "${normalizedPath}". Remove one of the conflicting paths.`);
|
|
20
|
+
}
|
|
21
|
+
originalPathByNormalizedPath.set(normalizedPath, openApiPath);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { pathJoin } from "../util/forward-slash-path.js";
|
|
2
2
|
import { Coder } from "./coder.js";
|
|
3
|
+
import { normalizeOpenApiPath } from "./openapi-path.js";
|
|
3
4
|
import { OperationTypeCoder, VersionedArgTypeCoder, } from "./operation-type-coder.js";
|
|
4
5
|
import { STREAMING_CONTENT_TYPES } from "./streaming-content-types.js";
|
|
5
6
|
/**
|
|
@@ -66,10 +67,7 @@ export class OperationCoder extends Coder {
|
|
|
66
67
|
return script.importType(operationTypeCoder);
|
|
67
68
|
}
|
|
68
69
|
modulePath() {
|
|
69
|
-
const pathString = this.requirement.url
|
|
70
|
-
.split("/")
|
|
71
|
-
.at(-2)
|
|
72
|
-
.replaceAll("~1", "/");
|
|
70
|
+
const pathString = normalizeOpenApiPath(this.requirement.url.split("/").at(-2).replaceAll("~1", "/"));
|
|
73
71
|
return `${pathJoin("routes", pathString)}.types.ts`;
|
|
74
72
|
}
|
|
75
73
|
}
|
|
@@ -9,6 +9,7 @@ import { ResponsesTypeCoder } from "./responses-type-coder.js";
|
|
|
9
9
|
import { SchemaTypeCoder } from "./schema-type-coder.js";
|
|
10
10
|
import { STREAMING_CONTENT_TYPES } from "./streaming-content-types.js";
|
|
11
11
|
import { TypeCoder } from "./type-coder.js";
|
|
12
|
+
import { normalizeOpenApiPath } from "./openapi-path.js";
|
|
12
13
|
import { Requirement } from "./requirement.js";
|
|
13
14
|
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words
|
|
14
15
|
function sanitizeIdentifier(value) {
|
|
@@ -145,10 +146,7 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
145
146
|
.join(" | ");
|
|
146
147
|
}
|
|
147
148
|
modulePath() {
|
|
148
|
-
const pathString = this.requirement.url
|
|
149
|
-
.split("/")
|
|
150
|
-
.at(-2)
|
|
151
|
-
.replaceAll("~1", "/");
|
|
149
|
+
const pathString = normalizeOpenApiPath(this.requirement.url.split("/").at(-2).replaceAll("~1", "/"));
|
|
152
150
|
return `${pathJoin("types/paths", pathString === "/" ? "/index" : pathString)}.types.ts`;
|
|
153
151
|
}
|
|
154
152
|
/**
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import nodePath from "node:path";
|
|
3
|
-
/* eslint-disable security/detect-non-literal-fs-filename -- pruning only traverses and removes files under destination
|
|
3
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- pruning only traverses and removes files under generator-owned destination subdirectories. */
|
|
4
4
|
import createDebug from "debug";
|
|
5
5
|
import { toForwardSlashPath } from "../util/forward-slash-path.js";
|
|
6
|
+
import { normalizeOpenApiPath } from "./openapi-path.js";
|
|
6
7
|
const debug = createDebug("counterfact:typescript-generator:prune");
|
|
7
8
|
/**
|
|
8
9
|
* Collects all .ts route files in a directory recursively.
|
|
@@ -11,21 +12,19 @@ const debug = createDebug("counterfact:typescript-generator:prune");
|
|
|
11
12
|
* @param currentPath - Current subdirectory being processed (relative to routesDir)
|
|
12
13
|
* @returns Array of relative paths (using forward slashes)
|
|
13
14
|
*/
|
|
14
|
-
async function
|
|
15
|
+
async function collectTypeScriptFiles(rootDir, currentPath = "") {
|
|
15
16
|
const files = [];
|
|
16
17
|
try {
|
|
17
|
-
const fullDir = currentPath
|
|
18
|
-
? nodePath.join(routesDir, currentPath)
|
|
19
|
-
: routesDir;
|
|
18
|
+
const fullDir = currentPath ? nodePath.join(rootDir, currentPath) : rootDir;
|
|
20
19
|
const entries = await fs.readdir(fullDir, { withFileTypes: true });
|
|
21
20
|
for (const entry of entries) {
|
|
22
21
|
const relativePath = currentPath
|
|
23
22
|
? `${currentPath}/${entry.name}`
|
|
24
23
|
: entry.name;
|
|
25
24
|
if (entry.isDirectory()) {
|
|
26
|
-
files.push(...(await
|
|
25
|
+
files.push(...(await collectTypeScriptFiles(rootDir, relativePath)));
|
|
27
26
|
}
|
|
28
|
-
else if (entry.name.endsWith(".ts")
|
|
27
|
+
else if (entry.name.endsWith(".ts")) {
|
|
29
28
|
files.push(relativePath);
|
|
30
29
|
}
|
|
31
30
|
}
|
|
@@ -71,7 +70,8 @@ async function removeEmptyDirectories(dir, rootDir) {
|
|
|
71
70
|
* @param openApiPath - The OpenAPI path string
|
|
72
71
|
*/
|
|
73
72
|
function openApiPathToRouteFile(openApiPath) {
|
|
74
|
-
const
|
|
73
|
+
const normalizedPath = normalizeOpenApiPath(openApiPath);
|
|
74
|
+
const filePath = normalizedPath === "/" ? "index" : normalizedPath.slice(1);
|
|
75
75
|
return `${filePath}.ts`;
|
|
76
76
|
}
|
|
77
77
|
/**
|
|
@@ -85,7 +85,7 @@ export async function pruneRoutes(destination, openApiPaths) {
|
|
|
85
85
|
const routesDir = nodePath.join(destination, "routes");
|
|
86
86
|
const expectedFiles = new Set(Array.from(openApiPaths).map(openApiPathToRouteFile));
|
|
87
87
|
debug("expected route files: %o", Array.from(expectedFiles));
|
|
88
|
-
const actualFiles = await
|
|
88
|
+
const actualFiles = (await collectTypeScriptFiles(routesDir)).filter((file) => nodePath.basename(file) !== "_.context.ts");
|
|
89
89
|
debug("actual route files: %o", actualFiles);
|
|
90
90
|
let prunedCount = 0;
|
|
91
91
|
for (const file of actualFiles) {
|
|
@@ -101,3 +101,46 @@ export async function pruneRoutes(destination, openApiPaths) {
|
|
|
101
101
|
debug("pruned %d files", prunedCount);
|
|
102
102
|
return prunedCount;
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Returns whether a path below `types/` belongs to a namespace populated by
|
|
106
|
+
* the OpenAPI generator. Context support and arbitrary user-authored type
|
|
107
|
+
* modules are deliberately outside this ownership policy.
|
|
108
|
+
*/
|
|
109
|
+
function isGeneratorOwnedTypePath(path) {
|
|
110
|
+
const [first, second] = toForwardSlashPath(path).split("/");
|
|
111
|
+
const generatedCategory = (segment) => segment === "paths" || segment === "components" || segment === "#";
|
|
112
|
+
return (path === "versions.ts" ||
|
|
113
|
+
generatedCategory(first) ||
|
|
114
|
+
generatedCategory(second));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Prunes obsolete files from the OpenAPI generator-owned namespaces below
|
|
118
|
+
* `types/`. Hidden files are included by the recursive directory walk.
|
|
119
|
+
*
|
|
120
|
+
* `types/_.context.ts` and paths outside the known generated namespaces are
|
|
121
|
+
* preserved because separate generators or users own them.
|
|
122
|
+
*
|
|
123
|
+
* @param destination - Base destination directory (contains `types/`).
|
|
124
|
+
* @param expectedPaths - Repository-relative generated paths to retain.
|
|
125
|
+
* @returns Number of files removed.
|
|
126
|
+
*/
|
|
127
|
+
export async function pruneTypes(destination, expectedPaths) {
|
|
128
|
+
const typesDir = nodePath.join(destination, "types");
|
|
129
|
+
const expectedFiles = new Set(Array.from(expectedPaths, (path) => toForwardSlashPath(path).replace(/^types\//u, "")));
|
|
130
|
+
const actualFiles = (await collectTypeScriptFiles(typesDir)).filter(isGeneratorOwnedTypePath);
|
|
131
|
+
let prunedCount = 0;
|
|
132
|
+
debug("expected type files: %o", Array.from(expectedFiles));
|
|
133
|
+
debug("actual generated type files: %o", actualFiles);
|
|
134
|
+
for (const file of actualFiles) {
|
|
135
|
+
const normalizedFile = toForwardSlashPath(file);
|
|
136
|
+
if (!expectedFiles.has(normalizedFile)) {
|
|
137
|
+
const fullPath = nodePath.join(typesDir, file);
|
|
138
|
+
debug("pruning %s", fullPath);
|
|
139
|
+
await fs.rm(fullPath);
|
|
140
|
+
prunedCount++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
await removeEmptyDirectories(typesDir, typesDir);
|
|
144
|
+
debug("pruned %d type files", prunedCount);
|
|
145
|
+
return prunedCount;
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "counterfact",
|
|
3
|
-
"version": "2.14.
|
|
3
|
+
"version": "2.14.2",
|
|
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",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
],
|
|
66
66
|
"sideEffects": false,
|
|
67
67
|
"scripts": {
|
|
68
|
-
"test": "yarn node --experimental-vm-modules ./node_modules/jest-cli/bin/jest --testPathIgnorePatterns=black-box",
|
|
68
|
+
"test": "yarn node --experimental-vm-modules ./node_modules/jest-cli/bin/jest --testPathIgnorePatterns=black-box --testPathIgnorePatterns=examples",
|
|
69
69
|
"test:black-box": "rimraf dist && yarn build && python3 -m pytest test-black-box/ -v",
|
|
70
70
|
"test:tsd": "tsd --typings ./dist/server/counterfact-types/index.ts --files ./test/**/*.test-d.ts",
|
|
71
71
|
"build": "rm -rf dist && tsc && copyfiles -f \"src/counterfact-types/*.ts\" dist/server/counterfact-types && copyfiles -f \"src/server/*.cjs\" dist/server",
|
|
@@ -82,10 +82,10 @@
|
|
|
82
82
|
"counterfact": "./bin/counterfact.js"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
|
-
"@changesets/cli": "2.31.
|
|
85
|
+
"@changesets/cli": "2.31.1",
|
|
86
86
|
"@eslint/js": "10.0.1",
|
|
87
87
|
"@jest/globals": "30.4.1",
|
|
88
|
-
"@swc/core": "1.15.
|
|
88
|
+
"@swc/core": "1.15.46",
|
|
89
89
|
"@swc/jest": "0.2.39",
|
|
90
90
|
"@testing-library/dom": "10.4.1",
|
|
91
91
|
"@types/debug": "4.1.13",
|
|
@@ -96,44 +96,44 @@
|
|
|
96
96
|
"@types/koa-proxy": "1.0.8",
|
|
97
97
|
"@types/koa-static": "4.0.4",
|
|
98
98
|
"@types/node": "22",
|
|
99
|
-
"@typescript-eslint/eslint-plugin": "8.
|
|
100
|
-
"@typescript-eslint/parser": "8.
|
|
99
|
+
"@typescript-eslint/eslint-plugin": "8.65.0",
|
|
100
|
+
"@typescript-eslint/parser": "8.65.0",
|
|
101
101
|
"copyfiles": "2.4.1",
|
|
102
|
-
"eslint": "10.
|
|
102
|
+
"eslint": "10.8.0",
|
|
103
103
|
"eslint-formatter-github-annotations": "0.1.0",
|
|
104
|
-
"eslint-import-resolver-typescript": "4.4.
|
|
104
|
+
"eslint-import-resolver-typescript": "4.4.5",
|
|
105
105
|
"eslint-plugin-etc": "2.0.3",
|
|
106
106
|
"eslint-plugin-file-progress": "4.0.0",
|
|
107
107
|
"eslint-plugin-import": "2.32.0",
|
|
108
|
-
"eslint-plugin-jest": "29.
|
|
109
|
-
"eslint-plugin-jest-dom": "5.
|
|
110
|
-
"eslint-plugin-n": "18.
|
|
108
|
+
"eslint-plugin-jest": "29.16.0",
|
|
109
|
+
"eslint-plugin-jest-dom": "5.6.0",
|
|
110
|
+
"eslint-plugin-n": "18.2.2",
|
|
111
111
|
"eslint-plugin-no-explicit-type-exports": "0.12.1",
|
|
112
|
-
"eslint-plugin-prettier": "5.5.
|
|
112
|
+
"eslint-plugin-prettier": "5.5.6",
|
|
113
113
|
"eslint-plugin-promise": "7.3.0",
|
|
114
|
-
"eslint-plugin-regexp": "3.1.
|
|
115
|
-
"eslint-plugin-security": "4.0.
|
|
114
|
+
"eslint-plugin-regexp": "3.1.1",
|
|
115
|
+
"eslint-plugin-security": "4.0.1",
|
|
116
116
|
"eslint-plugin-unused-imports": "4.4.1",
|
|
117
117
|
"husky": "9.1.7",
|
|
118
118
|
"jest": "30.4.2",
|
|
119
119
|
"jest-retries": "1.0.1",
|
|
120
|
-
"node-mocks-http": "1.
|
|
120
|
+
"node-mocks-http": "1.18.1",
|
|
121
121
|
"rimraf": "6.1.3",
|
|
122
122
|
"supertest": "7.2.2",
|
|
123
123
|
"tsd": "0.33.0",
|
|
124
124
|
"using-temporary-files": "2.2.1"
|
|
125
125
|
},
|
|
126
126
|
"dependencies": {
|
|
127
|
-
"@apidevtools/json-schema-ref-parser": "15.
|
|
127
|
+
"@apidevtools/json-schema-ref-parser": "15.5.0",
|
|
128
128
|
"@hapi/accept": "6.0.3",
|
|
129
129
|
"@types/json-schema": "7.0.15",
|
|
130
130
|
"ajv": "8.20.0",
|
|
131
131
|
"chokidar": "5.0.0",
|
|
132
|
-
"commander": "
|
|
132
|
+
"commander": "15.0.0",
|
|
133
133
|
"debug": "4.4.3",
|
|
134
|
-
"fs-extra": "11.
|
|
134
|
+
"fs-extra": "11.4.0",
|
|
135
135
|
"http-terminator": "3.2.0",
|
|
136
|
-
"js-yaml": "
|
|
136
|
+
"js-yaml": "5.2.2",
|
|
137
137
|
"json-schema-faker": "0.6.2",
|
|
138
138
|
"jsonpath-plus": "10.4.0",
|
|
139
139
|
"jsonwebtoken": "9.0.3",
|
|
@@ -143,16 +143,15 @@
|
|
|
143
143
|
"koa2-swagger-ui": "5.12.0",
|
|
144
144
|
"node-fetch": "3.3.2",
|
|
145
145
|
"open": "11.0.0",
|
|
146
|
-
"posthog-node": "5.
|
|
147
|
-
"precinct": "13.0.
|
|
148
|
-
"prettier": "3.8.
|
|
149
|
-
"recast": "0.23.
|
|
150
|
-
"tsx": "4.
|
|
146
|
+
"posthog-node": "5.46.0",
|
|
147
|
+
"precinct": "13.0.1",
|
|
148
|
+
"prettier": "3.8.5",
|
|
149
|
+
"recast": "0.23.12",
|
|
150
|
+
"tsx": "4.23.1",
|
|
151
151
|
"typescript": "6.0.3"
|
|
152
152
|
},
|
|
153
153
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
|
|
154
154
|
"resolutions": {
|
|
155
|
-
"js-yaml": "4.1.1",
|
|
156
155
|
"@typescript-eslint/utils": "^8.58.0"
|
|
157
156
|
}
|
|
158
157
|
}
|