counterfact 2.8.1 → 2.10.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 (48) hide show
  1. package/README.md +36 -13
  2. package/bin/README.md +39 -14
  3. package/bin/counterfact.js +18 -547
  4. package/bin/ts-loader.mjs +1 -0
  5. package/dist/api-runner.js +202 -0
  6. package/dist/app.js +72 -138
  7. package/dist/cli/banner.js +81 -0
  8. package/dist/cli/check-for-updates.js +45 -0
  9. package/dist/cli/run.js +304 -0
  10. package/dist/cli/telemetry.js +50 -0
  11. package/dist/migrate/paths-to-routes.js +1 -0
  12. package/dist/migrate/update-route-types.js +2 -1
  13. package/dist/msw.js +78 -0
  14. package/dist/repl/raw-http-client.js +3 -1
  15. package/dist/repl/repl.js +228 -60
  16. package/dist/server/counterfact-types/generic-response-builder.ts +1 -2
  17. package/dist/server/counterfact-types/open-api-parameters.ts +3 -0
  18. package/dist/server/determine-module-kind.js +1 -0
  19. package/dist/server/dispatcher.js +45 -2
  20. package/dist/server/file-discovery.js +1 -0
  21. package/dist/server/module-loader.js +8 -0
  22. package/dist/server/request-validator.js +42 -1
  23. package/dist/server/transpiler.js +1 -0
  24. package/dist/server/{admin-api-middleware.js → web-server/admin-api-middleware.js} +19 -9
  25. package/dist/server/web-server/create-koa-app.js +68 -0
  26. package/dist/server/web-server/openapi-middleware.js +34 -0
  27. package/dist/server/{koa-middleware.js → web-server/routes-middleware.js} +11 -8
  28. package/dist/typescript-generator/code-generator.js +2 -1
  29. package/dist/typescript-generator/coder.js +4 -2
  30. package/dist/typescript-generator/operation-coder.js +4 -4
  31. package/dist/typescript-generator/operation-type-coder.js +15 -14
  32. package/dist/typescript-generator/parameter-export-type-coder.js +2 -2
  33. package/dist/typescript-generator/parameters-type-coder.js +3 -3
  34. package/dist/typescript-generator/prune.js +1 -0
  35. package/dist/typescript-generator/repository.js +1 -0
  36. package/dist/typescript-generator/response-type-coder.js +7 -6
  37. package/dist/typescript-generator/responses-type-coder.js +3 -3
  38. package/dist/typescript-generator/scenario-file-generator.js +1 -0
  39. package/dist/typescript-generator/schema-coder.js +2 -2
  40. package/dist/typescript-generator/schema-type-coder.js +7 -5
  41. package/dist/typescript-generator/script.js +58 -3
  42. package/dist/util/ensure-directory-exists.js +1 -0
  43. package/dist/util/load-config-file.js +2 -2
  44. package/dist/util/read-file.js +16 -2
  45. package/dist/util/runtime-can-execute-erasable-ts.js +1 -0
  46. package/package.json +3 -2
  47. package/dist/server/create-koa-app.js +0 -65
  48. package/dist/server/openapi-middleware.js +0 -48
package/dist/repl/repl.js CHANGED
@@ -15,61 +15,125 @@ const ROUTE_BUILDER_METHODS = [
15
15
  "ready(",
16
16
  "send(",
17
17
  ];
