counterfact 2.11.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.
Files changed (39) hide show
  1. package/README.md +2 -1
  2. package/dist/api-runner.js +8 -2
  3. package/dist/app.js +8 -1
  4. package/dist/cli/run.js +44 -6
  5. package/dist/cli/telemetry.js +10 -4
  6. package/dist/migrate/update-route-types.js +1 -0
  7. package/dist/msw.js +1 -0
  8. package/dist/repl/repl.js +4 -0
  9. package/dist/server/counterfact-types/example.ts +5 -1
  10. package/dist/server/counterfact-types/generic-response-builder.ts +4 -0
  11. package/dist/server/counterfact-types/open-api-parameters.ts +8 -1
  12. package/dist/server/counterfact-types/response-builder.ts +5 -0
  13. package/dist/server/counterfact-types/wide-response-builder.ts +1 -0
  14. package/dist/server/dispatcher.js +60 -6
  15. package/dist/server/json-to-xml.js +32 -7
  16. package/dist/server/load-openapi-document.js +2 -2
  17. package/dist/server/module-loader.js +5 -0
  18. package/dist/server/openapi-document.js +18 -1
  19. package/dist/server/registry.js +22 -5
  20. package/dist/server/request-validator.js +1 -0
  21. package/dist/server/response-builder.js +28 -5
  22. package/dist/server/web-server/create-koa-app.js +4 -1
  23. package/dist/server/web-server/openapi-middleware.js +5 -0
  24. package/dist/server/web-server/routes-middleware.js +43 -1
  25. package/dist/typescript-generator/code-generator.js +25 -10
  26. package/dist/typescript-generator/coder.js +1 -1
  27. package/dist/typescript-generator/jsdoc.js +11 -7
  28. package/dist/typescript-generator/operation-coder.js +14 -0
  29. package/dist/typescript-generator/operation-type-coder.js +65 -9
  30. package/dist/typescript-generator/repository.js +97 -8
  31. package/dist/typescript-generator/requirement.js +25 -3
  32. package/dist/typescript-generator/response-type-coder.js +20 -7
  33. package/dist/typescript-generator/schema-coder.js +2 -2
  34. package/dist/typescript-generator/schema-type-coder.js +16 -3
  35. package/dist/typescript-generator/specification.js +17 -6
  36. package/dist/typescript-generator/streaming-content-types.js +16 -0
  37. package/dist/typescript-generator/versions-ts-generator.js +25 -0
  38. package/dist/util/apply-overlay.js +119 -0
  39. package/package.json +29 -29
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br>
6
6
 
7
- ![MIT License](https://img.shields.io/badge/license-MIT-blue) [![Coverage Status](https://coveralls.io/repos/github/counterfact/api-simulator/badge.svg)](https://coveralls.io/github/pmcelhaney/counterfact) ![friction 0%](https://img.shields.io/badge/friction-0%25-brightgreen)
7
+ ![MIT License](https://img.shields.io/badge/license-MIT-blue) [![Coverage Status](https://coveralls.io/repos/github/counterfact/api-simulator/badge.svg)](https://coveralls.io/github/pmcelhaney/counterfact) ![friction 0%](https://img.shields.io/badge/friction-0%25-brightgreen) ![Swagger 2.0](https://img.shields.io/badge/Swagger-2.0-85EA2D) ![OpenAPI 3.0-3.2](https://img.shields.io/badge/OpenAPI-3.x-6BA539)
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
@@ -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
@@ -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,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,11 +51,41 @@ 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
  }
56
58
  return undefined;
57
59
  }
