counterfact 2.10.0 → 2.11.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.
@@ -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 it is returned as-is. When it is omitted, a
25
- * single-entry array is constructed from `config.openApiPath`,
26
- * `config.prefix`, and `group = ""` so that the rest of the code never
27
- * needs to branch on single-vs-multiple specs.
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 [{ source: config.openApiPath, prefix: config.prefix, group: "" }];
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 seenGroups = new Set();
45
- const duplicateGroupNames = new Set();
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
- if (seenGroups.has(group)) {
49
- duplicateGroupNames.add(group);
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
- seenGroups.add(group);
91
+ seenKeys.add(key);
53
92
  }
54
- if (duplicateGroupNames.size === 0) {
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 (duplicate groups: ${[...duplicateGroupNames].join(", ")}).`);
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
- const runners = await Promise.all(normalizedSpecs.map((spec) => ApiRunner.create({ ...config, openApiPath: spec.source, prefix: spec.prefix }, spec.group)));
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
- await Promise.all(runners.map((runner) => runner.generate()));
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;
@@ -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://counterfact.dev/docs/usage.html",
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
@@ -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,8 +47,9 @@ 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
  }
@@ -253,7 +259,7 @@ function buildProgram(version, taglines) {
253
259
  .option("--watch-routes", "generate + watch routes for changes")
254
260
  .option("-s, --serve", "start the server")
255
261
  .option("-b, --build-cache", "builds the cache of compiled routes and types")
256
- .option("--no-admin-api", "disable the admin API at /_counterfact/api/*")
262
+ .option("--admin-api", "enable the admin API at /_counterfact/api/*")
257
263
  .option("-r, --repl", "start the REPL")
258
264
  .option("--proxy-url <string>", "proxy URL")
259
265
  .option("--admin-api-token <string>", "bearer token required for /_counterfact/api/* endpoints (defaults to COUNTERFACT_ADMIN_API_TOKEN)")
@@ -5,9 +5,7 @@ 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, when `COUNTERFACT_TELEMETRY_DISABLED=true`,
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,9 +13,6 @@ 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
  }
23
18
  /**
package/dist/repl/repl.js CHANGED
@@ -194,9 +194,6 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
194
194
  }
195
195
  seenGroups.add(binding.key);
196
196
  }
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
197
  }
201
198
  const rootBinding = groupedBindings[0];
202
199
  if (rootBinding === undefined) {
@@ -268,7 +265,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
268
265
  print("- context: the root context ( same as loadContext('/') )");
269
266
  print("- route('/some/path'): create a request builder for the given path");
270
267
  print("");
271
- print("For more information, see https://counterfact.dev/docs/usage.html");
268
+ print("For more information, see https://github.com/counterfact/api-simulator/blob/main/docs/usage.md");
272
269
  print("");
273
270
  this.clearBufferedCommand();
274
271
  this.displayPrompt();
@@ -6,6 +6,23 @@ import { isExplodedObjectQueryParam, validateRequest, } from "./request-validato
6
6
  import { validateResponse } from "./response-validator.js";
7
7
  import { Tools } from "./tools.js";
8
8
  const debug = createDebugger("counterfact:server:dispatcher");
9
+ /**
10
+ * Merges path-item-level and operation-level parameter arrays.
11
+ *
12
+ * Operation-level parameters take precedence when both arrays define a
13
+ * parameter with the same `name` and `in` location, per the OpenAPI
14
+ * specification.
15
+ */
16
+ function mergeParameters(pathItemParams, operationParams) {
17
+ const map = new Map();
18
+ for (const p of pathItemParams) {
19
+ map.set(`${p.in}:${p.name}`, p);
20
+ }
21
+ for (const p of operationParams) {
22
+ map.set(`${p.in}:${p.name}`, p);
23
+ }
24
+ return [...map.values()];
25
+ }
9
26
  /**
10
27
  * Parses the `Cookie` request header into a key/value map.
11
28
  *
@@ -91,12 +108,26 @@ export class Dispatcher {
91
108
  openApiDocument;
92
109
  fetch;
93
110
  config; // Add config property
94
- constructor(registry, contextRegistry, openApiDocument, config) {
111
+ /**
112
+ * The version label for this dispatcher's spec (e.g. `"v1"`, `"v2"`).
113
+ * Empty string when running without a version.
114
+ */
115
+ version;
116
+ /**
117
+ * Ordered list of all version labels for the API group this dispatcher
118
+ * belongs to. The first entry is the oldest version. Used by
119
+ * `$.minVersion()` at runtime to determine if the current version is
120
+ * greater than or equal to a given minimum version.
121
+ */
122
+ versions;
123
+ constructor(registry, contextRegistry, openApiDocument, config, version = "", versions = []) {
95
124
  this.registry = registry;
96
125
  this.contextRegistry = contextRegistry;
97
126
  this.openApiDocument = openApiDocument;
98
127
  this.fetch = fetch;
99
128
  this.config = config;
129
+ this.version = version;
130
+ this.versions = versions;
100
131
  }
101
132
  parameterTypes(parameters) {
102
133
  const types = {
@@ -111,43 +142,65 @@ export class Dispatcher {
111
142
  return types;
112
143
  }
113
144
  for (const parameter of parameters) {
114
- const type = parameter?.type;
145
+ const type = parameter?.type ?? parameter?.schema?.type;
115
146
  if (type !== undefined) {
116
147
  types[parameter.in].set(parameter.name, type === "integer" ? "number" : type);
117
148
  }
118
149
  }
119
150
  return types;
120
151
  }
121
- findOperation(path, method) {
122
- if (this.openApiDocument) {
123
- for (const key in this.openApiDocument.paths) {
124
- if (key.toLowerCase() === path.toLowerCase()) {
125
- return this.openApiDocument.paths[key]?.[method.toLowerCase()];
126
- }
152
+ findPathItem(path) {
153
+ if (!this.openApiDocument) {
154
+ return undefined;
155
+ }
156
+ for (const key in this.openApiDocument.paths) {
157
+ if (key.toLowerCase() === path.toLowerCase()) {
158
+ return this.openApiDocument.paths[key];
127
159
  }
128
160
  }
129
161
  return undefined;
130
162
  }
131
163
  /**
132
164
  * Resolves the OpenAPI operation for `path` and `method`, merging any
133
- * top-level `produces` array from the document root into the operation.
165
+ * top-level `produces` array from the document root and any path-item-level
166
+ * `parameters` into the operation.
167
+ *
168
+ * Per the OpenAPI specification, parameters defined at the path item level
169
+ * are shared across all operations on that path. Operation-level parameters
170
+ * take precedence when both define a parameter with the same `name` and `in`.
134
171
  *
135
172
  * @param path - The matched route path (e.g. `"/pets/{petId}"`).
136
173
  * @param method - The HTTP method.
137
174
  * @returns The {@link OpenApiOperation} if found, or `undefined`.
138
175
  */
139
176
  operationForPathAndMethod(path, method) {
140
- const operation = this.findOperation(path, method);
177
+ const pathItem = this.findPathItem(path);
178
+ if (pathItem === undefined) {
179
+ return undefined;
180
+ }
181
+ const operation = pathItem[method.toLowerCase()];
141
182
  if (operation === undefined) {
142
183
  return undefined;
143
184
  }
185
+ // Merge path-item-level parameters with operation-level parameters.
186
+ // Operation-level parameters take precedence on same name+in collision.
187
+ const pathItemParams = pathItem.parameters ?? [];
188
+ const operationParams = operation.parameters ?? [];
189
+ const mergedParameters = pathItemParams.length > 0
190
+ ? mergeParameters(pathItemParams, operationParams)
191
+ : operationParams.length > 0
192
+ ? operationParams
193
+ : undefined;
194
+ const mergedOperation = mergedParameters !== undefined
195
+ ? { ...operation, parameters: mergedParameters }
196
+ : operation;
144
197
  if (this.openApiDocument?.produces) {
145
198
  return {
146
199
  produces: this.openApiDocument.produces,
147
- ...operation,
200
+ ...mergedOperation,
148
201
  };
149
202
  }
150
- return operation;
203
+ return mergedOperation;
151
204
  }
152
205
  normalizeResponse(response, acceptHeader) {
153
206
  if (response.content !== undefined) {
@@ -285,6 +338,17 @@ export class Dispatcher {
285
338
  // @ts-expect-error - Might be pushing the limits of what TypeScript can do here
286
339
  response: createResponseBuilder(operation ?? { responses: {} }, this.config), // Pass config
287
340
  tools: new Tools({ headers }),
341
+ ...(this.version !== "" && {
342
+ version: this.version,
343
+ minVersion: (min) => {
344
+ const currentIdx = this.versions.indexOf(this.version);
345
+ const minIdx = this.versions.indexOf(min);
346
+ if (currentIdx === -1 || minIdx === -1) {
347
+ return false;
348
+ }
349
+ return currentIdx >= minIdx;
350
+ },
351
+ }),
288
352
  });
289
353
  if (response === undefined) {
290
354
  return {
@@ -166,7 +166,7 @@ export function adminApiMiddleware(pathPrefix, registry, contextRegistry, config
166
166
  port: config.port,
167
167
  proxyUrl: config.proxyUrl,
168
168
  prefix: config.prefix,
169
- startAdminApi: config.startAdminApi,
169
+ startAdminApi: config.startAdminApi ?? false,
170
170
  startRepl: config.startRepl,
171
171
  startServer: config.startServer,
172
172
  watch: config.watch,
@@ -22,12 +22,14 @@ const debug = createDebug("counterfact:typescript-generator:generate");
22
22
  export class CodeGenerator extends EventTarget {
23
23
  openapiPath;
24
24
  destination;
25
+ version;
25
26
  generateOptions;
26
27
  watcher;
27
- constructor(openApiPath, destination, generateOptions) {
28
+ constructor(openApiPath, destination, generateOptions, version = "") {
28
29
  super();
29
30
  this.openapiPath = openApiPath;
30
31
  this.destination = destination;
32
+ this.version = version;
31
33
  this.generateOptions = generateOptions;
32
34
  }
33
35
  /**
@@ -116,7 +118,7 @@ export class CodeGenerator extends EventTarget {
116
118
  }
117
119
  repository
118
120
  .get(`routes${path}.ts`)
119
- .export(new OperationCoder(operation, "", requestMethod, securitySchemes));
121
+ .export(new OperationCoder(operation, this.version, requestMethod, securitySchemes));
120
122
  });
121
123
  });
122
124
  debug("telling the repository to write the files to %s", destination);
@@ -1,6 +1,6 @@
1
1
  import { pathJoin } from "../util/forward-slash-path.js";
2
2
  import { Coder } from "./coder.js";
3
- import { OperationTypeCoder, } from "./operation-type-coder.js";
3
+ import { OperationTypeCoder, VersionedArgTypeCoder, } from "./operation-type-coder.js";
4
4
  /**
5
5
  * Generates the default route handler stub for a single OpenAPI operation.
6
6
  *
@@ -41,6 +41,14 @@ export class OperationCoder extends Coder {
41
41
  }
42
42
  typeDeclaration(_namespace, script) {
43
43
  const operationTypeCoder = new OperationTypeCoder(this.requirement, this.version, this.requestMethod, this.securitySchemes);
44
+ if (this.version !== "") {
45
+ // For versioned APIs: register this version's $-argument type on the
46
+ // shared script so that Script.versionsTypeStatements() can emit the
47
+ // merged handler type after all versions have been declared.
48
+ const versionedArgCoder = new VersionedArgTypeCoder(this.requirement, this.version, this.requestMethod, this.securitySchemes);
49
+ const sharedScript = script.repository.get(operationTypeCoder.modulePath());
50
+ sharedScript.declareVersion(versionedArgCoder, operationTypeCoder.getOperationBaseName());
51
+ }
44
52
  return script.importType(operationTypeCoder);
45
53
  }
46
54
  modulePath() {
@@ -8,6 +8,7 @@ import { RESERVED_WORDS } from "./reserved-words.js";
8
8
  import { ResponsesTypeCoder } from "./responses-type-coder.js";
9
9
  import { SchemaTypeCoder } from "./schema-type-coder.js";
10
10
  import { TypeCoder } from "./type-coder.js";
11
+ import { Requirement } from "./requirement.js";
11
12
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words
12
13
  function sanitizeIdentifier(value) {
13
14
  // Treat any run of non-identifier characters as a camelCase separator
@@ -32,6 +33,14 @@ function sanitizeIdentifier(value) {
32
33
  * `cookie`, `body`, `context`, `response`, and `user` arguments.
33
34
  *
34
35
  * Output is written to `types/paths/<route>.types.ts`.
36
+ *
37
+ * **Versioned APIs**: when `version` is non-empty this coder emits only a
38
+ * sentinel `{raw: ""}` export (suppressing the normal flat type) and
39
+ * registers a formatter on the shared script so that
40
+ * {@link Script.versionsTypeStatements} can later emit the merged
41
+ * `HTTP_<METHOD>_$_Versions` map and the `HTTP_<METHOD>` handler type.
42
+ * Each version's `$`-argument type is emitted to
43
+ * `types/<version>/paths/<path>.types.ts` by {@link VersionedArgTypeCoder}.
35
44
  */
36
45
  export class OperationTypeCoder extends TypeCoder {
37
46
  requestMethod;
@@ -141,14 +150,57 @@ export class OperationTypeCoder extends TypeCoder {
141
150
  }
142
151
  return "never";
143
152
  }
144
- writeCode(script) {
145
- script.comments = READ_ONLY_COMMENTS;
153
+ /**
154
+ * Returns the effective parameters for this operation by merging path-item-level
155
+ * parameters with operation-level parameters. Per the OpenAPI specification,
156
+ * operation-level parameters override path-item-level parameters that share
157
+ * the same `name` and `in` location.
158
+ *
159
+ * Uses `this.requirement.parent` (the path item requirement) to access
160
+ * path-item-level parameters directly, without URL string parsing.
161
+ *
162
+ * When the parent is not set (e.g. in unit tests that construct requirements
163
+ * directly), only the operation-level parameters are returned.
164
+ */
165
+ getEffectiveParameters() {
166
+ const operationParams = this.requirement.get("parameters");
167
+ const pathItemParams = this.requirement.parent?.get("parameters");
168
+ if (!pathItemParams) {
169
+ return operationParams;
170
+ }
171
+ if (!operationParams) {
172
+ return pathItemParams;
173
+ }
174
+ // Merge using a Map keyed on `${in}:${name}`.
175
+ // Path-level params are added first; operation-level overrides them.
176
+ const pathData = pathItemParams.data;
177
+ const opData = operationParams.data;
178
+ const map = new Map();
179
+ for (const p of pathData) {
180
+ map.set(`${p.in}:${p.name}`, p);
181
+ }
182
+ for (const p of opData) {
183
+ map.set(`${p.in}:${p.name}`, p);
184
+ }
185
+ return new Requirement([...map.values()], this.requirement.url, this.requirement.specification);
186
+ }
187
+ /**
188
+ * Builds the `OmitValueWhenNever<{…}>` dollar-argument type body and sets
189
+ * up all required shared-type imports on `script`.
190
+ *
191
+ * This helper is reused by both {@link writeCode} (non-versioned) and
192
+ * {@link VersionedArgTypeCoder.writeCode} (per-version file).
193
+ *
194
+ * @param script - The script to write imports and parameter-type exports into.
195
+ * @param baseName - Identifier prefix used for named parameter-type exports.
196
+ * @param modulePath - Repository-relative path for parameter-type exports.
197
+ */
198
+ buildDollarArgType(script, baseName, modulePath) {
146
199
  const xType = script.importSharedType("WideOperationArgument");
147
200
  script.importSharedType("OmitValueWhenNever");
148
- script.importSharedType("MaybePromise");
149
201
  script.importSharedType("COUNTERFACT_RESPONSE");
150
202
  const contextTypeImportName = script.importExternalType("Context", CONTEXT_FILE_TOKEN);
151
- const parameters = this.requirement.get("parameters");
203
+ const parameters = this.getEffectiveParameters();
152
204
  const queryType = new ParametersTypeCoder(parameters, this.version, "query").write(script);
153
205
  const pathType = new ParametersTypeCoder(parameters, this.version, "path").write(script);
154
206
  const headersType = new ParametersTypeCoder(parameters, this.version, "header").write(script);
@@ -168,13 +220,114 @@ export class OperationTypeCoder extends TypeCoder {
168
220
  const responseType = new ResponsesTypeCoder(this.requirement.get("responses"), this.version, openApi2MediaTypes).write(script);
169
221
  const proxyType = "(url: string) => COUNTERFACT_RESPONSE";
170
222
  const delayType = "(milliseconds: number, maxMilliseconds?: number) => Promise<void>";
171
- // Get the base name for this operation and export parameter types
172
- const baseName = this.getOperationBaseName();
173
- const modulePath = this.modulePath();
174
223
  const queryTypeName = this.exportParameterType(script, "query", queryType, baseName, modulePath);
175
224
  const pathTypeName = this.exportParameterType(script, "path", pathType, baseName, modulePath);
176
225
  const headersTypeName = this.exportParameterType(script, "headers", headersType, baseName, modulePath);
177
226
  const cookieTypeName = this.exportParameterType(script, "cookie", cookieType, baseName, modulePath);
178
- return `($: OmitValueWhenNever<{ query: ${queryTypeName}, path: ${pathTypeName}, headers: ${headersTypeName}, cookie: ${cookieTypeName}, body: ${bodyType}, context: ${contextTypeImportName}, response: ${responseType}, x: ${xType}, proxy: ${proxyType}, user: ${this.userType()}, delay: ${delayType} }>) => MaybePromise<COUNTERFACT_RESPONSE>`;
227
+ const versionLiteralType = this.version !== "" ? `"${this.version}"` : "never";
228
+ return `OmitValueWhenNever<{ query: ${queryTypeName}, path: ${pathTypeName}, headers: ${headersTypeName}, cookie: ${cookieTypeName}, body: ${bodyType}, context: ${contextTypeImportName}, response: ${responseType}, x: ${xType}, proxy: ${proxyType}, user: ${this.userType()}, delay: ${delayType}, version: ${versionLiteralType} }>`;
229
+ }
230
+ writeCode(script) {
231
+ script.comments = READ_ONLY_COMMENTS;
232
+ if (this.version !== "") {
233
+ // Versioned case: suppress the normal flat export and register a
234
+ // formatter so that Script.versionsTypeStatements() can emit the
235
+ // merged HTTP_<METHOD>_$_Versions + HTTP_<METHOD> types after all
236
+ // versions have been declared via declareVersion().
237
+ const versionedType = script.importVersionsType("Versioned");
238
+ const maybePromiseType = script.importSharedType("MaybePromise");
239
+ const counterfactResponseType = script.importSharedType("COUNTERFACT_RESPONSE");
240
+ const baseName = this.getOperationBaseName();
241
+ script.setVersionFormatter(baseName, (versionCodes) => {
242
+ const versionsTypeName = `${baseName}_$_Versions`;
243
+ const versionMap = Array.from(versionCodes, ([v, code]) => `"${v}": ${code}`).join("; ");
244
+ return [
245
+ `type ${versionsTypeName} = { ${versionMap} };`,
246
+ `export type ${baseName} = ($: ${versionedType}<${versionsTypeName}>) => ${maybePromiseType}<${counterfactResponseType}>;`,
247
+ ].join("\n");
248
+ });
249
+ // Return a raw-empty sentinel so exportStatements() emits nothing for
250
+ // this export entry. The real export is produced by
251
+ // versionsTypeStatements().
252
+ return { raw: "" };
253
+ }
254
+ // Non-versioned case: existing flat-type output.
255
+ // Import in the same order as the original writeCode so that the emitted
256
+ // import block is identical to the pre-refactor output (snapshot-safe).
257
+ script.importSharedType("WideOperationArgument");
258
+ script.importSharedType("OmitValueWhenNever");
259
+ script.importSharedType("MaybePromise");
260
+ script.importSharedType("COUNTERFACT_RESPONSE");
261
+ const baseName = this.getOperationBaseName();
262
+ const modulePath = this.modulePath();
263
+ const dollarArgType = this.buildDollarArgType(script, baseName, modulePath);
264
+ return `($: ${dollarArgType}) => MaybePromise<COUNTERFACT_RESPONSE>`;
265
+ }
266
+ }
267
+ /**
268
+ * Emits a per-version `$`-argument type to
269
+ * `types/<version>/paths/<path>.types.ts`.
270
+ *
271
+ * When called from a *different* script (e.g. the shared
272
+ * `types/paths/…` script via `Script.declareVersion`), `write()` delegates to
273
+ * `script.importType(this)` so that the type is written to the per-version
274
+ * file and an import is added to the calling script.
275
+ *
276
+ * Only the `OmitValueWhenNever<{…}>` type body is emitted — the
277
+ * function-wrapper `($: Versioned<…>) => MaybePromise<COUNTERFACT_RESPONSE>`
278
+ * is assembled by the shared script's `versionsTypeStatements()`.
279
+ */
280
+ export class VersionedArgTypeCoder extends OperationTypeCoder {
281
+ /**
282
+ * Include the version in the cache key so v1 and v2 coders are treated as
283
+ * distinct exports even when they share the same requirement URL.
284
+ */
285
+ get id() {
286
+ return `${super.id}:${this.version}`;
287
+ }
288
+ /**
289
+ * The per-version `$`-argument type is emitted to
290
+ * `types/<version>/paths/<path>.types.ts`, not to the shared path.
291
+ */
292
+ modulePath() {
293
+ const pathString = this.requirement.url
294
+ .split("/")
295
+ .at(-2)
296
+ .replaceAll("~1", "/");
297
+ return `${pathJoin(`types/${this.version}/paths`, pathString === "/" ? "/index" : pathString)}.types.ts`;
298
+ }
299
+ /**
300
+ * Names are version-qualified (e.g. `HTTP_GET_$_v1`) so that importing
301
+ * multiple versions into the shared script requires no aliasing.
302
+ */
303
+ *names() {
304
+ const baseName = `${this.getOperationBaseName()}_$_${sanitizeIdentifier(this.version)}`;
305
+ yield baseName;
306
+ let index = 1;
307
+ const MAX = 100;
308
+ while (index < MAX) {
309
+ index += 1;
310
+ yield `${baseName}${index}`;
311
+ }
312
+ }
313
+ /**
314
+ * When called from the per-version file itself, generate the actual type.
315
+ * When called from any other script (e.g. the shared file), export to the
316
+ * per-version file and import the result back into that script.
317
+ */
318
+ write(script) {
319
+ if (script.path === this.modulePath()) {
320
+ return this.writeCode(script);
321
+ }
322
+ return script.importType(this);
323
+ }
324
+ /**
325
+ * Generates the `OmitValueWhenNever<{…}>` dollar-argument type and writes
326
+ * it to the per-version script.
327
+ */
328
+ writeCode(script) {
329
+ script.comments = READ_ONLY_COMMENTS;
330
+ const baseName = this.getOperationBaseName();
331
+ return this.buildDollarArgType(script, baseName, this.modulePath());
179
332
  }
180
333
  }
@@ -10,6 +10,15 @@ export class Requirement {
10
10
  data;
11
11
  url;
12
12
  specification;
13
+ /**
14
+ * The requirement that produced this one via a `get()` call, or `undefined`
15
+ * for root requirements that were constructed directly.
16
+ *
17
+ * For path-traversal purposes this is the "logical" parent: when a `$ref` is
18
+ * followed, the parent is the resolved reference target rather than the
19
+ * `$ref` node itself.
20
+ */
21
+ parent;
13
22
  constructor(data, url = "", specification = undefined) {
14
23
  this.data = data;
15
24
  this.url = url;
@@ -53,7 +62,9 @@ export class Requirement {
53
62
  if (!this.has(key)) {
54
63
  return undefined;
55
64
  }
56
- return new Requirement(this.data[key], `${this.url}/${this.escapeJsonPointer(key)}`, this.specification);
65
+ const child = new Requirement(this.data[key], `${this.url}/${this.escapeJsonPointer(key)}`, this.specification);
66
+ child.parent = this;
67
+ return child;
57
68
  }
58
69
  /**
59
70
  * Navigates to a descendant node using a slash-delimited JSON Pointer path.
@@ -21,10 +21,16 @@ export class ResponsesTypeCoder extends TypeCoder {
21
21
  return statusCode;
22
22
  }
23
23
  buildResponseObjectType(script) {
24
- return printObjectWithoutQuotes(this.requirement.map((response, responseCode) => [
24
+ const entries = this.requirement.map((response, responseCode) => [
25
25
  this.normalizeStatusCode(responseCode),
26
26
  new ResponseTypeCoder(response, this.version, this.openApi2MediaTypes).write(script),
27
- ]));
27
+ ]);
28
+ const explicitEntries = entries.filter(([key]) => !key.startsWith("["));
29
+ const mappedEntries = entries.filter(([key]) => key.startsWith("["));
30
+ if (explicitEntries.length > 0 && mappedEntries.length > 0) {
31
+ return `${printObjectWithoutQuotes(explicitEntries)} & ${printObjectWithoutQuotes(mappedEntries)}`;
32
+ }
33
+ return printObjectWithoutQuotes(entries);
28
34
  }
29
35
  writeCode(script) {
30
36
  script.importSharedType("ResponseBuilderFactory");
@@ -18,6 +18,7 @@ export class Script {
18
18
  comments;
19
19
  exports;
20
20
  versions;
21
+ versionFormatters;
21
22
  imports;
22
23
  externalImport;
23
24
  cache;
@@ -28,6 +29,7 @@ export class Script {
28
29
  this.comments = [];
29
30
  this.exports = new Map();
30
31
  this.versions = new Map();
32
+ this.versionFormatters = new Map();
31
33
  this.imports = new Map();
32
34
  this.externalImport = new Map();
33
35
  this.cache = new Map();
@@ -175,9 +177,30 @@ export class Script {
175
177
  importSharedType(name) {
176
178
  return this.importExternal(name, pathJoin(this.relativePathToBase, "counterfact-types/index.ts"), true);
177
179
  }
180
+ /**
181
+ * Imports a type from the generated `types/versions.ts` module,
182
+ * resolving the path relative to this script's location in the repository.
183
+ *
184
+ * @param name - The type name to import (e.g. `"Versioned"`).
185
+ */
186
+ importVersionsType(name) {
187
+ return this.importExternal(name, pathJoin(this.relativePathToBase, "types/versions.ts"), true);
188
+ }
178
189
  exportType(coder) {
179
190
  return this.export(coder, true);
180
191
  }
192
+ /**
193
+ * Registers a formatter function for the merged versioned type emitted under
194
+ * `name` by {@link versionsTypeStatements}.
195
+ *
196
+ * When a formatter is present for a name, `versionsTypeStatements` delegates
197
+ * the entire type declaration to it instead of generating the default
198
+ * `Versions` object type. The formatter receives a `Map<version, importAlias>`
199
+ * and must return the complete TypeScript source for that operation type.
200
+ */
201
+ setVersionFormatter(name, formatter) {
202
+ this.versionFormatters.set(name, formatter);
203
+ }
181
204
  declareVersion(coder, name) {
182
205
  const version = coder.version;
183
206
  const versions = this.versions.get(name) ?? new Map();
@@ -254,11 +277,29 @@ export class Script {
254
277
  if (this.versions.size === 0) {
255
278
  return [];
256
279
  }
257
- const names = Array.from(this.versions, ([name, versions]) => {
258
- const mappedVersions = Array.from(versions, ([version, versionStatement]) => `"${version}": ${versionStatement.code}`);
259
- return `"${name}": { ${mappedVersions.join(", ")} }`;
260
- });
261
- return [`export type Versions = { ${names.join(", ")} };`];
280
+ const statements = [];
281
+ const unformatted = [];
282
+ for (const [name, versions] of this.versions) {
283
+ const formatter = this.versionFormatters.get(name);
284
+ if (formatter) {
285
+ const versionCodes = new Map(Array.from(versions, ([version, stmt]) => [
286
+ version,
287
+ stmt.code,
288
+ ]));
289
+ statements.push(formatter(versionCodes));
290
+ }
291
+ else {
292
+ unformatted.push([name, versions]);
293
+ }
294
+ }
295
+ if (unformatted.length > 0) {
296
+ const names = unformatted.map(([name, versions]) => {
297
+ const mappedVersions = Array.from(versions, ([version, versionStatement]) => `"${version}": ${versionStatement.code}`);
298
+ return `"${name}": { ${mappedVersions.join(", ")} }`;
299
+ });
300
+ statements.push(`export type Versions = { ${names.join(", ")} };`);
301
+ }
302
+ return statements;
262
303
  }
263
304
  /**
264
305
  * Formats the fully assembled script source with Prettier and returns it.
@@ -0,0 +1,57 @@
1
+ import { format } from "prettier";
2
+ /**
3
+ * Builds the `VersionsGTE` map: for each version V at index i,
4
+ * maps V to all versions at indices >= i (i.e. V and all later-declared
5
+ * versions, where "later" means "newer").
6
+ */
7
+ function buildVersionsGTE(versions) {
8
+ const result = new Map();
9
+ versions.forEach((version, index) => {
10
+ result.set(version, versions.slice(index));
11
+ });
12
+ return result;
13
+ }
14
+ /**
15
+ * Generates the TypeScript source text for `types/versions.ts`.
16
+ *
17
+ * Returns an empty string when `versions` is empty.
18
+ * The returned string is formatted with Prettier.
19
+ *
20
+ * @param versions - Ordered list of unique, non-empty version strings.
21
+ * The first entry is the oldest version.
22
+ */
23
+ export async function generateVersionsTsContent(versions) {
24
+ if (versions.length === 0) {
25
+ return "";
26
+ }
27
+ const versionsUnion = versions.map((v) => `"${v}"`).join(" | ");
28
+ const versionsGTE = buildVersionsGTE(versions);
29
+ const versionsGTEBody = Array.from(versionsGTE, ([v, gte]) => ` "${v}": ${gte.map((g) => `"${g}"`).join(" | ")};`).join("\n");
30
+ const source = [
31
+ "// This file is auto-generated by Counterfact. Do not edit.",
32
+ "",
33
+ `export type Versions = ${versionsUnion};`,
34
+ "",
35
+ "/**",
36
+ " * Maps each version to the set of versions that are greater than or equal to it.",
37
+ " * Used by `Versioned.minVersion()` to narrow which versions a handler must support.",
38
+ " */",
39
+ "export type VersionsGTE = {",
40
+ versionsGTEBody,
41
+ "};",
42
+ "",
43
+ "type VersionMap = Partial<Record<Versions, object>>;",
44
+ "",
45
+ "export type Versioned<",
46
+ " T extends VersionMap,",
47
+ " V extends keyof T & Versions = keyof T & Versions,",
48
+ "> = T[V] & {",
49
+ " version: V;",
50
+ " minVersion<M extends keyof T & Versions>(",
51
+ " min: M,",
52
+ " ): this is Versioned<T, Extract<V, VersionsGTE[M]>>;",
53
+ "};",
54
+ "",
55
+ ].join("\n");
56
+ return format(source, { parser: "typescript" });
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "counterfact",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.",
5
5
  "type": "module",
6
6
  "main": "./dist/app.js",