counterfact 2.12.0 → 2.14.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.
@@ -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({ ...config, openApiPath: spec.source, prefix: spec.prefix }, spec.group, spec.version ?? "", versionsByGroup.get(spec.group) ?? [])));
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
- ...mergedOperation,
246
+ ...operationWithSecurity,
210
247
  };
211
248
  }
212
- return mergedOperation;
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, { body, headers, query });
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: parseCookies(headers.cookie ?? headers.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": (item) => `data: ${JSON.stringify(item)}\n\n`,
29
- "application/json-seq": (item) => `\x1e${JSON.stringify(item)}\n`,
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[contentType] ?? defaultStreamFormatter;
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 `openApiPath` is a URL (HTTP sources are not watched).
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
- if (this.openapiPath.startsWith("http")) {
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(this.openapiPath, CHOKIDAR_OPTIONS).on("change", () => {
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
- if (!pathItemParams) {
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 overrides them.
189
- const pathData = pathItemParams.data;
190
- const opData = operationParams.data;
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 on
74
- * disk, preserving user edits. Type files (`types/…`) are always
75
- * overwritten.
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
- (await fs
92
+ if (shouldWriteRoutes) {
93
+ const fileExists = await fs
92
94
  .stat(fullPath)
93
95
  .then((stat) => stat.isFile())
94
- .catch(() => false))) {
95
- debug(`not overwriting ${fullPath}\n`);
96
- return;
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 - A local file path or HTTP(S) URL.
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
- this.rootRequirement = new Requirement((await bundle(urlOrPath, {
57
+ const document = (await bundle(urlOrPath, {
53
58
  resolve: { http: { safeUrlResolver: false } },
54
- })), urlOrPath, this);
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.12.0",
3
+ "version": "2.14.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",
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",
@@ -84,21 +85,21 @@
84
85
  "@changesets/cli": "2.31.0",
85
86
  "@eslint/js": "10.0.1",
86
87
  "@jest/globals": "30.4.1",
87
- "@swc/core": "1.15.33",
88
+ "@swc/core": "1.15.40",
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.2",
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.59.3",
99
- "@typescript-eslint/parser": "8.59.3",
99
+ "@typescript-eslint/eslint-plugin": "8.60.0",
100
+ "@typescript-eslint/parser": "8.60.0",
100
101
  "copyfiles": "2.4.1",
101
- "eslint": "10.3.0",
102
+ "eslint": "10.4.0",
102
103
  "eslint-formatter-github-annotations": "0.1.0",
103
104
  "eslint-import-resolver-typescript": "4.4.4",
104
105
  "eslint-plugin-etc": "2.0.3",
@@ -133,19 +134,20 @@
133
134
  "fs-extra": "11.3.5",
134
135
  "http-terminator": "3.2.0",
135
136
  "js-yaml": "4.1.1",
136
- "json-schema-faker": "0.6.1",
137
+ "json-schema-faker": "0.6.2",
138
+ "jsonpath-plus": "10.4.0",
137
139
  "jsonwebtoken": "9.0.3",
138
- "koa": "3.2.0",
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.34.1",
145
- "precinct": "12.3.2",
146
+ "posthog-node": "5.35.5",
147
+ "precinct": "13.0.0",
146
148
  "prettier": "3.8.3",
147
149
  "recast": "0.23.11",
148
- "tsx": "4.21.0",
150
+ "tsx": "4.22.3",
149
151
  "typescript": "6.0.3"
150
152
  },
151
153
  "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",