60
+ export function buildStartupTelemetryProperties(options, source, version, specs) {
61
+ const apiSources = specs?.map((spec) => spec.source) ?? [source];
62
+ const apiFileLocationHashes = apiSources
63
+ .filter((apiSource) => apiSource !== "_")
64
+ .map((apiSource) => hashTelemetryLocation(apiSource));
65
+ return {
66
+ alwaysFakeOptionals: Boolean(options.alwaysFakeOptionals),
67
+ apiFileLocationHashes,
68
+ buildCache: Boolean(options.buildCache),
69
+ generateRoutes: Boolean(options.generate) || Boolean(options.generateRoutes),
70
+ generateTypes: Boolean(options.generate) || Boolean(options.generateTypes),
71
+ mode: specs !== undefined
72
+ ? "multi-spec"
73
+ : source === "_"
74
+ ? "without-openapi"
75
+ : "single-spec",
76
+ openBrowser: Boolean(options.open),
77
+ port: options.port,
78
+ prune: Boolean(options.prune),
79
+ repl: Boolean(options.repl),
80
+ serve: Boolean(options.serve),
81
+ updateCheck: Boolean(options.updateCheck),
82
+ validateRequest: Boolean(options.validateRequest),
83
+ validateResponse: Boolean(options.validateResponse),
84
+ version,
85
+ watchRoutes: Boolean(options.watch) || Boolean(options.watchRoutes),
86
+ watchTypes: Boolean(options.watch) || Boolean(options.watchTypes),
87
+ };
88
+ }
58
89
  /**
59
90
  * Builds the Commander program with all CLI options and the action handler.
60
91
  * Factored out of `runCli` so it is easy to test or extend.
@@ -75,11 +106,17 @@ function buildProgram(version, taglines) {
75
106
  const configFilePath = resolve(options.config ?? "counterfact.yaml");
76
107
  const fileConfig = await loadConfigFile(configFilePath, options.config !== undefined);
77
108
  debug("fileConfig: %o", fileConfig);
109
+ const knownOptionKeys = new Set(program.options.map((option) => option.attributeName()));
78
110
  // Apply config file values for any option that was not explicitly set on
79
111
  // the command line (i.e. its source is "default" or it was never defined).
80
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
+ }
81
117
  const optionSource = program.getOptionValueSource(key);
82
118
  if (optionSource !== "cli") {
119
+ // eslint-disable-next-line security/detect-object-injection -- key is validated against known Commander option names above.
83
120
  options[key] = value;
84
121
  }
85
122
  }
@@ -110,12 +147,14 @@ function buildProgram(version, taglines) {
110
147
  const actions = ["repl", "serve", "watch", "generate", "buildCache"];
111
148
  if (!Object.keys(options).some((argument) => actions.some((action) => argument.startsWith(action)))) {
112
149
  for (const action of actions) {
150
+ // eslint-disable-next-line security/detect-object-injection -- action names come from the local allowlist above.
113
151
  options[action] = true;
114
152
  }
115
153
  }
116
154
  debug("options: %o", options);
117
155
  debug("source: %s", source);
118
156
  debug("destination: %s", destination);
157
+ const startupTelemetryProperties = buildStartupTelemetryProperties(options, source, version, specs);
119
158
  const openBrowser = options.open;
120
159
  const url = `http://localhost:${options.port}${options.prefix}`;
121
160
  const guiUrl = `${url}/counterfact/`;
@@ -140,6 +179,7 @@ function buildProgram(version, taglines) {
140
179
  prune: Boolean(options.prune),
141
180
  },
142
181
  openApiPath: source,
182
+ overlays: options.overlay ?? [],
143
183
  port: options.port,
144
184
  proxyPaths: new Map([["", Boolean(options.proxyUrl)]]),
145
185
  proxyUrl: options.proxyUrl ?? "",
@@ -213,6 +253,7 @@ function buildProgram(version, taglines) {
213
253
  process.exit(1);
214
254
  }
215
255
  debug("started server");
256
+ sendTelemetry("counterfact_started", startupTelemetryProperties);
216
257
  await updateCheckPromise;
217
258
  if (config.startRepl) {
218
259
  startRepl();
@@ -267,6 +308,7 @@ function buildProgram(version, taglines) {
267
308
  .option("--always-fake-optionals", "random responses will include optional fields")
268
309
  .option("--prune", "remove route files that no longer exist in the OpenAPI spec")
269
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], [])
270
312
  .option("--no-update-check", "disable the npm update check on startup")
271
313
  .option("--no-validate-request", "disable request validation against the OpenAPI spec")
272
314
  .option("--no-validate-response", "disable response validation against the OpenAPI spec")
@@ -300,10 +342,6 @@ export async function runCli(argv) {
300
342
  catch {
301
343
  taglines = ["counterfact — mock API server"];
302
344
  }
303
- // Fire telemetry once on startup — fire-and-forget, never blocks.
304
- if (isTelemetryEnabled()) {
305
- sendTelemetry(version);
306
- }
307
345
  debug("running counterfact CLI v%s", version);
308
346
  const program = buildProgram(version, taglines);
309
347
  await program.parseAsync(argv);
@@ -1,4 +1,4 @@
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";
@@ -15,24 +15,30 @@ export function isTelemetryEnabled() {
15
15
  return false;
16
16
  return true;
17
17
  }
18
+ export function hashTelemetryLocation(location) {
19
+ return createHash("sha256").update(location).digest("hex");
20
+ }
18
21
  /**
19
22
  * Fires a telemetry event to PostHog. Fire-and-forget — never blocks
20
23
  * startup and never surfaces errors to the user.
21
24
  */