18
+ function getScenarioCompletions(line, scenarioRegistry, groupedScenarioRegistries) {
19
+ function getPathCompletions(partial, registry) {
20
+ if (registry === undefined) {
21
+ return [[], partial];
22
+ }
23
+ const slashIdx = partial.lastIndexOf("/");
24
+ if (slashIdx === -1) {
25
+ const indexFunctions = registry.getExportedFunctionNames("index");
26
+ const fileKeys = registry.getFileKeys().filter((k) => k !== "index");
27
+ const topLevelPrefixes = [
28
+ ...new Set(fileKeys.map((k) => k.split("/")[0] + "/")),
29
+ ];
30
+ const allOptions = [...indexFunctions, ...topLevelPrefixes];
31
+ const matches = allOptions.filter((c) => c.startsWith(partial));
32
+ return [matches, partial];
33
+ }
34
+ const fileKey = partial.slice(0, slashIdx);
35
+ const funcPartial = partial.slice(slashIdx + 1);
36
+ const functions = registry.getExportedFunctionNames(fileKey);
37
+ const matches = functions
38
+ .filter((e) => e.startsWith(funcPartial))
39
+ .map((e) => `${fileKey}/${e}`);
40
+ return [matches, partial];
41
+ }
42
+ if (groupedScenarioRegistries !== undefined) {
43
+ if (!/^\.scenario(?:\s|$)/u.test(line)) {
44
+ return undefined;
45
+ }
46
+ const hasTrailingWhitespace = /\s$/u.test(line);
47
+ const args = line.trim().split(/\s+/u).slice(1);
48
+ const groupKeys = Object.keys(groupedScenarioRegistries);
49
+ if (args.length === 0) {
50
+ return [groupKeys, ""];
51
+ }
52
+ if (args.length === 1 && !hasTrailingWhitespace) {
53
+ const groupPartial = args[0] ?? "";
54
+ const matches = groupKeys.filter((key) => key.startsWith(groupPartial));
55
+ return [matches, groupPartial];
56
+ }
57
+ const selectedGroup = args[0] ?? "";
58
+ const selectedRegistry = groupedScenarioRegistries[selectedGroup];
59
+ if (selectedRegistry === undefined) {
60
+ const scenarioPartial = hasTrailingWhitespace
61
+ ? ""
62
+ : (args[args.length - 1] ?? "");
63
+ return [[], scenarioPartial];
64
+ }
65
+ if (args.length === 1 && hasTrailingWhitespace) {
66
+ return getPathCompletions("", selectedRegistry);
67
+ }
68
+ if (args.length === 2 && !hasTrailingWhitespace) {
69
+ const scenarioPartial = args[1];
70
+ if (scenarioPartial === undefined) {
71
+ return [[], ""];
72
+ }
73
+ return getPathCompletions(scenarioPartial, selectedRegistry);
74
+ }
75
+ // More than two args (or trailing whitespace after the second arg) means
76
+ // no additional `.scenario` arguments are valid in multi-API mode.
77
+ return [[], args[args.length - 1] ?? ""];
78
+ }
79
+ const applyMatch = line.match(/^\.scenario\s+(?<partial>\S*)$/u);
80
+ if (!applyMatch) {
81
+ return undefined;
82
+ }
83
+ const partial = applyMatch.groups?.["partial"] ?? "";
84
+ return getPathCompletions(partial, scenarioRegistry);
85
+ }
86
+ function getRouteBuilderMethodCompletions(line) {
87
+ const builderMatch = line.match(/route\(.*\)\.(?<partial>[a-zA-Z]*)$/u);
88
+ if (!builderMatch) {
89
+ return undefined;
90
+ }
91
+ const partial = builderMatch.groups?.["partial"] ?? "";
92
+ const matches = ROUTE_BUILDER_METHODS.filter((m) => m.startsWith(partial));
93
+ return [matches, partial];
94
+ }
95
+ function getRoutesForCompletion(registry, openApiDocument) {
96
+ const openApiPaths = openApiDocument
97
+ ? Object.keys(openApiDocument.paths)
98
+ : [];
99
+ if (openApiPaths.length > 0) {
100
+ return openApiPaths;
101
+ }
102
+ return registry.routes.map((route) => route.path);
103
+ }
104
+ function getRouteCompletions(line, routes) {
105
+ const routeMatch = line.match(/(?:client\.(?:get|post|put|patch|delete)|route)\("(?<partial>[^"]*)$/u);
106
+ if (!routeMatch) {
107
+ return undefined;
108
+ }
109
+ const partial = routeMatch.groups?.["partial"] ?? "";
110
+ const matches = routes.filter((route) => route.startsWith(partial));
111
+ return [matches, partial];
112
+ }
18
113
  /**
19
114
  * Creates a tab-completion function for the REPL.
20
115
  *
21
116
  * @param registry - The route registry used to complete path arguments for `route()` and `client.*()` calls.
22
117
  * @param fallback - Optional fallback completer (e.g. the Node.js built-in completer) invoked when no custom completion matches.
118
+ * @param openApiDocument - Optional OpenAPI document used as the source of route completions when available.
23
119
  * @param scenarioRegistry - When provided, enables tab completion for `.scenario` commands by enumerating
24
120
  * exported function names and file-key prefixes from the loaded scenario modules.
25
121
  */
