counterfact 2.5.1 → 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 (94) hide show
  1. package/README.md +103 -140
  2. package/bin/README.md +25 -4
  3. package/bin/counterfact.js +208 -24
  4. package/bin/register-ts-loader.mjs +17 -0
  5. package/bin/ts-loader.mjs +31 -0
  6. package/dist/app.js +31 -21
  7. package/dist/counterfact-types/cookie-options.js +1 -0
  8. package/dist/counterfact-types/counterfact-response.js +7 -0
  9. package/dist/counterfact-types/example-names.js +1 -0
  10. package/dist/counterfact-types/example.js +1 -0
  11. package/dist/counterfact-types/generic-response-builder.js +1 -0
  12. package/dist/counterfact-types/http-status-code.js +1 -0
  13. package/dist/counterfact-types/if-has-key.js +1 -0
  14. package/dist/counterfact-types/index.js +0 -1
  15. package/dist/counterfact-types/maybe-promise.js +1 -0
  16. package/dist/counterfact-types/media-type.js +1 -0
  17. package/dist/counterfact-types/omit-all.js +1 -0
  18. package/dist/counterfact-types/omit-value-when-never.js +1 -0
  19. package/dist/counterfact-types/open-api-content.js +1 -0
  20. package/dist/counterfact-types/open-api-operation.js +1 -0
  21. package/dist/counterfact-types/open-api-parameters.js +1 -0
  22. package/dist/counterfact-types/open-api-response.js +1 -0
  23. package/dist/counterfact-types/random-function.js +1 -0
  24. package/dist/counterfact-types/response-builder-factory.js +1 -0
  25. package/dist/counterfact-types/response-builder.js +1 -0
  26. package/dist/counterfact-types/wide-operation-argument.js +1 -0
  27. package/dist/counterfact-types/wide-response-builder.js +1 -0
  28. package/dist/migrate/update-route-types.js +30 -10
  29. package/dist/repl/raw-http-client.js +14 -14
  30. package/dist/repl/repl.js +119 -4
  31. package/dist/repl/route-builder.js +270 -0
  32. package/dist/server/config.js +1 -1
  33. package/dist/server/context-registry.js +44 -4
  34. package/dist/server/counterfact-types/cookie-options.ts +14 -0
  35. package/dist/server/counterfact-types/counterfact-response.ts +15 -0
  36. package/dist/server/counterfact-types/example-names.ts +13 -0
  37. package/dist/server/counterfact-types/example.ts +10 -0
  38. package/dist/server/counterfact-types/generic-response-builder.ts +164 -0
  39. package/dist/server/counterfact-types/http-status-code.ts +62 -0
  40. package/dist/server/counterfact-types/if-has-key.ts +19 -0
  41. package/dist/server/counterfact-types/index.ts +20 -328
  42. package/dist/server/counterfact-types/maybe-promise.ts +6 -0
  43. package/dist/server/counterfact-types/media-type.ts +6 -0
  44. package/dist/server/counterfact-types/omit-all.ts +11 -0
  45. package/dist/server/counterfact-types/omit-value-when-never.ts +11 -0
  46. package/dist/server/counterfact-types/open-api-content.ts +8 -0
  47. package/dist/server/counterfact-types/open-api-operation.ts +36 -0
  48. package/dist/server/counterfact-types/open-api-parameters.ts +16 -0
  49. package/dist/server/counterfact-types/open-api-response.ts +22 -0
  50. package/dist/server/counterfact-types/random-function.ts +9 -0
  51. package/dist/server/counterfact-types/response-builder-factory.ts +16 -0
  52. package/dist/server/counterfact-types/response-builder.ts +31 -0
  53. package/dist/server/counterfact-types/wide-operation-argument.ts +17 -0
  54. package/dist/server/counterfact-types/wide-response-builder.ts +26 -0
  55. package/dist/server/create-koa-app.js +1 -20
  56. package/dist/server/determine-module-kind.js +1 -1
  57. package/dist/server/dispatcher.js +39 -15
  58. package/dist/server/file-discovery.js +34 -0
  59. package/dist/server/json-to-xml.js +1 -1
  60. package/dist/server/koa-middleware.js +7 -1
  61. package/dist/server/load-openapi-document.js +13 -0
  62. package/dist/server/middleware-detector.js +8 -0
  63. package/dist/server/module-dependency-graph.js +4 -1
  64. package/dist/server/module-loader.js +81 -33
  65. package/dist/server/module-tree.js +26 -23
  66. package/dist/server/openapi-middleware.js +2 -2
  67. package/dist/server/openapi-watcher.js +35 -0
  68. package/dist/server/registry.js +2 -2
  69. package/dist/server/request-validator.js +57 -0
  70. package/dist/server/response-builder.js +3 -0
  71. package/dist/server/response-validator.js +58 -0
  72. package/dist/server/scenario-registry.js +29 -0
  73. package/dist/server/tools.js +2 -2
  74. package/dist/server/transpiler.js +13 -5
  75. package/dist/typescript-generator/coder.js +7 -2
  76. package/dist/typescript-generator/generate.js +155 -0
  77. package/dist/typescript-generator/jsdoc.js +45 -0
  78. package/dist/typescript-generator/operation-coder.js +1 -1
  79. package/dist/typescript-generator/operation-type-coder.js +5 -49
  80. package/dist/typescript-generator/parameters-type-coder.js +5 -1
  81. package/dist/typescript-generator/prune.js +2 -1
  82. package/dist/typescript-generator/read-only-comments.js +1 -1
  83. package/dist/typescript-generator/requirement.js +8 -1
  84. package/dist/typescript-generator/reserved-words.js +50 -0
  85. package/dist/typescript-generator/schema-type-coder.js +7 -1
  86. package/dist/typescript-generator/script.js +5 -3
  87. package/dist/typescript-generator/specification.js +7 -1
  88. package/dist/util/load-config-file.js +44 -0
  89. package/dist/util/runtime-can-execute-erasable-ts.js +22 -0
  90. package/package.json +12 -12
  91. package/dist/client/README.md +0 -14
  92. package/dist/client/index.html.hbs +0 -244
  93. package/dist/client/rapi-doc.html.hbs +0 -36
  94. package/dist/server/page-middleware.js +0 -23
