counterfact 2.6.0 → 2.7.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 (73) hide show
  1. package/README.md +103 -141
  2. package/bin/README.md +24 -4
  3. package/bin/counterfact.js +44 -1
  4. package/dist/app.js +15 -16
  5. package/dist/counterfact-types/cookie-options.js +1 -0
  6. package/dist/counterfact-types/counterfact-response.js +7 -0
  7. package/dist/counterfact-types/example-names.js +1 -0
  8. package/dist/counterfact-types/example.js +1 -0
  9. package/dist/counterfact-types/generic-response-builder.js +1 -0
  10. package/dist/counterfact-types/http-status-code.js +1 -0
  11. package/dist/counterfact-types/if-has-key.js +1 -0
  12. package/dist/counterfact-types/index.js +0 -1
  13. package/dist/counterfact-types/maybe-promise.js +1 -0
  14. package/dist/counterfact-types/media-type.js +1 -0
  15. package/dist/counterfact-types/omit-all.js +1 -0
  16. package/dist/counterfact-types/omit-value-when-never.js +1 -0
  17. package/dist/counterfact-types/open-api-content.js +1 -0
  18. package/dist/counterfact-types/open-api-operation.js +1 -0
  19. package/dist/counterfact-types/open-api-parameters.js +1 -0
  20. package/dist/counterfact-types/open-api-response.js +1 -0
  21. package/dist/counterfact-types/random-function.js +1 -0
  22. package/dist/counterfact-types/response-builder-factory.js +1 -0
  23. package/dist/counterfact-types/response-builder.js +1 -0
  24. package/dist/counterfact-types/wide-operation-argument.js +1 -0
  25. package/dist/counterfact-types/wide-response-builder.js +1 -0
  26. package/dist/repl/repl.js +96 -3
  27. package/dist/server/context-registry.js +17 -1
  28. package/dist/server/counterfact-types/cookie-options.ts +14 -0
  29. package/dist/server/counterfact-types/counterfact-response.ts +15 -0
  30. package/dist/server/counterfact-types/example-names.ts +13 -0
  31. package/dist/server/counterfact-types/example.ts +10 -0
  32. package/dist/server/counterfact-types/generic-response-builder.ts +164 -0
  33. package/dist/server/counterfact-types/http-status-code.ts +62 -0
  34. package/dist/server/counterfact-types/if-has-key.ts +19 -0
  35. package/dist/server/counterfact-types/index.ts +20 -338
  36. package/dist/server/counterfact-types/maybe-promise.ts +6 -0
  37. package/dist/server/counterfact-types/media-type.ts +6 -0
  38. package/dist/server/counterfact-types/omit-all.ts +11 -0
  39. package/dist/server/counterfact-types/omit-value-when-never.ts +11 -0
  40. package/dist/server/counterfact-types/open-api-content.ts +8 -0
  41. package/dist/server/counterfact-types/open-api-operation.ts +36 -0
  42. package/dist/server/counterfact-types/open-api-parameters.ts +16 -0
  43. package/dist/server/counterfact-types/open-api-response.ts +22 -0
  44. package/dist/server/counterfact-types/random-function.ts +9 -0
  45. package/dist/server/counterfact-types/response-builder-factory.ts +16 -0
  46. package/dist/server/counterfact-types/response-builder.ts +31 -0
  47. package/dist/server/counterfact-types/wide-operation-argument.ts +17 -0
  48. package/dist/server/counterfact-types/wide-response-builder.ts +26 -0
  49. package/dist/server/create-koa-app.js +1 -20
  50. package/dist/server/dispatcher.js +18 -5
  51. package/dist/server/json-to-xml.js +1 -1
  52. package/dist/server/koa-middleware.js +7 -1
  53. package/dist/server/load-openapi-document.js +13 -0
  54. package/dist/server/module-loader.js +76 -4
  55. package/dist/server/openapi-watcher.js +35 -0
  56. package/dist/server/request-validator.js +3 -7
  57. package/dist/server/response-builder.js +3 -0
  58. package/dist/server/response-validator.js +58 -0
  59. package/dist/server/scenario-registry.js +29 -0
  60. package/dist/server/tools.js +2 -2
  61. package/dist/typescript-generator/coder.js +4 -2
  62. package/dist/typescript-generator/generate.js +155 -0
  63. package/dist/typescript-generator/operation-coder.js +1 -1
  64. package/dist/typescript-generator/operation-type-coder.js +1 -49
  65. package/dist/typescript-generator/read-only-comments.js +1 -1
  66. package/dist/typescript-generator/requirement.js +8 -1
  67. package/dist/typescript-generator/reserved-words.js +50 -0
  68. package/dist/util/load-config-file.js +44 -0
  69. package/package.json +7 -8
  70. package/dist/client/README.md +0 -14
  71. package/dist/client/index.html.hbs +0 -244
  72. package/dist/client/rapi-doc.html.hbs +0 -36
  73. package/dist/server/page-middleware.js +0 -23
