counterfact 2.10.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/api-runner.js +19 -7
- package/dist/app.js +119 -15
- package/dist/cli/banner.js +1 -1
- package/dist/cli/run.js +42 -9
- package/dist/cli/telemetry.js +11 -10
- package/dist/migrate/update-route-types.js +1 -0
- package/dist/msw.js +1 -0
- package/dist/repl/repl.js +5 -4
- package/dist/server/counterfact-types/example.ts +5 -1
- package/dist/server/counterfact-types/generic-response-builder.ts +4 -0
- package/dist/server/counterfact-types/open-api-parameters.ts +8 -1
- package/dist/server/counterfact-types/response-builder.ts +5 -0
- package/dist/server/counterfact-types/wide-response-builder.ts +1 -0
- package/dist/server/dispatcher.js +87 -12
- package/dist/server/json-to-xml.js +32 -7
- package/dist/server/module-loader.js +5 -0
- package/dist/server/openapi-document.js +5 -0
- package/dist/server/registry.js +22 -5
- package/dist/server/response-builder.js +27 -5
- package/dist/server/web-server/admin-api-middleware.js +1 -1
- package/dist/server/web-server/create-koa-app.js +3 -1
- package/dist/server/web-server/openapi-middleware.js +1 -0
- package/dist/server/web-server/routes-middleware.js +43 -1
- package/dist/typescript-generator/code-generator.js +17 -6
- package/dist/typescript-generator/coder.js +1 -1
- package/dist/typescript-generator/jsdoc.js +11 -7
- package/dist/typescript-generator/operation-coder.js +23 -1
- package/dist/typescript-generator/operation-type-coder.js +184 -11
- package/dist/typescript-generator/requirement.js +36 -3
- package/dist/typescript-generator/response-type-coder.js +20 -7
- package/dist/typescript-generator/responses-type-coder.js +8 -2
- package/dist/typescript-generator/schema-coder.js +2 -2
- package/dist/typescript-generator/schema-type-coder.js +16 -3
- package/dist/typescript-generator/script.js +46 -5
- package/dist/typescript-generator/specification.js +3 -1
- package/dist/typescript-generator/streaming-content-types.js +16 -0
- package/dist/typescript-generator/versions-ts-generator.js +82 -0
- package/package.json +24 -26
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br>
|
|
6
6
|
|
|
7
|
-
 [](https://coveralls.io/github/pmcelhaney/counterfact) 
|
|
7
|
+
 [](https://coveralls.io/github/pmcelhaney/counterfact)   
|
|
8
8
|
|
|
9
9
|
</div>
|
|
10
10
|
|
|
@@ -17,6 +17,7 @@ Mock servers make it easy to get started, but hard to keep going.<br>
|
|
|
17
17
|
Counterfact is an API simulator without those limits.
|
|
18
18
|
|
|
19
19
|
Point it at an [OpenAPI](https://www.openapis.org) document and get a live, stateful API in seconds.
|
|
20
|
+
Supports Swagger 2.0 and OpenAPI 3.0, 3.1, and 3.2.
|
|
20
21
|
- Type-safe TypeScript handlers for every endpoint
|
|
21
22
|
- Hot reloading as you edit
|
|
22
23
|
- Shared state across routes
|
package/dist/api-runner.js
CHANGED
|
@@ -64,6 +64,11 @@ export class ApiRunner {
|
|
|
64
64
|
* Defaults to `""` (no subdirectory).
|
|
65
65
|
*/
|
|
66
66
|
group;
|
|
67
|
+
/**
|
|
68
|
+
* Optional version label for this runner's spec (e.g. `"v1"`, `"v2"`).
|
|
69
|
+
* Defaults to `""` (unversioned).
|
|
70
|
+
*/
|
|
71
|
+
version;
|
|
67
72
|
/**
|
|
68
73
|
* The subdirectory path segment derived from {@link group}.
|
|
69
74
|
* Returns `""` when `group` is empty, otherwise `"/${group}"`.
|
|
@@ -72,8 +77,9 @@ export class ApiRunner {
|
|
|
72
77
|
return this.group ? `/${this.group}` : "";
|
|
73
78
|
}
|
|
74
79
|
config;
|
|
75
|
-
constructor(config, nativeTs, openApiDocument, group) {
|
|
80
|
+
constructor(config, nativeTs, openApiDocument, group, version = "", versions = []) {
|
|
76
81
|
this.group = group;
|
|
82
|
+
this.version = version;
|
|
77
83
|
const modulesPath = this.group
|
|
78
84
|
? pathJoin(config.basePath, this.group)
|
|
79
85
|
: config.basePath;
|
|
@@ -87,8 +93,8 @@ export class ApiRunner {
|
|
|
87
93
|
this.contextRegistry = new ContextRegistry();
|
|
88
94
|
this.scenarioRegistry = new ScenarioRegistry();
|
|
89
95
|
this.scenarioFileGenerator = new ScenarioFileGenerator(modulesPath);
|
|
90
|
-
this.codeGenerator = new CodeGenerator(this.openApiPath, config.basePath + this.subdirectory, config.generate);
|
|
91
|
-
this.dispatcher = new Dispatcher(this.registry, this.contextRegistry, openApiDocument, config);
|
|
96
|
+
this.codeGenerator = new CodeGenerator(this.openApiPath, config.basePath + this.subdirectory, config.generate, version);
|
|
97
|
+
this.dispatcher = new Dispatcher(this.registry, this.contextRegistry, openApiDocument, config, version, versions);
|
|
92
98
|
this.transpiler = new Transpiler(pathJoin(modulesPath, "routes"), compiledPathsDirectory, "commonjs");
|
|
93
99
|
this.moduleLoader = new ModuleLoader(compiledPathsDirectory, this.registry, this.contextRegistry, pathJoin(modulesPath, "scenarios"), this.scenarioRegistry);
|
|
94
100
|
}
|
|
@@ -101,8 +107,10 @@ export class ApiRunner {
|
|
|
101
107
|
*
|
|
102
108
|
* @param config - Runtime configuration for this runner instance.
|
|
103
109
|
* @param group - Optional group name placing generated code in a subdirectory (default `""`).
|
|
110
|
+
* @param version - Optional version label for this spec (e.g. `"v1"`, `"v2"`).
|
|
111
|
+
* @param versions - Optional ordered list of all version labels in this group (oldest first).
|
|
104
112
|
*/
|
|
105
|
-
static async create(config, group = "") {
|
|
113
|
+
static async create(config, group = "", version = "", versions = []) {
|
|
106
114
|
const nativeTs = await runtimeCanExecuteErasableTs();
|
|
107
115
|
const modulesPath = group
|
|
108
116
|
? pathJoin(config.basePath, group)
|
|
@@ -114,7 +122,7 @@ export class ApiRunner {
|
|
|
114
122
|
const openApiDocument = config.openApiPath === "_"
|
|
115
123
|
? undefined
|
|
116
124
|
: await loadOpenApiDocument(config.openApiPath);
|
|
117
|
-
return new ApiRunner(config, nativeTs, openApiDocument, group);
|
|
125
|
+
return new ApiRunner(config, nativeTs, openApiDocument, group, version, versions);
|
|
118
126
|
}
|
|
119
127
|
/**
|
|
120
128
|
* Generates TypeScript route stubs and type files from the OpenAPI spec.
|
|
@@ -123,11 +131,15 @@ export class ApiRunner {
|
|
|
123
131
|
* - Routes and types are only generated when `config.openApiPath` is not `"_"`.
|
|
124
132
|
* - The scenario context type file is always generated when
|
|
125
133
|
* `config.generate.types` is `true`, even without a spec.
|
|
134
|
+
*
|
|
135
|
+
* @param repository - Optional shared repository. Pass a shared instance
|
|
136
|
+
* when multiple versioned specs in the same group should merge their types
|
|
137
|
+
* into the same output tree.
|
|
126
138
|
*/
|
|
127
|
-
async generate() {
|
|
139
|
+
async generate(repository) {
|
|
128
140
|
if (this.config.openApiPath !== "_" &&
|
|
129
141
|
(this.config.generate.routes || this.config.generate.types)) {
|
|
130
|
-
await this.codeGenerator.generate();
|
|
142
|
+
await this.codeGenerator.generate(repository);
|
|
131
143
|
}
|
|
132
144
|
if (this.config.generate.types) {
|
|
133
145
|
await this.scenarioFileGenerator.generate();
|
package/dist/app.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import nodePath from "node:path";
|
|
1
3
|
import { createHttpTerminator } from "http-terminator";
|
|
2
4
|
import { ApiRunner } from "./api-runner.js";
|
|
3
5
|
import { startRepl as startReplServer } from "./repl/repl.js";
|
|
4
6
|
import { createRouteFunction } from "./repl/route-builder.js";
|
|
5
7
|
import { createKoaApp } from "./server/web-server/create-koa-app.js";
|
|
8
|
+
import { Repository } from "./typescript-generator/repository.js";
|
|
9
|
+
import { ensureDirectoryExists } from "./util/ensure-directory-exists.js";
|
|
10
|
+
import { generateVersionsTsContent } from "./typescript-generator/versions-ts-generator.js";
|
|
6
11
|
export { loadOpenApiDocument } from "./server/load-openapi-document.js";
|
|
7
12
|
export { createMswHandlers, handleMswRequest, } from "./msw.js";
|
|
8
13
|
export async function runStartupScenario(scenarioRegistry, contextRegistry, config, openApiDocument) {
|
|
@@ -18,19 +23,47 @@ export async function runStartupScenario(scenarioRegistry, contextRegistry, conf
|
|
|
18
23
|
};
|
|
19
24
|
await indexModule["startup"](scenario$);
|
|
20
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Derives the URL prefix for a spec entry.
|
|
28
|
+
*
|
|
29
|
+
* Applies the following precedence rules:
|
|
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).
|
|
34
|
+
*/
|
|
35
|
+
function derivePrefix(spec) {
|
|
36
|
+
if (spec.prefix !== undefined) {
|
|
37
|
+
return spec.prefix;
|
|
38
|
+
}
|
|
39
|
+
if (spec.group && spec.version) {
|
|
40
|
+
return `/${spec.group}/${spec.version}`;
|
|
41
|
+
}
|
|
42
|
+
if (spec.group) {
|
|
43
|
+
return `/${spec.group}`;
|
|
44
|
+
}
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
21
47
|
/**
|
|
22
48
|
* Normalises the spec configuration to an array.
|
|
23
49
|
*
|
|
24
|
-
* When `specs` is provided
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
50
|
+
* When `specs` is provided, each entry's `prefix` is resolved via
|
|
51
|
+
* {@link derivePrefix} so the rest of the code can assume `prefix` is always
|
|
52
|
+
* a string. When `specs` is omitted, a single-entry array is constructed from
|
|
53
|
+
* `config.openApiPath`, `config.prefix`, and `group = ""`.
|
|
28
54
|
*/
|
|
29
55
|
function normalizeSpecs(config, specs) {
|
|
30
56
|
if (specs !== undefined) {
|
|
31
|
-
return specs;
|
|
57
|
+
return specs.map((spec) => ({ ...spec, prefix: derivePrefix(spec) }));
|
|
32
58
|
}
|
|
33
|
-
return [
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
source: config.openApiPath,
|
|
62
|
+
prefix: config.prefix,
|
|
63
|
+
group: "",
|
|
64
|
+
version: "",
|
|
65
|
+
},
|
|
66
|
+
];
|
|
34
67
|
}
|
|
35
68
|
function validateSpecGroups(specs) {
|
|
36
69
|
if (specs.length <= 1) {
|
|
@@ -41,20 +74,26 @@ function validateSpecGroups(specs) {
|
|
|
41
74
|
.filter(({ group }) => group === "")
|
|
42
75
|
.map(({ index }) => String(index + 1));
|
|
43
76
|
if (invalidSpecNumbers.length === 0) {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
77
|
+
const seenKeys = new Set();
|
|
78
|
+
const duplicateKeys = new Set();
|
|
46
79
|
for (const spec of specs) {
|
|
47
80
|
const group = spec.group.trim();
|
|
48
|
-
|
|
49
|
-
|
|
81
|
+
const version = spec.version?.trim() ?? "";
|
|
82
|
+
// Use group@version as the uniqueness key so that the same group can
|
|
83
|
+
// appear with different versions (e.g. v1 and v2 of the same API).
|
|
84
|
+
// The empty-group case is already rejected above, so `group` is always
|
|
85
|
+
// non-empty here and the `@version` suffix remains unambiguous.
|
|
86
|
+
const key = version ? `${group}@${version}` : group;
|
|
87
|
+
if (seenKeys.has(key)) {
|
|
88
|
+
duplicateKeys.add(key);
|
|
50
89
|
continue;
|
|
51
90
|
}
|
|
52
|
-
|
|
91
|
+
seenKeys.add(key);
|
|
53
92
|
}
|
|
54
|
-
if (
|
|
93
|
+
if (duplicateKeys.size === 0) {
|
|
55
94
|
return;
|
|
56
95
|
}
|
|
57
|
-
throw new Error(`Each spec must define a unique group when multiple APIs are configured (
|
|
96
|
+
throw new Error(`Each spec must define a unique group (and version) when multiple APIs are configured (duplicates: ${[...duplicateKeys].join(", ")}).`);
|
|
58
97
|
}
|
|
59
98
|
throw new Error(`Each spec must define a non-empty group when multiple APIs are configured (invalid spec entries: ${invalidSpecNumbers.join(", ")}).`);
|
|
60
99
|
}
|
|
@@ -81,7 +120,18 @@ function validateSpecGroups(specs) {
|
|
|
81
120
|
export async function counterfact(config, specs) {
|
|
82
121
|
const normalizedSpecs = normalizeSpecs({ openApiPath: config.openApiPath, prefix: config.prefix }, specs);
|
|
83
122
|
validateSpecGroups(normalizedSpecs);
|
|
84
|
-
|
|
123
|
+
// Compute the ordered versions per group (oldest first, as declared in specs).
|
|
124
|
+
// This list is passed to each runner so that $.minVersion() can compare
|
|
125
|
+
// version positions at runtime.
|
|
126
|
+
const versionsByGroup = new Map();
|
|
127
|
+
for (const spec of normalizedSpecs) {
|
|
128
|
+
const version = spec.version ?? "";
|
|
129
|
+
if (version) {
|
|
130
|
+
const existing = versionsByGroup.get(spec.group) ?? [];
|
|
131
|
+
versionsByGroup.set(spec.group, [...existing, version]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const runners = await Promise.all(normalizedSpecs.map((spec) => ApiRunner.create({ ...config, openApiPath: spec.source, prefix: spec.prefix }, spec.group, spec.version ?? "", versionsByGroup.get(spec.group) ?? [])));
|
|
85
135
|
const koaApp = createKoaApp({
|
|
86
136
|
runners,
|
|
87
137
|
config,
|
|
@@ -89,7 +139,61 @@ export async function counterfact(config, specs) {
|
|
|
89
139
|
// The REPL is configured using the first runner.
|
|
90
140
|
const primaryRunner = runners[0];
|
|
91
141
|
async function start(options) {
|
|
92
|
-
|
|
142
|
+
// Serialize generate() calls within each group to avoid concurrent writes
|
|
143
|
+
// to the same output directory. Runners that share a group share the same
|
|
144
|
+
// basePath subdirectory (and therefore the same counterfact-types
|
|
145
|
+
// destination), so running them in parallel would cause a race when both
|
|
146
|
+
// try to create that directory at startup. Different groups are still
|
|
147
|
+
// generated in parallel.
|
|
148
|
+
//
|
|
149
|
+
// When multiple versioned specs share the same group, they also share a
|
|
150
|
+
// single Repository instance so that the shared `types/paths/…` files
|
|
151
|
+
// accumulate all versions into a merged Versioned<…> type instead of each
|
|
152
|
+
// overwriting the previous version's types.
|
|
153
|
+
const runnersByGroup = new Map();
|
|
154
|
+
for (const runner of runners) {
|
|
155
|
+
const bucket = runnersByGroup.get(runner.group) ?? [];
|
|
156
|
+
bucket.push(runner);
|
|
157
|
+
runnersByGroup.set(runner.group, bucket);
|
|
158
|
+
}
|
|
159
|
+
await Promise.all(Array.from(runnersByGroup.values()).map(async (bucket) => {
|
|
160
|
+
const sharedRepository = bucket.length > 1 ? new Repository() : undefined;
|
|
161
|
+
for (const runner of bucket) {
|
|
162
|
+
await runner.generate(sharedRepository);
|
|
163
|
+
}
|
|
164
|
+
}));
|
|
165
|
+
if (options.generate?.types) {
|
|
166
|
+
// Build a per-group map of unique non-empty version strings in
|
|
167
|
+
// declaration order. new Set() preserves insertion order so the first
|
|
168
|
+
// occurrence of each version is kept and duplicates are dropped without
|
|
169
|
+
// reordering.
|
|
170
|
+
const versionsByGroup = new Map();
|
|
171
|
+
for (const spec of normalizedSpecs) {
|
|
172
|
+
const group = spec.group;
|
|
173
|
+
const version = (spec.version ?? "").trim();
|
|
174
|
+
if (version === "") {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const existing = versionsByGroup.get(group) ?? [];
|
|
178
|
+
if (!existing.includes(version)) {
|
|
179
|
+
existing.push(version);
|
|
180
|
+
}
|
|
181
|
+
versionsByGroup.set(group, existing);
|
|
182
|
+
}
|
|
183
|
+
// Write <basePath>/<group>/types/versions.ts for every group that has
|
|
184
|
+
// at least one versioned spec. When the group is empty the path
|
|
185
|
+
// collapses to <basePath>/types/versions.ts (the single-spec case).
|
|
186
|
+
await Promise.all(Array.from(versionsByGroup.entries()).map(async ([group, versions]) => {
|
|
187
|
+
const content = await generateVersionsTsContent(versions);
|
|
188
|
+
const versionsFilePath = group
|
|
189
|
+
? nodePath.join(config.basePath, group, "types", "versions.ts")
|
|
190
|
+
: nodePath.join(config.basePath, "types", "versions.ts");
|
|
191
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- path is derived from the caller-supplied basePath and fixed suffixes. */
|
|
192
|
+
await ensureDirectoryExists(versionsFilePath);
|
|
193
|
+
await fs.writeFile(versionsFilePath, content, "utf8");
|
|
194
|
+
/* eslint-enable security/detect-non-literal-fs-filename */
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
93
197
|
await Promise.all(runners.map((runner) => runner.watch()));
|
|
94
198
|
await Promise.all(runners.map((runner) => runner.start(options)));
|
|
95
199
|
let httpTerminator;
|
package/dist/cli/banner.js
CHANGED
|
@@ -67,7 +67,7 @@ export function createIntroduction(params) {
|
|
|
67
67
|
` API Base URL ${url}`,
|
|
68
68
|
source === "_" ? undefined : ` Swagger UI ${swaggerUrl}`,
|
|
69
69
|
"",
|
|
70
|
-
" Instructions https://
|
|
70
|
+
" Instructions https://github.com/counterfact/api-simulator/blob/main/docs/usage.md",
|
|
71
71
|
" Help/feedback https://github.com/pmcelhaney/counterfact/issues",
|
|
72
72
|
"",
|
|
73
73
|
...telemetryWarning,
|
package/dist/cli/run.js
CHANGED
|
@@ -13,7 +13,7 @@ import { pathResolve } from "../util/forward-slash-path.js";
|
|
|
13
13
|
import { loadConfigFile } from "../util/load-config-file.js";
|
|
14
14
|
import { createIntroduction } from "./banner.js";
|
|
15
15
|
import { checkForUpdates } from "./check-for-updates.js";
|
|
16
|
-
import { isTelemetryEnabled, sendTelemetry } from "./telemetry.js";
|
|
16
|
+
import { hashTelemetryLocation, isTelemetryEnabled, sendTelemetry, } from "./telemetry.js";
|
|
17
17
|
const debug = createDebug("counterfact:cli:run");
|
|
18
18
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
19
|
const DEFAULT_PORT = 3100;
|
|
@@ -22,18 +22,23 @@ 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}` with defaults.
|
|
25
|
+
* - **Array**: each entry is mapped to `{source, prefix, group, version}` 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
|
|
29
29
|
* (single spec derived from config).
|
|
30
|
+
*
|
|
31
|
+
* Note: `prefix` is intentionally left `undefined` when not supplied so that
|
|
32
|
+
* `normalizeSpecs` (in `app.ts`) can derive it automatically from
|
|
33
|
+
* `group`/`version`.
|
|
30
34
|
*/
|
|
31
35
|
export function normalizeSpecOption(specOption) {
|
|
32
36
|
if (Array.isArray(specOption)) {
|
|
33
37
|
return specOption.map((entry) => ({
|
|
34
38
|
source: entry.source,
|
|
35
|
-
prefix: entry.prefix
|
|
39
|
+
prefix: entry.prefix,
|
|
36
40
|
group: entry.group ?? "",
|
|
41
|
+
version: entry.version,
|
|
37
42
|
}));
|
|
38
43
|
}
|
|
39
44
|
if (typeof specOption === "object" &&
|
|
@@ -42,13 +47,43 @@ export function normalizeSpecOption(specOption) {
|
|
|
42
47
|
return [
|
|
43
48
|
{
|
|
44
49
|
source: specOption.source,
|
|
45
|
-
prefix: specOption.prefix
|
|
50
|
+
prefix: specOption.prefix,
|
|
46
51
|
group: specOption.group ?? "",
|
|
52
|
+
version: specOption.version,
|
|
47
53
|
},
|
|
48
54
|
];
|
|
49
55
|
}
|
|
50
56
|
return undefined;
|
|
51
57
|
}
|
|
58
|
+
export function buildStartupTelemetryProperties(options, source, version, specs) {
|
|
59
|
+
const apiSources = specs?.map((spec) => spec.source) ?? [source];
|
|
60
|
+
const apiFileLocationHashes = apiSources
|
|
61
|
+
.filter((apiSource) => apiSource !== "_")
|
|
62
|
+
.map((apiSource) => hashTelemetryLocation(apiSource));
|
|
63
|
+
return {
|
|
64
|
+
alwaysFakeOptionals: Boolean(options.alwaysFakeOptionals),
|
|
65
|
+
apiFileLocationHashes,
|
|
66
|
+
buildCache: Boolean(options.buildCache),
|
|
67
|
+
generateRoutes: Boolean(options.generate) || Boolean(options.generateRoutes),
|
|
68
|
+
generateTypes: Boolean(options.generate) || Boolean(options.generateTypes),
|
|
69
|
+
mode: specs !== undefined
|
|
70
|
+
? "multi-spec"
|
|
71
|
+
: source === "_"
|
|
72
|
+
? "without-openapi"
|
|
73
|
+
: "single-spec",
|
|
74
|
+
openBrowser: Boolean(options.open),
|
|
75
|
+
port: options.port,
|
|
76
|
+
prune: Boolean(options.prune),
|
|
77
|
+
repl: Boolean(options.repl),
|
|
78
|
+
serve: Boolean(options.serve),
|
|
79
|
+
updateCheck: Boolean(options.updateCheck),
|
|
80
|
+
validateRequest: Boolean(options.validateRequest),
|
|
81
|
+
validateResponse: Boolean(options.validateResponse),
|
|
82
|
+
version,
|
|
83
|
+
watchRoutes: Boolean(options.watch) || Boolean(options.watchRoutes),
|
|
84
|
+
watchTypes: Boolean(options.watch) || Boolean(options.watchTypes),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
52
87
|
/**
|
|
53
88
|
* Builds the Commander program with all CLI options and the action handler.
|
|
54
89
|
* Factored out of `runCli` so it is easy to test or extend.
|
|
@@ -110,6 +145,7 @@ function buildProgram(version, taglines) {
|
|
|
110
145
|
debug("options: %o", options);
|
|
111
146
|
debug("source: %s", source);
|
|
112
147
|
debug("destination: %s", destination);
|
|
148
|
+
const startupTelemetryProperties = buildStartupTelemetryProperties(options, source, version, specs);
|
|
113
149
|
const openBrowser = options.open;
|
|
114
150
|
const url = `http://localhost:${options.port}${options.prefix}`;
|
|
115
151
|
const guiUrl = `${url}/counterfact/`;
|
|
@@ -207,6 +243,7 @@ function buildProgram(version, taglines) {
|
|
|
207
243
|
process.exit(1);
|
|
208
244
|
}
|
|
209
245
|
debug("started server");
|
|
246
|
+
sendTelemetry("counterfact_started", startupTelemetryProperties);
|
|
210
247
|
await updateCheckPromise;
|
|
211
248
|
if (config.startRepl) {
|
|
212
249
|
startRepl();
|
|
@@ -253,7 +290,7 @@ function buildProgram(version, taglines) {
|
|
|
253
290
|
.option("--watch-routes", "generate + watch routes for changes")
|
|
254
291
|
.option("-s, --serve", "start the server")
|
|
255
292
|
.option("-b, --build-cache", "builds the cache of compiled routes and types")
|
|
256
|
-
.option("--
|
|
293
|
+
.option("--admin-api", "enable the admin API at /_counterfact/api/*")
|
|
257
294
|
.option("-r, --repl", "start the REPL")
|
|
258
295
|
.option("--proxy-url <string>", "proxy URL")
|
|
259
296
|
.option("--admin-api-token <string>", "bearer token required for /_counterfact/api/* endpoints (defaults to COUNTERFACT_ADMIN_API_TOKEN)")
|
|
@@ -294,10 +331,6 @@ export async function runCli(argv) {
|
|
|
294
331
|
catch {
|
|
295
332
|
taglines = ["counterfact — mock API server"];
|
|
296
333
|
}
|
|
297
|
-
// Fire telemetry once on startup — fire-and-forget, never blocks.
|
|
298
|
-
if (isTelemetryEnabled()) {
|
|
299
|
-
sendTelemetry(version);
|
|
300
|
-
}
|
|
301
334
|
debug("running counterfact CLI v%s", version);
|
|
302
335
|
const program = buildProgram(version, taglines);
|
|
303
336
|
await program.parseAsync(argv);
|
package/dist/cli/telemetry.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { PostHog } from "posthog-node";
|
|
3
3
|
const POSTHOG_API_KEY = "phc_msXmBxiL8FVugNMLCx9bnPQGqfEMqmyBjnVkKhHkN3m7";
|
|
4
4
|
const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
5
5
|
/**
|
|
6
6
|
* Returns `true` when telemetry should be sent.
|
|
7
7
|
*
|
|
8
|
-
* Telemetry is disabled in CI
|
|
9
|
-
* or before the May 2026 rollout date unless the user has explicitly opted
|
|
10
|
-
* in with `COUNTERFACT_TELEMETRY_DISABLED=false`.
|
|
8
|
+
* Telemetry is disabled in CI or when `COUNTERFACT_TELEMETRY_DISABLED=true`.
|
|
11
9
|
*/
|
|
12
10
|
export function isTelemetryEnabled() {
|
|
13
11
|
if (process.env["CI"])
|
|
@@ -15,29 +13,32 @@ export function isTelemetryEnabled() {
|
|
|
15
13
|
const telemetryDisabledEnv = process.env["COUNTERFACT_TELEMETRY_DISABLED"];
|
|
16
14
|
if (telemetryDisabledEnv === "true")
|
|
17
15
|
return false;
|
|
18
|
-
const isBeforeRollout = new Date() < new Date("2026-05-01");
|
|
19
|
-
if (isBeforeRollout && telemetryDisabledEnv !== "false")
|
|
20
|
-
return false;
|
|
21
16
|
return true;
|
|
22
17
|
}
|
|
18
|
+
export function hashTelemetryLocation(location) {
|
|
19
|
+
return createHash("sha256").update(location).digest("hex");
|
|
20
|
+
}
|
|
23
21
|
/**
|
|
24
22
|
* Fires a telemetry event to PostHog. Fire-and-forget — never blocks
|
|
25
23
|
* startup and never surfaces errors to the user.
|
|
26
24
|
*/
|
|
27
|
-
export function sendTelemetry(
|
|
25
|
+
export function sendTelemetry(event, properties = {}) {
|
|
26
|
+
if (!isTelemetryEnabled()) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
28
29
|
const telemetryKey = process.env["POSTHOG_API_KEY"] ?? POSTHOG_API_KEY;
|
|
29
30
|
const telemetryHost = process.env["POSTHOG_HOST"] ?? POSTHOG_HOST;
|
|
30
31
|
try {
|
|
31
32
|
const posthog = new PostHog(telemetryKey, { host: telemetryHost });
|
|
32
33
|
posthog.capture({
|
|
33
34
|
distinctId: randomUUID(),
|
|
34
|
-
event
|
|
35
|
+
event,
|
|
35
36
|
properties: {
|
|
36
|
-
version,
|
|
37
37
|
nodeVersion: process.version,
|
|
38
38
|
platform: process.platform,
|
|
39
39
|
arch: process.arch,
|
|
40
40
|
source: "counterfact-cli",
|
|
41
|
+
...properties,
|
|
41
42
|
},
|
|
42
43
|
});
|
|
43
44
|
posthog.flush().catch(() => {
|
package/dist/msw.js
CHANGED
package/dist/repl/repl.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import repl from "node:repl";
|
|
2
|
+
import { sendTelemetry } from "../cli/telemetry.js";
|
|
2
3
|
import { RawHttpClient } from "./raw-http-client.js";
|
|
3
4
|
import { createRouteFunction } from "./route-builder.js";
|
|
4
5
|
function printToStdout(line) {
|
|
@@ -194,9 +195,6 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
194
195
|
}
|
|
195
196
|
seenGroups.add(binding.key);
|
|
196
197
|
}
|
|
197
|
-
if (duplicateGroups.size > 0) {
|
|
198
|
-
throw new Error(`Duplicate API groups are not allowed when multiple APIs are configured (duplicate groups: ${[...duplicateGroups].join(", ")}).`);
|
|
199
|
-
}
|
|
200
198
|
}
|
|
201
199
|
const rootBinding = groupedBindings[0];
|
|
202
200
|
if (rootBinding === undefined) {
|
|
@@ -261,6 +259,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
261
259
|
: undefined);
|
|
262
260
|
replServer.defineCommand("counterfact", {
|
|
263
261
|
action() {
|
|
262
|
+
sendTelemetry("repl_command_used", { command: "counterfact" });
|
|
264
263
|
print("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.");
|
|
265
264
|
print("Except that it's connected to the running server, which you can access with the following globals:");
|
|
266
265
|
print("");
|
|
@@ -268,7 +267,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
268
267
|
print("- context: the root context ( same as loadContext('/') )");
|
|
269
268
|
print("- route('/some/path'): create a request builder for the given path");
|
|
270
269
|
print("");
|
|
271
|
-
print("For more information, see https://
|
|
270
|
+
print("For more information, see https://github.com/counterfact/api-simulator/blob/main/docs/usage.md");
|
|
272
271
|
print("");
|
|
273
272
|
this.clearBufferedCommand();
|
|
274
273
|
this.displayPrompt();
|
|
@@ -277,6 +276,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
277
276
|
});
|
|
278
277
|
replServer.defineCommand("proxy", {
|
|
279
278
|
action(text) {
|
|
279
|
+
sendTelemetry("repl_command_used", { command: "proxy" });
|
|
280
280
|
if (text === "help" || text === "") {
|
|
281
281
|
print(".proxy [on|off] - turn the proxy on/off at the root level");
|
|
282
282
|
print(".proxy [on|off] <path-prefix> - turn the proxy on for a path");
|
|
@@ -316,6 +316,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
316
316
|
: {};
|
|
317
317
|
replServer.defineCommand("scenario", {
|
|
318
318
|
async action(text) {
|
|
319
|
+
sendTelemetry("repl_command_used", { command: "scenario" });
|
|
319
320
|
const trimmedText = text.trim();
|
|
320
321
|
const parsedArgs = trimmedText.split(/\s+/u).filter(Boolean);
|
|
321
322
|
const usage = isMultiApi
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* Represents a named example defined in an OpenAPI document.
|
|
3
3
|
* Examples can be referenced by route handlers via the `.example(name)` method
|
|
4
4
|
* on the response builder.
|
|
5
|
+
*
|
|
6
|
+
* OpenAPI 3.2 adds `dataValue` as a structured alternative to `value`.
|
|
7
|
+
* When present, `dataValue` is preferred over `value`.
|
|
5
8
|
*/
|
|
6
9
|
export interface Example {
|
|
10
|
+
dataValue?: unknown;
|
|
7
11
|
description: string;
|
|
8
12
|
summary: string;
|
|
9
|
-
value
|
|
13
|
+
value?: unknown;
|
|
10
14
|
}
|
|
@@ -130,6 +130,10 @@ export type GenericResponseBuilderInner<
|
|
|
130
130
|
: (name: ExampleNames<Response>) => COUNTERFACT_RESPONSE;
|
|
131
131
|
text: MaybeShortcut<["text/plain"], Response>;
|
|
132
132
|
xml: MaybeShortcut<["application/xml", "text/xml"], Response>;
|
|
133
|
+
stream: MaybeShortcut<
|
|
134
|
+
["text/event-stream", "application/jsonl", "application/json-seq"],
|
|
135
|
+
Response
|
|
136
|
+
>;
|
|
133
137
|
}>;
|
|
134
138
|
|
|
135
139
|
/**
|
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export interface OpenApiParameters {
|
|
8
8
|
explode?: boolean;
|
|
9
|
-
in:
|
|
9
|
+
in:
|
|
10
|
+
| "body"
|
|
11
|
+
| "cookie"
|
|
12
|
+
| "formData"
|
|
13
|
+
| "header"
|
|
14
|
+
| "path"
|
|
15
|
+
| "query"
|
|
16
|
+
| "querystring";
|
|
10
17
|
name: string;
|
|
11
18
|
required?: boolean;
|
|
12
19
|
schema?: {
|
|
@@ -26,6 +26,11 @@ export interface ResponseBuilder {
|
|
|
26
26
|
random: () => MaybePromise<ResponseBuilder>;
|
|
27
27
|
randomLegacy: () => MaybePromise<ResponseBuilder>;
|
|
28
28
|
status?: number;
|
|
29
|
+
stream: (iterable: AsyncIterable<unknown>) => {
|
|
30
|
+
body: AsyncIterable<unknown>;
|
|
31
|
+
contentType: string;
|
|
32
|
+
status?: number;
|
|
33
|
+
};
|
|
29
34
|
text: (body: unknown) => ResponseBuilder;
|
|
30
35
|
xml: (body: unknown) => ResponseBuilder;
|
|
31
36
|
}
|