26
- export function createCompleter(registry, fallback, scenarioRegistry) {
122
+ export function createCompleter(registry, fallback, openApiDocument, scenarioRegistry, groupedScenarioRegistries) {
123
+ const routes = getRoutesForCompletion(registry, openApiDocument);
27
124
  return (line, callback) => {
28
- // Check for .scenario completion: .scenario <partial>
29
- const applyMatch = line.match(/^\.scenario\s+(?<partial>\S*)$/u);
30
- if (applyMatch) {
31
- const partial = applyMatch.groups?.["partial"] ?? "";
32
- if (scenarioRegistry !== undefined) {
33
- const slashIdx = partial.lastIndexOf("/");
34
- if (slashIdx === -1) {
35
- // No slash: complete exports from "index" key + top-level file prefixes
36
- const indexFunctions = scenarioRegistry.getExportedFunctionNames("index");
37
- const fileKeys = scenarioRegistry
38
- .getFileKeys()
39
- .filter((k) => k !== "index");
40
- const topLevelPrefixes = [
41
- ...new Set(fileKeys.map((k) => k.split("/")[0] + "/")),
42
- ];
43
- const allOptions = [...indexFunctions, ...topLevelPrefixes];
44
- const matches = allOptions.filter((c) => c.startsWith(partial));
45
- callback(null, [matches, partial]);
46
- }
47
- else {
48
- // Has slash: complete exports from the named file key
49
- const fileKey = partial.slice(0, slashIdx);
50
- const funcPartial = partial.slice(slashIdx + 1);
51
- const functions = scenarioRegistry.getExportedFunctionNames(fileKey);
52
- const matches = functions
53
- .filter((e) => e.startsWith(funcPartial))
54
- .map((e) => `${fileKey}/${e}`);
55
- callback(null, [matches, partial]);
56
- }
57
- }
58
- else {
59
- callback(null, [[], partial]);
60
- }
125
+ const scenarioCompletions = getScenarioCompletions(line, scenarioRegistry, groupedScenarioRegistries);
126
+ if (scenarioCompletions !== undefined) {
127
+ callback(null, scenarioCompletions);
61
128
  return;
62
129
  }
63
- // Check for RouteBuilder method completion: route("..."). or chained calls
64
- const builderMatch = line.match(/route\(.*\)\.(?<partial>[a-zA-Z]*)$/u);
65
- if (builderMatch) {
66
- const partial = builderMatch.groups?.["partial"] ?? "";
67
- const matches = ROUTE_BUILDER_METHODS.filter((m) => m.startsWith(partial));
68
- callback(null, [matches, partial]);
130
+ const routeBuilderCompletions = getRouteBuilderMethodCompletions(line);
131
+ if (routeBuilderCompletions !== undefined) {
132
+ callback(null, routeBuilderCompletions);
69
133
  return;
70
134
  }
71
- const match = line.match(/(?:client\.(?:get|post|put|patch|delete)|route)\("(?<partial>[^"]*)$/u);
72
- if (!match) {
135
+ const routeCompletions = getRouteCompletions(line, routes);
136
+ if (routeCompletions === undefined) {
73
137
  if (fallback) {
74
138
  fallback(line, callback);
75
139
  }
@@ -78,10 +142,7 @@ export function createCompleter(registry, fallback, scenarioRegistry) {
78
142
  }
79
143
  return;
80
144
  }
81
- const partial = match.groups?.["partial"] ?? "";
82
- const routes = registry.routes.map((route) => route.path);
83
- const matches = routes.filter((route) => route.startsWith(partial));
84
- callback(null, [matches, partial]);
145
+ callback(null, routeCompletions);
85
146
  };
86
147
  }
87
148
  /**
@@ -103,7 +164,52 @@ export function createCompleter(registry, fallback, scenarioRegistry) {
103
164
  * @param scenarioRegistry - Optional scenario registry for `.scenario` support.
104
165
  * @returns The configured Node.js REPL server instance.
105
166
  */
106
- export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument, scenarioRegistry) {
167
+ export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument, scenarioRegistry, apiBindings) {
168
+ const bindings = apiBindings === undefined || apiBindings.length === 0
169
+ ? [
170
+ {
171
+ contextRegistry,
172
+ group: "",
173
+ openApiDocument,
174
+ registry,
175
+ scenarioRegistry,
176
+ },
177
+ ]
178
+ : apiBindings;
179
+ const isMultiApi = bindings.length > 1;
180
+ const groupedBindings = bindings.map((binding) => ({
181
+ ...binding,
182
+ key: binding.group.trim(),
183
+ }));
184
+ if (isMultiApi) {
185
+ const invalidBindings = groupedBindings.filter((binding) => binding.key === "");
186
+ if (invalidBindings.length > 0) {
187
+ throw new Error("Each API binding must define a non-empty group when multiple APIs are configured.");
188
+ }
189
+ const seenGroups = new Set();
190
+ const duplicateGroups = new Set();
191
+ for (const binding of groupedBindings) {
192
+ if (seenGroups.has(binding.key)) {
193
+ duplicateGroups.add(binding.key);
194
+ }
195
+ seenGroups.add(binding.key);
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
+ }
201
+ const rootBinding = groupedBindings[0];
202
+ if (rootBinding === undefined) {
203
+ throw new Error("startRepl requires at least one API binding");
204
+ }
205
+ const groupedLoadContext = Object.fromEntries(groupedBindings.map((binding) => [
206
+ binding.key,
207
+ (path) => binding.contextRegistry.find(path),
208
+ ]));
209
+ const groupedRoute = Object.fromEntries(groupedBindings.map((binding) => [
210
+ binding.key,
211
+ createRouteFunction(config.port, "localhost", binding.openApiDocument),
212
+ ]));
107
213
  function printProxyStatus() {
108
214
  if (config.proxyUrl === "") {
109
215
  print("The proxy URL is not set.");
@@ -147,7 +253,12 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
147
253
  const builtinCompleter = replServer.completer;
148
254
  // completer is typed as readonly in @types/node but is writable at runtime
149
255
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
150
- replServer.completer = createCompleter(registry, builtinCompleter, scenarioRegistry);
256
+ replServer.completer = createCompleter(rootBinding.registry, builtinCompleter, rootBinding.openApiDocument, rootBinding.scenarioRegistry, isMultiApi
257
+ ? Object.fromEntries(groupedBindings.map((binding) => [
258
+ binding.key,
259
+ binding.scenarioRegistry,
260
+ ]))
261
+ : undefined);
151
262
  replServer.defineCommand("counterfact", {
152
263
  action() {
153
264
  print("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.");
@@ -186,17 +297,63 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
186
297
  },
187
298
  help: 'proxy configuration (".proxy help" for details)',
188
299
  });
189
- replServer.context.loadContext = (path) => contextRegistry.find(path);
190
- replServer.context.context = replServer.context.loadContext("/");
300
+ replServer.context.loadContext = isMultiApi
301
+ ? groupedLoadContext
302
+ : groupedLoadContext[rootBinding.key];
303
+ replServer.context.context = isMultiApi
304
+ ? Object.fromEntries(groupedBindings.map((binding) => [
305
+ binding.key,
306
+ binding.contextRegistry.find("/"),
307
+ ]))
308
+ : rootBinding.contextRegistry.find("/");
191
309
  replServer.context.client = new RawHttpClient("localhost", config.port);
192
310
  replServer.context.RawHttpClient = RawHttpClient;
193
- replServer.context.route = createRouteFunction(config.port, "localhost", openApiDocument);
194
- replServer.context.routes = {};
311
+ replServer.context.route = isMultiApi
312
+ ? groupedRoute
313
+ : groupedRoute[rootBinding.key];
314
+ replServer.context.routes = isMultiApi
315
+ ? Object.fromEntries(groupedBindings.map((binding) => [binding.key, {}]))
316
+ : {};
195
317
  replServer.defineCommand("scenario", {
196
318
  async action(text) {
197
- const parts = text.trim().split("/").filter(Boolean);
319
+ const trimmedText = text.trim();
320
+ const parsedArgs = trimmedText.split(/\s+/u).filter(Boolean);
321
+ const usage = isMultiApi
322
+ ? "usage: .scenario <group> <path>"
323
+ : "usage: .scenario <path>";
324
+ const { selectedBinding, scenarioPath } = (() => {
325
+ if (!isMultiApi) {
326
+ if (trimmedText === "") {
327
+ return { scenarioPath: undefined, selectedBinding: undefined };
328
+ }
329
+ return { scenarioPath: trimmedText, selectedBinding: rootBinding };
330
+ }
331
+ if (parsedArgs.length !== 2) {
332
+ return { scenarioPath: undefined, selectedBinding: undefined };
333
+ }
334
+ return {
335
+ scenarioPath: parsedArgs[1],
336
+ selectedBinding: groupedBindings.find((binding) => binding.key === parsedArgs[0]),
337
+ };
338
+ })();
339
+ if (selectedBinding === undefined || scenarioPath === undefined) {
340
+ if (isMultiApi &&
341
+ scenarioPath !== undefined &&
342
+ selectedBinding === undefined) {
343
+ const groupName = parsedArgs[0] ?? "";
344
+ const availableGroups = groupedBindings.map((binding) => binding.key);
345
+ print(`Error: Unknown API group "${groupName}". Available groups: ${availableGroups.join(", ")}`);
346
+ }
347
+ else {
348
+ print(usage);
349
+ }
350
+ this.clearBufferedCommand();
351
+ this.displayPrompt();
352
+ return;
353
+ }
354
+ const parts = scenarioPath.split("/").filter(Boolean);
198
355
  if (parts.length === 0) {
199
- print("usage: .scenario <path>");
356
+ print(usage);
200
357
  this.clearBufferedCommand();
201
358
  this.displayPrompt();
202
359
  return;
@@ -209,7 +366,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
209
366
  }
210
367
  const functionName = parts[parts.length - 1] ?? "";
211
368
  const fileKey = parts.length === 1 ? "index" : parts.slice(0, -1).join("/");
212
- const module = scenarioRegistry?.getModule(fileKey);
369
+ const module = selectedBinding.scenarioRegistry?.getModule(fileKey);
213
370
  if (module === undefined) {
214
371
  print(`Error: Could not find scenario file "${fileKey}"`);
215
372
  this.clearBufferedCommand();
@@ -224,11 +381,20 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
224
381
  return;
225
382
  }
226
383
  try {
384
+ const selectedRoutes = isMultiApi
385
+ ? replServer.context["routes"][selectedBinding.key]
386
+ : replServer.context["routes"];
387
+ if (isMultiApi && selectedRoutes === undefined) {
388
+ print(`Error: Could not resolve routes for API group "${selectedBinding.key}"`);
389
+ this.clearBufferedCommand();
390
+ this.displayPrompt();
391
+ return;
392
+ }
227
393
  const applyContext = {
228
- context: replServer.context["context"],
229
- loadContext: replServer.context["loadContext"],
230
- route: replServer.context["route"],
231
- routes: replServer.context["routes"],
394
+ context: selectedBinding.contextRegistry.find("/"),
395
+ loadContext: ((path) => selectedBinding.contextRegistry.find(path)),
396
+ route: groupedRoute[selectedBinding.key],
397
+ routes: selectedRoutes,
232
398
  };
233
399
  await fn(applyContext);
234
400
  print(`Applied ${text.trim()}`);
@@ -239,7 +405,9 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
239
405
  this.clearBufferedCommand();
240
406
  this.displayPrompt();
241
407
  },
242
- help: 'apply a scenario script (".scenario <path>" calls the named export from scenarios/)',
408
+ help: isMultiApi
409
+ ? 'apply a scenario script (".scenario <group> <path>" calls the named export from that group\'s scenarios/)'
410
+ : 'apply a scenario script (".scenario <path>" calls the named export from scenarios/)',
243
411
  });
244
412
  return replServer;
245
413
  }
@@ -157,8 +157,7 @@ export type GenericResponseBuilder<
157
157
  : object extends OmitValueWhenNever<Omit<Response, "examples">>
158
158
  ? COUNTERFACT_RESPONSE
159
159
  : keyof OmitValueWhenNever<Omit<Response, "examples">> extends "headers"
160
- ? {
161
- ALL_REMAINING_HEADERS_ARE_OPTIONAL: COUNTERFACT_RESPONSE;
160
+ ? COUNTERFACT_RESPONSE & {
162
161
  header: HeaderFunction<Response>;
163
162
  }
164
163
  : GenericResponseBuilderInner<Response>;
@@ -5,12 +5,15 @@
5
5
  * argument object.
6
6
  */
7
7
  export interface OpenApiParameters {
8
+ explode?: boolean;
8
9
  in: "body" | "cookie" | "formData" | "header" | "path" | "query";
9
10
  name: string;
10
11
  required?: boolean;
11
12
  schema?: {
12
13
  [key: string]: unknown;
14
+ properties?: Record<string, unknown>;
13
15
  type?: string;
14
16
  };
17
+ style?: string;
15
18
  type?: "string" | "number" | "integer" | "boolean";
16
19
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ /* eslint-disable security/detect-non-literal-fs-filename -- module kind detection only probes package.json while walking parent directories. */
4
5
  const DEFAULT_MODULE_KIND = "commonjs";
5
6
  /**
6
7
  * Determines whether a module file should be treated as CommonJS or ESM.
@@ -2,7 +2,7 @@ import { mediaTypes } from "@hapi/accept";
2
2
  import createDebugger from "debug";
3
3
  import fetch, { Headers } from "node-fetch";
4
4
  import { createResponseBuilder } from "./response-builder.js";
5
- import { validateRequest } from "./request-validator.js";
5
+ import { isExplodedObjectQueryParam, validateRequest, } from "./request-validator.js";
6
6
  import { validateResponse } from "./response-validator.js";
7
7
  import { Tools } from "./tools.js";
8
8
  const debug = createDebugger("counterfact:server:dispatcher");
@@ -36,6 +36,45 @@ function parseCookies(cookieHeader) {
36
36
  }
37
37
  return cookies;
38
38
  }
39
+ /**
40
+ * Gathers exploded object query parameters back into a nested object under the
41
+ * parameter's own name.
42
+ *
43
+ * Per OpenAPI 3.x, a query parameter of type `object` with `style: form` and
44
+ * `explode: true` (both defaults for query params) is serialised as individual
45
+ * query parameters — one per object property. This function reconstructs the
46
+ * object so that the route handler can access `$.query.<paramName>` rather than
47
+ * only the individual flat parameters.
48
+ *
49
+ * Properties that are "claimed" by an object parameter are removed from the
50
+ * top-level map and placed under the parameter name. Unclaimed keys remain at
51
+ * the top level.
52
+ *
53
+ * @param query - The raw parsed query-string map from the HTTP request.
54
+ * @param parameters - The OpenAPI parameter definitions for the current operation.
55
+ * @returns A new map with exploded object parameters reconstructed as nested objects.
56
+ */
57
+ export function collectExplodedObjectParams(query, parameters) {
58
+ const result = { ...query };
59
+ for (const parameter of parameters) {
60
+ if (!isExplodedObjectQueryParam(parameter))
61
+ continue;
62
+ const properties = parameter.schema?.properties;
63
+ if (!properties)
64
+ continue;
65
+ const obj = {};
66
+ for (const key of Object.keys(properties)) {
67
+ if (key in result) {
68
+ obj[key] = result[key];
69
+ delete result[key];
70
+ }
71
+ }
72
+ if (Object.keys(obj).length > 0) {
73
+ result[parameter.name] = obj;
74
+ }
75
+ }
76
+ return result;
77
+ }
39
78
  /**
40
79
  * Core HTTP request dispatcher.
41
80
  *
@@ -208,6 +247,9 @@ export class Dispatcher {
208
247
  };
209
248
  }
210
249
  }
250
+ // Reconstruct exploded object query parameters so that `$.query.<name>`
251
+ // contains the assembled object instead of only the individual flat keys.
252
+ const processedQuery = collectExplodedObjectParams(query, operation?.parameters ?? []);
211
253
  const continuousDistribution = (min, max) => {
212
254
  return min + Math.random() * (max - min);
213
255
  };
@@ -238,7 +280,8 @@ export class Dispatcher {
238
280
  status: fetchResponse.status,
239
281
  };
240
282
  },
241
- query,
283
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- object params are reconstructed and typed by generated route types
284
+ query: processedQuery,
242
285
  // @ts-expect-error - Might be pushing the limits of what TypeScript can do here
243
286
  response: createResponseBuilder(operation ?? { responses: {} }, this.config), // Pass config
244
287
  tools: new Tools({ headers }),
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
+ /* eslint-disable security/detect-non-literal-fs-filename -- discovery walks directories rooted at basePath and uses Dirent-provided names. */
3
4
  import { toForwardSlashPath, pathJoin } from "../util/forward-slash-path.js";
4
5
  import { escapePathForWindows } from "../util/windows-escape.js";
5
6
  const JS_EXTENSIONS = new Set(["cjs", "cts", "js", "mjs", "mts", "ts"]);
@@ -1,6 +1,7 @@
1
1
  import { once } from "node:events";
2
2
  import fs from "node:fs/promises";
3
3
  import nodePath, { basename } from "node:path";
4
+ /* eslint-disable security/detect-non-literal-fs-filename -- readJson resolves paths against the current context directory before file access. */
4
5
  import { watch } from "chokidar";
5
6
  import createDebug from "debug";
6
7
  import { CHOKIDAR_OPTIONS } from "./constants.js";
@@ -185,6 +186,13 @@ export class ModuleLoader extends EventTarget {
185
186
  const isSyntaxError = importError instanceof SyntaxError ||
186
187
  String(importError).startsWith("SyntaxError:");
187
188
  const displayPath = pathRelative(process.cwd(), unescapePathForWindows(pathName));
189
+ if (this.isContextFile(pathName)) {
190
+ const warning = isSyntaxError
191
+ ? `Warning: There is a syntax error in the context file: ${displayPath}`
192
+ : `Warning: There was an error loading the context file: ${displayPath}`;
193
+ process.stdout.write(`\n${warning}\n`);
194
+ return;
195
+ }
188
196
  const message = isSyntaxError
189
197
  ? `There is a syntax error in the route file: ${displayPath}`
190
198
  : `There was an error loading the route file: ${displayPath}`;
@@ -4,10 +4,51 @@ const ajv = new Ajv({
4
4
  strict: false,
5
5
  coerceTypes: false,
6
6
  });
7
+ /**
8
+ * Returns `true` when a query parameter should be serialized as exploded form
9
+ * style — meaning its object properties appear as individual query parameters
10
+ * instead of being passed under the parameter's own name.
11
+ *
12
+ * Per OpenAPI 3.x: for `in: query`, the default `style` is `form` and the
13
+ * default `explode` for `form` is `true`. An object-type parameter with these
14
+ * defaults (or with them set explicitly) uses exploded form serialization.
15
+ */
16
+ export function isExplodedObjectQueryParam(parameter) {
17
+ if (parameter.in !== "query")
18
+ return false;
19
+ const schema = parameter.schema;
20
+ if (!schema)
21
+ return false;
22
+ // Must be an object type (explicit type or implied by presence of properties)
23
+ const isObjectType = schema.type === "object" || schema.properties !== undefined;
24
+ if (!isObjectType)
25
+ return false;
26
+ // style must be "form" (the default for query params) or unset
27
+ if (parameter.style !== undefined && parameter.style !== "form")
28
+ return false;
29
+ // explode must not be explicitly false (default for form style is true)
30
+ if (parameter.explode === false)
31
+ return false;
32
+ return true;
33
+ }
7
34
  function findMissingRequired(parameters, location, values) {
8
35
  return parameters
9
36
  .filter((p) => p.in === location && p.required === true)
10
- .filter((p) => !(p.name in values) || values[p.name] === undefined)
37
+ .filter((p) => {
38
+ // For exploded object query params the individual properties appear as
39
+ // separate query parameters, so we check for those instead of the name.
40
+ if (location === "query" && isExplodedObjectQueryParam(p)) {
41
+ const properties = p.schema?.properties;
42
+ if (!properties) {
43
+ // Free-form object with no declared properties: treat as always
44
+ // satisfied — we cannot know which keys belong to the parameter.
45
+ return false;
46
+ }
47
+ // The parameter is "missing" only when none of its properties are present.
48
+ return !Object.keys(properties).some((key) => key in values);
49
+ }
50
+ return !(p.name in values) || values[p.name] === undefined;
51
+ })
11
52
  .map((p) => `${location} parameter '${p.name}' is required`);
12
53
  }
13
54
  export function validateRequest(operation, request) {
@@ -1,6 +1,7 @@
1
1
  // Stryker disable all
2
2
  import { once } from "node:events";
3
3
  import fs from "node:fs/promises";
4
+ /* eslint-disable security/detect-non-literal-fs-filename -- transpiler consumes watched source files and writes paired outputs under configured directories. */
4
5
  import { watch as chokidarWatch } from "chokidar";
5
6
  import createDebug from "debug";
6
7
  import ts from "typescript";
@@ -24,16 +24,25 @@ function isLoopbackIp(ip) {
24
24
  /**
25
25
  * Admin API middleware for programmatic access to Counterfact internals.
26
26
  * Exposes context management, proxy configuration, and route discovery
27
- * through HTTP endpoints at /_counterfact/api/*
27
+ * through HTTP endpoints at the given path prefix.
28
28
  *
29
29
  * This enables AI agents and external tools to interact with the mock server
30
30
  * in the same way the REPL does, but via HTTP requests.
31
+ *
32
+ * @param pathPrefix - The URL path prefix at which the admin API is mounted,
33
+ * e.g. `"/_counterfact/api"`. Requests to paths that do not start with this
34
+ * prefix fall through to the next middleware.
35
+ * @param registry - The route registry used to list available routes.
36
+ * @param contextRegistry - The context registry used to read and update
37
+ * per-path context objects.
38
+ * @param config - Server configuration (proxy settings, port, etc.).
39
+ * @returns A Koa middleware function.
31
40
  */
32
- export function adminApiMiddleware(registry, contextRegistry, config) {
41
+ export function adminApiMiddleware(pathPrefix, registry, contextRegistry, config) {
33
42
  return async (ctx, next) => {
34
43
  const { pathname } = ctx.URL;
35
- // Only handle admin API routes
36
- if (!pathname.startsWith("/_counterfact/api/")) {
44
+ // Only handle admin API routes (exact prefix or paths beneath it)
45
+ if (pathname !== pathPrefix && !pathname.startsWith(`${pathPrefix}/`)) {
37
46
  return await next();
38
47
  }
39
48
  // ===== Admin API Access Guard =====
@@ -72,9 +81,10 @@ export function adminApiMiddleware(registry, contextRegistry, config) {
72
81
  }
73
82
  }
74
83
  debug("Admin API request: %s %s", ctx.method, pathname);
75
- // Extract route components: ["_counterfact", "api", "resource", ...rest]
76
- const parts = pathname.split("/").filter(Boolean);
77
- const [, , resource, ...rest] = parts;
84
+ // Extract the resource path after the prefix
85
+ const subPath = pathname.slice(pathPrefix.length);
86
+ const parts = subPath.split("/").filter(Boolean);
87
+ const [resource, ...rest] = parts;
78
88
  try {
79
89
  // ===== Health Check =====
80
90
  if (resource === "health" && ctx.method === "GET") {
@@ -83,7 +93,7 @@ export function adminApiMiddleware(registry, contextRegistry, config) {
83
93
  port: config.port,
84
94
  uptime: process.uptime(),
85
95
  basePath: config.basePath,
86
- routePrefix: config.routePrefix,
96
+ prefix: config.prefix,
87
97
  };
88
98
  return;
89
99
  }
@@ -155,7 +165,7 @@ export function adminApiMiddleware(registry, contextRegistry, config) {
155
165
  openApiPath: config.openApiPath,
156
166
  port: config.port,
157
167
  proxyUrl: config.proxyUrl,
158
- routePrefix: config.routePrefix,
168
+ prefix: config.prefix,
159
169
  startAdminApi: config.startAdminApi,
160
170
  startRepl: config.startRepl,
161
171
  startServer: config.startServer,