@@ -50,5 +50,5 @@ export function jsonToXml(json, schema, keyName = "root") {
50
50
  if (typeof json === "object" && json !== null) {
51
51
  return objectToXml(json, schema, name);
52
52
  }
53
- return `<${name}>${String(json)}</${name}>`;
53
+ return `<${name}>${xmlEscape(String(json))}</${name}>`;
54
54
  }
@@ -49,7 +49,7 @@ export function koaMiddleware(dispatcher, config, proxy = koaProxy) {
49
49
  return await next();
50
50
  }
51
51
  const auth = getAuthObject(ctx);
52
- const { body, headers, query } = ctx.request;
52
+ const { body, headers, query, rawBody } = ctx.request;
53
53
  const path = ctx.request.path.slice(routePrefix.length);
54
54
  const method = ctx.request.method;
55
55
  if (isProxyEnabledForPath(path, config) && proxyUrl) {
@@ -69,6 +69,7 @@ export function koaMiddleware(dispatcher, config, proxy = koaProxy) {
69
69
  path,
70
70
  /* @ts-expect-error the value of a querystring item can be an array and we don't have a solution for that yet */
71
71
  query,
72
+ rawBody: method === "HEAD" || method === "GET" ? undefined : rawBody,
72
73
  req: { path: "", ...ctx.req },
73
74
  });
74
75
  ctx.body = response.body;
@@ -88,6 +89,11 @@ export function koaMiddleware(dispatcher, config, proxy = koaProxy) {
88
89
  }
89
90
  }
90
91
  }
92
+ if (response.appendedHeaders) {
93
+ for (const [key, value] of response.appendedHeaders) {
94
+ ctx.res.appendHeader(key, value);
95
+ }
96
+ }
91
97
  ctx.status = response.status ?? HTTP_STATUS_CODE_OK;
92
98
  return undefined;
93
99
  };