@@ -11,8 +11,8 @@ const colors = {
11
11
  blue: "\x1b[34m",
12
12
  };
13
13
  function isLikelyJson(headersBlock, body) {
14
- const m = headersBlock.match(/^content-type:\s*([^\r\n;]+)/im);
15
- const ct = (m?.[1] ?? "").toLowerCase();
14
+ const m = headersBlock.match(/^content-type:\s*(?<contentType>[^\r\n;]+)/im);
15
+ const ct = (m?.groups?.["contentType"] ?? "").toLowerCase();
16
16
  if (ct.includes("application/json") || ct.includes("+json"))
17
17
  return true;
18
18
  const s = body.trim();
@@ -30,7 +30,7 @@ function highlightJson(text) {
30
30
  return text;
31
31
  }
32
32
  const pretty = JSON.stringify(obj, null, 2);
33
- return pretty.replace(/("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, (match, str, colon, boolOrNull) => {
33
+ return pretty.replace(/(?<str>"(?:\\.|[^"\\])*")(?<colon>\s*:)?|\b(?<boolOrNull>true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, (match, str, colon, boolOrNull) => {
34
34
  if (str) {
35
35
  if (colon)
36
36
  return `${colors.blue}${str}${colors.reset}${colon}`;
@@ -60,31 +60,31 @@ export class RawHttpClient {
60
60
  this.port = port;
61
61
  }
62
62
  get(path, headers = {}) {
63
- this.#send("GET", path, "", headers);
63
+ return this.#send("GET", path, "", headers);
64
64
  }
65
65
  head(path, headers = {}) {
66
- this.#send("HEAD", path, "", headers);
66
+ return this.#send("HEAD", path, "", headers);
67
67
  }
68
68
  post(path, body = "", headers = {}) {
69
- this.#send("POST", path, body, headers);
69
+ return this.#send("POST", path, body, headers);
70
70
  }
71
71
  put(path, body = "", headers = {}) {
72
- this.#send("PUT", path, body, headers);
72
+ return this.#send("PUT", path, body, headers);
73
73
  }
74
74
  delete(path, headers = {}) {
75
- this.#send("DELETE", path, "", headers);
75
+ return this.#send("DELETE", path, "", headers);
76
76
  }
77
77
  connect(path, headers = {}) {
78
- this.#send("CONNECT", path, "", headers);
78
+ return this.#send("CONNECT", path, "", headers);
79
79
  }
80
80
  options(path, headers = {}) {
81
- this.#send("OPTIONS", path, "", headers);
81
+ return this.#send("OPTIONS", path, "", headers);
82
82
  }
83
83
  trace(path, headers = {}) {
84
- this.#send("TRACE", path, "", headers);
84
+ return this.#send("TRACE", path, "", headers);
85
85
  }
86
86
  patch(path, body = "", headers = {}) {
87
- this.#send("PATCH", path, body, headers);
87
+ return this.#send("PATCH", path, body, headers);
88
88
  }
89
89
  #send(method, path, bodyAsStringOrObject, headers) {
90
90
  const requestNumber = ++this.requestNumber;
@@ -146,9 +146,9 @@ export class RawHttpClient {
146
146
  const lines = head.split("\r\n");
147
147
  const statusLine = lines[0] ?? "";
148
148
  let statusColor = colors.green;
149
- const match = statusLine.match(/HTTP\/\d+\.\d+\s+(\d+)/);
149
+ const match = statusLine.match(/HTTP\/\d+\.\d+\s+(?<statusCode>\d+)/);
150
150
  if (match) {
151
- const code = Number(match[1]);
151
+ const code = Number(match.groups?.["statusCode"]);
152
152
  if (code >= 400)
153
153
  statusColor = colors.red;
154
154
  else if (code >= 300)
package/dist/repl/repl.js CHANGED
@@ -1,11 +1,74 @@
1
1
  import repl from "node:repl";
2
2
  import { RawHttpClient } from "./raw-http-client.js";
3
+ import { createRouteFunction } from "./route-builder.js";
3
4
  function printToStdout(line) {
4
5
  process.stdout.write(`${line}\n`);
5
6
  }
6
- export function createCompleter(registry, fallback) {
7
+ const ROUTE_BUILDER_METHODS = [
8
+ "body(",
9
+ "headers(",
10
+ "help(",
11
+ "method(",
12
+ "missing(",
13
+ "path(",
14
+ "query(",
15
+ "ready(",
16
+ "send(",
17
+ ];
18
+ /**
19
+ * Creates a tab-completion function for the REPL.
20
+ *
21
+ * @param registry - The route registry used to complete path arguments for `route()` and `client.*()` calls.
22
+ * @param fallback - Optional fallback completer (e.g. the Node.js built-in completer) invoked when no custom completion matches.
23
+ * @param scenarioRegistry - When provided, enables tab completion for `.apply` commands by enumerating
24
+ * exported function names and file-key prefixes from the loaded scenario modules.
25
+ */
26
+ export function createCompleter(registry, fallback, scenarioRegistry) {
7
27
  return (line, callback) => {
8
- const match = line.match(/client\.(?:get|post|put|patch|delete)\("(?<partial>[^"]*)$/u);
28
+ // Check for .apply completion: .apply <partial>
29
+ const applyMatch = line.match(/^\.apply\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
+ }
61
+ return;
62
+ }
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]);
69
+ return;
70
+ }
71
+ const match = line.match(/(?:client\.(?:get|post|put|patch|delete)|route)\("(?<partial>[^"]*)$/u);
9
72
  if (!match) {
10
73
  if (fallback) {
11
74
  fallback(line, callback);
@@ -21,7 +84,7 @@ export function createCompleter(registry, fallback) {
21
84
  callback(null, [matches, partial]);
22
85
  };
23
86
  }
24
- export function startRepl(contextRegistry, registry, config, print = printToStdout) {
87
+ export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument, scenarioRegistry) {
25
88
  function printProxyStatus() {
26
89
  if (config.proxyUrl === "") {
27
90
  print("The proxy URL is not set.");
@@ -65,7 +128,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
65
128
  const builtinCompleter = replServer.completer;
66
129
  // completer is typed as readonly in @types/node but is writable at runtime
67
130
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
- replServer.completer = createCompleter(registry, builtinCompleter);
131
+ replServer.completer = createCompleter(registry, builtinCompleter, scenarioRegistry);
69
132
  replServer.defineCommand("counterfact", {
70
133
  action() {
71
134
  print("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.");
@@ -73,6 +136,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
73
136
  print("");
74
137
  print("- loadContext('/some/path'): to access the context object for a given path");
75
138
  print("- context: the root context ( same as loadContext('/') )");
139
+ print("- route('/some/path'): create a request builder for the given path");
76
140
  print("");
77
141
  print("For more information, see https://counterfact.dev/docs/usage.html");
78
142
  print("");
@@ -107,5 +171,56 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
107
171
  replServer.context.context = replServer.context.loadContext("/");
108
172
  replServer.context.client = new RawHttpClient("localhost", config.port);
109
173
  replServer.context.RawHttpClient = RawHttpClient;
174
+ replServer.context.route = createRouteFunction(config.port, "localhost", openApiDocument);
175
+ replServer.context.routes = {};
176
+ replServer.defineCommand("apply", {
177
+ async action(text) {
178
+ const parts = text.trim().split("/").filter(Boolean);
179
+ if (parts.length === 0) {
180
+ print("usage: .apply <path>");
181
+ this.clearBufferedCommand();
182
+ this.displayPrompt();
183
+ return;
184
+ }
185
+ if (parts.some((part) => part === ".." || part === ".")) {
186
+ print("Error: Path must not contain '.' or '..' segments");
187
+ this.clearBufferedCommand();
188
+ this.displayPrompt();
189
+ return;
190
+ }
191
+ const functionName = parts[parts.length - 1] ?? "";
192
+ const fileKey = parts.length === 1 ? "index" : parts.slice(0, -1).join("/");
193
+ const module = scenarioRegistry?.getModule(fileKey);
194
+ if (module === undefined) {
195
+ print(`Error: Could not find scenario file "${fileKey}"`);
196
+ this.clearBufferedCommand();
197
+ this.displayPrompt();
198
+ return;
199
+ }
200
+ const fn = module[functionName];
201
+ if (typeof fn !== "function") {
202
+ print(`Error: "${functionName}" is not a function exported from "${fileKey}"`);
203
+ this.clearBufferedCommand();
204
+ this.displayPrompt();
205
+ return;
206
+ }
207
+ try {
208
+ const applyContext = {
209
+ context: replServer.context["context"],
210
+ loadContext: replServer.context["loadContext"],
211
+ route: replServer.context["route"],
212
+ routes: replServer.context["routes"],
213
+ };
214
+ await fn(applyContext);
215
+ print(`Applied ${text.trim()}`);
216
+ }
217
+ catch (error) {
218
+ print(`Error: ${String(error)}`);
219
+ }
220
+ this.clearBufferedCommand();
221
+ this.displayPrompt();
222
+ },
223
+ help: 'apply a scenario script (".apply <path>" calls the named export from scenarios/)',
224
+ });
110
225
  return replServer;
111
226
  }
@@ -0,0 +1,270 @@
1
+ import { RawHttpClient } from "./raw-http-client.js";
2
+ export class RouteBuilder {
3
+ routePath;
4
+ _body;
5
+ _headerParams;
6
+ _host;
7
+ _method;
8
+ _openApiDocument;
9
+ _pathParams;
10
+ _port;
11
+ _queryParams;
12
+ _operation;
13
+ constructor(routePath, options) {
14
+ this.routePath = routePath;
15
+ this._method = options.method;
16
+ this._pathParams = options.pathParams ?? {};
17
+ this._queryParams = options.queryParams ?? {};
18
+ this._headerParams = options.headerParams ?? {};
19
+ this._body = options.body;
20
+ this._port = options.port;
21
+ this._host = options.host ?? "localhost";
22
+ this._openApiDocument = options.openApiDocument;
23
+ this._operation = this._resolveOperation();
24
+ }
25
+ _resolveOperation() {
26
+ if (!this._openApiDocument || !this._method)
27
+ return undefined;
28
+ const method = this._method.toLowerCase();
29
+ const normalizedPath = this.routePath.toLowerCase();
30
+ for (const key of Object.keys(this._openApiDocument.paths)) {
31
+ if (key.toLowerCase() === normalizedPath) {
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
+ return this._openApiDocument.paths[key][method];
34
+ }
35
+ }
36
+ return undefined;
37
+ }
38
+ clone(overrides) {
39
+ return new RouteBuilder(this.routePath, {
40
+ body: "body" in overrides ? overrides.body : this._body,
41
+ headerParams: overrides.headerParams ?? this._headerParams,
42
+ host: this._host,
43
+ method: overrides.method ?? this._method,
44
+ openApiDocument: this._openApiDocument,
45
+ pathParams: overrides.pathParams ?? this._pathParams,
46
+ port: this._port,
47
+ queryParams: overrides.queryParams ?? this._queryParams,
48
+ });
49
+ }
50
+ method(method) {
51
+ return this.clone({ method: method.toUpperCase() });
52
+ }
53
+ path(params) {
54
+ return this.clone({ pathParams: { ...this._pathParams, ...params } });
55
+ }
56
+ query(params) {
57
+ return this.clone({ queryParams: { ...this._queryParams, ...params } });
58
+ }
59
+ headers(params) {
60
+ return this.clone({ headerParams: { ...this._headerParams, ...params } });
61
+ }
62
+ body(body) {
63
+ return this.clone({ body });
64
+ }
65
+ getOperation() {
66
+ return this._operation;
67
+ }
68
+ ready() {
69
+ if (!this._method)
70
+ return false;
71
+ return this.missing() === undefined;
72
+ }
73
+ missing() {
74
+ const operation = this.getOperation();
75
+ if (!operation?.parameters)
76
+ return undefined;
77
+ const missingParams = {};
78
+ for (const param of operation.parameters) {
79
+ if (!param.required)
80
+ continue;
81
+ const paramType = param.type ?? param.schema?.type ?? "string";
82
+ const paramInfo = {
83
+ description: param.description,
84
+ name: param.name,
85
+ type: paramType,
86
+ };
87
+ if (param.in === "path" && !(param.name in this._pathParams)) {
88
+ missingParams.path = [...(missingParams.path ?? []), paramInfo];
89
+ }
90
+ else if (param.in === "query" && !(param.name in this._queryParams)) {
91
+ missingParams.query = [...(missingParams.query ?? []), paramInfo];
92
+ }
93
+ else if (param.in === "header" && !(param.name in this._headerParams)) {
94
+ missingParams.header = [...(missingParams.header ?? []), paramInfo];
95
+ }
96
+ }
97
+ if (Object.keys(missingParams).length === 0)
98
+ return undefined;
99
+ return missingParams;
100
+ }
101
+ help() {
102
+ const method = this._method ?? "[no method set]";
103
+ const operation = this.getOperation();
104
+ const lines = [];
105
+ lines.push(`${method} ${this.routePath}`);
106
+ if (operation?.summary) {
107
+ lines.push("");
108
+ lines.push("Summary:");
109
+ lines.push(` ${operation.summary}`);
110
+ }
111
+ if (operation?.description) {
112
+ lines.push("");
113
+ lines.push("Description:");
114
+ lines.push(` ${operation.description}`);
115
+ }
116
+ if (operation?.parameters && operation.parameters.length > 0) {
117
+ const pathParams = operation.parameters.filter((p) => p.in === "path");
118
+ const queryParams = operation.parameters.filter((p) => p.in === "query");
119
+ const headerParams = operation.parameters.filter((p) => p.in === "header");
120
+ if (pathParams.length > 0) {
121
+ lines.push("");
122
+ lines.push("Path Parameters:");
123
+ for (const p of pathParams) {
124
+ const paramType = p.type ?? p.schema?.type ?? "string";
125
+ const required = p.required ? "required" : "optional";
126
+ lines.push(` ${p.name} (${paramType}, ${required})`);
127
+ if (p.description)
128
+ lines.push(` Description: ${p.description}`);
129
+ if (p.enum)
130
+ lines.push(` Allowed values: ${p.enum.join(" | ")}`);
131
+ }
132
+ }
133
+ if (queryParams.length > 0) {
134
+ lines.push("");
135
+ lines.push("Query Parameters:");
136
+ for (const p of queryParams) {
137
+ const paramType = p.type ?? p.schema?.type ?? "string";
138
+ const required = p.required ? "required" : "optional";
139
+ lines.push(` ${p.name} (${paramType}, ${required})`);
140
+ if (p.description)
141
+ lines.push(` Description: ${p.description}`);
142
+ const enumValues = p.enum ?? p.schema?.enum;
143
+ if (enumValues)
144
+ lines.push(` Allowed values: ${enumValues.join(" | ")}`);
145
+ }
146
+ }
147
+ if (headerParams.length > 0) {
148
+ lines.push("");
149
+ lines.push("Headers:");
150
+ for (const p of headerParams) {
151
+ const paramType = p.type ?? p.schema?.type ?? "string";
152
+ const required = p.required ? "required" : "optional";
153
+ lines.push(` ${p.name} (${paramType}, ${required})`);
154
+ if (p.description)
155
+ lines.push(` Description: ${p.description}`);
156
+ }
157
+ }
158
+ }
159
+ if (operation?.responses) {
160
+ lines.push("");
161
+ lines.push("Responses:");
162
+ for (const [status, response] of Object.entries(operation.responses)) {
163
+ lines.push(` ${status}`);
164
+ if (response.description) {
165
+ lines.push(` Description: ${response.description}`);
166
+ }
167
+ }
168
+ }
169
+ return lines.join("\n");
170
+ }
171
+ async send() {
172
+ if (!this._method) {
173
+ throw new Error('No HTTP method set. Use .method("get") to set the method.');
174
+ }
175
+ const missing = this.missing();
176
+ if (missing) {
177
+ const lines = [
178
+ "Cannot execute request.",
179
+ "",
180
+ "Missing required parameters:",
181
+ ];
182
+ if (missing.path) {
183
+ lines.push(" path:");
184
+ for (const p of missing.path) {
185
+ lines.push(` - ${p.name} (${p.type ?? "string"})`);
186
+ }
187
+ }
188
+ if (missing.query) {
189
+ lines.push(" query:");
190
+ for (const p of missing.query) {
191
+ lines.push(` - ${p.name} (${p.type ?? "string"})`);
192
+ }
193
+ }
194
+ if (missing.header) {
195
+ lines.push(" header:");
196
+ for (const p of missing.header) {
197
+ lines.push(` - ${p.name} (${p.type ?? "string"})`);
198
+ }
199
+ }
200
+ throw new Error(lines.join("\n"));
201
+ }
202
+ // Build URL with path parameters substituted
203
+ let url = this.routePath;
204
+ for (const [key, value] of Object.entries(this._pathParams)) {
205
+ url = url.replaceAll(`{${key}}`, String(value));
206
+ }
207
+ // Append query string
208
+ const queryEntries = Object.entries(this._queryParams);
209
+ if (queryEntries.length > 0) {
210
+ const qs = new URLSearchParams(queryEntries.map(([k, v]) => [k, String(v)])).toString();
211
+ url = `${url}?${qs}`;
212
+ }
213
+ const client = new RawHttpClient(this._host, this._port);
214
+ const headers = Object.fromEntries(Object.entries(this._headerParams).map(([k, v]) => [k, String(v)]));
215
+ const method = this._method.toLowerCase();
216
+ switch (method) {
217
+ case "get":
218
+ return client.get(url, headers);
219
+ case "head":
220
+ return client.head(url, headers);
221
+ case "delete":
222
+ return client.delete(url, headers);
223
+ case "options":
224
+ return client.options(url, headers);
225
+ case "trace":
226
+ return client.trace(url, headers);
227
+ case "post":
228
+ return client.post(url, this._body, headers);
229
+ case "put":
230
+ return client.put(url, this._body, headers);
231
+ case "patch":
232
+ return client.patch(url, this._body, headers);
233
+ default:
234
+ throw new Error(`Unsupported HTTP method: ${this._method}`);
235
+ }
236
+ }
237
+ [Symbol.for("nodejs.util.inspect.custom")]() {
238
+ const method = this._method ?? "[no method set]";
239
+ const operation = this.getOperation();
240
+ const lines = [];
241
+ lines.push(`${method} ${this.routePath}`);
242
+ if (operation?.parameters) {
243
+ const pathParams = operation.parameters.filter((p) => p.in === "path");
244
+ const queryParams = operation.parameters.filter((p) => p.in === "query");
245
+ if (pathParams.length > 0) {
246
+ lines.push("");
247
+ lines.push("Path:");
248
+ for (const p of pathParams) {
249
+ const value = this._pathParams[p.name];
250
+ lines.push(` ${p.name}: ${value !== undefined ? String(value) : "[missing]"}`);
251
+ }
252
+ }
253
+ if (queryParams.length > 0) {
254
+ lines.push("");
255
+ lines.push("Query:");
256
+ for (const p of queryParams) {
257
+ const value = this._queryParams[p.name];
258
+ const label = p.required ? "[missing]" : "[optional]";
259
+ lines.push(` ${p.name}: ${value !== undefined ? String(value) : label}`);
260
+ }
261
+ }
262
+ }
263
+ lines.push("");
264
+ lines.push(`Ready: ${this.ready()}`);
265
+ return lines.join("\n");
266
+ }
267
+ }
268
+ export function createRouteFunction(port, host, openApiDocument) {
269
+ return (routePath) => new RouteBuilder(routePath, { host, openApiDocument, port });
270
+ }
@@ -1 +1 @@
1
- export const DUMMY_EXPORT_FOR_TEST_COVERAGE = 1;
1
+ export {};
@@ -1,15 +1,40 @@
1
- import cloneDeep from "lodash/cloneDeep.js";
2
1
  export class Context {
3
2
  constructor() { }
4
3
  }
5
4
  export function parentPath(path) {
6
5
  return String(path.split("/").slice(0, -1).join("/")) || "/";
7
6
  }
8
- export class ContextRegistry {
7
+ /**
8
+ * Deep-clones an object for caching purposes.
9
+ * Plain objects and class instances have their own enumerable properties
10
+ * recursively cloned (preserving the prototype chain). Functions are copied
11
+ * by reference since they are not structurally comparable data.
12
+ */
13
+ function cloneForCache(value) {
14
+ if (value === null || typeof value === "function") {
15
+ return value;
16
+ }
17
+ if (typeof value !== "object") {
18
+ return value;
19
+ }
20
+ if (Array.isArray(value)) {
21
+ return value.map(cloneForCache);
22
+ }
23
+ const proto = Object.getPrototypeOf(value);
24
+ const clone = proto !== null && proto !== Object.prototype
25
+ ? Object.create(proto)
26
+ : {};
27
+ for (const key of Object.keys(value)) {
28
+ clone[key] = cloneForCache(value[key]);
29
+ }
30
+ return clone;
31
+ }
32
+ export class ContextRegistry extends EventTarget {
9
33
  entries = new Map();
10
34
  cache = new Map();
11
35
  seen = new Set();
12
36
  constructor() {
37
+ super();
13
38
  this.add("/", {});
14
39
  }
15
40
  getContextIgnoreCase(map, key) {
@@ -23,7 +48,22 @@ export class ContextRegistry {
23
48
  }
24
49
  add(path, context) {
25
50
  this.entries.set(path, context);
26
- this.cache.set(path, cloneDeep(context));
51
+ this.cache.set(path, cloneForCache(context));
52
+ this.dispatchEvent(new Event("context-changed"));
53
+ }
54
+ /**
55
+ * Removes the context entry for the given path and dispatches a
56
+ * "context-changed" event so that listeners (e.g. the scenario-context type
57
+ * generator) can regenerate type files in response to the removal.
58
+ *
59
+ * @param path - The route path whose context entry should be deleted
60
+ * (e.g. "/pets").
61
+ */
62
+ remove(path) {
63
+ this.entries.delete(path);
64
+ this.cache.delete(path);
65
+ this.seen.delete(path);
66
+ this.dispatchEvent(new Event("context-changed"));
27
67
  }
28
68
  find(path) {
29
69
  return (this.getContextIgnoreCase(this.entries, path) ??
@@ -45,7 +85,7 @@ export class ContextRegistry {
45
85
  }
46
86
  }
47
87
  Object.setPrototypeOf(context, Object.getPrototypeOf(updatedContext));
48
- this.cache.set(path, cloneDeep(updatedContext));
88
+ this.cache.set(path, cloneForCache(updatedContext));
49
89
  }
50
90
  getAllPaths() {
51
91
  return Array.from(this.entries.keys());
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Options for setting an HTTP cookie on a response.
3
+ * These correspond to standard `Set-Cookie` attributes and are passed to the
4
+ * `.cookie()` method on the response builder.
5
+ */
6
+ export interface CookieOptions {
7
+ domain?: string;
8
+ expires?: Date;
9
+ httpOnly?: boolean;
10
+ maxAge?: number;
11
+ path?: string;
12
+ sameSite?: "lax" | "none" | "strict";
13
+ secure?: boolean;
14
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A unique symbol used as a brand for the `COUNTERFACT_RESPONSE` type.
3
+ * This prevents arbitrary objects from being accidentally treated as a
4
+ * completed response value.
5
+ */
6
+ const counterfactResponse = Symbol("Counterfact Response");
7
+
8
+ /**
9
+ * The terminal value type returned by the fluent response builder once all
10
+ * required fields (body, headers, etc.) have been provided. When a route
11
+ * handler returns this type, Counterfact treats the response as complete.
12
+ */
13
+ export type COUNTERFACT_RESPONSE = {
14
+ [counterfactResponse]: typeof counterfactResponse;
15
+ };
@@ -0,0 +1,13 @@
1
+ import type { OpenApiResponse } from "./open-api-response.js";
2
+
3
+ /**
4
+ * Extracts the union of named example keys defined on an OpenAPI response.
5
+ * Resolves to `never` when the response has no named examples.
6
+ * Used to constrain the argument to the `.example(name)` method on the
7
+ * response builder.
8
+ */
9
+ export type ExampleNames<Response extends OpenApiResponse> = Response extends {
10
+ examples: infer E;
11
+ }
12
+ ? keyof E & string
13
+ : never;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Represents a named example defined in an OpenAPI document.
3
+ * Examples can be referenced by route handlers via the `.example(name)` method
4
+ * on the response builder.
5
+ */
6
+ export interface Example {
7
+ description: string;
8
+ summary: string;
9
+ value: unknown;
10
+ }