counterfact 2.6.0 → 2.8.1
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 +14 -207
- package/bin/README.md +24 -4
- package/bin/counterfact.js +54 -3
- package/dist/app.js +81 -28
- 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 +2 -3
- package/dist/repl/raw-http-client.js +19 -0
- package/dist/repl/repl.js +116 -4
- package/dist/repl/route-builder.js +68 -0
- package/dist/server/constants.js +8 -0
- package/dist/server/context-registry.js +70 -1
- 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 -338
- 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 +28 -24
- package/dist/server/determine-module-kind.js +13 -0
- package/dist/server/dispatcher.js +64 -5
- package/dist/server/file-discovery.js +20 -9
- package/dist/server/is-proxy-enabled-for-path.js +12 -0
- package/dist/server/json-to-xml.js +11 -1
- package/dist/server/koa-middleware.js +25 -2
- package/dist/server/load-openapi-document.js +6 -0
- package/dist/server/module-dependency-graph.js +25 -0
- package/dist/server/module-loader.js +112 -17
- package/dist/server/module-tree.js +36 -0
- package/dist/server/openapi-document.js +69 -0
- package/dist/server/openapi-middleware.js +34 -5
- package/dist/server/openapi-watcher.js +35 -0
- package/dist/server/registry.js +89 -0
- package/dist/server/request-validator.js +3 -7
- package/dist/server/response-builder.js +18 -0
- package/dist/server/response-validator.js +58 -0
- package/dist/server/scenario-registry.js +55 -0
- package/dist/server/tools.js +29 -2
- package/dist/server/transpiler.js +23 -9
- package/dist/typescript-generator/code-generator.js +117 -4
- package/dist/typescript-generator/coder.js +80 -2
- package/dist/typescript-generator/operation-coder.js +13 -5
- package/dist/typescript-generator/operation-type-coder.js +40 -53
- package/dist/typescript-generator/parameters-type-coder.js +2 -4
- package/dist/typescript-generator/prune.js +2 -1
- package/dist/typescript-generator/read-only-comments.js +1 -1
- package/dist/typescript-generator/repository.js +76 -20
- package/dist/typescript-generator/requirement.js +77 -1
- package/dist/typescript-generator/reserved-words.js +50 -0
- package/dist/typescript-generator/scenario-file-generator.js +235 -0
- package/dist/typescript-generator/script.js +70 -7
- package/dist/typescript-generator/specification.js +27 -0
- package/dist/util/ensure-directory-exists.js +7 -0
- package/dist/util/forward-slash-path.js +63 -0
- package/dist/util/load-config-file.js +44 -0
- package/dist/util/read-file.js +11 -0
- package/dist/util/runtime-can-execute-erasable-ts.js +11 -0
- package/dist/util/windows-escape.js +18 -0
- package/package.json +9 -10
- 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
- package/dist/typescript-generator/generate.js +0 -63
|
@@ -51,6 +51,16 @@ function stringifyBody(body) {
|
|
|
51
51
|
}
|
|
52
52
|
return JSON.stringify(body);
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* A minimal HTTP/1.1 client that communicates over a raw TCP socket.
|
|
56
|
+
*
|
|
57
|
+
* Used in the Counterfact REPL (`client.*`) to send requests to the local mock
|
|
58
|
+
* server and pretty-print the request and response to `stdout` with ANSI
|
|
59
|
+
* colours.
|
|
60
|
+
*
|
|
61
|
+
* Unlike `fetch` or Axios, `RawHttpClient` does not buffer or parse the
|
|
62
|
+
* response — the raw HTTP response string is returned from every method.
|
|
63
|
+
*/
|
|
54
64
|
export class RawHttpClient {
|
|
55
65
|
host;
|
|
56
66
|
port;
|
|
@@ -59,30 +69,39 @@ export class RawHttpClient {
|
|
|
59
69
|
this.host = host;
|
|
60
70
|
this.port = port;
|
|
61
71
|
}
|
|
72
|
+
/** Sends a `GET` request and returns the raw HTTP response string. */
|
|
62
73
|
get(path, headers = {}) {
|
|
63
74
|
return this.#send("GET", path, "", headers);
|
|
64
75
|
}
|
|
76
|
+
/** Sends a `HEAD` request and returns the raw HTTP response string. */
|
|
65
77
|
head(path, headers = {}) {
|
|
66
78
|
return this.#send("HEAD", path, "", headers);
|
|
67
79
|
}
|
|
80
|
+
/** Sends a `POST` request with `body` and returns the raw HTTP response string. */
|
|
68
81
|
post(path, body = "", headers = {}) {
|
|
69
82
|
return this.#send("POST", path, body, headers);
|
|
70
83
|
}
|
|
84
|
+
/** Sends a `PUT` request with `body` and returns the raw HTTP response string. */
|
|
71
85
|
put(path, body = "", headers = {}) {
|
|
72
86
|
return this.#send("PUT", path, body, headers);
|
|
73
87
|
}
|
|
88
|
+
/** Sends a `DELETE` request and returns the raw HTTP response string. */
|
|
74
89
|
delete(path, headers = {}) {
|
|
75
90
|
return this.#send("DELETE", path, "", headers);
|
|
76
91
|
}
|
|
92
|
+
/** Sends a `CONNECT` request and returns the raw HTTP response string. */
|
|
77
93
|
connect(path, headers = {}) {
|
|
78
94
|
return this.#send("CONNECT", path, "", headers);
|
|
79
95
|
}
|
|
96
|
+
/** Sends an `OPTIONS` request and returns the raw HTTP response string. */
|
|
80
97
|
options(path, headers = {}) {
|
|
81
98
|
return this.#send("OPTIONS", path, "", headers);
|
|
82
99
|
}
|
|
100
|
+
/** Sends a `TRACE` request and returns the raw HTTP response string. */
|
|
83
101
|
trace(path, headers = {}) {
|
|
84
102
|
return this.#send("TRACE", path, "", headers);
|
|
85
103
|
}
|
|
104
|
+
/** Sends a `PATCH` request with `body` and returns the raw HTTP response string. */
|
|
86
105
|
patch(path, body = "", headers = {}) {
|
|
87
106
|
return this.#send("PATCH", path, body, headers);
|
|
88
107
|
}
|
package/dist/repl/repl.js
CHANGED
|
@@ -15,8 +15,51 @@ const ROUTE_BUILDER_METHODS = [
|
|
|
15
15
|
"ready(",
|
|
16
16
|
"send(",
|
|
17
17
|
];
|
|
18
|
-
|
|
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 `.scenario` commands by enumerating
|
|
24
|
+
* exported function names and file-key prefixes from the loaded scenario modules.
|
|
25
|
+
*/
|
|
26
|
+
export function createCompleter(registry, fallback, scenarioRegistry) {
|
|
19
27
|
return (line, callback) => {
|
|
28
|
+
// Check for .scenario completion: .scenario <partial>
|
|
29
|
+
const applyMatch = line.match(/^\.scenario\s+(?<partial>\S*)$/u);
|
|
30
|
+
if (applyMatch) {
|
|
31
|
+
const partial = applyMatch.groups?.["partial"] ?? "";
|
|
32
|
+
if (scenarioRegistry !== undefined) {
|
|
33
|
+
const slashIdx = partial.lastIndexOf("/");
|
|
34
|
+
if (slashIdx === -1) {
|
|
35
|
+
// No slash: complete exports from "index" key + top-level file prefixes
|
|
36
|
+
const indexFunctions = scenarioRegistry.getExportedFunctionNames("index");
|
|
37
|
+
const fileKeys = scenarioRegistry
|
|
38
|
+
.getFileKeys()
|
|
39
|
+
.filter((k) => k !== "index");
|
|
40
|
+
const topLevelPrefixes = [
|
|
41
|
+
...new Set(fileKeys.map((k) => k.split("/")[0] + "/")),
|
|
42
|
+
];
|
|
43
|
+
const allOptions = [...indexFunctions, ...topLevelPrefixes];
|
|
44
|
+
const matches = allOptions.filter((c) => c.startsWith(partial));
|
|
45
|
+
callback(null, [matches, partial]);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// Has slash: complete exports from the named file key
|
|
49
|
+
const fileKey = partial.slice(0, slashIdx);
|
|
50
|
+
const funcPartial = partial.slice(slashIdx + 1);
|
|
51
|
+
const functions = scenarioRegistry.getExportedFunctionNames(fileKey);
|
|
52
|
+
const matches = functions
|
|
53
|
+
.filter((e) => e.startsWith(funcPartial))
|
|
54
|
+
.map((e) => `${fileKey}/${e}`);
|
|
55
|
+
callback(null, [matches, partial]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
callback(null, [[], partial]);
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
20
63
|
// Check for RouteBuilder method completion: route("..."). or chained calls
|
|
21
64
|
const builderMatch = line.match(/route\(.*\)\.(?<partial>[a-zA-Z]*)$/u);
|
|
22
65
|
if (builderMatch) {
|
|
@@ -41,7 +84,26 @@ export function createCompleter(registry, fallback) {
|
|
|
41
84
|
callback(null, [matches, partial]);
|
|
42
85
|
};
|
|
43
86
|
}
|
|
44
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Launches the interactive Counterfact REPL.
|
|
89
|
+
*
|
|
90
|
+
* The REPL is a standard Node.js REPL augmented with:
|
|
91
|
+
* - `context` / `loadContext(path)` globals wired to the {@link ContextRegistry}.
|
|
92
|
+
* - `client` — a {@link RawHttpClient} pre-configured for `localhost`.
|
|
93
|
+
* - `route(path)` — creates a {@link RouteBuilder} for the given path.
|
|
94
|
+
* - `.counterfact` — help command.
|
|
95
|
+
* - `.proxy` — proxy configuration command.
|
|
96
|
+
* - `.scenario` — runs a named scenario function from the scenarios directory.
|
|
97
|
+
*
|
|
98
|
+
* @param contextRegistry - The live context registry.
|
|
99
|
+
* @param registry - The route registry (used for tab completion).
|
|
100
|
+
* @param config - Server configuration.
|
|
101
|
+
* @param print - Output function; defaults to writing to `stdout`.
|
|
102
|
+
* @param openApiDocument - Optional OpenAPI document for tab completion.
|
|
103
|
+
* @param scenarioRegistry - Optional scenario registry for `.scenario` support.
|
|
104
|
+
* @returns The configured Node.js REPL server instance.
|
|
105
|
+
*/
|
|
106
|
+
export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument, scenarioRegistry) {
|
|
45
107
|
function printProxyStatus() {
|
|
46
108
|
if (config.proxyUrl === "") {
|
|
47
109
|
print("The proxy URL is not set.");
|
|
@@ -80,12 +142,12 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
80
142
|
}
|
|
81
143
|
}
|
|
82
144
|
const replServer = repl.start({
|
|
83
|
-
prompt: "⬣> ",
|
|
145
|
+
prompt: "\x1b[38;2;0;113;181m⬣> \x1b[0m",
|
|
84
146
|
});
|
|
85
147
|
const builtinCompleter = replServer.completer;
|
|
86
148
|
// completer is typed as readonly in @types/node but is writable at runtime
|
|
87
149
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
88
|
-
replServer.completer = createCompleter(registry, builtinCompleter);
|
|
150
|
+
replServer.completer = createCompleter(registry, builtinCompleter, scenarioRegistry);
|
|
89
151
|
replServer.defineCommand("counterfact", {
|
|
90
152
|
action() {
|
|
91
153
|
print("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.");
|
|
@@ -129,5 +191,55 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
129
191
|
replServer.context.client = new RawHttpClient("localhost", config.port);
|
|
130
192
|
replServer.context.RawHttpClient = RawHttpClient;
|
|
131
193
|
replServer.context.route = createRouteFunction(config.port, "localhost", openApiDocument);
|
|
194
|
+
replServer.context.routes = {};
|
|
195
|
+
replServer.defineCommand("scenario", {
|
|
196
|
+
async action(text) {
|
|
197
|
+
const parts = text.trim().split("/").filter(Boolean);
|
|
198
|
+
if (parts.length === 0) {
|
|
199
|
+
print("usage: .scenario <path>");
|
|
200
|
+
this.clearBufferedCommand();
|
|
201
|
+
this.displayPrompt();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (parts.some((part) => part === ".." || part === ".")) {
|
|
205
|
+
print("Error: Path must not contain '.' or '..' segments");
|
|
206
|
+
this.clearBufferedCommand();
|
|
207
|
+
this.displayPrompt();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const functionName = parts[parts.length - 1] ?? "";
|
|
211
|
+
const fileKey = parts.length === 1 ? "index" : parts.slice(0, -1).join("/");
|
|
212
|
+
const module = scenarioRegistry?.getModule(fileKey);
|
|
213
|
+
if (module === undefined) {
|
|
214
|
+
print(`Error: Could not find scenario file "${fileKey}"`);
|
|
215
|
+
this.clearBufferedCommand();
|
|
216
|
+
this.displayPrompt();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const fn = module[functionName];
|
|
220
|
+
if (typeof fn !== "function") {
|
|
221
|
+
print(`Error: "${functionName}" is not a function exported from "${fileKey}"`);
|
|
222
|
+
this.clearBufferedCommand();
|
|
223
|
+
this.displayPrompt();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const applyContext = {
|
|
228
|
+
context: replServer.context["context"],
|
|
229
|
+
loadContext: replServer.context["loadContext"],
|
|
230
|
+
route: replServer.context["route"],
|
|
231
|
+
routes: replServer.context["routes"],
|
|
232
|
+
};
|
|
233
|
+
await fn(applyContext);
|
|
234
|
+
print(`Applied ${text.trim()}`);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
print(`Error: ${String(error)}`);
|
|
238
|
+
}
|
|
239
|
+
this.clearBufferedCommand();
|
|
240
|
+
this.displayPrompt();
|
|
241
|
+
},
|
|
242
|
+
help: 'apply a scenario script (".scenario <path>" calls the named export from scenarios/)',
|
|
243
|
+
});
|
|
132
244
|
return replServer;
|
|
133
245
|
}
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { RawHttpClient } from "./raw-http-client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Immutable fluent builder for constructing and sending HTTP requests from the
|
|
4
|
+
* Counterfact REPL.
|
|
5
|
+
*
|
|
6
|
+
* Each builder method returns a **new** `RouteBuilder` instance with the
|
|
7
|
+
* updated field — the original is never mutated. When all required parameters
|
|
8
|
+
* are set, call {@link send} to execute the request.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* // Inside the REPL:
|
|
12
|
+
* route("/pets/{petId}").method("get").path({ petId: 1 }).send();
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
2
15
|
export class RouteBuilder {
|
|
3
16
|
routePath;
|
|
4
17
|
_body;
|
|
@@ -47,29 +60,63 @@ export class RouteBuilder {
|
|
|
47
60
|
queryParams: overrides.queryParams ?? this._queryParams,
|
|
48
61
|
});
|
|
49
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Returns a new builder with the HTTP method set.
|
|
65
|
+
*
|
|
66
|
+
* @param method - HTTP method name (case-insensitive, e.g. `"get"`, `"POST"`).
|
|
67
|
+
*/
|
|
50
68
|
method(method) {
|
|
51
69
|
return this.clone({ method: method.toUpperCase() });
|
|
52
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Returns a new builder with additional path parameters merged in.
|
|
73
|
+
*
|
|
74
|
+
* @param params - Key/value map of path variable names to values.
|
|
75
|
+
*/
|
|
53
76
|
path(params) {
|
|
54
77
|
return this.clone({ pathParams: { ...this._pathParams, ...params } });
|
|
55
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Returns a new builder with additional query parameters merged in.
|
|
81
|
+
*
|
|
82
|
+
* @param params - Key/value map of query parameter names to values.
|
|
83
|
+
*/
|
|
56
84
|
query(params) {
|
|
57
85
|
return this.clone({ queryParams: { ...this._queryParams, ...params } });
|
|
58
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Returns a new builder with additional request headers merged in.
|
|
89
|
+
*
|
|
90
|
+
* @param params - Key/value map of header names to values.
|
|
91
|
+
*/
|
|
59
92
|
headers(params) {
|
|
60
93
|
return this.clone({ headerParams: { ...this._headerParams, ...params } });
|
|
61
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Returns a new builder with the request body set.
|
|
97
|
+
*
|
|
98
|
+
* @param body - The request body (will be serialised to JSON or sent as-is).
|
|
99
|
+
*/
|
|
62
100
|
body(body) {
|
|
63
101
|
return this.clone({ body });
|
|
64
102
|
}
|
|
65
103
|
getOperation() {
|
|
66
104
|
return this._operation;
|
|
67
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Returns `true` when a method is set and no required parameters are
|
|
108
|
+
* missing.
|
|
109
|
+
*/
|
|
68
110
|
ready() {
|
|
69
111
|
if (!this._method)
|
|
70
112
|
return false;
|
|
71
113
|
return this.missing() === undefined;
|
|
72
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Returns a {@link MissingParams} object describing all required parameters
|
|
117
|
+
* that have not yet been set, or `undefined` when nothing is missing (or
|
|
118
|
+
* when the operation has no parameters).
|
|
119
|
+
*/
|
|
73
120
|
missing() {
|
|
74
121
|
const operation = this.getOperation();
|
|
75
122
|
if (!operation?.parameters)
|
|
@@ -98,6 +145,10 @@ export class RouteBuilder {
|
|
|
98
145
|
return undefined;
|
|
99
146
|
return missingParams;
|
|
100
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Returns a human-readable help string describing the operation, its
|
|
150
|
+
* parameters, and the expected responses.
|
|
151
|
+
*/
|
|
101
152
|
help() {
|
|
102
153
|
const method = this._method ?? "[no method set]";
|
|
103
154
|
const operation = this.getOperation();
|
|
@@ -168,6 +219,13 @@ export class RouteBuilder {
|
|
|
168
219
|
}
|
|
169
220
|
return lines.join("\n");
|
|
170
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Executes the HTTP request and returns the parsed response body.
|
|
224
|
+
*
|
|
225
|
+
* @throws When no HTTP method has been set.
|
|
226
|
+
* @throws When required parameters are missing.
|
|
227
|
+
* @throws When an unsupported HTTP method is used.
|
|
228
|
+
*/
|
|
171
229
|
async send() {
|
|
172
230
|
if (!this._method) {
|
|
173
231
|
throw new Error('No HTTP method set. Use .method("get") to set the method.');
|
|
@@ -265,6 +323,16 @@ export class RouteBuilder {
|
|
|
265
323
|
return lines.join("\n");
|
|
266
324
|
}
|
|
267
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* Creates a factory function that constructs a {@link RouteBuilder} for a
|
|
328
|
+
* given route path, pre-configured with the server's host, port, and OpenAPI
|
|
329
|
+
* document.
|
|
330
|
+
*
|
|
331
|
+
* @param port - The port the Counterfact server is listening on.
|
|
332
|
+
* @param host - The server hostname (default `"localhost"`).
|
|
333
|
+
* @param openApiDocument - Optional OpenAPI document for parameter introspection.
|
|
334
|
+
* @returns A function `(routePath: string) => RouteBuilder`.
|
|
335
|
+
*/
|
|
268
336
|
export function createRouteFunction(port, host, openApiDocument) {
|
|
269
337
|
return (routePath) => new RouteBuilder(routePath, { host, openApiDocument, port });
|
|
270
338
|
}
|
package/dist/server/constants.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default options passed to every chokidar watcher in Counterfact.
|
|
3
|
+
*
|
|
4
|
+
* - `ignoreInitial: true` — suppresses the initial `"add"` events emitted for
|
|
5
|
+
* files already present when the watcher starts.
|
|
6
|
+
* - `usePolling: true` on Windows — chokidar's native FSEvents are unreliable
|
|
7
|
+
* on Windows; polling is more reliable there.
|
|
8
|
+
*/
|
|
1
9
|
export const CHOKIDAR_OPTIONS = {
|
|
2
10
|
ignoreInitial: true,
|
|
3
11
|
usePolling: process.platform === "win32",
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A context object that lives at a specific route path and is shared across
|
|
3
|
+
* all requests to that path (and its descendants).
|
|
4
|
+
*
|
|
5
|
+
* Route handlers receive this object as `$.context` and may freely add or
|
|
6
|
+
* modify properties to maintain state between requests.
|
|
7
|
+
*/
|
|
1
8
|
export class Context {
|
|
2
9
|
constructor() { }
|
|
3
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Returns the parent path of a route path by stripping the last segment.
|
|
13
|
+
*
|
|
14
|
+
* @param path - A route path such as `"/pets/1"`.
|
|
15
|
+
* @returns The parent path (e.g. `"/pets"`), or `"/"` for top-level paths.
|
|
16
|
+
*/
|
|
4
17
|
export function parentPath(path) {
|
|
5
18
|
return String(path.split("/").slice(0, -1).join("/")) || "/";
|
|
6
19
|
}
|
|
@@ -29,11 +42,25 @@ function cloneForCache(value) {
|
|
|
29
42
|
}
|
|
30
43
|
return clone;
|
|
31
44
|
}
|
|
32
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Registry of per-path {@link Context} objects that persist state across
|
|
47
|
+
* requests.
|
|
48
|
+
*
|
|
49
|
+
* The registry is case-insensitive for path lookups and uses a write-through
|
|
50
|
+
* cache to detect which context properties have changed between hot-reloads.
|
|
51
|
+
* It extends {@link EventTarget} so that listeners can react to structural
|
|
52
|
+
* changes (e.g. to regenerate type files):
|
|
53
|
+
*
|
|
54
|
+
* ```ts
|
|
55
|
+
* contextRegistry.addEventListener("context-changed", () => { ... });
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export class ContextRegistry extends EventTarget {
|
|
33
59
|
entries = new Map();
|
|
34
60
|
cache = new Map();
|
|
35
61
|
seen = new Set();
|
|
36
62
|
constructor() {
|
|
63
|
+
super();
|
|
37
64
|
this.add("/", {});
|
|
38
65
|
}
|
|
39
66
|
getContextIgnoreCase(map, key) {
|
|
@@ -45,14 +72,54 @@ export class ContextRegistry {
|
|
|
45
72
|
}
|
|
46
73
|
return undefined;
|
|
47
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Registers a new context for `path`, replacing any existing one, and
|
|
77
|
+
* dispatches a `"context-changed"` event so listeners can react.
|
|
78
|
+
*
|
|
79
|
+
* @param path - The route path (e.g. `"/pets"`).
|
|
80
|
+
* @param context - The context object to store.
|
|
81
|
+
*/
|
|
48
82
|
add(path, context) {
|
|
49
83
|
this.entries.set(path, context);
|
|
50
84
|
this.cache.set(path, cloneForCache(context));
|
|
85
|
+
this.dispatchEvent(new Event("context-changed"));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Removes the context entry for the given path and dispatches a
|
|
89
|
+
* "context-changed" event so that listeners (e.g. the _.context type
|
|
90
|
+
* generator) can regenerate type files in response to the removal.
|
|
91
|
+
*
|
|
92
|
+
* @param path - The route path whose context entry should be deleted
|
|
93
|
+
* (e.g. "/pets").
|
|
94
|
+
*/
|
|
95
|
+
remove(path) {
|
|
96
|
+
this.entries.delete(path);
|
|
97
|
+
this.cache.delete(path);
|
|
98
|
+
this.seen.delete(path);
|
|
99
|
+
this.dispatchEvent(new Event("context-changed"));
|
|
51
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Finds the context for `path`, walking up the path hierarchy until a
|
|
103
|
+
* context is found. Falls back to `"/"` which always has a context.
|
|
104
|
+
*
|
|
105
|
+
* @param path - The route path to look up.
|
|
106
|
+
* @returns The nearest ancestor context (or the root context).
|
|
107
|
+
*/
|
|
52
108
|
find(path) {
|
|
53
109
|
return (this.getContextIgnoreCase(this.entries, path) ??
|
|
54
110
|
this.find(parentPath(path)));
|
|
55
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Merges `updatedContext` into the existing context for `path`.
|
|
114
|
+
*
|
|
115
|
+
* On the first call for a path the context is added directly. On subsequent
|
|
116
|
+
* calls only properties whose values differ from the cached snapshot are
|
|
117
|
+
* applied, preserving live mutations made by route handlers between reloads.
|
|
118
|
+
*
|
|
119
|
+
* @param path - The route path (e.g. `"/pets"`).
|
|
120
|
+
* @param updatedContext - The new context instance (typically freshly
|
|
121
|
+
* constructed from the reloaded `_.context.ts` file).
|
|
122
|
+
*/
|
|
56
123
|
update(path, updatedContext) {
|
|
57
124
|
if (updatedContext === undefined) {
|
|
58
125
|
return;
|
|
@@ -71,9 +138,11 @@ export class ContextRegistry {
|
|
|
71
138
|
Object.setPrototypeOf(context, Object.getPrototypeOf(updatedContext));
|
|
72
139
|
this.cache.set(path, cloneForCache(updatedContext));
|
|
73
140
|
}
|
|
141
|
+
/** Returns all registered route paths as an array. */
|
|
74
142
|
getAllPaths() {
|
|
75
143
|
return Array.from(this.entries.keys());
|
|
76
144
|
}
|
|
145
|
+
/** Returns a plain object mapping every registered path to its context. */
|
|
77
146
|
getAllContexts() {
|
|
78
147
|
const result = {};
|
|
79
148
|
for (const [path, context] of this.entries.entries()) {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { COUNTERFACT_RESPONSE } from "./counterfact-response.js";
|
|
2
|
+
import type { CookieOptions } from "./cookie-options.js";
|
|
3
|
+
import type { ExampleNames } from "./example-names.js";
|
|
4
|
+
import type { IfHasKey } from "./if-has-key.js";
|
|
5
|
+
import type { MediaType } from "./media-type.js";
|
|
6
|
+
import type { OmitAll } from "./omit-all.js";
|
|
7
|
+
import type { OmitValueWhenNever } from "./omit-value-when-never.js";
|
|
8
|
+
import type { OpenApiResponse } from "./open-api-response.js";
|
|
9
|
+
import type { RandomFunction } from "./random-function.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns `never` when `Record` is an empty object type (`{}`), signalling
|
|
13
|
+
* that there are no remaining choices available on the response builder.
|
|
14
|
+
*/
|
|
15
|
+
type NeverIfEmpty<Record> = object extends Record ? never : Record;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extracts the union of schema types from a map of media-type content entries.
|
|
19
|
+
* Used to type the body argument of shortcut methods like `.json()` or `.html()`.
|
|
20
|
+
*/
|
|
21
|
+
type SchemasOf<T extends { [key: string]: { schema: unknown } }> = {
|
|
22
|
+
[K in keyof T]: T[K]["schema"];
|
|
23
|
+
}[keyof T];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Produces a builder method for a shortcut (e.g. `.json()`, `.html()`) when
|
|
27
|
+
* the response contains at least one of the given `ContentTypes`, and `never`
|
|
28
|
+
* otherwise. Calling the method narrows the builder by removing those content
|
|
29
|
+
* types from the remaining options.
|
|
30
|
+
*/
|
|
31
|
+
type MaybeShortcut<
|
|
32
|
+
ContentTypes extends MediaType[],
|
|
33
|
+
Response extends OpenApiResponse,
|
|
34
|
+
> = IfHasKey<
|
|
35
|
+
Response["content"],
|
|
36
|
+
ContentTypes,
|
|
37
|
+
(body: SchemasOf<Response["content"]>) => GenericResponseBuilder<{
|
|
38
|
+
content: NeverIfEmpty<OmitAll<Response["content"], ContentTypes>>;
|
|
39
|
+
headers: Response["headers"];
|
|
40
|
+
requiredHeaders: Response["requiredHeaders"];
|
|
41
|
+
}>,
|
|
42
|
+
never
|
|
43
|
+
>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The type of the `.match(contentType, body)` method on the generic response
|
|
47
|
+
* builder. Calling it narrows the builder by removing the chosen content type
|
|
48
|
+
* from the remaining options.
|
|
49
|
+
*/
|
|
50
|
+
type MatchFunction<Response extends OpenApiResponse> = <
|
|
51
|
+
ContentType extends MediaType & keyof Response["content"],
|
|
52
|
+
>(
|
|
53
|
+
contentType: ContentType,
|
|
54
|
+
body: Response["content"][ContentType]["schema"],
|
|
55
|
+
) => GenericResponseBuilder<{
|
|
56
|
+
content: NeverIfEmpty<Omit<Response["content"], ContentType>>;
|
|
57
|
+
headers: Response["headers"];
|
|
58
|
+
requiredHeaders: Response["requiredHeaders"];
|
|
59
|
+
}>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The type of the `.header(name, value)` method on the generic response
|
|
63
|
+
* builder. Calling it narrows the builder by removing the satisfied header
|
|
64
|
+
* from the set of required headers.
|
|
65
|
+
*/
|
|
66
|
+
type HeaderFunction<Response extends OpenApiResponse> = <
|
|
67
|
+
Header extends string & keyof Response["headers"],
|
|
68
|
+
>(
|
|
69
|
+
header: Header,
|
|
70
|
+
value: Response["headers"][Header]["schema"],
|
|
71
|
+
) => GenericResponseBuilder<{
|
|
72
|
+
content: NeverIfEmpty<Response["content"]>;
|
|
73
|
+
headers: NeverIfEmpty<Omit<Response["headers"], Header>>;
|
|
74
|
+
requiredHeaders: Exclude<Response["requiredHeaders"], Header>;
|
|
75
|
+
}>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The inner shape of the generic response builder, listing all methods that
|
|
79
|
+
* are currently available given the remaining response constraints.
|
|
80
|
+
* Methods whose type resolves to `never` are stripped by `OmitValueWhenNever`.
|
|
81
|
+
*
|
|
82
|
+
* Note: `[T] extends [never]` (non-distributive tuple wrapping) is used
|
|
83
|
+
* alongside `[keyof T] extends [never]` to correctly handle both `T = never`
|
|
84
|
+
* (spec-generated no-body) and `T = {}` (all content types consumed) cases.
|
|
85
|
+
* TypeScript evaluates `keyof never` as `string | number | symbol`, so a
|
|
86
|
+
* direct `[keyof never] extends [never]` check would incorrectly return false.
|
|
87
|
+
*/
|
|
88
|
+
export type GenericResponseBuilderInner<
|
|
89
|
+
Response extends OpenApiResponse = OpenApiResponse,
|
|
90
|
+
> = OmitValueWhenNever<{
|
|
91
|
+
binary: MaybeShortcut<["application/octet-stream"], Response>;
|
|
92
|
+
cookie: (
|
|
93
|
+
name: string,
|
|
94
|
+
value: string,
|
|
95
|
+
options?: CookieOptions,
|
|
96
|
+
) => GenericResponseBuilder<Response>;
|
|
97
|
+
empty: [Response["content"]] extends [never]
|
|
98
|
+
? () => COUNTERFACT_RESPONSE
|
|
99
|
+
: [keyof Response["content"]] extends [never]
|
|
100
|
+
? () => COUNTERFACT_RESPONSE
|
|
101
|
+
: never;
|
|
102
|
+
header: [Response["headers"]] extends [never]
|
|
103
|
+
? never
|
|
104
|
+
: [keyof Response["headers"]] extends [never]
|
|
105
|
+
? never
|
|
106
|
+
: HeaderFunction<Response>;
|
|
107
|
+
html: MaybeShortcut<["text/html"], Response>;
|
|
108
|
+
json: MaybeShortcut<
|
|
109
|
+
[
|
|
110
|
+
"application/json",
|
|
111
|
+
"text/json",
|
|
112
|
+
"text/x-json",
|
|
113
|
+
"application/xml",
|
|
114
|
+
"text/xml",
|
|
115
|
+
],
|
|
116
|
+
Response
|
|
117
|
+
>;
|
|
118
|
+
match: [Response["content"]] extends [never]
|
|
119
|
+
? never
|
|
120
|
+
: [keyof Response["content"]] extends [never]
|
|
121
|
+
? never
|
|
122
|
+
: MatchFunction<Response>;
|
|
123
|
+
random: [Response["content"]] extends [never]
|
|
124
|
+
? never
|
|
125
|
+
: [keyof Response["content"]] extends [never]
|
|
126
|
+
? never
|
|
127
|
+
: RandomFunction;
|
|
128
|
+
example: [ExampleNames<Response>] extends [never]
|
|
129
|
+
? never
|
|
130
|
+
: (name: ExampleNames<Response>) => COUNTERFACT_RESPONSE;
|
|
131
|
+
text: MaybeShortcut<["text/plain"], Response>;
|
|
132
|
+
xml: MaybeShortcut<["application/xml", "text/xml"], Response>;
|
|
133
|
+
}>;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The strongly-typed, fluent response builder generated for each operation in
|
|
137
|
+
* a route handler. Its available methods are derived from the OpenAPI response
|
|
138
|
+
* schema: as methods are called, the builder type narrows until all required
|
|
139
|
+
* content and headers have been provided, at which point it resolves to
|
|
140
|
+
* `COUNTERFACT_RESPONSE`.
|
|
141
|
+
*
|
|
142
|
+
* When a Response type carries an `examples` key it is a spec-generated
|
|
143
|
+
* response (either the initial no-body builder or a builder that still has
|
|
144
|
+
* content/headers to satisfy). Those always go through
|
|
145
|
+
* `GenericResponseBuilderInner`, which exposes `empty()` when `content` is
|
|
146
|
+
* `never`.
|
|
147
|
+
*
|
|
148
|
+
* When a Response type has no `examples` key it is a narrowed type produced
|
|
149
|
+
* by a method call (e.g. `.json()` sets the body and returns a type without
|
|
150
|
+
* `examples`). Those go through the existing collapse logic so that
|
|
151
|
+
* fully-satisfied responses resolve directly to `COUNTERFACT_RESPONSE`.
|
|
152
|
+
*/
|
|
153
|
+
export type GenericResponseBuilder<
|
|
154
|
+
Response extends OpenApiResponse = OpenApiResponse,
|
|
155
|
+
> = "examples" extends keyof Response
|
|
156
|
+
? GenericResponseBuilderInner<Response>
|
|
157
|
+
: object extends OmitValueWhenNever<Omit<Response, "examples">>
|
|
158
|
+
? COUNTERFACT_RESPONSE
|
|
159
|
+
: keyof OmitValueWhenNever<Omit<Response, "examples">> extends "headers"
|
|
160
|
+
? {
|
|
161
|
+
ALL_REMAINING_HEADERS_ARE_OPTIONAL: COUNTERFACT_RESPONSE;
|
|
162
|
+
header: HeaderFunction<Response>;
|
|
163
|
+
}
|
|
164
|
+
: GenericResponseBuilderInner<Response>;
|