@@ -0,0 +1,13 @@
1
+ import { dereference } from "@apidevtools/json-schema-ref-parser";
2
+ import createDebug from "debug";
3
+ const debug = createDebug("counterfact:server:load-openapi-document");
4
+ export async function loadOpenApiDocument(source) {
5
+ try {
6
+ return (await dereference(source));
7
+ }
8
+ catch (error) {
9
+ debug("could not load OpenAPI document from %s: %o", source, error);
10
+ const details = error instanceof Error ? error.message : String(error);
11
+ throw new Error(`Could not load the OpenAPI spec from "${source}".\n${details}`, { cause: error });
12
+ }
13
+ }
@@ -17,17 +17,22 @@ export class ModuleLoader extends EventTarget {
17
17
  basePath;
18
18
  registry;
19
19
  watcher;
20
+ scenariosWatcher;
20
21
  contextRegistry;
22
+ scenariosPath;
23
+ scenarioRegistry;
21
24
  dependencyGraph = new ModuleDependencyGraph();
22
25
  fileDiscovery;
23
26
  uncachedImport = async function (moduleName) {
24
27
  throw new Error(`uncachedImport not set up; importing ${moduleName}`);
25
28
  };
26
- constructor(basePath, registry, contextRegistry = new ContextRegistry()) {
29
+ constructor(basePath, registry, contextRegistry = new ContextRegistry(), scenariosPath, scenarioRegistry) {
27
30
  super();
28
31
  this.basePath = basePath.replaceAll("\\", "/");
29
32
  this.registry = registry;
30
33
  this.contextRegistry = contextRegistry;
34
+ this.scenariosPath = scenariosPath?.replaceAll("\\", "/");
35
+ this.scenarioRegistry = scenarioRegistry;
31
36
  this.fileDiscovery = new FileDiscovery(this.basePath);
32
37
  }
33
38
  async watch() {
@@ -37,7 +42,7 @@ export class ModuleLoader extends EventTarget {
37
42
  return;
38
43
  const pathName = pathNameOriginal.replaceAll("\\", "/");
39
44
  if (pathName.includes("$.context") && eventName === "add") {
40
- process.stdout.write(`\n\n!!! The file at ${pathName} needs a minor update.\n See https://github.com/pmcelhaney/counterfact/blob/main/docs/context-change.md\n\n\n`);
45
+ process.stdout.write(`\n\n!!! The file at ${pathName} needs a minor update.\n See https://github.com/pmcelhaney/counterfact/blob/main/docs/faq.md\n\n\n`);
41
46
  return;
42
47
  }
43
48
  if (!["add", "change", "unlink"].includes(eventName)) {
@@ -50,6 +55,9 @@ export class ModuleLoader extends EventTarget {
50
55
  if (eventName === "unlink") {
51
56
  this.registry.remove(url);
52
57
  this.dispatchEvent(new Event("remove"));
58
+ if (this.isContextFile(pathName)) {
59
+ this.contextRegistry.remove(unescapePathForWindows(parts.dir).replaceAll("\\", "/") || "/");
60
+ }
53
61
  return;
54
62
  }
55
63
  const dependencies = this.dependencyGraph.dependentsOf(pathName);
@@ -59,13 +67,78 @@ export class ModuleLoader extends EventTarget {
59
67
  }
60
68
  });
61
69
  await once(this.watcher, "ready");
70
+ if (this.scenariosPath && this.scenarioRegistry) {
71
+ const JS_EXTENSIONS = ["js", "mjs", "cjs", "ts", "mts", "cts"];
72
+ const scenariosPath = this.scenariosPath;
73
+ this.scenariosWatcher = watch(scenariosPath, CHOKIDAR_OPTIONS).on("all", (eventName, pathNameOriginal) => {
74
+ if (!JS_EXTENSIONS.some((ext) => pathNameOriginal.endsWith(`.${ext}`)))
75
+ return;
76
+ if (!["add", "change", "unlink"].includes(eventName))
77
+ return;
78
+ const pathName = pathNameOriginal.replaceAll("\\", "/");
79
+ if (eventName === "unlink") {
80
+ const fileKey = this.scenarioFileKey(pathName);
81
+ this.scenarioRegistry?.remove(fileKey);
82
+ return;
83
+ }
84
+ void this.loadScenarioFile(pathName);
85
+ });
86
+ await once(this.scenariosWatcher, "ready");
87
+ }
62
88
  }
63
89
  async stopWatching() {
64
90
  await this.watcher?.close();
91
+ await this.scenariosWatcher?.close();
92
+ }
93
+ isContextFile(pathName) {
94
+ return basename(pathName).startsWith("_.context.");
65
95
  }
66
96
  async load(directory = "") {
67
97
  const files = await this.fileDiscovery.findFiles(directory);
68
98
  await Promise.all(files.map((file) => this.loadEndpoint(file)));
99
+ await this.loadScenarios();
100
+ }
101
+ shouldLoadScenarioFile(pathName) {
102
+ return !pathName.endsWith(".d.ts") && !pathName.endsWith(".map");
103
+ }
104
+ async loadScenarios() {
105
+ if (!this.scenariosPath || !this.scenarioRegistry)
106
+ return;
107
+ try {
108
+ const fileDiscovery = new FileDiscovery(this.scenariosPath);
109
+ const files = await fileDiscovery.findFiles();
110
+ const loadableFiles = files.filter((file) => this.shouldLoadScenarioFile(file));
111
+ await Promise.all(loadableFiles.map((file) => this.loadScenarioFile(file)));
112
+ }
113
+ catch {
114
+ // Scenarios directory does not exist yet — that's fine.
115
+ }
116
+ }
117
+ scenarioFileKey(pathName) {
118
+ const normalizedScenariosPath = (this.scenariosPath ?? "").replaceAll("\\", "/");
119
+ const directory = dirname(pathName.slice(normalizedScenariosPath.length)).replaceAll("\\", "/");
120
+ const name = nodePath.parse(basename(pathName)).name;
121
+ const url = unescapePathForWindows(`/${nodePath.join(directory, name)}`
122
+ .replaceAll("\\", "/")
123
+ .replaceAll(/\/+/gu, "/"));
124
+ return url.slice(1); // strip leading "/"
125
+ }
126
+ async loadScenarioFile(pathName) {
127
+ if (!this.scenariosPath || !this.scenarioRegistry)
128
+ return;
129
+ const fileKey = this.scenarioFileKey(pathName);
130
+ try {
131
+ const doImport = (await determineModuleKind(pathName)) === "commonjs"
132
+ ? uncachedRequire
133
+ : uncachedImport;
134
+ const module = await doImport(pathName);
135
+ if (module) {
136
+ this.scenarioRegistry.add(fileKey, module);
137
+ }
138
+ }
139
+ catch (error) {
140
+ process.stdout.write(`\nError loading scenario ${pathName}:\n${String(error)}\n`);
141
+ }
69
142
  }
70
143
  async loadEndpoint(pathName) {
71
144
  debug("importing module: %s", pathName);
@@ -113,8 +186,7 @@ export class ModuleLoader extends EventTarget {
113
186
  return;
114
187
  }
115
188
  this.dispatchEvent(new Event("add"));
116
- if (basename(pathName).startsWith("_.context.") &&
117
- isContextModule(endpoint)) {
189
+ if (this.isContextFile(pathName) && isContextModule(endpoint)) {
118
190
  const loadContext = (path) => this.contextRegistry.find(path);
119
191
  const contextDir = nodePath.dirname(unescapePathForWindows(pathName));
120
192
  const readJson = async (relativePath) => {
@@ -0,0 +1,35 @@
1
+ import { watch } from "chokidar";
2
+ import createDebug from "debug";
3
+ import { waitForEvent } from "../util/wait-for-event.js";
4
+ import { CHOKIDAR_OPTIONS } from "./constants.js";
5
+ import { loadOpenApiDocument } from "./load-openapi-document.js";
6
+ const debug = createDebug("counterfact:server:openapi-watcher");
7
+ export class OpenApiWatcher {
8
+ openApiPath;
9
+ dispatcher;
10
+ watcher;
11
+ constructor(openApiPath, dispatcher) {
12
+ this.openApiPath = openApiPath;
13
+ this.dispatcher = dispatcher;
14
+ }
15
+ async watch() {
16
+ if (this.openApiPath === "_" || this.openApiPath.startsWith("http")) {
17
+ return;
18
+ }
19
+ this.watcher = watch(this.openApiPath, CHOKIDAR_OPTIONS).on("change", () => {
20
+ void (async () => {
21
+ try {
22
+ this.dispatcher.openApiDocument = await loadOpenApiDocument(this.openApiPath);
23
+ debug("reloaded OpenAPI document from %s", this.openApiPath);
24
+ }
25
+ catch (error) {
26
+ debug("failed to reload OpenAPI document from %s: %o", this.openApiPath, error);
27
+ }
28
+ })();
29
+ });
30
+ await waitForEvent(this.watcher, "ready");
31
+ }
32
+ async stopWatching() {
33
+ await this.watcher?.close();
34
+ }
35
+ }
@@ -1,7 +1,7 @@
1
1
  import Ajv from "ajv";
2
2
  const ajv = new Ajv({
3
3
  allErrors: true,
4
- unknownFormats: "ignore",
4
+ strict: false,
5
5
  coerceTypes: false,
6
6
  });
7
7
  function findMissingRequired(parameters, location, values) {
@@ -30,9 +30,7 @@ export function validateRequest(operation, request) {
30
30
  const valid = ajv.validate(schema, request.body);
31
31
  if (!valid && ajv.errors) {
32
32
  for (const error of ajv.errors) {
33
- const path = error.instancePath ??
34
- error.dataPath ??
35
- "";
33
+ const path = error.instancePath ?? "";
36
34
  errors.push(`body${path} ${error.message ?? "is invalid"}`);
37
35
  }
38
36
  }
@@ -47,9 +45,7 @@ export function validateRequest(operation, request) {
47
45
  const valid = ajv.validate(bodyParam.schema, request.body);
48
46
  if (!valid && ajv.errors) {
49
47
  for (const error of ajv.errors) {
50
- const path = error.instancePath ??
51
- error.dataPath ??
52
- "";
48
+ const path = error.instancePath ?? "";
53
49
  errors.push(`body${path} ${error.message ?? "is invalid"}`);
54
50
  }
55
51
  }
@@ -112,6 +112,9 @@ export function createResponseBuilder(operation, config) {
112
112
  ],
113
113
  };
114
114
  },
115
+ empty() {
116
+ return { ...this, content: undefined };
117
+ },
115
118
  example(name) {
116
119
  if (operation.produces) {
117
120
  return unknownStatusCodeResponse(this.status);
@@ -0,0 +1,58 @@
1
+ import Ajv from "ajv";
2
+ const ajv = new Ajv({
3
+ allErrors: true,
4
+ strict: false,
5
+ coerceTypes: false,
6
+ });
7
+ export function validateResponse(operation, response) {
8
+ if (!operation) {
9
+ return { errors: [], valid: true };
10
+ }
11
+ const errors = [];
12
+ const statusKey = response.status !== undefined ? String(response.status) : undefined;
13
+ const responseSpec = (statusKey !== undefined ? operation.responses[statusKey] : undefined) ??
14
+ operation.responses.default;
15
+ if (!responseSpec) {
16
+ return { errors: [], valid: true };
17
+ }
18
+ const specHeaders = responseSpec.headers ?? {};
19
+ const actualHeaders = response.headers ?? {};
20
+ for (const [name, headerSpec] of Object.entries(specHeaders)) {
21
+ const actualValue = actualHeaders[name] ?? actualHeaders[name.toLowerCase()];
22
+ if (headerSpec.required === true && actualValue === undefined) {
23
+ errors.push(`response header '${name}' is required`);
24
+ continue;
25
+ }
26
+ if (actualValue !== undefined && headerSpec.schema !== undefined) {
27
+ const coercedValue = typeof actualValue === "string"
28
+ ? coerceHeaderValue(actualValue, headerSpec.schema)
29
+ : actualValue;
30
+ const valid = ajv.validate(headerSpec.schema, coercedValue);
31
+ if (!valid && ajv.errors) {
32
+ for (const error of ajv.errors) {
33
+ const path = error.instancePath ?? "";
34
+ errors.push(`response header '${name}'${path} ${error.message ?? "is invalid"}`);
35
+ }
36
+ }
37
+ }
38
+ }
39
+ return {
40
+ errors,
41
+ valid: errors.length === 0,
42
+ };
43
+ }
44
+ function coerceHeaderValue(value, schema) {
45
+ const type = schema.type;
46
+ if (type === "integer" || type === "number") {
47
+ const num = Number(value);
48
+ return Number.isNaN(num) ? value : num;
49
+ }
50
+ if (type === "boolean") {
51
+ if (value === "true")
52
+ return true;
53
+ if (value === "false")
54
+ return false;
55
+ return value;
56
+ }
57
+ return value;
58
+ }
@@ -0,0 +1,29 @@
1
+ export class ScenarioRegistry {
2
+ modules = new Map();
3
+ add(key, module) {
4
+ this.modules.set(key, module);
5
+ }
6
+ remove(key) {
7
+ this.modules.delete(key);
8
+ }
9
+ getModule(fileKey) {
10
+ return this.modules.get(fileKey);
11
+ }
12
+ /**
13
+ * Returns the names of all exported functions for the given file key.
14
+ * Used for tab completion.
15
+ */
16
+ getExportedFunctionNames(fileKey) {
17
+ const module = this.modules.get(fileKey);
18
+ if (!module)
19
+ return [];
20
+ return Object.keys(module).filter((k) => typeof module[k] === "function");
21
+ }
22
+ /**
23
+ * Returns all loaded file keys (e.g. "index", "myscript", "sub/script").
24
+ * Used for tab completion to enumerate available scenario files.
25
+ */
26
+ getFileKeys() {
27
+ return [...this.modules.keys()];
28
+ }
29
+ }
@@ -8,13 +8,13 @@ export class Tools {
8
8
  return array[Math.floor(Math.random() * array.length)];
9
9
  }
10
10
  accepts(contentType) {
11
- const acceptHeader = this.headers.Accept;
11
+ const acceptHeader = Object.entries(this.headers).find(([key]) => key.toLowerCase() === "accept")?.[1];
12
12
  if (acceptHeader === "" || acceptHeader === undefined) {
13
13
  return true;
14
14
  }
15
15
  const acceptTypes = String(acceptHeader).split(",");
16
16
  return acceptTypes.some((acceptType) => {
17
- const [type, subtype] = acceptType.split("/");
17
+ const [type, subtype] = acceptType.trim().split("/");
18
18
  return ((type === "*" || type === contentType.split("/")[0]) &&
19
19
  (subtype === "*" || subtype === contentType.split("/")[1]));
20
20
  });
@@ -1,3 +1,4 @@
1
+ import { RESERVED_WORDS } from "./reserved-words.js";
1
2
  export class Coder {
2
3
  requirement;
3
4
  constructor(requirement) {
@@ -35,12 +36,13 @@ export class Coder {
35
36
  const name = rawName
36
37
  .replace(/^\d/u, (digit) => `_${digit}`)
37
38
  .replaceAll(/[^\w$]/gu, "_");
38
- yield name;
39
+ const baseName = RESERVED_WORDS.has(name) ? `${name}_` : name;
40
+ yield baseName;
39
41
  let index = 1;
40
42
  const MAX_NAMES_TO_GENERATE_BEFORE_GIVING_UP = 100;
41
43
  while (index < MAX_NAMES_TO_GENERATE_BEFORE_GIVING_UP) {
42
44
  index += 1;
43
- yield name + index;
45
+ yield baseName + index;
44
46
  }
45
47
  }
46
48
  typeDeclaration(_namespace, _script) {
@@ -60,4 +60,159 @@ export async function generate(source, destination, generateOptions, repository
60
60
  debug("telling the repository to write the files to %s", destination);
61
61
  await repository.writeFiles(destination, generateOptions);
62
62
  debug("finished writing the files");
63
+ if (generateOptions.types) {
64
+ await writeApplyContextType(destination);
65
+ await writeDefaultScenariosIndex(destination);
66
+ }
67
+ }
68
+ async function collectContextFiles(destination) {
69
+ const routesDir = nodePath.join(destination, "routes");
70
+ const results = [];
71
+ if (!existsSync(routesDir)) {
72
+ return results;
73
+ }
74
+ await walkForContextFiles(routesDir, routesDir, results);
75
+ results.sort((a, b) => b.depth - a.depth);
76
+ return results;
77
+ }
78
+ async function walkForContextFiles(routesDir, currentDir, results) {
79
+ let entries;
80
+ try {
81
+ entries = await fs.readdir(currentDir, { withFileTypes: true });
82
+ }
83
+ catch {
84
+ return;
85
+ }
86
+ for (const entry of entries) {
87
+ if (entry.isDirectory()) {
88
+ await walkForContextFiles(routesDir, nodePath.join(currentDir, entry.name), results);
89
+ }
90
+ else if (entry.name === "_.context.ts") {
91
+ const relDir = nodePath
92
+ .relative(routesDir, currentDir)
93
+ .replaceAll("\\", "/");
94
+ const routePath = relDir === "" ? "/" : `/${relDir}`;
95
+ const depth = relDir === "" ? 0 : relDir.split("/").length;
96
+ const importPath = relDir === "" ? "../routes/_.context" : `../routes/${relDir}/_.context`;
97
+ const alias = routePathToAlias(routePath);
98
+ results.push({ importPath, alias, routePath, depth });
99
+ }
100
+ }
101
+ }
102
+ function routePathToAlias(routePath) {
103
+ if (routePath === "/") {
104
+ return "Context";
105
+ }
106
+ return (routePath
107
+ .split("/")
108
+ .filter(Boolean)
109
+ .map((seg) => seg
110
+ .replace(/\{(.+?)\}/g, (_match, name) => name.replace(/[^a-z0-9]/gi, " "))
111
+ .replace(/[-_\s]([a-z])/g, (_match, c) => c.toUpperCase())
112
+ .replace(/^[a-z]/, (c) => c.toUpperCase())
113
+ .replace(/[^a-z0-9]/gi, ""))
114
+ .join("") + "Context");
115
+ }
116
+ const PARAM_SEGMENT_REGEX = /^\{.+\}$/u;
117
+ function buildLoadContextOverload(routePath, alias) {
118
+ if (routePath === "/") {
119
+ return ' loadContext(path: "/" | `/${string}`): ' + alias + ";";
120
+ }
121
+ const segments = routePath.split("/").filter(Boolean);
122
+ const hasParam = segments.some((seg) => PARAM_SEGMENT_REGEX.test(seg));
123
+ if (!hasParam) {
124
+ return ` loadContext(path: "${routePath}" | \`${routePath}/\${string}\`): ${alias};`;
125
+ }
126
+ const templatePath = `/${segments
127
+ .map((seg) => (PARAM_SEGMENT_REGEX.test(seg) ? "${string}" : seg))
128
+ .join("/")}`;
129
+ return ` loadContext(path: \`${templatePath}\`): ${alias};`;
130
+ }
131
+ function buildApplyContextContent(contextFiles) {
132
+ const rootContext = contextFiles.find((f) => f.routePath === "/");
133
+ const contextType = rootContext
134
+ ? rootContext.alias
135
+ : "Record<string, unknown>";
136
+ const importLines = contextFiles.map(({ importPath, alias }) => alias === "Context"
137
+ ? `import type { Context } from "${importPath}";`
138
+ : `import type { Context as ${alias} } from "${importPath}";`);
139
+ const overloadLines = contextFiles.map(({ alias, routePath }) => buildLoadContextOverload(routePath, alias));
140
+ const parts = [
141
+ "// This file is generated by Counterfact. Do not edit manually.",
142
+ ...importLines,
143
+ "",
144
+ "export interface ApplyContext {",
145
+ ' /** Root context, same as loadContext("/") */',
146
+ ` context: ${contextType};`,
147
+ " /** Load a context object for a specific path */",
148
+ ...overloadLines,
149
+ " loadContext(path: string): Record<string, unknown>;",
150
+ " /** Named route builders stored in the REPL execution context */",
151
+ " routes: Record<string, unknown>;",
152
+ " /** Create a new route builder for a given path */",
153
+ " route: (path: string) => unknown;",
154
+ "}",
155
+ "",
156
+ "/** A scenario function that receives the live REPL environment */",
157
+ "export type Scenario = ($: ApplyContext) => Promise<void> | void;",
158
+ "",
159
+ ];
160
+ return parts.join("\n");
161
+ }
162
+ export async function writeApplyContextType(destination) {
163
+ const typesDir = nodePath.join(destination, "types");
164
+ const filePath = nodePath.join(typesDir, "scenario-context.ts");
165
+ const contextFiles = await collectContextFiles(destination);
166
+ const content = buildApplyContextContent(contextFiles);
167
+ await fs.mkdir(typesDir, { recursive: true });
168
+ await fs.writeFile(filePath, content, "utf8");
169
+ }
170
+ const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/scenario-context.js";
171
+
172
+ /**
173
+ * Scenario scripts are plain TypeScript functions that receive the live REPL
174
+ * environment and can read or mutate server state. Run them from the REPL with:
175
+ * .apply <functionName>
176
+ */
177
+
178
+ /**
179
+ * Read or mutate the root context (same object routes see as $.context):
180
+ * $.context.<property> = <value>;
181
+ *
182
+ * Load a context for a specific path:
183
+ * const petsCtx = $.loadContext("/pets");
184
+ *
185
+ * Store a pre-configured route builder for later use in the REPL:
186
+ * $.routes.myRequest = $.route("/pets").method("get");
187
+ */
188
+
189
+ /**
190
+ * An example scenario. To use it in the REPL, type:
191
+ * .apply help
192
+ */
193
+ export const help: Scenario = ($) => {
194
+ void $;
195
+
196
+ console.log(
197
+ [
198
+ "Scenarios are functions that populate the context object",
199
+ "and / or the REPL environment. They are intended to",
200
+ "populate your environment with specific data and",
201
+ "configurations for testing purposes.",
202
+ ].join("\\n"),
203
+ );
204
+
205
+ console.log(
206
+ "\\nScenarios (including this one) are defined in the ./scenarios directory.",
207
+ );
208
+ };
209
+ `;
210
+ async function writeDefaultScenariosIndex(destination) {
211
+ const scenariosDir = nodePath.join(destination, "scenarios");
212
+ const filePath = nodePath.join(scenariosDir, "index.ts");
213
+ if (existsSync(filePath)) {
214
+ return;
215
+ }
216
+ await fs.mkdir(scenariosDir, { recursive: true });
217
+ await fs.writeFile(filePath, DEFAULT_SCENARIOS_INDEX, "utf8");
63
218
  }
@@ -22,7 +22,7 @@ export class OperationCoder extends Coder {
22
22
  if (firstResponse === undefined ||
23
23
  !("content" in firstResponse || "schema" in firstResponse)) {
24
24
  return `async ($) => {
25
- return $.response[${firstStatusCode === "default" ? 200 : firstStatusCode}];
25
+ return $.response[${firstStatusCode === "default" ? 200 : firstStatusCode}].empty();
26
26
  }`;
27
27
  }
28
28
  return `async ($) => {
@@ -4,59 +4,11 @@ import { buildJsDoc } from "./jsdoc.js";
4
4
  import { ParameterExportTypeCoder } from "./parameter-export-type-coder.js";
5
5
  import { ParametersTypeCoder } from "./parameters-type-coder.js";
6
6
  import { READ_ONLY_COMMENTS } from "./read-only-comments.js";
7
+ import { RESERVED_WORDS } from "./reserved-words.js";
7
8
  import { ResponsesTypeCoder } from "./responses-type-coder.js";
8
9
  import { SchemaTypeCoder } from "./schema-type-coder.js";
9
10
  import { TypeCoder } from "./type-coder.js";
10
11
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words
11
- const RESERVED_WORDS = new Set([
12
- "break",
13
- "case",
14
- "catch",
15
- "class",
16
- "const",
17
- "continue",
18
- "debugger",
19
- "default",
20
- "delete",
21
- "do",
22
- "else",
23
- "export",
24
- "extends",
25
- "false",
26
- "finally",
27
- "for",
28
- "function",
29
- "if",
30
- "import",
31
- "in",
32
- "instanceof",
33
- "new",
34
- "null",
35
- "return",
36
- "static",
37
- "super",
38
- "switch",
39
- "this",
40
- "throw",
41
- "true",
42
- "try",
43
- "typeof",
44
- "var",
45
- "void",
46
- "while",
47
- "with",
48
- "yield",
49
- "await",
50
- "enum",
51
- "implements",
52
- "interface",
53
- "let",
54
- "package",
55
- "private",
56
- "protected",
57
- "public",
58
- "type",
59
- ]);
60
12
  function sanitizeIdentifier(value) {
61
13
  // Treat any run of non-identifier characters as a camelCase separator
62
14
  let result = value.replaceAll(/[^\w$]+(?<next>.)/gu, (_, char) => char.toUpperCase());
@@ -1,5 +1,5 @@
1
1
  export const READ_ONLY_COMMENTS = [
2
2
  "This code was automatically generated from an OpenAPI description.",
3
3
  "Do not edit this file. Edit the OpenAPI file instead.",
4
- "For more information, see https://github.com/pmcelhaney/counterfact/blob/main/docs/faq-generated-code.md",
4
+ "For more information, see https://github.com/pmcelhaney/counterfact/blob/main/docs/faq.md",
5
5
  ];
@@ -36,7 +36,14 @@ export class Requirement {
36
36
  // Unescape URL encoded characters (e.g. %20 -> " ")
37
37
  // Technically we should not be unescaping, but it came up in https://github.com/pmcelhaney/counterfact/issues/1083
38
38
  // and I can't think of a reason anyone would intentionally put a % in a key name.
39
- .map(unescape);
39
+ .map((part) => {
40
+ try {
41
+ return decodeURIComponent(part);
42
+ }
43
+ catch {
44
+ return part;
45
+ }
46
+ });
40
47
  // eslint-disable-next-line @typescript-eslint/no-this-alias
41
48
  let result = this;
42
49
  for (const part of parts) {