counterfact 2.3.0 → 2.4.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.
@@ -47,7 +47,7 @@ function padTagLine(tagLine) {
47
47
  }
48
48
 
49
49
  function createWatchMessage(config) {
50
- let watchMessage = "";
50
+ let watchMessage;
51
51
 
52
52
  switch (true) {
53
53
  case config.watch.routes && config.watch.types: {
@@ -113,8 +113,6 @@ async function main(source, destination) {
113
113
  source = options.spec;
114
114
  }
115
115
 
116
- const args = process.argv;
117
-
118
116
  const destinationPath = nodePath.resolve(destination).replaceAll("\\", "/");
119
117
 
120
118
  const basePath = nodePath.resolve(destinationPath).replaceAll("\\", "/");
@@ -192,9 +190,8 @@ async function main(source, destination) {
192
190
  debug("loading counterfact (%o)", configForLogging);
193
191
 
194
192
  let didMigrate = false;
195
- let didMigrateRouteTypes = false;
193
+ let didMigrateRouteTypes;
196
194
 
197
- // eslint-disable-next-line n/no-sync
198
195
  if (fs.existsSync(nodePath.join(config.basePath, "paths"))) {
199
196
  await pathsToRoutes(config.basePath);
200
197
  await fs.promises.rmdir(nodePath.join(config.basePath, "paths"), {
@@ -210,7 +207,7 @@ async function main(source, destination) {
210
207
  didMigrate = true;
211
208
  }
212
209
 
213
- const { start } = await counterfact(config);
210
+ const { start, startRepl } = await counterfact(config);
214
211
 
215
212
  debug("loaded counterfact", configForLogging);
216
213
 
@@ -257,6 +254,10 @@ async function main(source, destination) {
257
254
  await start(config);
258
255
  debug("started server");
259
256
 
257
+ if (config.startRepl) {
258
+ startRepl();
259
+ }
260
+
260
261
  if (openBrowser) {
261
262
  debug("opening browser");
262
263
  await open(guiUrl);
package/dist/app.js CHANGED
@@ -2,8 +2,7 @@ import fs, { rm } from "node:fs/promises";
2
2
  import nodePath from "node:path";
3
3
  import { dereference } from "@apidevtools/json-schema-ref-parser";
4
4
  import { createHttpTerminator } from "http-terminator";
5
- import yaml from "js-yaml";
6
- import { startRepl } from "./repl/repl.js";
5
+ import { startRepl as startReplServer } from "./repl/repl.js";
7
6
  import { ContextRegistry } from "./server/context-registry.js";
8
7
  import { createKoaApp } from "./server/create-koa-app.js";
9
8
  import { Dispatcher, } from "./server/dispatcher.js";
@@ -12,7 +11,6 @@ import { ModuleLoader } from "./server/module-loader.js";
12
11
  import { Registry } from "./server/registry.js";
13
12
  import { Transpiler } from "./server/transpiler.js";
14
13
  import { CodeGenerator } from "./typescript-generator/code-generator.js";
15
- import { readFile } from "./util/read-file.js";
16
14
  const allowedMethods = [
17
15
  "all",
18
16
  "head",
@@ -25,9 +23,7 @@ const allowedMethods = [
25
23
  ];
26
24
  export async function loadOpenApiDocument(source) {
27
25
  try {
28
- const text = await readFile(source);
29
- const openApiDocument = await yaml.load(text);
30
- return (await dereference(openApiDocument));
26
+ return (await dereference(source));
31
27
  }
32
28
  catch {
33
29
  return undefined;
@@ -46,7 +42,7 @@ export async function handleMswRequest(request) {
46
42
  export async function createMswHandlers(config, ModuleLoaderClass = ModuleLoader) {
47
43
  // TODO: For some reason the Vitest Custom Commands needed by Vitest Browser mode fail on fs.readFile when they are called from the nested loadOpenApiDocument function.
48
44
  // If we "pre-read" the file here it works. This is a workaround to avoid the issue.
49
- const _ = await fs.readFile(config.openApiPath);
45
+ await fs.readFile(config.openApiPath);
50
46
  const openApiDocument = await loadOpenApiDocument(config.openApiPath);
51
47
  if (openApiDocument === undefined) {
52
48
  throw new Error(`Could not load OpenAPI document from ${config.openApiPath}`);
@@ -92,7 +88,7 @@ export async function counterfact(config) {
92
88
  const middleware = koaMiddleware(dispatcher, config);
93
89
  const koaApp = createKoaApp(registry, middleware, config, contextRegistry);
94
90
  async function start(options) {
95
- const { generate, startRepl: shouldStartRepl, startServer, watch, buildCache, } = options;
91
+ const { generate, startServer, watch, buildCache } = options;
96
92
  if (generate.routes || generate.types) {
97
93
  await codeGenerator.generate();
98
94
  }
@@ -116,9 +112,7 @@ export async function counterfact(config) {
116
112
  await transpiler.watch();
117
113
  await transpiler.stopWatching();
118
114
  }
119
- const replServer = shouldStartRepl && startRepl(contextRegistry, config);
120
115
  return {
121
- replServer,
122
116
  async stop() {
123
117
  await codeGenerator.stopWatching();
124
118
  await transpiler.stopWatching();
@@ -133,5 +127,6 @@ export async function counterfact(config) {
133
127
  koaMiddleware: middleware,
134
128
  registry,
135
129
  start,
130
+ startRepl: () => startReplServer(contextRegistry, registry, config),
136
131
  };
137
132
  }
@@ -1,5 +1,2 @@
1
1
  const counterfactResponse = Symbol("Counterfact Response");
2
- const counterfactResponseObject = {
3
- [counterfactResponse]: counterfactResponse,
4
- };
5
2
  export {};
@@ -94,7 +94,7 @@ async function updateRouteFile(filePath, methodToTypeName) {
94
94
  // Build a map of old type names to new type names found in this file
95
95
  const replacements = new Map();
96
96
  // Find all import statements with HTTP_ patterns
97
- const importRegex = /import\s+type\s+\{(?<types>[^}]+)\}\s+from\s+["'](?<source>[^"']+)["'];?/gu;
97
+ const importRegex = /import\s+type\s+\{(?<types>[^}]+)\}\s+from\s+["'][^"']+["'];?/gu;
98
98
  let importMatch;
99
99
  while ((importMatch = importRegex.exec(content)) !== null) {
100
100
  const importedTypes = importMatch.groups.types
package/dist/repl/repl.js CHANGED
@@ -3,7 +3,25 @@ import { RawHttpClient } from "./RawHttpClient.js";
3
3
  function printToStdout(line) {
4
4
  process.stdout.write(`${line}\n`);
5
5
  }
6
- export function startRepl(contextRegistry, config, print = printToStdout) {
6
+ export function createCompleter(registry, fallback) {
7
+ return (line, callback) => {
8
+ const match = line.match(/client\.(?:get|post|put|patch|delete)\("(?<partial>[^"]*)$/u);
9
+ if (!match) {
10
+ if (fallback) {
11
+ fallback(line, callback);
12
+ }
13
+ else {
14
+ callback(null, [[], line]);
15
+ }
16
+ return;
17
+ }
18
+ const partial = match.groups?.["partial"] ?? "";
19
+ const routes = registry.routes.map((route) => route.path);
20
+ const matches = routes.filter((route) => route.startsWith(partial));
21
+ callback(null, [matches, partial]);
22
+ };
23
+ }
24
+ export function startRepl(contextRegistry, registry, config, print = printToStdout) {
7
25
  function printProxyStatus() {
8
26
  if (config.proxyUrl === "") {
9
27
  print("The proxy URL is not set.");
@@ -41,7 +59,13 @@ export function startRepl(contextRegistry, config, print = printToStdout) {
41
59
  print(`Requests to ${printEndpoint} will be handled by local code`);
42
60
  }
43
61
  }
44
- const replServer = repl.start({ prompt: "⬣> " });
62
+ const replServer = repl.start({
63
+ prompt: "⬣> ",
64
+ });
65
+ const builtinCompleter = replServer.completer;
66
+ // completer is typed as readonly in @types/node but is writable at runtime
67
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
+ replServer.completer = createCompleter(registry, builtinCompleter);
45
69
  replServer.defineCommand("counterfact", {
46
70
  action() {
47
71
  print("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.");
@@ -118,10 +118,10 @@ export function adminApiMiddleware(registry, contextRegistry, config) {
118
118
  // ===== Update Context =====
119
119
  if (resource === "contexts" && rest.length > 0 && ctx.method === "POST") {
120
120
  const path = "/" + rest.join("/");
121
- const newContext = ctx.request.body;
122
- if (!newContext ||
123
- typeof newContext !== "object" ||
124
- Array.isArray(newContext)) {
121
+ const newContextCandidate = ctx.request.body;
122
+ if (!newContextCandidate ||
123
+ typeof newContextCandidate !== "object" ||
124
+ Array.isArray(newContextCandidate)) {
125
125
  ctx.status = 400;
126
126
  ctx.body = {
127
127
  success: false,
@@ -130,6 +130,7 @@ export function adminApiMiddleware(registry, contextRegistry, config) {
130
130
  return;
131
131
  }
132
132
  // Update the context using the registry's smart diffing
133
+ const newContext = newContextCandidate;
133
134
  contextRegistry.update(path, newContext);
134
135
  ctx.body = {
135
136
  success: true,
@@ -10,14 +10,22 @@ interface Example {
10
10
  value: unknown;
11
11
  }
12
12
 
13
+ interface CookieOptions {
14
+ domain?: string;
15
+ expires?: Date;
16
+ httpOnly?: boolean;
17
+ maxAge?: number;
18
+ path?: string;
19
+ sameSite?: "lax" | "none" | "strict";
20
+ secure?: boolean;
21
+ }
22
+
13
23
  const counterfactResponse = Symbol("Counterfact Response");
14
24
 
15
- const counterfactResponseObject = {
16
- [counterfactResponse]: counterfactResponse,
25
+ type COUNTERFACT_RESPONSE = {
26
+ [counterfactResponse]: typeof counterfactResponse;
17
27
  };
18
28
 
19
- type COUNTERFACT_RESPONSE = typeof counterfactResponseObject;
20
-
21
29
  type MediaType = `${string}/${string}`;
22
30
 
23
31
  type MaybePromise<T> = T | Promise<T>;
@@ -55,12 +63,12 @@ type IfHasKey<
55
63
  infer FirstKey extends string,
56
64
  ...infer RestKeys extends string[],
57
65
  ]
58
- ? keyof SomeObject extends FirstKey
59
- ? Yes
60
- : IfHasKey<SomeObject, RestKeys, Yes, No>
66
+ ? Extract<keyof SomeObject, `${string}${FirstKey}${string}`> extends never
67
+ ? IfHasKey<SomeObject, RestKeys, Yes, No>
68
+ : Yes
61
69
  : No;
62
70
 
63
- type SchemasOf<T extends { [key: string]: { schema: any } }> = {
71
+ type SchemasOf<T extends { [key: string]: { schema: unknown } }> = {
64
72
  [K in keyof T]: T[K]["schema"];
65
73
  }[keyof T];
66
74
 
@@ -78,7 +86,7 @@ type MaybeShortcut<
78
86
  never
79
87
  >;
80
88
 
81
- type NeverIfEmpty<Record> = {} extends Record ? never : Record;
89
+ type NeverIfEmpty<Record> = object extends Record ? never : Record;
82
90
 
83
91
  type MatchFunction<Response extends OpenApiResponse> = <
84
92
  ContentType extends MediaType & keyof Response["content"],
@@ -102,9 +110,7 @@ type HeaderFunction<Response extends OpenApiResponse> = <
102
110
  requiredHeaders: Exclude<Response["requiredHeaders"], Header>;
103
111
  }>;
104
112
 
105
- type RandomFunction<Response extends OpenApiResponse> = <
106
- Header extends string & keyof Response["headers"],
107
- >() => COUNTERFACT_RESPONSE;
113
+ type RandomFunction = () => MaybePromise<COUNTERFACT_RESPONSE>;
108
114
 
109
115
  type ExampleNames<Response extends OpenApiResponse> = Response extends {
110
116
  examples: infer E;
@@ -114,15 +120,21 @@ type ExampleNames<Response extends OpenApiResponse> = Response extends {
114
120
 
115
121
  interface ResponseBuilder {
116
122
  [status: number | `${number} ${string}`]: ResponseBuilder;
123
+ binary: (body: Uint8Array | string) => ResponseBuilder;
117
124
  content?: { body: unknown; type: string }[];
125
+ cookie: (
126
+ name: string,
127
+ value: string,
128
+ options?: CookieOptions,
129
+ ) => ResponseBuilder;
118
130
  example: (name: string) => ResponseBuilder;
119
131
  header: (name: string, value: string) => ResponseBuilder;
120
- headers: { [name: string]: string };
132
+ headers: { [name: string]: string | string[] };
121
133
  html: (body: unknown) => ResponseBuilder;
122
134
  json: (body: unknown) => ResponseBuilder;
123
135
  match: (contentType: string, body: unknown) => ResponseBuilder;
124
- random: () => ResponseBuilder;
125
- randomLegacy: () => ResponseBuilder;
136
+ random: () => MaybePromise<ResponseBuilder>;
137
+ randomLegacy: () => MaybePromise<ResponseBuilder>;
126
138
  status?: number;
127
139
  text: (body: unknown) => ResponseBuilder;
128
140
  xml: (body: unknown) => ResponseBuilder;
@@ -131,6 +143,12 @@ interface ResponseBuilder {
131
143
  export type GenericResponseBuilderInner<
132
144
  Response extends OpenApiResponse = OpenApiResponse,
133
145
  > = OmitValueWhenNever<{
146
+ binary: MaybeShortcut<["application/octet-stream"], Response>;
147
+ cookie: (
148
+ name: string,
149
+ value: string,
150
+ options?: CookieOptions,
151
+ ) => GenericResponseBuilder<Response>;
134
152
  header: [keyof Response["headers"]] extends [never]
135
153
  ? never
136
154
  : HeaderFunction<Response>;
@@ -148,9 +166,7 @@ export type GenericResponseBuilderInner<
148
166
  match: [keyof Response["content"]] extends [never]
149
167
  ? never
150
168
  : MatchFunction<Response>;
151
- random: [keyof Response["content"]] extends [never]
152
- ? never
153
- : RandomFunction<Response>;
169
+ random: [keyof Response["content"]] extends [never] ? never : RandomFunction;
154
170
  example: [ExampleNames<Response>] extends [never]
155
171
  ? never
156
172
  : (name: ExampleNames<Response>) => COUNTERFACT_RESPONSE;
@@ -266,12 +282,18 @@ interface OpenApiOperation {
266
282
  }
267
283
 
268
284
  interface WideResponseBuilder {
285
+ binary: (body: Uint8Array | string) => WideResponseBuilder;
269
286
  example: (name: string) => WideResponseBuilder;
287
+ cookie: (
288
+ name: string,
289
+ value: string,
290
+ options?: CookieOptions,
291
+ ) => WideResponseBuilder;
270
292
  header: (body: unknown) => WideResponseBuilder;
271
293
  html: (body: unknown) => WideResponseBuilder;
272
294
  json: (body: unknown) => WideResponseBuilder;
273
295
  match: (contentType: string, body: unknown) => WideResponseBuilder;
274
- random: () => WideResponseBuilder;
296
+ random: () => MaybePromise<WideResponseBuilder>;
275
297
  text: (body: unknown) => WideResponseBuilder;
276
298
  xml: (body: unknown) => WideResponseBuilder;
277
299
  }
@@ -289,6 +311,7 @@ interface WideOperationArgument {
289
311
  export type { COUNTERFACT_RESPONSE };
290
312
 
291
313
  export type {
314
+ CookieOptions,
292
315
  ExampleNames,
293
316
  HttpStatusCode,
294
317
  MaybePromise,
@@ -1,4 +1,3 @@
1
- /* eslint-disable n/no-sync */
2
1
  import { existsSync } from "node:fs";
3
2
  import fs from "node:fs/promises";
4
3
  import path from "node:path";
@@ -4,6 +4,26 @@ import fetch, { Headers } from "node-fetch";
4
4
  import { createResponseBuilder } from "./response-builder.js";
5
5
  import { Tools } from "./tools.js";
6
6
  const debug = createDebugger("counterfact:server:dispatcher");
7
+ function parseCookies(cookieHeader) {
8
+ const cookies = {};
9
+ for (const part of cookieHeader.split(";")) {
10
+ const eqIndex = part.indexOf("=");
11
+ if (eqIndex === -1) {
12
+ continue;
13
+ }
14
+ const key = part.slice(0, eqIndex).trim();
15
+ const value = part.slice(eqIndex + 1).trim();
16
+ if (key && !(key in cookies)) {
17
+ try {
18
+ cookies[key] = decodeURIComponent(value);
19
+ }
20
+ catch {
21
+ cookies[key] = value;
22
+ }
23
+ }
24
+ }
25
+ return cookies;
26
+ }
7
27
  export class Dispatcher {
8
28
  registry;
9
29
  contextRegistry;
@@ -143,6 +163,7 @@ export class Dispatcher {
143
163
  : continuousDistribution(milliseconds, maxMilliseconds);
144
164
  return new Promise((resolve) => setTimeout(resolve, delayInMs));
145
165
  },
166
+ cookie: parseCookies(headers.cookie ?? headers.Cookie ?? ""),
146
167
  headers,
147
168
  proxy: async (url) => {
148
169
  if (body !== undefined && headers.contentType !== "application/json") {
@@ -72,10 +72,19 @@ export function koaMiddleware(dispatcher, config, proxy = koaProxy) {
72
72
  req: { path: "", ...ctx.req },
73
73
  });
74
74
  ctx.body = response.body;
75
+ if (response.contentType !== undefined &&
76
+ response.contentType !== "unknown/unknown") {
77
+ ctx.type = response.contentType;
78
+ }
75
79
  if (response.headers) {
76
80
  for (const [key, value] of Object.entries(response.headers)) {
77
81
  if (!HEADERS_TO_DROP.has(key.toLowerCase())) {
78
- ctx.set(key, value.toString());
82
+ if (Array.isArray(value)) {
83
+ ctx.set(key, value);
84
+ }
85
+ else {
86
+ ctx.set(key, value.toString());
87
+ }
79
88
  }
80
89
  }
81
90
  }
@@ -1,4 +1,3 @@
1
- /* eslint-disable n/no-sync */
2
1
  import { once } from "node:events";
3
2
  import { existsSync } from "node:fs";
4
3
  import fs from "node:fs/promises";
@@ -103,7 +102,7 @@ export class ModuleLoader extends EventTarget {
103
102
  const doImport = (await determineModuleKind(pathName)) === "commonjs"
104
103
  ? uncachedRequire
105
104
  : uncachedImport;
106
- const endpoint = (await doImport(pathName).catch((err) => {
105
+ const endpoint = (await doImport(pathName).catch(() => {
107
106
  console.log("ERROR");
108
107
  }));
109
108
  this.dispatchEvent(new Event("add"));
@@ -17,6 +17,7 @@ export class ModuleTree {
17
17
  if (remainingSegments.length === 0) {
18
18
  return directory;
19
19
  }
20
+ const isNewDirectory = directory.directories[segment.toLowerCase()] === undefined;
20
21
  const nextDirectory = (directory.directories[segment.toLowerCase()] ??= {
21
22
  directories: {},
22
23
  files: {},
@@ -24,6 +25,12 @@ export class ModuleTree {
24
25
  name: segment.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
25
26
  rawName: segment,
26
27
  });
28
+ if (isNewDirectory && segment.startsWith("{")) {
29
+ const ambiguousWildcardDirectories = Object.values(directory.directories).filter((subdirectory) => subdirectory.isWildcard);
30
+ if (ambiguousWildcardDirectories.length > 1) {
31
+ process.stderr.write(`[counterfact] ERROR: Ambiguous wildcard paths detected. Multiple wildcard directories exist at the same level: ${ambiguousWildcardDirectories.map((d) => d.rawName).join(", ")}. Requests may be routed unpredictably.\n`);
32
+ }
33
+ }
27
34
  return this.putDirectory(nextDirectory, remainingSegments);
28
35
  }
29
36
  addModuleToDirectory(directory, segments, module) {
@@ -41,6 +48,12 @@ export class ModuleTree {
41
48
  name: filename.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
42
49
  rawName: filename,
43
50
  };
51
+ if (filename.startsWith("{")) {
52
+ const ambiguousWildcardFiles = Object.values(targetDirectory.files).filter((file) => file.isWildcard);
53
+ if (ambiguousWildcardFiles.length > 1) {
54
+ process.stderr.write(`[counterfact] ERROR: Ambiguous wildcard paths detected. Multiple wildcard files exist at the same path level: ${ambiguousWildcardFiles.map((f) => f.rawName).join(", ")}. Requests may be routed unpredictably.\n`);
55
+ }
56
+ }
44
57
  }
45
58
  add(url, module) {
46
59
  this.addModuleToDirectory(this.root, url.split("/").slice(1), module);
@@ -75,8 +88,31 @@ export class ModuleTree {
75
88
  }
76
89
  return "";
77
90
  }
78
- const match = directory.files[normalizedSegment(segment, directory)] ??
79
- Object.values(directory.files).find((file) => file.isWildcard && this.fileModuleDefined(file, method));
91
+ const exactMatchFile = directory.files[normalizedSegment(segment, directory)];
92
+ // If the URL segment literally matches a file key (e.g., requesting "/{x}"
93
+ // as a literal URL value), exactMatchFile may be a wildcard file. In that
94
+ // case, fall through to wildcard matching below.
95
+ if (exactMatchFile !== undefined && !exactMatchFile.isWildcard) {
96
+ return {
97
+ ...exactMatchFile,
98
+ matchedPath: `${matchedPath}/${exactMatchFile.rawName}`,
99
+ pathVariables,
100
+ };
101
+ }
102
+ const wildcardFiles = Object.values(directory.files).filter((file) => file.isWildcard && this.fileModuleDefined(file, method));
103
+ if (wildcardFiles.length > 1) {
104
+ const firstWildcard = wildcardFiles[0];
105
+ return {
106
+ ...firstWildcard,
107
+ ambiguous: true,
108
+ matchedPath: `${matchedPath}/${firstWildcard.rawName}`,
109
+ pathVariables: {
110
+ ...pathVariables,
111
+ [firstWildcard.name]: segment,
112
+ },
113
+ };
114
+ }
115
+ const match = exactMatchFile ?? wildcardFiles[0];
80
116
  if (match === undefined) {
81
117
  return undefined;
82
118
  }
@@ -113,16 +149,21 @@ export class ModuleTree {
113
149
  return this.matchWithinDirectory(exactMatch, remainingSegments, pathVariables, `${matchedPath}/${segment}`, method);
114
150
  }
115
151
  const wildcardDirectories = Object.values(directory.directories).filter((subdirectory) => subdirectory.isWildcard);
152
+ const wildcardMatches = [];
116
153
  for (const wildcardDirectory of wildcardDirectories) {
117
- const match = this.matchWithinDirectory(wildcardDirectory, remainingSegments, {
154
+ const wildcardMatch = this.matchWithinDirectory(wildcardDirectory, remainingSegments, {
118
155
  ...pathVariables,
119
156
  [wildcardDirectory.name]: segment,
120
157
  }, `${matchedPath}/${wildcardDirectory.rawName}`, method);
121
- if (match !== undefined) {
122
- return match;
158
+ if (wildcardMatch !== undefined) {
159
+ wildcardMatches.push(wildcardMatch);
123
160
  }
124
161
  }
125
- return undefined;
162
+ if (wildcardMatches.length > 1) {
163
+ const firstMatch = wildcardMatches[0];
164
+ return { ...firstMatch, ambiguous: true };
165
+ }
166
+ return wildcardMatches[0];
126
167
  }
127
168
  match(url, method) {
128
169
  return this.matchWithinDirectory(this.root, url.split("/").slice(1), {}, "", method);
@@ -1,9 +1,9 @@
1
+ import { bundle } from "@apidevtools/json-schema-ref-parser";
1
2
  import yaml from "js-yaml";
2
- import { readFile } from "../util/read-file.js";
3
3
  export function openapiMiddleware(openApiPath, url) {
4
4
  return async (ctx, next) => {
5
5
  if (ctx.URL.pathname === "/counterfact/openapi") {
6
- const openApiDocument = (await yaml.load(await readFile(openApiPath)));
6
+ const openApiDocument = (await bundle(openApiPath));
7
7
  openApiDocument.servers ??= [];
8
8
  openApiDocument.servers.unshift({
9
9
  description: "Counterfact",
@@ -57,6 +57,7 @@ export class Registry {
57
57
  handler(url, method) {
58
58
  const match = this.moduleTree.match(url, method);
59
59
  return {
60
+ ambiguous: match?.ambiguous ?? false,
60
61
  matchedPath: match?.matchedPath ?? "",
61
62
  module: match?.module,
62
63
  path: match?.pathVariables ?? {},
@@ -71,6 +72,14 @@ export class Registry {
71
72
  endpoint(httpRequestMethod, url, parameterTypes = {}) {
72
73
  const handler = this.handler(url, httpRequestMethod);
73
74
  debug("handler for %s: %o", url, handler);
75
+ if (handler.ambiguous) {
76
+ return () => ({
77
+ body: `Ambiguous wildcard paths: the request to ${url} matches multiple routes. Please resolve the ambiguity in your API spec or route handlers.`,
78
+ contentType: "text/plain",
79
+ headers: {},
80
+ status: 500,
81
+ });
82
+ }
74
83
  const execute = handler.module?.[httpRequestMethod];
75
84
  if (!execute) {
76
85
  debug(`Could not find a ${httpRequestMethod} method matching ${url}\n`);
@@ -1,11 +1,12 @@
1
- import { JSONSchemaFaker } from "json-schema-faker";
1
+ import { generate } from "json-schema-faker";
2
2
  import { jsonToXml } from "./json-to-xml.js";
3
- JSONSchemaFaker.option("useExamplesValue", true);
4
- JSONSchemaFaker.option("minItems", 0);
5
- JSONSchemaFaker.option("maxItems", 20);
6
- JSONSchemaFaker.option("failOnInvalidTypes", false);
7
- JSONSchemaFaker.option("failOnInvalidFormat", false);
8
- JSONSchemaFaker.option("fillProperties", false);
3
+ const DEFAULT_GENERATE_OPTIONS = {
4
+ useExamplesValue: true,
5
+ minItems: 0,
6
+ maxItems: 20,
7
+ failOnInvalidTypes: false,
8
+ fillProperties: false,
9
+ };
9
10
  function convertToXmlIfNecessary(type, body, schema) {
10
11
  if (type.endsWith("/xml")) {
11
12
  return jsonToXml(body, schema, "root");
@@ -18,6 +19,32 @@ function oneOf(items) {
18
19
  }
19
20
  return oneOf(Object.values(items));
20
21
  }
22
+ function serializeCookie(name, value, options = {}) {
23
+ const parts = [`${name}=${value}`];
24
+ if (options.path !== undefined) {
25
+ parts.push(`Path=${options.path}`);
26
+ }
27
+ if (options.domain !== undefined) {
28
+ parts.push(`Domain=${options.domain}`);
29
+ }
30
+ if (options.maxAge !== undefined) {
31
+ parts.push(`Max-Age=${options.maxAge}`);
32
+ }
33
+ if (options.expires !== undefined) {
34
+ parts.push(`Expires=${options.expires.toUTCString()}`);
35
+ }
36
+ if (options.httpOnly) {
37
+ parts.push("HttpOnly");
38
+ }
39
+ if (options.secure) {
40
+ parts.push("Secure");
41
+ }
42
+ if (options.sameSite !== undefined) {
43
+ const sameSiteMap = { lax: "Lax", none: "None", strict: "Strict" };
44
+ parts.push(`SameSite=${sameSiteMap[options.sameSite]}`);
45
+ }
46
+ return parts.join("; ");
47
+ }
21
48
  function unknownStatusCodeResponse(statusCode) {
22
49
  return {
23
50
  content: [
@@ -41,6 +68,28 @@ export function createResponseBuilder(operation, config) {
41
68
  },
42
69
  };
43
70
  },
71
+ binary(body) {
72
+ const buffer = typeof body === "string"
73
+ ? Buffer.from(body, "base64")
74
+ : Buffer.from(body);
75
+ return this.match("application/octet-stream", buffer);
76
+ },
77
+ cookie(name, value, options = {}) {
78
+ const cookieString = serializeCookie(name, value, options);
79
+ const existing = this.headers?.["set-cookie"];
80
+ const existingArray = Array.isArray(existing)
81
+ ? existing
82
+ : existing !== undefined
83
+ ? [existing]
84
+ : [];
85
+ return {
86
+ ...this,
87
+ headers: {
88
+ ...this.headers,
89
+ "set-cookie": [...existingArray, cookieString],
90
+ },
91
+ };
92
+ },
44
93
  html(body) {
45
94
  return this.match("text/html", body);
46
95
  },
@@ -73,6 +122,18 @@ export function createResponseBuilder(operation, config) {
73
122
  return unknownStatusCodeResponse(this.status);
74
123
  }
75
124
  const { content } = response;
125
+ const exampleExists = Object.values(content).some((contentType) => contentType?.examples?.[name] !== undefined);
126
+ if (!exampleExists) {
127
+ return {
128
+ content: [
129
+ {
130
+ body: `The OpenAPI document does not define an example named "${name}" for status code ${this.status ?? "unknown"}`,
131
+ type: "text/plain",
132
+ },
133
+ ],
134
+ status: 500,
135
+ };
136
+ }
76
137
  return {
77
138
  ...this,
78
139
  content: Object.keys(content).map((type) => ({
@@ -81,12 +142,15 @@ export function createResponseBuilder(operation, config) {
81
142
  })),
82
143
  };
83
144
  },
84
- random() {
85
- if (config?.alwaysFakeOptionals) {
86
- JSONSchemaFaker.option("alwaysFakeOptionals", true);
87
- JSONSchemaFaker.option("fixedProbabilities", true);
88
- JSONSchemaFaker.option("optionalsProbability", 1.0);
89
- }
145
+ async random() {
146
+ const generateOptions = config?.alwaysFakeOptionals
147
+ ? {
148
+ ...DEFAULT_GENERATE_OPTIONS,
149
+ alwaysFakeOptionals: true,
150
+ fixedProbabilities: true,
151
+ optionalsProbability: 1.0,
152
+ }
153
+ : DEFAULT_GENERATE_OPTIONS;
90
154
  if (operation.produces) {
91
155
  return this.randomLegacy();
92
156
  }
@@ -99,24 +163,26 @@ export function createResponseBuilder(operation, config) {
99
163
  const generatedHeaders = {};
100
164
  for (const [name, header] of Object.entries(response.headers ?? {})) {
101
165
  if (header.required && !(name in (this.headers ?? {}))) {
102
- generatedHeaders[name] = JSONSchemaFaker.generate(header.schema ?? { type: "string" });
166
+ generatedHeaders[name] = (await generate((header.schema ?? { type: "string" }), generateOptions));
103
167
  }
104
168
  }
105
169
  return {
106
170
  ...this,
107
- content: Object.keys(content).map((type) => ({
171
+ content: await Promise.all(Object.keys(content).map(async (type) => ({
108
172
  body: convertToXmlIfNecessary(type, content[type]?.examples
109
173
  ? oneOf(Object.values(content[type]?.examples ?? []).map((example) => example.value))
110
- : JSONSchemaFaker.generate(content[type]?.schema ?? { type: "object" }), content[type]?.schema),
174
+ : await generate((content[type]?.schema ?? {
175
+ type: "object",
176
+ }), generateOptions), content[type]?.schema),
111
177
  type,
112
- })),
178
+ }))),
113
179
  headers: {
114
180
  ...generatedHeaders,
115
181
  ...this.headers,
116
182
  },
117
183
  };
118
184
  },
119
- randomLegacy() {
185
+ async randomLegacy() {
120
186
  const response = operation.responses[this.status ?? "default"] ??
121
187
  operation.responses.default;
122
188
  if (response === undefined) {
@@ -124,7 +190,7 @@ export function createResponseBuilder(operation, config) {
124
190
  }
125
191
  const body = response.examples
126
192
  ? oneOf(response.examples)
127
- : JSONSchemaFaker.generate(response.schema ?? { type: "object" });
193
+ : await generate((response.schema ?? { type: "object" }), DEFAULT_GENERATE_OPTIONS);
128
194
  return {
129
195
  ...this,
130
196
  content: operation.produces?.map((type) => ({
@@ -1,6 +1,4 @@
1
- import { JSONSchemaFaker } from "json-schema-faker";
2
- JSONSchemaFaker.option("useExamplesValue", true);
3
- JSONSchemaFaker.option("fillProperties", false);
1
+ import { generate } from "json-schema-faker";
4
2
  export class Tools {
5
3
  headers;
6
4
  constructor({ headers = {}, } = {}) {
@@ -22,6 +20,6 @@ export class Tools {
22
20
  });
23
21
  }
24
22
  randomFromSchema(schema) {
25
- return JSONSchemaFaker.generate(schema);
23
+ return generate(schema, { useExamplesValue: true, fillProperties: false });
26
24
  }
27
25
  }
@@ -1,4 +1,3 @@
1
- /* eslint-disable n/no-sync */
2
1
  import { existsSync } from "node:fs";
3
2
  import fs from "node:fs/promises";
4
3
  import nodePath from "node:path";
@@ -102,6 +102,7 @@ export class OperationTypeCoder extends TypeCoder {
102
102
  const queryType = new ParametersTypeCoder(parameters, "query").write(script);
103
103
  const pathType = new ParametersTypeCoder(parameters, "path").write(script);
104
104
  const headersType = new ParametersTypeCoder(parameters, "header").write(script);
105
+ const cookieType = new ParametersTypeCoder(parameters, "cookie").write(script);
105
106
  const bodyRequirement = this.requirement.get("consumes") ||
106
107
  this.requirement.specification?.rootRequirement?.get("consumes")
107
108
  ? parameters
@@ -121,6 +122,7 @@ export class OperationTypeCoder extends TypeCoder {
121
122
  const queryTypeName = this.exportParameterType(script, "query", queryType, baseName, modulePath);
122
123
  const pathTypeName = this.exportParameterType(script, "path", pathType, baseName, modulePath);
123
124
  const headersTypeName = this.exportParameterType(script, "headers", headersType, baseName, modulePath);
124
- return `($: OmitValueWhenNever<{ query: ${queryTypeName}, path: ${pathTypeName}, headers: ${headersTypeName}, body: ${bodyType}, context: ${contextTypeImportName}, response: ${responseType}, x: ${xType}, proxy: ${proxyType}, user: ${this.userType()}, delay: ${delayType} }>) => MaybePromise<${this.responseTypes(script)} | { status: 415, contentType: "text/plain", body: string } | COUNTERFACT_RESPONSE | { ALL_REMAINING_HEADERS_ARE_OPTIONAL: COUNTERFACT_RESPONSE }>`;
125
+ const cookieTypeName = this.exportParameterType(script, "cookie", cookieType, baseName, modulePath);
126
+ return `($: OmitValueWhenNever<{ query: ${queryTypeName}, path: ${pathTypeName}, headers: ${headersTypeName}, cookie: ${cookieTypeName}, body: ${bodyType}, context: ${contextTypeImportName}, response: ${responseType}, x: ${xType}, proxy: ${proxyType}, user: ${this.userType()}, delay: ${delayType} }>) => MaybePromise<${this.responseTypes(script)} | { status: 415, contentType: "text/plain", body: string } | COUNTERFACT_RESPONSE | { ALL_REMAINING_HEADERS_ARE_OPTIONAL: COUNTERFACT_RESPONSE }>`;
125
127
  }
126
128
  }
@@ -1,4 +1,3 @@
1
- /* eslint-disable n/no-sync */
2
1
  import { existsSync } from "node:fs";
3
2
  import fs from "node:fs/promises";
4
3
  import nodePath, { dirname } from "node:path";
@@ -38,6 +37,7 @@ export class Repository {
38
37
  if (!existsSync(sourcePath)) {
39
38
  return false;
40
39
  }
40
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins
41
41
  return fs.cp(sourcePath, destinationPath, { recursive: true });
42
42
  }
43
43
  async writeFiles(destination, { routes, types }) {
@@ -25,7 +25,7 @@ export class Requirement {
25
25
  }
26
26
  return new Requirement(this.data[item], `${this.url}/${this.escapeJsonPointer(item)}`, this.specification);
27
27
  }
28
- select(path, data = this.data, basePath = "") {
28
+ select(path) {
29
29
  const parts = path
30
30
  .split("/")
31
31
  .map(this.unescapeJsonPointer)
@@ -80,13 +80,16 @@ export class SchemaTypeCoder extends TypeCoder {
80
80
  }
81
81
  writeCode(script) {
82
82
  // script.comments = READ_ONLY_COMMENTS;
83
- const { allOf, anyOf, oneOf, type } = this.requirement.data;
83
+ const { allOf, anyOf, oneOf, type, format } = this.requirement.data;
84
84
  if (allOf ?? anyOf ?? oneOf) {
85
85
  return this.writeGroup(script, { allOf, anyOf, oneOf });
86
86
  }
87
87
  if (this.requirement.has("enum")) {
88
88
  return this.writeEnum(script, this.requirement.get("enum"));
89
89
  }
90
+ if ((type === "string" && format === "binary") || type === "file") {
91
+ return "Uint8Array | string";
92
+ }
90
93
  return this.writeType(script, type);
91
94
  }
92
95
  }
@@ -1,6 +1,6 @@
1
1
  import nodePath from "node:path";
2
2
  import createDebugger from "debug";
3
- import prettier from "prettier";
3
+ import { format } from "prettier";
4
4
  const debug = createDebugger("counterfact:typescript-generator:script");
5
5
  export class Script {
6
6
  constructor(repository, path) {
@@ -137,7 +137,7 @@ export class Script {
137
137
  });
138
138
  }
139
139
  contents() {
140
- return prettier.format([
140
+ return format([
141
141
  this.comments.map((comment) => `// ${comment}`).join("\n"),
142
142
  this.comments.length > 0 ? "\n\n" : "",
143
143
  this.externalImportStatements().join("\n"),
@@ -1,4 +1,3 @@
1
- import nodePath from "node:path";
2
1
  import createDebug from "debug";
3
2
  import { Requirement } from "./requirement.js";
4
3
  import { bundle } from "@apidevtools/json-schema-ref-parser";
@@ -1,4 +1,3 @@
1
- /* eslint-disable n/no-sync */
2
1
  import fs from "node:fs";
3
2
  import nodePath from "node:path";
4
3
  export function ensureDirectoryExists(filePath) {
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "counterfact",
3
- "version": "2.3.0",
3
+ "version": "2.4.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
- "exports": "./dist/app.js",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/app.js"
10
+ }
11
+ },
8
12
  "types": "./dist/server/types.d.ts",
9
13
  "typesVersions": {
10
14
  "*": {
@@ -49,7 +53,7 @@
49
53
  "swagger-tools"
50
54
  ],
51
55
  "engines": {
52
- "node": ">=17.0.0"
56
+ "node": ">=22"
53
57
  },
54
58
  "bin": {
55
59
  "counterfact": "./bin/counterfact.js"
@@ -61,7 +65,7 @@
61
65
  "sideEffects": false,
62
66
  "scripts": {
63
67
  "test": "yarn node --experimental-vm-modules ./node_modules/jest-cli/bin/jest --testPathIgnorePatterns=black-box",
64
- "test:black-box": "rimraf dist && rimraf out && yarn build && yarn node --experimental-vm-modules ./node_modules/jest-cli/bin/jest black-box --forceExit --coverage=false",
68
+ "test:black-box": "rimraf dist && yarn build && python3 -m pytest test-black-box/ -v",
65
69
  "test:mutants": "stryker run stryker.config.json",
66
70
  "test:tsd": "tsd --typings ./dist/server/counterfact-types/index.ts --files ./test/**/*.test-d.ts",
67
71
  "build": "rm -rf dist && tsc && copyfiles -f \"src/client/**\" dist/client && copyfiles -f \"src/counterfact-types/*.ts\" dist/server/counterfact-types && copyfiles -f \"src/server/*.cjs\" dist/server",
@@ -78,10 +82,12 @@
78
82
  },
79
83
  "devDependencies": {
80
84
  "@changesets/cli": "2.30.0",
85
+ "@eslint/js": "10.0.1",
86
+ "@jest/globals": "^30.3.0",
81
87
  "@stryker-mutator/core": "9.6.0",
82
88
  "@stryker-mutator/jest-runner": "9.6.0",
83
89
  "@stryker-mutator/typescript-checker": "9.6.0",
84
- "@swc/core": "1.15.18",
90
+ "@swc/core": "1.15.21",
85
91
  "@swc/jest": "0.2.39",
86
92
  "@testing-library/dom": "10.4.1",
87
93
  "@types/debug": "^4.1.12",
@@ -92,16 +98,17 @@
92
98
  "@types/koa-proxy": "1.0.8",
93
99
  "@types/koa-static": "4.0.4",
94
100
  "@types/lodash": "4.17.24",
101
+ "@types/node": "22",
95
102
  "@typescript-eslint/eslint-plugin": "^8.53.0",
96
103
  "@typescript-eslint/parser": "^8.53.0",
97
104
  "copyfiles": "2.4.1",
98
- "eslint": "9.39.4",
105
+ "eslint": "10.1.0",
99
106
  "eslint-formatter-github-annotations": "0.1.0",
100
107
  "eslint-import-resolver-typescript": "4.4.4",
101
108
  "eslint-plugin-etc": "2.0.3",
102
- "eslint-plugin-file-progress": "3.0.2",
109
+ "eslint-plugin-file-progress": "4.0.0",
103
110
  "eslint-plugin-import": "2.32.0",
104
- "eslint-plugin-jest": "29.15.0",
111
+ "eslint-plugin-jest": "29.15.1",
105
112
  "eslint-plugin-jest-dom": "5.5.0",
106
113
  "eslint-plugin-n": "^17.24.0",
107
114
  "eslint-plugin-no-explicit-type-exports": "0.12.1",
@@ -129,10 +136,10 @@
129
136
  "debug": "4.4.3",
130
137
  "fetch": "1.1.0",
131
138
  "fs-extra": "11.3.4",
132
- "handlebars": "4.7.8",
139
+ "handlebars": "4.7.9",
133
140
  "http-terminator": "3.2.0",
134
141
  "js-yaml": "4.1.1",
135
- "json-schema-faker": "0.5.9",
142
+ "json-schema-faker": "0.6.0",
136
143
  "jsonwebtoken": "9.0.3",
137
144
  "koa": "3.1.2",
138
145
  "koa-bodyparser": "4.4.1",
@@ -144,10 +151,11 @@
144
151
  "patch-package": "8.0.1",
145
152
  "precinct": "12.2.0",
146
153
  "prettier": "3.8.1",
147
- "typescript": "5.9.3"
154
+ "typescript": "6.0.2"
148
155
  },
149
156
  "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
150
157
  "resolutions": {
151
- "js-yaml": "4.1.1"
158
+ "js-yaml": "4.1.1",
159
+ "@typescript-eslint/utils": "^8.58.0"
152
160
  }
153
161
  }