counterfact 2.12.0 → 2.14.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 +53 -37
- package/dist/api-runner.js +8 -2
- package/dist/app.js +8 -1
- package/dist/cli/run.js +12 -1
- package/dist/server/dispatcher.js +48 -5
- package/dist/server/load-openapi-document.js +2 -2
- package/dist/server/openapi-document.js +13 -1
- package/dist/server/request-validator.js +1 -0
- package/dist/server/response-builder.js +1 -0
- package/dist/server/web-server/create-koa-app.js +1 -0
- package/dist/server/web-server/openapi-middleware.js +4 -0
- package/dist/server/web-server/routes-middleware.js +5 -5
- package/dist/typescript-generator/code-generator.js +12 -6
- package/dist/typescript-generator/operation-type-coder.js +42 -6
- package/dist/typescript-generator/repository.js +97 -8
- package/dist/typescript-generator/specification.js +16 -7
- package/dist/util/apply-overlay.js +119 -0
- package/dist/util/load-config-file.js +3 -0
- package/package.json +30 -29
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
|
@@ -59,6 +59,11 @@ export class ApiRunner {
|
|
|
59
59
|
openApiPath;
|
|
60
60
|
/** URL prefix that this runner intercepts (default `""`). */
|
|
61
61
|
prefix;
|
|
62
|
+
/**
|
|
63
|
+
* Ordered list of overlay file paths/URLs applied to the OpenAPI document
|
|
64
|
+
* after loading. Empty when no overlays are configured.
|
|
65
|
+
*/
|
|
66
|
+
overlays;
|
|
62
67
|
/**
|
|
63
68
|
* Optional group name that places generated code in a subdirectory.
|
|
64
69
|
* Defaults to `""` (no subdirectory).
|
|
@@ -89,11 +94,12 @@ export class ApiRunner {
|
|
|
89
94
|
this.openApiDocument = openApiDocument;
|
|
90
95
|
this.openApiPath = config.openApiPath;
|
|
91
96
|
this.prefix = config.prefix;
|
|
97
|
+
this.overlays = config.overlays ?? [];
|
|
92
98
|
this.registry = new Registry();
|
|
93
99
|
this.contextRegistry = new ContextRegistry();
|
|
94
100
|
this.scenarioRegistry = new ScenarioRegistry();
|
|
95
101
|
this.scenarioFileGenerator = new ScenarioFileGenerator(modulesPath);
|
|
96
|
-
this.codeGenerator = new CodeGenerator(this.openApiPath, config.basePath + this.subdirectory, config.generate, version);
|
|
102
|
+
this.codeGenerator = new CodeGenerator(this.openApiPath, config.basePath + this.subdirectory, config.generate, version, config.overlays ?? []);
|
|
97
103
|
this.dispatcher = new Dispatcher(this.registry, this.contextRegistry, openApiDocument, config, version, versions);
|
|
98
104
|
this.transpiler = new Transpiler(pathJoin(modulesPath, "routes"), compiledPathsDirectory, "commonjs");
|
|
99
105
|
this.moduleLoader = new ModuleLoader(compiledPathsDirectory, this.registry, this.contextRegistry, pathJoin(modulesPath, "scenarios"), this.scenarioRegistry);
|
|
@@ -121,7 +127,7 @@ export class ApiRunner {
|
|
|
121
127
|
}
|
|
122
128
|
const openApiDocument = config.openApiPath === "_"
|
|
123
129
|
? undefined
|
|
124
|
-
: await loadOpenApiDocument(config.openApiPath);
|
|
130
|
+
: await loadOpenApiDocument(config.openApiPath, config.overlays ?? []);
|
|
125
131
|
return new ApiRunner(config, nativeTs, openApiDocument, group, version, versions);
|
|
126
132
|
}
|
|
127
133
|
/**
|
package/dist/app.js
CHANGED
|
@@ -131,7 +131,14 @@ export async function counterfact(config, specs) {
|
|
|
131
131
|
versionsByGroup.set(spec.group, [...existing, version]);
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
-
const runners = await Promise.all(normalizedSpecs.map((spec) => ApiRunner.create({
|
|
134
|
+
const runners = await Promise.all(normalizedSpecs.map((spec) => ApiRunner.create({
|
|
135
|
+
...config,
|
|
136
|
+
openApiPath: spec.source,
|
|
137
|
+
// Per-spec overlays take precedence; fall back to config-level overlays
|
|
138
|
+
// so that the --overlay CLI flag works in single-spec mode.
|
|
139
|
+
overlays: spec.overlays ?? config.overlays ?? [],
|
|
140
|
+
prefix: spec.prefix,
|
|
141
|
+
}, spec.group, spec.version ?? "", versionsByGroup.get(spec.group) ?? [])));
|
|
135
142
|
const koaApp = createKoaApp({
|
|
136
143
|
runners,
|
|
137
144
|
config,
|
package/dist/cli/run.js
CHANGED
|
@@ -22,7 +22,7 @@ const DEFAULT_PORT = 3100;
|
|
|
22
22
|
* CLI flag) into an array of {@link SpecConfig} objects, or `undefined` when
|
|
23
23
|
* the option is a plain string (single OpenAPI document path).
|
|
24
24
|
*
|
|
25
|
-
* - **Array**: each entry is mapped to `{source, prefix, group, version}` with defaults.
|
|
25
|
+
* - **Array**: each entry is mapped to `{source, prefix, group, version, overlays}` with defaults.
|
|
26
26
|
* - **Object**: wrapped in a single-element array.
|
|
27
27
|
* - **String / undefined**: returns `undefined` — caller handles the string
|
|
28
28
|
* case (it shifts the positional argument) and the `undefined` case
|
|
@@ -39,6 +39,7 @@ export function normalizeSpecOption(specOption) {
|
|
|
39
39
|
prefix: entry.prefix,
|
|
40
40
|
group: entry.group ?? "",
|
|
41
41
|
version: entry.version,
|
|
42
|
+
overlays: entry.overlays,
|
|
42
43
|
}));
|
|
43
44
|
}
|
|
44
45
|
if (typeof specOption === "object" &&
|
|
@@ -50,6 +51,7 @@ export function normalizeSpecOption(specOption) {
|
|
|
50
51
|
prefix: specOption.prefix,
|
|
51
52
|
group: specOption.group ?? "",
|
|
52
53
|
version: specOption.version,
|
|
54
|
+
overlays: specOption.overlays,
|
|
53
55
|
},
|
|
54
56
|
];
|
|
55
57
|
}
|
|
@@ -104,11 +106,17 @@ function buildProgram(version, taglines) {
|
|
|
104
106
|
const configFilePath = resolve(options.config ?? "counterfact.yaml");
|
|
105
107
|
const fileConfig = await loadConfigFile(configFilePath, options.config !== undefined);
|
|
106
108
|
debug("fileConfig: %o", fileConfig);
|
|
109
|
+
const knownOptionKeys = new Set(program.options.map((option) => option.attributeName()));
|
|
107
110
|
// Apply config file values for any option that was not explicitly set on
|
|
108
111
|
// the command line (i.e. its source is "default" or it was never defined).
|
|
109
112
|
for (const [key, value] of Object.entries(fileConfig)) {
|
|
113
|
+
if (!knownOptionKeys.has(key)) {
|
|
114
|
+
debug("ignoring unknown config key %s", key);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
110
117
|
const optionSource = program.getOptionValueSource(key);
|
|
111
118
|
if (optionSource !== "cli") {
|
|
119
|
+
// eslint-disable-next-line security/detect-object-injection -- key is validated against known Commander option names above.
|
|
112
120
|
options[key] = value;
|
|
113
121
|
}
|
|
114
122
|
}
|
|
@@ -139,6 +147,7 @@ function buildProgram(version, taglines) {
|
|
|
139
147
|
const actions = ["repl", "serve", "watch", "generate", "buildCache"];
|
|
140
148
|
if (!Object.keys(options).some((argument) => actions.some((action) => argument.startsWith(action)))) {
|
|
141
149
|
for (const action of actions) {
|
|
150
|
+
// eslint-disable-next-line security/detect-object-injection -- action names come from the local allowlist above.
|
|
142
151
|
options[action] = true;
|
|
143
152
|
}
|
|
144
153
|
}
|
|
@@ -170,6 +179,7 @@ function buildProgram(version, taglines) {
|
|
|
170
179
|
prune: Boolean(options.prune),
|
|
171
180
|
},
|
|
172
181
|
openApiPath: source,
|
|
182
|
+
overlays: options.overlay ?? [],
|
|
173
183
|
port: options.port,
|
|
174
184
|
proxyPaths: new Map([["", Boolean(options.proxyUrl)]]),
|
|
175
185
|
proxyUrl: options.proxyUrl ?? "",
|
|
@@ -298,6 +308,7 @@ function buildProgram(version, taglines) {
|
|
|
298
308
|
.option("--always-fake-optionals", "random responses will include optional fields")
|
|
299
309
|
.option("--prune", "remove route files that no longer exist in the OpenAPI spec")
|
|
300
310
|
.option("--spec <string>", "path or URL to OpenAPI document (alternative to the positional [openapi.yaml] argument)")
|
|
311
|
+
.option("--overlay <path>", "path or URL to an OpenAPI overlay file to apply (repeatable)", (value, previous) => [...previous, value], [])
|
|
301
312
|
.option("--no-update-check", "disable the npm update check on startup")
|
|
302
313
|
.option("--no-validate-request", "disable request validation against the OpenAPI spec")
|
|
303
314
|
.option("--no-validate-response", "disable response validation against the OpenAPI spec")
|
|
@@ -165,6 +165,36 @@ export class Dispatcher {
|
|
|
165
165
|
}
|
|
166
166
|
return undefined;
|
|
167
167
|
}
|
|
168
|
+
apiKeySecurityParameters() {
|
|
169
|
+
const schemes = this.openApiDocument?.components?.securitySchemes;
|
|
170
|
+
return Object.values(schemes ?? {})
|
|
171
|
+
.filter(({ in: location, name, type }) => type === "apiKey" &&
|
|
172
|
+
typeof name === "string" &&
|
|
173
|
+
(location === "header" ||
|
|
174
|
+
location === "query" ||
|
|
175
|
+
location === "cookie"))
|
|
176
|
+
.map(({ in: location, name }) => ({
|
|
177
|
+
in: location,
|
|
178
|
+
name: name,
|
|
179
|
+
required: false,
|
|
180
|
+
schema: { type: "string" },
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
authWithApiKey(auth, cookie, headers, query) {
|
|
184
|
+
const apiKeyScheme = this.apiKeySecurityParameters().find((parameter) => ["cookie", "header", "query"].includes(parameter.in));
|
|
185
|
+
if (!apiKeyScheme) {
|
|
186
|
+
return auth;
|
|
187
|
+
}
|
|
188
|
+
const apiKey = apiKeyScheme.in === "query"
|
|
189
|
+
? query[apiKeyScheme.name]
|
|
190
|
+
: apiKeyScheme.in === "cookie"
|
|
191
|
+
? cookie[apiKeyScheme.name]
|
|
192
|
+
: Object.entries(headers).find(([key]) => key.toLowerCase() === apiKeyScheme.name.toLowerCase())?.[1];
|
|
193
|
+
const normalizedApiKey = Array.isArray(apiKey)
|
|
194
|
+
? (apiKey[0] ?? "")
|
|
195
|
+
: (apiKey ?? "");
|
|
196
|
+
return { ...auth, apiKey: normalizedApiKey };
|
|
197
|
+
}
|
|
168
198
|
/**
|
|
169
199
|
* Resolves the OpenAPI operation for `path` and `method`, merging any
|
|
170
200
|
* top-level `produces` array from the document root and any path-item-level
|
|
@@ -203,13 +233,20 @@ export class Dispatcher {
|
|
|
203
233
|
const mergedOperation = mergedParameters !== undefined
|
|
204
234
|
? { ...operation, parameters: mergedParameters }
|
|
205
235
|
: operation;
|
|
236
|
+
const apiKeyParameters = this.apiKeySecurityParameters();
|
|
237
|
+
const operationWithSecurity = apiKeyParameters.length > 0
|
|
238
|
+
? {
|
|
239
|
+
...mergedOperation,
|
|
240
|
+
parameters: mergeParameters(mergedOperation.parameters ?? [], apiKeyParameters),
|
|
241
|
+
}
|
|
242
|
+
: mergedOperation;
|
|
206
243
|
if (this.openApiDocument?.produces) {
|
|
207
244
|
return {
|
|
208
245
|
produces: this.openApiDocument.produces,
|
|
209
|
-
...
|
|
246
|
+
...operationWithSecurity,
|
|
210
247
|
};
|
|
211
248
|
}
|
|
212
|
-
return
|
|
249
|
+
return operationWithSecurity;
|
|
213
250
|
}
|
|
214
251
|
normalizeResponse(response, acceptHeader) {
|
|
215
252
|
if (response.content !== undefined) {
|
|
@@ -299,8 +336,14 @@ export class Dispatcher {
|
|
|
299
336
|
};
|
|
300
337
|
}
|
|
301
338
|
const operation = this.operationForPathAndMethod(matchedPath, method);
|
|
339
|
+
const requestCookie = parseCookies(headers.cookie ?? headers.Cookie ?? "");
|
|
302
340
|
if (this.config?.validateRequests !== false) {
|
|
303
|
-
const validation = validateRequest(operation, {
|
|
341
|
+
const validation = validateRequest(operation, {
|
|
342
|
+
body,
|
|
343
|
+
cookie: requestCookie,
|
|
344
|
+
headers,
|
|
345
|
+
query,
|
|
346
|
+
});
|
|
304
347
|
if (!validation.valid) {
|
|
305
348
|
return {
|
|
306
349
|
body: `Request validation failed:\n${validation.errors.join("\n")}`,
|
|
@@ -316,7 +359,7 @@ export class Dispatcher {
|
|
|
316
359
|
return min + Math.random() * (max - min);
|
|
317
360
|
};
|
|
318
361
|
const response = await this.registry.endpoint(method, path, this.parameterTypes(operation?.parameters))({
|
|
319
|
-
auth,
|
|
362
|
+
auth: this.authWithApiKey(auth, requestCookie, headers, query),
|
|
320
363
|
body,
|
|
321
364
|
context: this.contextRegistry.find(matchedPath),
|
|
322
365
|
async delay(milliseconds = 0, maxMilliseconds = 0) {
|
|
@@ -325,7 +368,7 @@ export class Dispatcher {
|
|
|
325
368
|
: continuousDistribution(milliseconds, maxMilliseconds);
|
|
326
369
|
return new Promise((resolve) => setTimeout(resolve, delayInMs));
|
|
327
370
|
},
|
|
328
|
-
cookie:
|
|
371
|
+
cookie: requestCookie,
|
|
329
372
|
headers,
|
|
330
373
|
proxy: async (url) => {
|
|
331
374
|
delete headers.host;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { OpenApiDocument } from "./openapi-document.js";
|
|
2
|
-
export async function loadOpenApiDocument(source) {
|
|
3
|
-
const document = new OpenApiDocument(source);
|
|
2
|
+
export async function loadOpenApiDocument(source, overlays = []) {
|
|
3
|
+
const document = new OpenApiDocument(source, overlays);
|
|
4
4
|
await document.load();
|
|
5
5
|
return document;
|
|
6
6
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { watch } from "chokidar";
|
|
2
2
|
import createDebug from "debug";
|
|
3
3
|
import { dereference } from "@apidevtools/json-schema-ref-parser";
|
|
4
|
+
import { applyOverlays } from "../util/apply-overlay.js";
|
|
4
5
|
import { waitForEvent } from "../util/wait-for-event.js";
|
|
5
6
|
import { sendTelemetry } from "../cli/telemetry.js";
|
|
6
7
|
import { CHOKIDAR_OPTIONS } from "./constants.js";
|
|
@@ -14,13 +15,20 @@ const debug = createDebug("counterfact:server:openapi-document");
|
|
|
14
15
|
export class OpenApiDocument extends EventTarget {
|
|
15
16
|
/** The path or URL of the OpenAPI source file. */
|
|
16
17
|
source;
|
|
18
|
+
/**
|
|
19
|
+
* Optional ordered list of overlay file paths/URLs to apply after each
|
|
20
|
+
* load of the document.
|
|
21
|
+
*/
|
|
22
|
+
overlays;
|
|
17
23
|
basePath;
|
|
24
|
+
components;
|
|
18
25
|
paths = {};
|
|
19
26
|
produces;
|
|
20
27
|
watcher;
|
|
21
|
-
constructor(source) {
|
|
28
|
+
constructor(source, overlays = []) {
|
|
22
29
|
super();
|
|
23
30
|
this.source = source;
|
|
31
|
+
this.overlays = overlays;
|
|
24
32
|
}
|
|
25
33
|
/**
|
|
26
34
|
* Reads the source file and populates the document's properties.
|
|
@@ -29,7 +37,11 @@ export class OpenApiDocument extends EventTarget {
|
|
|
29
37
|
async load() {
|
|
30
38
|
try {
|
|
31
39
|
const data = (await dereference(this.source));
|
|
40
|
+
if (this.overlays.length > 0) {
|
|
41
|
+
await applyOverlays(data, this.overlays);
|
|
42
|
+
}
|
|
32
43
|
this.basePath = data.basePath;
|
|
44
|
+
this.components = data.components;
|
|
33
45
|
this.paths = data.paths;
|
|
34
46
|
this.produces = data.produces;
|
|
35
47
|
}
|
|
@@ -62,6 +62,7 @@ export function validateRequest(operation, request) {
|
|
|
62
62
|
// by the registry before the route handler is called.
|
|
63
63
|
errors.push(...findMissingRequired(parameters, "query", request.query));
|
|
64
64
|
errors.push(...findMissingRequired(parameters, "header", request.headers));
|
|
65
|
+
errors.push(...findMissingRequired(parameters, "cookie", request.cookie));
|
|
65
66
|
// Validate request body (OpenAPI 3.x requestBody)
|
|
66
67
|
if (operation.requestBody?.content !== undefined) {
|
|
67
68
|
const schema = operation.requestBody.content["application/json"]?.schema ??
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { generate } from "json-schema-faker";
|
|
2
|
+
/* eslint-disable security/detect-object-injection -- OpenAPI response/content maps are spec-defined dictionaries accessed by status code, media type, and example name. */
|
|
2
3
|
import { jsonToXml } from "./json-to-xml.js";
|
|
3
4
|
import { STREAMING_CONTENT_TYPES } from "../typescript-generator/streaming-content-types.js";
|
|
4
5
|
const DEFAULT_GENERATE_OPTIONS = {
|
|
@@ -29,6 +29,7 @@ export function createKoaApp({ runners, config, }) {
|
|
|
29
29
|
app.use(openapiMiddleware(`/counterfact/openapi${runner.subdirectory}`, {
|
|
30
30
|
path: runner.openApiPath,
|
|
31
31
|
baseUrl: `//localhost:${config.port}${runner.prefix}`,
|
|
32
|
+
overlays: runner.overlays,
|
|
32
33
|
}));
|
|
33
34
|
app.use(koaSwagger({
|
|
34
35
|
routePrefix: `/counterfact/swagger${runner.subdirectory}`,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { bundle } from "@apidevtools/json-schema-ref-parser";
|
|
2
2
|
import { dump } from "js-yaml";
|
|
3
|
+
import { applyOverlays } from "../../util/apply-overlay.js";
|
|
3
4
|
/**
|
|
4
5
|
* Returns a Koa middleware that serves a bundled OpenAPI document as YAML at
|
|
5
6
|
* the given `pathPrefix`.
|
|
@@ -22,6 +23,9 @@ export function openapiMiddleware(pathPrefix, document) {
|
|
|
22
23
|
return await next();
|
|
23
24
|
}
|
|
24
25
|
const openApiDocument = (await bundle(document.path));
|
|
26
|
+
if (document.overlays && document.overlays.length > 0) {
|
|
27
|
+
await applyOverlays(openApiDocument, document.overlays);
|
|
28
|
+
}
|
|
25
29
|
openApiDocument.servers ??= [];
|
|
26
30
|
openApiDocument.servers.unshift({
|
|
27
31
|
name: "Counterfact",
|
|
@@ -24,10 +24,10 @@ const HEADERS_TO_DROP = new Set([
|
|
|
24
24
|
* SSE/JSONL/JSON-seq formatter map. Each entry maps a content-type to the
|
|
25
25
|
* function that serialises a single stream item into the wire format.
|
|
26
26
|
*/
|
|
27
|
-
const STREAMING_FORMATTERS =
|
|
28
|
-
"text/event-stream"
|
|
29
|
-
"application/json-seq"
|
|
30
|
-
|
|
27
|
+
const STREAMING_FORMATTERS = new Map([
|
|
28
|
+
["text/event-stream", (item) => `data: ${JSON.stringify(item)}\n\n`],
|
|
29
|
+
["application/json-seq", (item) => `\x1e${JSON.stringify(item)}\n`],
|
|
30
|
+
]);
|
|
31
31
|
function defaultStreamFormatter(item) {
|
|
32
32
|
return `${JSON.stringify(item)}\n`;
|
|
33
33
|
}
|
|
@@ -42,7 +42,7 @@ function isAsyncIterable(value) {
|
|
|
42
42
|
* each item according to the given content type.
|
|
43
43
|
*/
|
|
44
44
|
function asyncIterableToReadable(iterable, contentType) {
|
|
45
|
-
const formatter = STREAMING_FORMATTERS
|
|
45
|
+
const formatter = STREAMING_FORMATTERS.get(contentType) ?? defaultStreamFormatter;
|
|
46
46
|
async function* generate() {
|
|
47
47
|
for await (const item of iterable) {
|
|
48
48
|
yield formatter(item);
|
|
@@ -23,13 +23,15 @@ export class CodeGenerator extends EventTarget {
|
|
|
23
23
|
openapiPath;
|
|
24
24
|
destination;
|
|
25
25
|
version;
|
|
26
|
+
overlays;
|
|
26
27
|
generateOptions;
|
|
27
28
|
watcher;
|
|
28
|
-
constructor(openApiPath, destination, generateOptions, version = "") {
|
|
29
|
+
constructor(openApiPath, destination, generateOptions, version = "", overlays = []) {
|
|
29
30
|
super();
|
|
30
31
|
this.openapiPath = openApiPath;
|
|
31
32
|
this.destination = destination;
|
|
32
33
|
this.version = version;
|
|
34
|
+
this.overlays = overlays;
|
|
33
35
|
this.generateOptions = generateOptions;
|
|
34
36
|
}
|
|
35
37
|
/**
|
|
@@ -87,7 +89,7 @@ export class CodeGenerator extends EventTarget {
|
|
|
87
89
|
await this.buildCacheDirectory(destination);
|
|
88
90
|
debug("done initializing the .cache directory");
|
|
89
91
|
debug("creating specification from %s", this.openapiPath);
|
|
90
|
-
const specification = await Specification.fromFile(this.openapiPath);
|
|
92
|
+
const specification = await Specification.fromFile(this.openapiPath, this.overlays);
|
|
91
93
|
debug("created specification: $o", specification);
|
|
92
94
|
debug("reading the #/paths from the specification");
|
|
93
95
|
const paths = await this.getPathsFromSpecification(specification);
|
|
@@ -135,16 +137,20 @@ export class CodeGenerator extends EventTarget {
|
|
|
135
137
|
debug("finished writing the files");
|
|
136
138
|
}
|
|
137
139
|
/**
|
|
138
|
-
* Starts watching the OpenAPI document for changes.
|
|
140
|
+
* Starts watching the OpenAPI document and any overlay files for changes.
|
|
139
141
|
*
|
|
140
|
-
* Has no effect when
|
|
142
|
+
* Has no effect when neither source is watchable (for example, an HTTP
|
|
143
|
+
* OpenAPI source with no local overlays).
|
|
141
144
|
* Resolves once the watcher is ready.
|
|
142
145
|
*/
|
|
143
146
|
async watch() {
|
|
144
|
-
|
|
147
|
+
const watchablePaths = this.openapiPath.startsWith("http")
|
|
148
|
+
? [...this.overlays]
|
|
149
|
+
: [this.openapiPath, ...this.overlays];
|
|
150
|
+
if (watchablePaths.length === 0) {
|
|
145
151
|
return;
|
|
146
152
|
}
|
|
147
|
-
this.watcher = watch(
|
|
153
|
+
this.watcher = watch(watchablePaths, CHOKIDAR_OPTIONS).on("change", () => {
|
|
148
154
|
void this.generate().then(() => {
|
|
149
155
|
this.dispatchEvent(new Event("generate"));
|
|
150
156
|
return true;
|
|
@@ -163,6 +163,23 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
163
163
|
}
|
|
164
164
|
return "never";
|
|
165
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Returns the TypeScript type for the `auth` argument.
|
|
168
|
+
*
|
|
169
|
+
* Includes basic-auth credentials when present and `apiKey` when at least one
|
|
170
|
+
* apiKey security scheme is configured.
|
|
171
|
+
*/
|
|
172
|
+
authType() {
|
|
173
|
+
const fields = new Set();
|
|
174
|
+
if (this.securitySchemes.some(({ scheme, type }) => type === "http" && scheme === "basic")) {
|
|
175
|
+
fields.add("username?: string");
|
|
176
|
+
fields.add("password?: string");
|
|
177
|
+
}
|
|
178
|
+
if (this.securitySchemes.some(({ type }) => type === "apiKey")) {
|
|
179
|
+
fields.add("apiKey: string");
|
|
180
|
+
}
|
|
181
|
+
return fields.size === 0 ? "never" : `{${[...fields].join(", ")}}`;
|
|
182
|
+
}
|
|
166
183
|
/**
|
|
167
184
|
* Returns the effective parameters for this operation by merging path-item-level
|
|
168
185
|
* parameters with operation-level parameters. Per the OpenAPI specification,
|
|
@@ -178,16 +195,32 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
178
195
|
getEffectiveParameters() {
|
|
179
196
|
const operationParams = this.requirement.get("parameters");
|
|
180
197
|
const pathItemParams = this.requirement.parent?.get("parameters");
|
|
181
|
-
|
|
198
|
+
const apiKeyParameters = this.securitySchemes
|
|
199
|
+
.filter(({ in: location, name, type }) => type === "apiKey" &&
|
|
200
|
+
typeof name === "string" &&
|
|
201
|
+
(location === "header" ||
|
|
202
|
+
location === "query" ||
|
|
203
|
+
location === "cookie"))
|
|
204
|
+
.map(({ in: location, name }) => ({
|
|
205
|
+
in: location,
|
|
206
|
+
name,
|
|
207
|
+
required: true,
|
|
208
|
+
schema: { type: "string" },
|
|
209
|
+
}));
|
|
210
|
+
if (!pathItemParams && !operationParams && apiKeyParameters.length === 0) {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
if (!pathItemParams && apiKeyParameters.length === 0) {
|
|
182
214
|
return operationParams;
|
|
183
215
|
}
|
|
184
|
-
if (!operationParams) {
|
|
216
|
+
if (!operationParams && apiKeyParameters.length === 0) {
|
|
185
217
|
return pathItemParams;
|
|
186
218
|
}
|
|
187
219
|
// Merge using a Map keyed on `${in}:${name}`.
|
|
188
|
-
// Path-level params are added first; operation-level
|
|
189
|
-
|
|
190
|
-
const
|
|
220
|
+
// Path-level params are added first; operation-level and security-level
|
|
221
|
+
// params override them.
|
|
222
|
+
const pathData = pathItemParams?.data ?? [];
|
|
223
|
+
const opData = operationParams?.data ?? [];
|
|
191
224
|
const map = new Map();
|
|
192
225
|
for (const p of pathData) {
|
|
193
226
|
map.set(`${p.in}:${p.name}`, p);
|
|
@@ -195,6 +228,9 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
195
228
|
for (const p of opData) {
|
|
196
229
|
map.set(`${p.in}:${p.name}`, p);
|
|
197
230
|
}
|
|
231
|
+
for (const p of apiKeyParameters) {
|
|
232
|
+
map.set(`${p.in}:${p.name}`, p);
|
|
233
|
+
}
|
|
198
234
|
return new Requirement([...map.values()], this.requirement.url, this.requirement.specification);
|
|
199
235
|
}
|
|
200
236
|
/**
|
|
@@ -245,7 +281,7 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
245
281
|
: "never";
|
|
246
282
|
const querystringTypeName = this.exportParameterType(script, "querystring", querystringType, baseName, modulePath);
|
|
247
283
|
const versionLiteralType = this.version !== "" ? `"${this.version}"` : "never";
|
|
248
|
-
return `OmitValueWhenNever<{ query: ${queryTypeName}, querystring: ${querystringTypeName}, path: ${pathTypeName}, headers: ${headersTypeName}, cookie: ${cookieTypeName}, body: ${bodyType}, context: ${contextTypeImportName}, response: ${responseType}, x: ${xType}, proxy: ${proxyType}, user: ${this.userType()}, delay: ${delayType}, version: ${versionLiteralType} }>`;
|
|
284
|
+
return `OmitValueWhenNever<{ query: ${queryTypeName}, querystring: ${querystringTypeName}, path: ${pathTypeName}, headers: ${headersTypeName}, cookie: ${cookieTypeName}, body: ${bodyType}, context: ${contextTypeImportName}, response: ${responseType}, x: ${xType}, proxy: ${proxyType}, auth: ${this.authType()}, user: ${this.userType()}, delay: ${delayType}, version: ${versionLiteralType} }>`;
|
|
249
285
|
}
|
|
250
286
|
writeCode(script) {
|
|
251
287
|
script.comments = READ_ONLY_COMMENTS;
|
|
@@ -70,9 +70,11 @@ export class Repository {
|
|
|
70
70
|
/**
|
|
71
71
|
* Waits for all scripts to finish, then writes each one to disk.
|
|
72
72
|
*
|
|
73
|
-
* Route files (`routes/…`) are never overwritten if they already exist
|
|
74
|
-
* disk, preserving user edits.
|
|
75
|
-
*
|
|
73
|
+
* Route files (`routes/…`) are never fully overwritten if they already exist
|
|
74
|
+
* on disk, preserving user edits. However, if the generated script contains
|
|
75
|
+
* HTTP-method handler exports that are absent from the existing file, those
|
|
76
|
+
* new exports (and their `import type` statements) are appended to the file.
|
|
77
|
+
* Type files (`types/…`) are always overwritten.
|
|
76
78
|
*
|
|
77
79
|
* @param destination - Absolute path to the output root directory.
|
|
78
80
|
* @param options - Controls which artefacts are written.
|
|
@@ -87,13 +89,16 @@ export class Repository {
|
|
|
87
89
|
await ensureDirectoryExists(fullPath);
|
|
88
90
|
const shouldWriteRoutes = routes && path.startsWith("routes");
|
|
89
91
|
const shouldWriteTypes = types && !path.startsWith("routes");
|
|
90
|
-
if (shouldWriteRoutes
|
|
91
|
-
|
|
92
|
+
if (shouldWriteRoutes) {
|
|
93
|
+
const fileExists = await fs
|
|
92
94
|
.stat(fullPath)
|
|
93
95
|
.then((stat) => stat.isFile())
|
|
94
|
-
.catch(() => false)
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
.catch(() => false);
|
|
97
|
+
if (fileExists) {
|
|
98
|
+
debug(`route file exists, checking for new handlers: ${fullPath}`);
|
|
99
|
+
await this.appendNewHandlers(fullPath, contents.replaceAll(CONTEXT_FILE_TOKEN, this.findContextPath(destination, path)));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
97
102
|
}
|
|
98
103
|
if (shouldWriteRoutes || shouldWriteTypes) {
|
|
99
104
|
debug("about to write", fullPath);
|
|
@@ -139,6 +144,90 @@ export class Context {
|
|
|
139
144
|
}
|
|
140
145
|
`);
|
|
141
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Appends any HTTP-method handler exports that appear in `generatedContent`
|
|
149
|
+
* but are absent from the existing file at `fullPath`.
|
|
150
|
+
*
|
|
151
|
+
* For each new export the corresponding `import type` statement is inserted
|
|
152
|
+
* after the last existing import line (or prepended when no imports exist),
|
|
153
|
+
* and the export block is appended at the end of the file.
|
|
154
|
+
*
|
|
155
|
+
* @param fullPath - Absolute path of the route file to update.
|
|
156
|
+
* @param generatedContent - The fully-generated file content (used as the
|
|
157
|
+
* source of new import and export statements).
|
|
158
|
+
*/
|
|
159
|
+
async appendNewHandlers(fullPath, generatedContent) {
|
|
160
|
+
const existingContent = await fs.readFile(fullPath, "utf8");
|
|
161
|
+
// Names already exported by the existing file (e.g. GET, POST).
|
|
162
|
+
// RegExp match groups are typed as optional strings, so narrow defensively.
|
|
163
|
+
const existingExportNames = new Set(Array.from(existingContent.matchAll(/^export\s+const\s+(\w+)/gmu), (m) => m[1]).filter((name) => name !== undefined));
|
|
164
|
+
// All named exports in the generated content together with their type names.
|
|
165
|
+
const generatedExports = Array.from(generatedContent.matchAll(/^export\s+const\s+(\w+)\s*:\s*(\w+)/gmu), (m) => ({ methodName: m[1], typeName: m[2] })).filter((value) => value.methodName !== undefined && value.typeName !== undefined);
|
|
166
|
+
const newExports = generatedExports.filter(({ methodName }) => !existingExportNames.has(methodName));
|
|
167
|
+
if (newExports.length === 0) {
|
|
168
|
+
debug(`no new handlers to append to ${fullPath}`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
debug(`appending ${newExports.length} new handler(s) to ${fullPath}: %o`, newExports.map(({ methodName }) => methodName));
|
|
172
|
+
const newImportLines = [];
|
|
173
|
+
const newExportBlocks = [];
|
|
174
|
+
for (const { methodName, typeName } of newExports) {
|
|
175
|
+
// Both names come from \w+ captures so they are safe identifiers, but
|
|
176
|
+
// guard explicitly to satisfy static analysis and avoid RegExp injection.
|
|
177
|
+
if (!/^\w+$/u.test(typeName) || !/^\w+$/u.test(methodName)) {
|
|
178
|
+
debug(`skipping handler with unsafe name – methodName: %s, typeName: %s`, methodName, typeName);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
// Find the `import type { TypeName } from "..."` line for this type.
|
|
182
|
+
const importMatch = generatedContent.match(new RegExp(`^import\\s+type\\s+\\{[^}]*\\b${typeName}\\b[^}]*\\}\\s+from\\s+["'][^"']+["'];`, "mu"));
|
|
183
|
+
if (importMatch?.[0] && !existingContent.includes(importMatch[0])) {
|
|
184
|
+
newImportLines.push(importMatch[0]);
|
|
185
|
+
}
|
|
186
|
+
// Find the export block: from `export const METHOD` to the closing `};`.
|
|
187
|
+
// The generated code is always Prettier-formatted, so the closing brace
|
|
188
|
+
// and semicolon of every top-level arrow-function export appear on their
|
|
189
|
+
// own line as `\n};`.
|
|
190
|
+
const startMatch = new RegExp(`^export\\s+const\\s+${methodName}\\b`, "mu").exec(generatedContent);
|
|
191
|
+
if (startMatch) {
|
|
192
|
+
const fromExport = generatedContent.slice(startMatch.index);
|
|
193
|
+
const closingIndex = fromExport.indexOf("\n};");
|
|
194
|
+
if (closingIndex !== -1) {
|
|
195
|
+
// Include the closing `};` (3 chars: \n, }, ;)
|
|
196
|
+
newExportBlocks.push(fromExport.slice(0, closingIndex + 3));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
let updatedContent = existingContent;
|
|
201
|
+
// Insert new import lines right after the last existing import statement.
|
|
202
|
+
if (newImportLines.length > 0) {
|
|
203
|
+
const importMatches = [...existingContent.matchAll(/^import\s[^\n]*/gmu)];
|
|
204
|
+
if (importMatches.length > 0) {
|
|
205
|
+
const lastImport = importMatches[importMatches.length - 1];
|
|
206
|
+
const importIndex = lastImport?.index;
|
|
207
|
+
const insertPos = importIndex === undefined
|
|
208
|
+
? 0
|
|
209
|
+
: (() => {
|
|
210
|
+
const lineEnd = existingContent.indexOf("\n", importIndex);
|
|
211
|
+
return lineEnd === -1 ? existingContent.length : lineEnd + 1;
|
|
212
|
+
})();
|
|
213
|
+
updatedContent =
|
|
214
|
+
existingContent.slice(0, insertPos) +
|
|
215
|
+
newImportLines.join("\n") +
|
|
216
|
+
"\n" +
|
|
217
|
+
existingContent.slice(insertPos);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
updatedContent = newImportLines.join("\n") + "\n" + existingContent;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Append new export blocks at the end of the file.
|
|
224
|
+
if (newExportBlocks.length > 0) {
|
|
225
|
+
const separator = updatedContent.endsWith("\n") ? "\n" : "\n\n";
|
|
226
|
+
updatedContent += separator + newExportBlocks.join("\n\n") + "\n";
|
|
227
|
+
}
|
|
228
|
+
await fs.writeFile(fullPath, updatedContent);
|
|
229
|
+
debug(`appended new handlers to ${fullPath}`);
|
|
230
|
+
}
|
|
142
231
|
/**
|
|
143
232
|
* Returns the path of the `_.context.ts` file that is nearest to `path` in
|
|
144
233
|
* the directory hierarchy, relative to the script's output directory.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { bundle } from "@apidevtools/json-schema-ref-parser";
|
|
2
2
|
import createDebug from "debug";
|
|
3
|
+
import { applyOverlays } from "../util/apply-overlay.js";
|
|
3
4
|
import { Requirement } from "./requirement.js";
|
|
4
5
|
const debug = createDebug("counterfact:typescript-generator:specification");
|
|
5
6
|
/**
|
|
@@ -23,12 +24,14 @@ export class Specification {
|
|
|
23
24
|
* Loads the OpenAPI document at `urlOrPath`, bundles all external `$ref`
|
|
24
25
|
* references, and returns a fully initialised {@link Specification}.
|
|
25
26
|
*
|
|
26
|
-
* @param urlOrPath
|
|
27
|
+
* @param urlOrPath - A local file path or HTTP(S) URL.
|
|
28
|
+
* @param overlays - Optional ordered list of overlay file paths/URLs to
|
|
29
|
+
* apply after loading the document.
|
|
27
30
|
* @throws When the document cannot be found or parsed.
|
|
28
31
|
*/
|
|
29
|
-
static async fromFile(urlOrPath) {
|
|
32
|
+
static async fromFile(urlOrPath, overlays = []) {
|
|
30
33
|
const specification = new Specification();
|
|
31
|
-
await specification.load(urlOrPath);
|
|
34
|
+
await specification.load(urlOrPath, overlays);
|
|
32
35
|
return specification;
|
|
33
36
|
}
|
|
34
37
|
/**
|
|
@@ -42,16 +45,22 @@ export class Specification {
|
|
|
42
45
|
return this.rootRequirement.select(url.slice(2));
|
|
43
46
|
}
|
|
44
47
|
/**
|
|
45
|
-
* Loads (or reloads) the specification from `urlOrPath
|
|
48
|
+
* Loads (or reloads) the specification from `urlOrPath`, then applies any
|
|
49
|
+
* overlay files listed in `overlays` in order.
|
|
46
50
|
*
|
|
47
51
|
* @param urlOrPath - A local file path or HTTP(S) URL.
|
|
52
|
+
* @param overlays - Optional ordered list of overlay file paths/URLs.
|
|
48
53
|
* @throws When the document cannot be found or parsed.
|
|
49
54
|
*/
|
|
50
|
-
async load(urlOrPath) {
|
|
55
|
+
async load(urlOrPath, overlays = []) {
|
|
51
56
|
try {
|
|
52
|
-
|
|
57
|
+
const document = (await bundle(urlOrPath, {
|
|
53
58
|
resolve: { http: { safeUrlResolver: false } },
|
|
54
|
-
}))
|
|
59
|
+
}));
|
|
60
|
+
if (overlays.length > 0) {
|
|
61
|
+
await applyOverlays(document, overlays);
|
|
62
|
+
}
|
|
63
|
+
this.rootRequirement = new Requirement(document, urlOrPath, this);
|
|
55
64
|
}
|
|
56
65
|
catch (error) {
|
|
57
66
|
const details = error instanceof Error ? error.message : String(error);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { load as loadYaml } from "js-yaml";
|
|
2
|
+
import { JSONPath } from "jsonpath-plus";
|
|
3
|
+
import { readFile } from "./read-file.js";
|
|
4
|
+
/**
|
|
5
|
+
* Deeply merges `source` into `target`, overwriting scalar values and
|
|
6
|
+
* recursively merging plain objects. Arrays and non-plain-object values in
|
|
7
|
+
* `source` always overwrite the corresponding entry in `target`.
|
|
8
|
+
*/
|
|
9
|
+
function deepMerge(target, source) {
|
|
10
|
+
for (const [key, value] of Object.entries(source)) {
|
|
11
|
+
// Guard against prototype pollution attacks.
|
|
12
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (typeof value === "object" &&
|
|
16
|
+
value !== null &&
|
|
17
|
+
!Array.isArray(value) &&
|
|
18
|
+
typeof target[key] === "object" &&
|
|
19
|
+
target[key] !== null &&
|
|
20
|
+
!Array.isArray(target[key])) {
|
|
21
|
+
deepMerge(target[key], value);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
target[key] = value;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Applies a list of overlay actions to `document` in place.
|
|
30
|
+
*
|
|
31
|
+
* Each action may either:
|
|
32
|
+
* - **update**: deep-merge the `action.update` object into every node matched
|
|
33
|
+
* by the JSONPath `action.target`.
|
|
34
|
+
* - **remove**: delete every node matched by `action.target` from its parent.
|
|
35
|
+
*
|
|
36
|
+
* @param document - The OpenAPI document object to mutate.
|
|
37
|
+
* @param actions - The ordered list of overlay actions to apply.
|
|
38
|
+
*/
|
|
39
|
+
export function applyOverlayActions(document, actions) {
|
|
40
|
+
for (const action of actions) {
|
|
41
|
+
const results = JSONPath({
|
|
42
|
+
path: action.target,
|
|
43
|
+
json: document,
|
|
44
|
+
resultType: "all",
|
|
45
|
+
});
|
|
46
|
+
if (action.remove === true) {
|
|
47
|
+
// Iterate in reverse so that removing by numeric index doesn't shift
|
|
48
|
+
// subsequent items in the same parent array.
|
|
49
|
+
for (const result of [...results].reverse()) {
|
|
50
|
+
const { parent, parentProperty } = result;
|
|
51
|
+
if (Array.isArray(parent)) {
|
|
52
|
+
parent.splice(Number(parentProperty), 1);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
delete parent[String(parentProperty)];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else if (action.update !== undefined) {
|
|
60
|
+
for (const result of results) {
|
|
61
|
+
if (typeof result.value === "object" &&
|
|
62
|
+
result.value !== null &&
|
|
63
|
+
!Array.isArray(result.value)) {
|
|
64
|
+
deepMerge(result.value, action.update);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Loads and parses an overlay file (YAML or JSON), validates that it looks
|
|
72
|
+
* like a valid OpenAPI overlay document, and returns the parsed object.
|
|
73
|
+
*
|
|
74
|
+
* @param overlayPath - Path or URL to the overlay file.
|
|
75
|
+
* @throws When the file cannot be read, parsed, or does not contain an
|
|
76
|
+
* `overlay` version field and an `actions` array.
|
|
77
|
+
*/
|
|
78
|
+
export async function loadOverlay(overlayPath) {
|
|
79
|
+
let content;
|
|
80
|
+
try {
|
|
81
|
+
content = await readFile(overlayPath);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const details = error instanceof Error ? error.message : String(error);
|
|
85
|
+
throw new Error(`Could not read overlay file "${overlayPath}".\n${details}`, { cause: error });
|
|
86
|
+
}
|
|
87
|
+
let parsed;
|
|
88
|
+
try {
|
|
89
|
+
parsed = loadYaml(content);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
const details = error instanceof Error ? error.message : String(error);
|
|
93
|
+
throw new Error(`Could not parse overlay file "${overlayPath}".\n${details}`, { cause: error });
|
|
94
|
+
}
|
|
95
|
+
if (typeof parsed !== "object" ||
|
|
96
|
+
parsed === null ||
|
|
97
|
+
!("overlay" in parsed) ||
|
|
98
|
+
!("actions" in parsed) ||
|
|
99
|
+
!Array.isArray(parsed.actions)) {
|
|
100
|
+
throw new Error(`"${overlayPath}" does not appear to be a valid OpenAPI overlay file. ` +
|
|
101
|
+
`Expected an object with "overlay" and "actions" fields.`);
|
|
102
|
+
}
|
|
103
|
+
return parsed;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Applies all overlays listed in `overlayPaths` to `document` in order.
|
|
107
|
+
*
|
|
108
|
+
* Each overlay is loaded from disk (or a URL), parsed, and its actions are
|
|
109
|
+
* applied sequentially. The document is mutated in place.
|
|
110
|
+
*
|
|
111
|
+
* @param document - The OpenAPI document object to mutate.
|
|
112
|
+
* @param overlayPaths - Ordered list of paths/URLs to overlay files.
|
|
113
|
+
*/
|
|
114
|
+
export async function applyOverlays(document, overlayPaths) {
|
|
115
|
+
for (const overlayPath of overlayPaths) {
|
|
116
|
+
const overlay = await loadOverlay(overlayPath);
|
|
117
|
+
applyOverlayActions(document, overlay.actions);
|
|
118
|
+
}
|
|
119
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "counterfact",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.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",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": {
|
|
9
|
-
"import": "./dist/app.js"
|
|
9
|
+
"import": "./dist/app.js",
|
|
10
|
+
"types": "./dist/server/types.d.ts"
|
|
10
11
|
}
|
|
11
12
|
},
|
|
12
13
|
"types": "./dist/server/types.d.ts",
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
],
|
|
65
66
|
"sideEffects": false,
|
|
66
67
|
"scripts": {
|
|
67
|
-
"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",
|
|
68
69
|
"test:black-box": "rimraf dist && yarn build && python3 -m pytest test-black-box/ -v",
|
|
69
70
|
"test:tsd": "tsd --typings ./dist/server/counterfact-types/index.ts --files ./test/**/*.test-d.ts",
|
|
70
71
|
"build": "rm -rf dist && tsc && copyfiles -f \"src/counterfact-types/*.ts\" dist/server/counterfact-types && copyfiles -f \"src/server/*.cjs\" dist/server",
|
|
@@ -81,76 +82,76 @@
|
|
|
81
82
|
"counterfact": "./bin/counterfact.js"
|
|
82
83
|
},
|
|
83
84
|
"devDependencies": {
|
|
84
|
-
"@changesets/cli": "2.31.
|
|
85
|
+
"@changesets/cli": "2.31.1",
|
|
85
86
|
"@eslint/js": "10.0.1",
|
|
86
87
|
"@jest/globals": "30.4.1",
|
|
87
|
-
"@swc/core": "1.15.
|
|
88
|
+
"@swc/core": "1.15.46",
|
|
88
89
|
"@swc/jest": "0.2.39",
|
|
89
90
|
"@testing-library/dom": "10.4.1",
|
|
90
91
|
"@types/debug": "4.1.13",
|
|
91
92
|
"@types/jest": "30.0.0",
|
|
92
93
|
"@types/js-yaml": "4.0.9",
|
|
93
|
-
"@types/koa": "3.0.
|
|
94
|
+
"@types/koa": "3.0.3",
|
|
94
95
|
"@types/koa-bodyparser": "4.3.13",
|
|
95
96
|
"@types/koa-proxy": "1.0.8",
|
|
96
97
|
"@types/koa-static": "4.0.4",
|
|
97
98
|
"@types/node": "22",
|
|
98
|
-
"@typescript-eslint/eslint-plugin": "8.
|
|
99
|
-
"@typescript-eslint/parser": "8.
|
|
99
|
+
"@typescript-eslint/eslint-plugin": "8.65.0",
|
|
100
|
+
"@typescript-eslint/parser": "8.65.0",
|
|
100
101
|
"copyfiles": "2.4.1",
|
|
101
|
-
"eslint": "10.
|
|
102
|
+
"eslint": "10.8.0",
|
|
102
103
|
"eslint-formatter-github-annotations": "0.1.0",
|
|
103
|
-
"eslint-import-resolver-typescript": "4.4.
|
|
104
|
+
"eslint-import-resolver-typescript": "4.4.5",
|
|
104
105
|
"eslint-plugin-etc": "2.0.3",
|
|
105
106
|
"eslint-plugin-file-progress": "4.0.0",
|
|
106
107
|
"eslint-plugin-import": "2.32.0",
|
|
107
|
-
"eslint-plugin-jest": "29.
|
|
108
|
-
"eslint-plugin-jest-dom": "5.
|
|
109
|
-
"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",
|
|
110
111
|
"eslint-plugin-no-explicit-type-exports": "0.12.1",
|
|
111
|
-
"eslint-plugin-prettier": "5.5.
|
|
112
|
+
"eslint-plugin-prettier": "5.5.6",
|
|
112
113
|
"eslint-plugin-promise": "7.3.0",
|
|
113
|
-
"eslint-plugin-regexp": "3.1.
|
|
114
|
-
"eslint-plugin-security": "4.0.
|
|
114
|
+
"eslint-plugin-regexp": "3.1.1",
|
|
115
|
+
"eslint-plugin-security": "4.0.1",
|
|
115
116
|
"eslint-plugin-unused-imports": "4.4.1",
|
|
116
117
|
"husky": "9.1.7",
|
|
117
118
|
"jest": "30.4.2",
|
|
118
119
|
"jest-retries": "1.0.1",
|
|
119
|
-
"node-mocks-http": "1.
|
|
120
|
+
"node-mocks-http": "1.18.1",
|
|
120
121
|
"rimraf": "6.1.3",
|
|
121
122
|
"supertest": "7.2.2",
|
|
122
123
|
"tsd": "0.33.0",
|
|
123
124
|
"using-temporary-files": "2.2.1"
|
|
124
125
|
},
|
|
125
126
|
"dependencies": {
|
|
126
|
-
"@apidevtools/json-schema-ref-parser": "15.
|
|
127
|
+
"@apidevtools/json-schema-ref-parser": "15.5.0",
|
|
127
128
|
"@hapi/accept": "6.0.3",
|
|
128
129
|
"@types/json-schema": "7.0.15",
|
|
129
130
|
"ajv": "8.20.0",
|
|
130
131
|
"chokidar": "5.0.0",
|
|
131
|
-
"commander": "
|
|
132
|
+
"commander": "15.0.0",
|
|
132
133
|
"debug": "4.4.3",
|
|
133
|
-
"fs-extra": "11.
|
|
134
|
+
"fs-extra": "11.4.0",
|
|
134
135
|
"http-terminator": "3.2.0",
|
|
135
|
-
"js-yaml": "
|
|
136
|
-
"json-schema-faker": "0.6.
|
|
136
|
+
"js-yaml": "5.2.2",
|
|
137
|
+
"json-schema-faker": "0.6.2",
|
|
138
|
+
"jsonpath-plus": "10.4.0",
|
|
137
139
|
"jsonwebtoken": "9.0.3",
|
|
138
|
-
"koa": "3.2.
|
|
140
|
+
"koa": "3.2.1",
|
|
139
141
|
"koa-bodyparser": "4.4.1",
|
|
140
142
|
"koa-proxies": "0.12.4",
|
|
141
143
|
"koa2-swagger-ui": "5.12.0",
|
|
142
144
|
"node-fetch": "3.3.2",
|
|
143
145
|
"open": "11.0.0",
|
|
144
|
-
"posthog-node": "5.
|
|
145
|
-
"precinct": "
|
|
146
|
-
"prettier": "3.8.
|
|
147
|
-
"recast": "0.23.
|
|
148
|
-
"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",
|
|
149
151
|
"typescript": "6.0.3"
|
|
150
152
|
},
|
|
151
153
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
|
|
152
154
|
"resolutions": {
|
|
153
|
-
"js-yaml": "4.1.1",
|
|
154
155
|
"@typescript-eslint/utils": "^8.58.0"
|
|
155
156
|
}
|
|
156
157
|
}
|