22
- export function sendTelemetry(version) {
25
+ export function sendTelemetry(event, properties = {}) {
26
+ if (!isTelemetryEnabled()) {
27
+ return;
28
+ }
23
29
  const telemetryKey = process.env["POSTHOG_API_KEY"] ?? POSTHOG_API_KEY;
24
30
  const telemetryHost = process.env["POSTHOG_HOST"] ?? POSTHOG_HOST;
25
31
  try {
26
32
  const posthog = new PostHog(telemetryKey, { host: telemetryHost });
27
33
  posthog.capture({
28
34
  distinctId: randomUUID(),
29
- event: "counterfact_started",
35
+ event,
30
36
  properties: {
31
- version,
32
37
  nodeVersion: process.version,
33
38
  platform: process.platform,
34
39
  arch: process.arch,
35
40
  source: "counterfact-cli",
41
+ ...properties,
36
42
  },
37
43
  });
38
44
  posthog.flush().catch(() => {
@@ -14,6 +14,7 @@ const HTTP_METHODS = [
14
14
  "PATCH",
15
15
  "HEAD",
16
16
  "OPTIONS",
17
+ "QUERY",
17
18
  ];
18
19
  // Pre-compile regex patterns derived from HTTP_METHODS
19
20
  const HTTP_METHOD_ALTERNATION = HTTP_METHODS.join("|");
package/dist/msw.js CHANGED
@@ -14,6 +14,7 @@ const allowedMethods = [
14
14
  "delete",
15
15
  "patch",
16
16
  "options",
17
+ "query",
17
18
  ];
18
19
  const mswHandlers = {};
19
20
  /**
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) {
@@ -258,6 +259,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
258
259
  : undefined);
259
260
  replServer.defineCommand("counterfact", {
260
261
  action() {
262
+ sendTelemetry("repl_command_used", { command: "counterfact" });
261
263
  print("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.");
262
264
  print("Except that it's connected to the running server, which you can access with the following globals:");
263
265
  print("");
@@ -274,6 +276,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
274
276
  });
275
277
  replServer.defineCommand("proxy", {
276
278
  action(text) {
279
+ sendTelemetry("repl_command_used", { command: "proxy" });
277
280
  if (text === "help" || text === "") {
278
281
  print(".proxy [on|off] - turn the proxy on/off at the root level");
279
282
  print(".proxy [on|off] <path-prefix> - turn the proxy on for a path");
@@ -313,6 +316,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
313
316
  : {};
314
317
  replServer.defineCommand("scenario", {
315
318
  async action(text) {
319
+ sendTelemetry("repl_command_used", { command: "scenario" });
316
320
  const trimmedText = text.trim();
317
321
  const parsedArgs = trimmedText.split(/\s+/u).filter(Boolean);
318
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: unknown;
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: "body" | "cookie" | "formData" | "header" | "path" | "query";
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
  }
@@ -23,4 +23,5 @@ export interface WideResponseBuilder {
23
23
  random: () => MaybePromise<WideResponseBuilder>;
24
24
  text: (body: unknown) => WideResponseBuilder;
25
25
  xml: (body: unknown) => WideResponseBuilder;
26
+ stream: (body: AsyncIterable<unknown>) => WideResponseBuilder;
26
27
  }
@@ -142,6 +142,11 @@ export class Dispatcher {
142
142
  return types;
143
143
  }
144
144
  for (const parameter of parameters) {
145
+ // querystring parameters represent the entire query string as a single
146
+ // typed object; they are not individually coerced by name.
147
+ if (parameter.in === "querystring") {
148
+ continue;
149
+ }
145
150
  const type = parameter?.type ?? parameter?.schema?.type;
146
151
  if (type !== undefined) {
147
152
  types[parameter.in].set(parameter.name, type === "integer" ? "number" : type);
@@ -160,6 +165,36 @@ export class Dispatcher {
160
165
  }
161
166
  return undefined;
162
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
+ }
163
198
  /**
164
199
  * Resolves the OpenAPI operation for `path` and `method`, merging any
165
200
  * top-level `produces` array from the document root and any path-item-level
@@ -178,7 +213,11 @@ export class Dispatcher {
178
213
  if (pathItem === undefined) {
179
214
  return undefined;
180
215
  }
181
- const operation = pathItem[method.toLowerCase()];
216
+ const normalizedMethod = method.toLowerCase();
217
+ const operation = pathItem[normalizedMethod] ??
218
+ pathItem.additionalOperations?.[method] ??
219
+ pathItem.additionalOperations?.[method.toUpperCase()] ??
220
+ pathItem.additionalOperations?.[normalizedMethod];
182
221
  if (operation === undefined) {
183
222
  return undefined;
184
223
  }
@@ -194,13 +233,20 @@ export class Dispatcher {
194
233
  const mergedOperation = mergedParameters !== undefined
195
234
  ? { ...operation, parameters: mergedParameters }
196
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;
197
243
  if (this.openApiDocument?.produces) {
198
244
  return {
199
245
  produces: this.openApiDocument.produces,
200
- ...mergedOperation,
246
+ ...operationWithSecurity,
201
247
  };
202
248
  }
203
- return mergedOperation;
249
+ return operationWithSecurity;
204
250
  }
205
251
  normalizeResponse(response, acceptHeader) {
206
252
  if (response.content !== undefined) {
@@ -290,8 +336,14 @@ export class Dispatcher {
290
336
  };
291
337
  }
292
338
  const operation = this.operationForPathAndMethod(matchedPath, method);
339
+ const requestCookie = parseCookies(headers.cookie ?? headers.Cookie ?? "");
293
340
  if (this.config?.validateRequests !== false) {
294
- const validation = validateRequest(operation, { body, headers, query });
341
+ const validation = validateRequest(operation, {
342
+ body,
343
+ cookie: requestCookie,
344
+ headers,
345
+ query,
346
+ });
295
347
  if (!validation.valid) {
296
348
  return {
297
349
  body: `Request validation failed:\n${validation.errors.join("\n")}`,
@@ -307,7 +359,7 @@ export class Dispatcher {
307
359
  return min + Math.random() * (max - min);
308
360
  };
309
361
  const response = await this.registry.endpoint(method, path, this.parameterTypes(operation?.parameters))({
310
- auth,
362
+ auth: this.authWithApiKey(auth, requestCookie, headers, query),
311
363
  body,
312
364
  context: this.contextRegistry.find(matchedPath),
313
365
  async delay(milliseconds = 0, maxMilliseconds = 0) {
@@ -316,7 +368,7 @@ export class Dispatcher {
316
368
  : continuousDistribution(milliseconds, maxMilliseconds);
317
369
  return new Promise((resolve) => setTimeout(resolve, delayInMs));
318
370
  },
319
- cookie: parseCookies(headers.cookie ?? headers.Cookie ?? ""),
371
+ cookie: requestCookie,
320
372
  headers,
321
373
  proxy: async (url) => {
322
374
  delete headers.host;
@@ -335,6 +387,8 @@ export class Dispatcher {
335
387
  },
336
388
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- object params are reconstructed and typed by generated route types
337
389
  query: processedQuery,
390
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- typed by generated route types; entire query string as a single object
391
+ querystring: processedQuery,
338
392
  // @ts-expect-error - Might be pushing the limits of what TypeScript can do here
339
393
  response: createResponseBuilder(operation ?? { responses: {} }, this.config), // Pass config
340
394
  tools: new Tools({ headers }),
@@ -22,19 +22,44 @@ function xmlEscape(xmlString) {
22
22
  }
23
23
  });
24
24
  }
25
+ function resolveNodeType(schema) {
26
+ if (schema?.xml?.nodeType !== undefined) {
27
+ return schema.xml.nodeType;
28
+ }
29
+ if (schema?.xml?.attribute || schema?.attribute) {
30
+ return "attribute";
31
+ }
32
+ return "element";
33
+ }
25
34
  function objectToXml(json, schema, name) {
26
35
  const xml = [];
27
36
  const attributes = [];
28
37
  Object.entries(json).forEach(([key, value]) => {
29
38
  const properties = schema?.properties?.[key];
30
- if (properties?.attribute) {
31
- attributes.push(` ${key}="${xmlEscape(String(value))}"`);
32
- }
33
- else {
34
- xml.push(jsonToXml(value, properties, key));
39
+ const nodeType = resolveNodeType(properties);
40
+ const xmlName = properties?.xml?.name ?? key;
41
+ switch (nodeType) {
42
+ case "attribute": {
43
+ attributes.push(` ${xmlName}="${xmlEscape(String(value))}"`);
44
+ break;
45
+ }
46
+ case "text": {
47
+ xml.push(xmlEscape(String(value)));
48
+ break;
49
+ }
50
+ case "cdata": {
51
+ xml.push(`<![CDATA[${String(value)}]]>`);
52
+ break;
53
+ }
54
+ case "none": {
55
+ break;
56
+ }
57
+ default: {
58
+ xml.push(jsonToXml(value, properties, key));
59
+ }
35
60
  }
36
61
  });
37
- return `<${name}${attributes.join("")}>${String(xml.join(""))}</${name}>`;
62
+ return `<${name}${attributes.join("")}>${xml.join("")}</${name}>`;
38
63
  }
39
64
  /**
40
65
  * Converts a JSON value to an XML string using optional OpenAPI `xml` schema
@@ -52,7 +77,7 @@ export function jsonToXml(json, schema, keyName = "root") {
52
77
  const items = json
53
78
  .map((item) => jsonToXml(item, schema?.items, name))
54
79
  .join("");
55
- if (schema?.xml?.wrapped) {
80
+ if (schema?.xml?.wrapped || schema?.xml?.nodeType === "element") {
56
81
  return `<${name}>${items}</${name}>`;
57
82
  }
58
83
  return items;
@@ -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
  }
@@ -11,6 +11,7 @@ import { FileDiscovery } from "./file-discovery.js";
11
11
  import { isContextModule, isMiddlewareModule, } from "./middleware-detector.js";
12
12
  import { ModuleDependencyGraph } from "./module-dependency-graph.js";
13
13
  import { uncachedImport } from "./uncached-import.js";
14
+ import { sendTelemetry } from "../cli/telemetry.js";
14
15
  import { toForwardSlashPath, pathDirname, pathRelative, } from "../util/forward-slash-path.js";
15
16
  import { unescapePathForWindows } from "../util/windows-escape.js";
16
17
  const { uncachedRequire } = await import("./uncached-require.cjs");
@@ -74,6 +75,10 @@ export class ModuleLoader extends EventTarget {
74
75
  return;
75
76
  }
76
77
  const parts = nodePath.parse(pathName.replace(this.basePath, ""));
78
+ sendTelemetry("file_change_detected", {
79
+ changeType: eventName,
80
+ fileType: this.isContextFile(pathName) ? "context" : "route",
81
+ });
77
82
  const url = unescapePathForWindows(toForwardSlashPath(`/${parts.dir}/${parts.name}`).replaceAll(/\/+/gu, "/"));
78
83
  if (eventName === "unlink") {
79
84
  this.registry.remove(url);
@@ -1,7 +1,9 @@
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";
6
+ import { sendTelemetry } from "../cli/telemetry.js";
5
7
  import { CHOKIDAR_OPTIONS } from "./constants.js";
6
8
  const debug = createDebug("counterfact:server:openapi-document");
7
9
  /**
@@ -13,13 +15,20 @@ const debug = createDebug("counterfact:server:openapi-document");
13
15
  export class OpenApiDocument extends EventTarget {
14
16
  /** The path or URL of the OpenAPI source file. */
15
17
  source;
18
+ /**
19
+ * Optional ordered list of overlay file paths/URLs to apply after each
20
+ * load of the document.
21
+ */
22
+ overlays;
16
23
  basePath;
24
+ components;
17
25
  paths = {};
18
26
  produces;
19
27
  watcher;
20
- constructor(source) {
28
+ constructor(source, overlays = []) {
21
29
  super();
22
30
  this.source = source;
31
+ this.overlays = overlays;
23
32
  }
24
33
  /**
25
34
  * Reads the source file and populates the document's properties.
@@ -28,7 +37,11 @@ export class OpenApiDocument extends EventTarget {
28
37
  async load() {
29
38
  try {
30
39
  const data = (await dereference(this.source));
40
+ if (this.overlays.length > 0) {
41
+ await applyOverlays(data, this.overlays);
42
+ }
31
43
  this.basePath = data.basePath;
44
+ this.components = data.components;
32
45
  this.paths = data.paths;
33
46
  this.produces = data.produces;
34
47
  }
@@ -49,6 +62,10 @@ export class OpenApiDocument extends EventTarget {
49
62
  return;
50
63
  }
51
64
  this.watcher = watch(this.source, CHOKIDAR_OPTIONS).on("change", () => {
65
+ sendTelemetry("file_change_detected", {
66
+ changeType: "change",
67
+ fileType: "openapi",
68
+ });
52
69
  void (async () => {
53
70
  try {
54
71
  await this.load();