counterfact 2.5.0 → 2.6.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 +1 -0
- package/bin/README.md +1 -0
- package/bin/counterfact.js +164 -23
- package/bin/register-ts-loader.mjs +17 -0
- package/bin/ts-loader.mjs +31 -0
- package/dist/app.js +23 -12
- package/dist/migrate/update-route-types.js +47 -29
- package/dist/repl/raw-http-client.js +14 -14
- package/dist/repl/repl.js +24 -2
- package/dist/repl/route-builder.js +270 -0
- package/dist/server/config.js +1 -1
- package/dist/server/context-registry.js +27 -3
- package/dist/server/counterfact-types/index.ts +11 -1
- package/dist/server/determine-module-kind.js +1 -1
- package/dist/server/dispatcher.js +21 -10
- package/dist/server/file-discovery.js +34 -0
- package/dist/server/middleware-detector.js +8 -0
- package/dist/server/module-dependency-graph.js +4 -1
- package/dist/server/module-loader.js +7 -31
- package/dist/server/module-tree.js +26 -23
- package/dist/server/openapi-middleware.js +2 -2
- package/dist/server/registry.js +2 -2
- package/dist/server/request-validator.js +61 -0
- package/dist/server/transpiler.js +13 -5
- package/dist/typescript-generator/coder.js +8 -4
- package/dist/typescript-generator/generate.js +3 -3
- package/dist/typescript-generator/jsdoc.js +45 -0
- package/dist/typescript-generator/operation-coder.js +8 -5
- package/dist/typescript-generator/operation-type-coder.js +21 -11
- package/dist/typescript-generator/parameter-export-type-coder.js +4 -1
- package/dist/typescript-generator/parameters-type-coder.js +6 -1
- package/dist/typescript-generator/prune.js +11 -11
- package/dist/typescript-generator/repository.js +1 -1
- package/dist/typescript-generator/requirement.js +10 -5
- package/dist/typescript-generator/response-type-coder.js +10 -5
- package/dist/typescript-generator/responses-type-coder.js +1 -0
- package/dist/typescript-generator/schema-coder.js +5 -5
- package/dist/typescript-generator/schema-type-coder.js +23 -12
- package/dist/typescript-generator/script.js +18 -5
- package/dist/typescript-generator/specification.js +13 -4
- package/dist/util/ensure-directory-exists.js +1 -1
- package/dist/util/runtime-can-execute-erasable-ts.js +22 -0
- package/package.json +7 -6
package/dist/repl/repl.js
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
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
|
}
|
|
7
|
+
const ROUTE_BUILDER_METHODS = [
|
|
8
|
+
"body(",
|
|
9
|
+
"headers(",
|
|
10
|
+
"help(",
|
|
11
|
+
"method(",
|
|
12
|
+
"missing(",
|
|
13
|
+
"path(",
|
|
14
|
+
"query(",
|
|
15
|
+
"ready(",
|
|
16
|
+
"send(",
|
|
17
|
+
];
|
|
6
18
|
export function createCompleter(registry, fallback) {
|
|
7
19
|
return (line, callback) => {
|
|
8
|
-
|
|
20
|
+
// Check for RouteBuilder method completion: route("..."). or chained calls
|
|
21
|
+
const builderMatch = line.match(/route\(.*\)\.(?<partial>[a-zA-Z]*)$/u);
|
|
22
|
+
if (builderMatch) {
|
|
23
|
+
const partial = builderMatch.groups?.["partial"] ?? "";
|
|
24
|
+
const matches = ROUTE_BUILDER_METHODS.filter((m) => m.startsWith(partial));
|
|
25
|
+
callback(null, [matches, partial]);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const match = line.match(/(?:client\.(?:get|post|put|patch|delete)|route)\("(?<partial>[^"]*)$/u);
|
|
9
29
|
if (!match) {
|
|
10
30
|
if (fallback) {
|
|
11
31
|
fallback(line, callback);
|
|
@@ -21,7 +41,7 @@ export function createCompleter(registry, fallback) {
|
|
|
21
41
|
callback(null, [matches, partial]);
|
|
22
42
|
};
|
|
23
43
|
}
|
|
24
|
-
export function startRepl(contextRegistry, registry, config, print = printToStdout) {
|
|
44
|
+
export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument) {
|
|
25
45
|
function printProxyStatus() {
|
|
26
46
|
if (config.proxyUrl === "") {
|
|
27
47
|
print("The proxy URL is not set.");
|
|
@@ -73,6 +93,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
73
93
|
print("");
|
|
74
94
|
print("- loadContext('/some/path'): to access the context object for a given path");
|
|
75
95
|
print("- context: the root context ( same as loadContext('/') )");
|
|
96
|
+
print("- route('/some/path'): create a request builder for the given path");
|
|
76
97
|
print("");
|
|
77
98
|
print("For more information, see https://counterfact.dev/docs/usage.html");
|
|
78
99
|
print("");
|
|
@@ -107,5 +128,6 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
107
128
|
replServer.context.context = replServer.context.loadContext("/");
|
|
108
129
|
replServer.context.client = new RawHttpClient("localhost", config.port);
|
|
109
130
|
replServer.context.RawHttpClient = RawHttpClient;
|
|
131
|
+
replServer.context.route = createRouteFunction(config.port, "localhost", openApiDocument);
|
|
110
132
|
return replServer;
|
|
111
133
|
}
|
|
@@ -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,10 +1,34 @@
|
|
|
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
|
}
|
|
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
|
+
}
|
|
8
32
|
export class ContextRegistry {
|
|
9
33
|
entries = new Map();
|
|
10
34
|
cache = new Map();
|
|
@@ -23,7 +47,7 @@ export class ContextRegistry {
|
|
|
23
47
|
}
|
|
24
48
|
add(path, context) {
|
|
25
49
|
this.entries.set(path, context);
|
|
26
|
-
this.cache.set(path,
|
|
50
|
+
this.cache.set(path, cloneForCache(context));
|
|
27
51
|
}
|
|
28
52
|
find(path) {
|
|
29
53
|
return (this.getContextIgnoreCase(this.entries, path) ??
|
|
@@ -45,7 +69,7 @@ export class ContextRegistry {
|
|
|
45
69
|
}
|
|
46
70
|
}
|
|
47
71
|
Object.setPrototypeOf(context, Object.getPrototypeOf(updatedContext));
|
|
48
|
-
this.cache.set(path,
|
|
72
|
+
this.cache.set(path, cloneForCache(updatedContext));
|
|
49
73
|
}
|
|
50
74
|
getAllPaths() {
|
|
51
75
|
return Array.from(this.entries.keys());
|
|
@@ -255,8 +255,10 @@ type HttpStatusCode =
|
|
|
255
255
|
interface OpenApiParameters {
|
|
256
256
|
in: "body" | "cookie" | "formData" | "header" | "path" | "query";
|
|
257
257
|
name: string;
|
|
258
|
+
required?: boolean;
|
|
258
259
|
schema?: {
|
|
259
|
-
|
|
260
|
+
[key: string]: unknown;
|
|
261
|
+
type?: string;
|
|
260
262
|
};
|
|
261
263
|
type?: "string" | "number" | "integer" | "boolean";
|
|
262
264
|
}
|
|
@@ -264,6 +266,14 @@ interface OpenApiParameters {
|
|
|
264
266
|
interface OpenApiOperation {
|
|
265
267
|
parameters?: OpenApiParameters[];
|
|
266
268
|
produces?: string[];
|
|
269
|
+
requestBody?: {
|
|
270
|
+
content?: {
|
|
271
|
+
[mediaType: string]: {
|
|
272
|
+
schema: { [key: string]: unknown };
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
required?: boolean;
|
|
276
|
+
};
|
|
267
277
|
responses: {
|
|
268
278
|
[status: string]: {
|
|
269
279
|
content?: {
|
|
@@ -6,7 +6,7 @@ export async function determineModuleKind(modulePath) {
|
|
|
6
6
|
if (modulePath.endsWith(".cjs")) {
|
|
7
7
|
return "commonjs";
|
|
8
8
|
}
|
|
9
|
-
if (modulePath.endsWith(".mjs")) {
|
|
9
|
+
if (modulePath.endsWith(".mjs") || modulePath.endsWith(".ts")) {
|
|
10
10
|
return "module";
|
|
11
11
|
}
|
|
12
12
|
if (modulePath === path.parse(modulePath).root) {
|
|
@@ -2,6 +2,7 @@ import { mediaTypes } from "@hapi/accept";
|
|
|
2
2
|
import createDebugger from "debug";
|
|
3
3
|
import fetch, { Headers } from "node-fetch";
|
|
4
4
|
import { createResponseBuilder } from "./response-builder.js";
|
|
5
|
+
import { validateRequest } from "./request-validator.js";
|
|
5
6
|
import { Tools } from "./tools.js";
|
|
6
7
|
const debug = createDebugger("counterfact:server:dispatcher");
|
|
7
8
|
function parseCookies(cookieHeader) {
|
|
@@ -17,7 +18,8 @@ function parseCookies(cookieHeader) {
|
|
|
17
18
|
try {
|
|
18
19
|
cookies[key] = decodeURIComponent(value);
|
|
19
20
|
}
|
|
20
|
-
catch {
|
|
21
|
+
catch (error) {
|
|
22
|
+
debug("could not decode cookie value for key %s: %o", key, error);
|
|
21
23
|
cookies[key] = value;
|
|
22
24
|
}
|
|
23
25
|
}
|
|
@@ -39,12 +41,12 @@ export class Dispatcher {
|
|
|
39
41
|
}
|
|
40
42
|
parameterTypes(parameters) {
|
|
41
43
|
const types = {
|
|
42
|
-
body:
|
|
43
|
-
cookie:
|
|
44
|
-
formData:
|
|
45
|
-
header:
|
|
46
|
-
path:
|
|
47
|
-
query:
|
|
44
|
+
body: new Map(),
|
|
45
|
+
cookie: new Map(),
|
|
46
|
+
formData: new Map(),
|
|
47
|
+
header: new Map(),
|
|
48
|
+
path: new Map(),
|
|
49
|
+
query: new Map(),
|
|
48
50
|
};
|
|
49
51
|
if (!parameters) {
|
|
50
52
|
return types;
|
|
@@ -52,8 +54,7 @@ export class Dispatcher {
|
|
|
52
54
|
for (const parameter of parameters) {
|
|
53
55
|
const type = parameter?.type;
|
|
54
56
|
if (type !== undefined) {
|
|
55
|
-
types[parameter.in]
|
|
56
|
-
type === "integer" ? "number" : type;
|
|
57
|
+
types[parameter.in].set(parameter.name, type === "integer" ? "number" : type);
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
return types;
|
|
@@ -137,7 +138,7 @@ export class Dispatcher {
|
|
|
137
138
|
// If the incoming path includes the base path, remove it
|
|
138
139
|
if (this.openApiDocument?.basePath !== undefined &&
|
|
139
140
|
path.toLowerCase().startsWith(this.openApiDocument.basePath.toLowerCase())) {
|
|
140
|
-
path = path.
|
|
141
|
+
path = path.slice(this.openApiDocument.basePath.length);
|
|
141
142
|
}
|
|
142
143
|
const { matchedPath } = this.registry.handler(path, method);
|
|
143
144
|
if (!this.registry.exists(method, path) &&
|
|
@@ -150,6 +151,16 @@ export class Dispatcher {
|
|
|
150
151
|
};
|
|
151
152
|
}
|
|
152
153
|
const operation = this.operationForPathAndMethod(matchedPath, method);
|
|
154
|
+
if (this.config?.validateRequests !== false) {
|
|
155
|
+
const validation = validateRequest(operation, { body, headers, query });
|
|
156
|
+
if (!validation.valid) {
|
|
157
|
+
return {
|
|
158
|
+
body: `Request validation failed:\n${validation.errors.join("\n")}`,
|
|
159
|
+
contentType: "text/plain",
|
|
160
|
+
status: 400,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
153
164
|
const continuousDistribution = (min, max) => {
|
|
154
165
|
return min + Math.random() * (max - min);
|
|
155
166
|
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import nodePath from "node:path";
|
|
4
|
+
import { escapePathForWindows } from "../util/windows-escape.js";
|
|
5
|
+
const JS_EXTENSIONS = new Set(["cjs", "cts", "js", "mjs", "mts", "ts"]);
|
|
6
|
+
export class FileDiscovery {
|
|
7
|
+
basePath;
|
|
8
|
+
constructor(basePath) {
|
|
9
|
+
this.basePath = basePath.replaceAll("\\", "/");
|
|
10
|
+
}
|
|
11
|
+
async findFiles(directory = "") {
|
|
12
|
+
const fullDir = nodePath
|
|
13
|
+
.join(this.basePath, directory)
|
|
14
|
+
.replaceAll("\\", "/");
|
|
15
|
+
if (!existsSync(fullDir)) {
|
|
16
|
+
throw new Error(`Directory does not exist ${fullDir}`);
|
|
17
|
+
}
|
|
18
|
+
const entries = await fs.readdir(fullDir, { withFileTypes: true });
|
|
19
|
+
const results = await Promise.all(entries.map(async (entry) => {
|
|
20
|
+
if (entry.isDirectory()) {
|
|
21
|
+
return this.findFiles(nodePath.join(directory, entry.name).replaceAll("\\", "/"));
|
|
22
|
+
}
|
|
23
|
+
const extension = entry.name.split(".").at(-1);
|
|
24
|
+
if (!JS_EXTENSIONS.has(extension ?? "")) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const fullPath = nodePath
|
|
28
|
+
.join(this.basePath, directory, entry.name)
|
|
29
|
+
.replaceAll("\\", "/");
|
|
30
|
+
return [escapePathForWindows(fullPath)];
|
|
31
|
+
}));
|
|
32
|
+
return results.flat();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function isContextModule(module) {
|
|
2
|
+
return "Context" in module && typeof module.Context === "function";
|
|
3
|
+
}
|
|
4
|
+
export function isMiddlewareModule(module) {
|
|
5
|
+
return ("middleware" in module &&
|
|
6
|
+
typeof Object.getOwnPropertyDescriptor(module, "middleware")?.value ===
|
|
7
|
+
"function");
|
|
8
|
+
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { dirname, resolve } from "node:path";
|
|
2
|
+
import createDebug from "debug";
|
|
2
3
|
import precinct from "precinct";
|
|
4
|
+
const debug = createDebug("counterfact:server:module-dependency-graph");
|
|
3
5
|
export class ModuleDependencyGraph {
|
|
4
6
|
dependents = new Map();
|
|
5
7
|
loadDependencies(path) {
|
|
6
8
|
try {
|
|
7
9
|
return precinct.paperwork(path);
|
|
8
10
|
}
|
|
9
|
-
catch {
|
|
11
|
+
catch (error) {
|
|
12
|
+
debug("could not load dependencies for %s: %o", path, error);
|
|
10
13
|
return [];
|
|
11
14
|
}
|
|
12
15
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { once } from "node:events";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
2
|
import fs from "node:fs/promises";
|
|
4
3
|
import nodePath, { basename, dirname } from "node:path";
|
|
5
4
|
import { watch } from "chokidar";
|
|
@@ -7,25 +6,20 @@ import createDebug from "debug";
|
|
|
7
6
|
import { CHOKIDAR_OPTIONS } from "./constants.js";
|
|
8
7
|
import { ContextRegistry } from "./context-registry.js";
|
|
9
8
|
import { determineModuleKind } from "./determine-module-kind.js";
|
|
9
|
+
import { FileDiscovery } from "./file-discovery.js";
|
|
10
|
+
import { isContextModule, isMiddlewareModule, } from "./middleware-detector.js";
|
|
10
11
|
import { ModuleDependencyGraph } from "./module-dependency-graph.js";
|
|
11
12
|
import { uncachedImport } from "./uncached-import.js";
|
|
13
|
+
import { unescapePathForWindows } from "../util/windows-escape.js";
|
|
12
14
|
const { uncachedRequire } = await import("./uncached-require.cjs");
|
|
13
15
|
const debug = createDebug("counterfact:server:module-loader");
|
|
14
|
-
import { escapePathForWindows, unescapePathForWindows, } from "../util/windows-escape.js";
|
|
15
|
-
function isContextModule(module) {
|
|
16
|
-
return "Context" in module && typeof module.Context === "function";
|
|
17
|
-
}
|
|
18
|
-
function isMiddlewareModule(module) {
|
|
19
|
-
return ("middleware" in module &&
|
|
20
|
-
typeof Object.getOwnPropertyDescriptor(module, "middleware")?.value ===
|
|
21
|
-
"function");
|
|
22
|
-
}
|
|
23
16
|
export class ModuleLoader extends EventTarget {
|
|
24
17
|
basePath;
|
|
25
18
|
registry;
|
|
26
19
|
watcher;
|
|
27
20
|
contextRegistry;
|
|
28
21
|
dependencyGraph = new ModuleDependencyGraph();
|
|
22
|
+
fileDiscovery;
|
|
29
23
|
uncachedImport = async function (moduleName) {
|
|
30
24
|
throw new Error(`uncachedImport not set up; importing ${moduleName}`);
|
|
31
25
|
};
|
|
@@ -34,6 +28,7 @@ export class ModuleLoader extends EventTarget {
|
|
|
34
28
|
this.basePath = basePath.replaceAll("\\", "/");
|
|
35
29
|
this.registry = registry;
|
|
36
30
|
this.contextRegistry = contextRegistry;
|
|
31
|
+
this.fileDiscovery = new FileDiscovery(this.basePath);
|
|
37
32
|
}
|
|
38
33
|
async watch() {
|
|
39
34
|
this.watcher = watch(this.basePath, CHOKIDAR_OPTIONS).on("all", (eventName, pathNameOriginal) => {
|
|
@@ -69,27 +64,8 @@ export class ModuleLoader extends EventTarget {
|
|
|
69
64
|
await this.watcher?.close();
|
|
70
65
|
}
|
|
71
66
|
async load(directory = "") {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
const files = await fs.readdir(nodePath.join(this.basePath, directory).replaceAll("\\", "/"), {
|
|
76
|
-
withFileTypes: true,
|
|
77
|
-
});
|
|
78
|
-
const imports = files.flatMap(async (file) => {
|
|
79
|
-
const extension = file.name.split(".").at(-1);
|
|
80
|
-
if (file.isDirectory()) {
|
|
81
|
-
await this.load(nodePath.join(directory, file.name).replaceAll("\\", "/"));
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
if (!["cjs", "cts", "js", "mjs", "mts", "ts"].includes(extension ?? "")) {
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
const fullPath = nodePath
|
|
88
|
-
.join(this.basePath, directory, file.name)
|
|
89
|
-
.replaceAll("\\", "/");
|
|
90
|
-
await this.loadEndpoint(escapePathForWindows(fullPath));
|
|
91
|
-
});
|
|
92
|
-
await Promise.all(imports);
|
|
67
|
+
const files = await this.fileDiscovery.findFiles(directory);
|
|
68
|
+
await Promise.all(files.map((file) => this.loadEndpoint(file)));
|
|
93
69
|
}
|
|
94
70
|
async loadEndpoint(pathName) {
|
|
95
71
|
debug("importing module: %s", pathName);
|