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.
- package/README.md +103 -140
- package/bin/README.md +25 -4
- package/bin/counterfact.js +208 -24
- package/bin/register-ts-loader.mjs +17 -0
- package/bin/ts-loader.mjs +31 -0
- package/dist/app.js +31 -21
- package/dist/counterfact-types/cookie-options.js +1 -0
- package/dist/counterfact-types/counterfact-response.js +7 -0
- package/dist/counterfact-types/example-names.js +1 -0
- package/dist/counterfact-types/example.js +1 -0
- package/dist/counterfact-types/generic-response-builder.js +1 -0
- package/dist/counterfact-types/http-status-code.js +1 -0
- package/dist/counterfact-types/if-has-key.js +1 -0
- package/dist/counterfact-types/index.js +0 -1
- package/dist/counterfact-types/maybe-promise.js +1 -0
- package/dist/counterfact-types/media-type.js +1 -0
- package/dist/counterfact-types/omit-all.js +1 -0
- package/dist/counterfact-types/omit-value-when-never.js +1 -0
- package/dist/counterfact-types/open-api-content.js +1 -0
- package/dist/counterfact-types/open-api-operation.js +1 -0
- package/dist/counterfact-types/open-api-parameters.js +1 -0
- package/dist/counterfact-types/open-api-response.js +1 -0
- package/dist/counterfact-types/random-function.js +1 -0
- package/dist/counterfact-types/response-builder-factory.js +1 -0
- package/dist/counterfact-types/response-builder.js +1 -0
- package/dist/counterfact-types/wide-operation-argument.js +1 -0
- package/dist/counterfact-types/wide-response-builder.js +1 -0
- package/dist/migrate/update-route-types.js +30 -10
- package/dist/repl/raw-http-client.js +14 -14
- package/dist/repl/repl.js +119 -4
- package/dist/repl/route-builder.js +270 -0
- package/dist/server/config.js +1 -1
- package/dist/server/context-registry.js +44 -4
- package/dist/server/counterfact-types/cookie-options.ts +14 -0
- package/dist/server/counterfact-types/counterfact-response.ts +15 -0
- package/dist/server/counterfact-types/example-names.ts +13 -0
- package/dist/server/counterfact-types/example.ts +10 -0
- package/dist/server/counterfact-types/generic-response-builder.ts +164 -0
- package/dist/server/counterfact-types/http-status-code.ts +62 -0
- package/dist/server/counterfact-types/if-has-key.ts +19 -0
- package/dist/server/counterfact-types/index.ts +20 -328
- package/dist/server/counterfact-types/maybe-promise.ts +6 -0
- package/dist/server/counterfact-types/media-type.ts +6 -0
- package/dist/server/counterfact-types/omit-all.ts +11 -0
- package/dist/server/counterfact-types/omit-value-when-never.ts +11 -0
- package/dist/server/counterfact-types/open-api-content.ts +8 -0
- package/dist/server/counterfact-types/open-api-operation.ts +36 -0
- package/dist/server/counterfact-types/open-api-parameters.ts +16 -0
- package/dist/server/counterfact-types/open-api-response.ts +22 -0
- package/dist/server/counterfact-types/random-function.ts +9 -0
- package/dist/server/counterfact-types/response-builder-factory.ts +16 -0
- package/dist/server/counterfact-types/response-builder.ts +31 -0
- package/dist/server/counterfact-types/wide-operation-argument.ts +17 -0
- package/dist/server/counterfact-types/wide-response-builder.ts +26 -0
- package/dist/server/create-koa-app.js +1 -20
- package/dist/server/determine-module-kind.js +1 -1
- package/dist/server/dispatcher.js +39 -15
- package/dist/server/file-discovery.js +34 -0
- package/dist/server/json-to-xml.js +1 -1
- package/dist/server/koa-middleware.js +7 -1
- package/dist/server/load-openapi-document.js +13 -0
- package/dist/server/middleware-detector.js +8 -0
- package/dist/server/module-dependency-graph.js +4 -1
- package/dist/server/module-loader.js +81 -33
- package/dist/server/module-tree.js +26 -23
- package/dist/server/openapi-middleware.js +2 -2
- package/dist/server/openapi-watcher.js +35 -0
- package/dist/server/registry.js +2 -2
- package/dist/server/request-validator.js +57 -0
- package/dist/server/response-builder.js +3 -0
- package/dist/server/response-validator.js +58 -0
- package/dist/server/scenario-registry.js +29 -0
- package/dist/server/tools.js +2 -2
- package/dist/server/transpiler.js +13 -5
- package/dist/typescript-generator/coder.js +7 -2
- package/dist/typescript-generator/generate.js +155 -0
- package/dist/typescript-generator/jsdoc.js +45 -0
- package/dist/typescript-generator/operation-coder.js +1 -1
- package/dist/typescript-generator/operation-type-coder.js +5 -49
- package/dist/typescript-generator/parameters-type-coder.js +5 -1
- package/dist/typescript-generator/prune.js +2 -1
- package/dist/typescript-generator/read-only-comments.js +1 -1
- package/dist/typescript-generator/requirement.js +8 -1
- package/dist/typescript-generator/reserved-words.js +50 -0
- package/dist/typescript-generator/schema-type-coder.js +7 -1
- package/dist/typescript-generator/script.js +5 -3
- package/dist/typescript-generator/specification.js +7 -1
- package/dist/util/load-config-file.js +44 -0
- package/dist/util/runtime-can-execute-erasable-ts.js +22 -0
- package/package.json +12 -12
- package/dist/client/README.md +0 -14
- package/dist/client/index.html.hbs +0 -244
- package/dist/client/rapi-doc.html.hbs +0 -36
- 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?.[
|
|
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(/("(?:\\.|[^"\\])*")(
|
|
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+(
|
|
149
|
+
const match = statusLine.match(/HTTP\/\d+\.\d+\s+(?<statusCode>\d+)/);
|
|
150
150
|
if (match) {
|
|
151
|
-
const code = Number(match[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/server/config.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
+
}
|