counterfact 2.7.0 → 2.9.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 +5 -160
- package/bin/README.md +39 -14
- package/bin/counterfact.js +18 -539
- package/bin/ts-loader.mjs +1 -0
- package/dist/api-runner.js +202 -0
- package/dist/app.js +102 -114
- package/dist/cli/banner.js +81 -0
- package/dist/cli/check-for-updates.js +45 -0
- package/dist/cli/run.js +304 -0
- package/dist/cli/telemetry.js +50 -0
- package/dist/migrate/paths-to-routes.js +1 -0
- package/dist/migrate/update-route-types.js +3 -3
- package/dist/msw.js +78 -0
- package/dist/repl/raw-http-client.js +22 -1
- package/dist/repl/repl.js +250 -63
- package/dist/repl/route-builder.js +68 -0
- package/dist/server/constants.js +8 -0
- package/dist/server/context-registry.js +54 -1
- package/dist/server/determine-module-kind.js +14 -0
- package/dist/server/dispatcher.js +46 -0
- package/dist/server/file-discovery.js +21 -9
- package/dist/server/is-proxy-enabled-for-path.js +12 -0
- package/dist/server/json-to-xml.js +10 -0
- package/dist/server/load-openapi-document.js +4 -11
- package/dist/server/module-dependency-graph.js +25 -0
- package/dist/server/module-loader.js +52 -21
- package/dist/server/module-tree.js +36 -0
- package/dist/server/openapi-document.js +69 -0
- package/dist/server/registry.js +89 -0
- package/dist/server/response-builder.js +15 -0
- package/dist/server/scenario-registry.js +26 -0
- package/dist/server/tools.js +27 -0
- package/dist/server/transpiler.js +24 -9
- package/dist/server/{admin-api-middleware.js → web-server/admin-api-middleware.js} +19 -9
- package/dist/server/web-server/create-koa-app.js +68 -0
- package/dist/server/web-server/openapi-middleware.js +34 -0
- package/dist/server/{koa-middleware.js → web-server/routes-middleware.js} +26 -6
- package/dist/typescript-generator/code-generator.js +118 -4
- package/dist/typescript-generator/coder.js +76 -0
- package/dist/typescript-generator/operation-coder.js +12 -4
- package/dist/typescript-generator/operation-type-coder.js +39 -4
- package/dist/typescript-generator/parameters-type-coder.js +2 -4
- package/dist/typescript-generator/prune.js +3 -1
- package/dist/typescript-generator/repository.js +77 -20
- package/dist/typescript-generator/requirement.js +69 -0
- package/dist/typescript-generator/{generate.js → scenario-file-generator.js} +99 -81
- package/dist/typescript-generator/script.js +70 -7
- package/dist/typescript-generator/specification.js +27 -0
- package/dist/util/ensure-directory-exists.js +8 -0
- package/dist/util/forward-slash-path.js +63 -0
- package/dist/util/load-config-file.js +2 -2
- package/dist/util/read-file.js +27 -2
- package/dist/util/runtime-can-execute-erasable-ts.js +12 -0
- package/dist/util/windows-escape.js +18 -0
- package/package.json +5 -4
- package/dist/server/create-koa-app.js +0 -42
- package/dist/server/openapi-middleware.js +0 -19
|
@@ -6,6 +6,15 @@ import { validateRequest } from "./request-validator.js";
|
|
|
6
6
|
import { validateResponse } from "./response-validator.js";
|
|
7
7
|
import { Tools } from "./tools.js";
|
|
8
8
|
const debug = createDebugger("counterfact:server:dispatcher");
|
|
9
|
+
/**
|
|
10
|
+
* Parses the `Cookie` request header into a key/value map.
|
|
11
|
+
*
|
|
12
|
+
* Duplicate keys are silently dropped (first occurrence wins) and values are
|
|
13
|
+
* percent-decoded where possible.
|
|
14
|
+
*
|
|
15
|
+
* @param cookieHeader - The raw `Cookie` header string.
|
|
16
|
+
* @returns A record mapping cookie name to decoded value.
|
|
17
|
+
*/
|
|
9
18
|
function parseCookies(cookieHeader) {
|
|
10
19
|
const cookies = {};
|
|
11
20
|
for (const part of cookieHeader.split(";")) {
|
|
@@ -27,6 +36,16 @@ function parseCookies(cookieHeader) {
|
|
|
27
36
|
}
|
|
28
37
|
return cookies;
|
|
29
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Core HTTP request dispatcher.
|
|
41
|
+
*
|
|
42
|
+
* Receives incoming requests from the Koa middleware layer, matches them
|
|
43
|
+
* against the {@link Registry}, optionally validates the request and response
|
|
44
|
+
* against the OpenAPI spec, and invokes the matching route-handler function.
|
|
45
|
+
*
|
|
46
|
+
* Content-negotiation (Accept header handling) is performed before returning
|
|
47
|
+
* the response so the caller always receives the most appropriate content type.
|
|
48
|
+
*/
|
|
30
49
|
export class Dispatcher {
|
|
31
50
|
registry;
|
|
32
51
|
contextRegistry;
|
|
@@ -70,6 +89,14 @@ export class Dispatcher {
|
|
|
70
89
|
}
|
|
71
90
|
return undefined;
|
|
72
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolves the OpenAPI operation for `path` and `method`, merging any
|
|
94
|
+
* top-level `produces` array from the document root into the operation.
|
|
95
|
+
*
|
|
96
|
+
* @param path - The matched route path (e.g. `"/pets/{petId}"`).
|
|
97
|
+
* @param method - The HTTP method.
|
|
98
|
+
* @returns The {@link OpenApiOperation} if found, or `undefined`.
|
|
99
|
+
*/
|
|
73
100
|
operationForPathAndMethod(path, method) {
|
|
74
101
|
const operation = this.findOperation(path, method);
|
|
75
102
|
if (operation === undefined) {
|
|
@@ -108,6 +135,16 @@ export class Dispatcher {
|
|
|
108
135
|
"unknown/unknown",
|
|
109
136
|
};
|
|
110
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Picks the best matching content entry from a multi-type response using the
|
|
140
|
+
* request's `Accept` header preferences.
|
|
141
|
+
*
|
|
142
|
+
* @param acceptHeader - The value of the `Accept` request header.
|
|
143
|
+
* @param content - Array of `{ type, body }` objects representing all
|
|
144
|
+
* available content-type variants.
|
|
145
|
+
* @returns The first entry whose MIME type satisfies the accept preferences,
|
|
146
|
+
* or `undefined` when none match.
|
|
147
|
+
*/
|
|
111
148
|
selectContent(acceptHeader, content) {
|
|
112
149
|
const preferredMediaTypes = mediaTypes(acceptHeader);
|
|
113
150
|
for (const mediaType of preferredMediaTypes) {
|
|
@@ -132,6 +169,15 @@ export class Dispatcher {
|
|
|
132
169
|
}
|
|
133
170
|
return false;
|
|
134
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Main request handler.
|
|
174
|
+
*
|
|
175
|
+
* Orchestrates base-path stripping, route matching, request validation,
|
|
176
|
+
* handler invocation, content negotiation, and response validation.
|
|
177
|
+
*
|
|
178
|
+
* @param request - The incoming request descriptor.
|
|
179
|
+
* @returns A promise that resolves to a {@link CounterfactResponseObject}.
|
|
180
|
+
*/
|
|
135
181
|
async request({ auth, body, headers = {}, method, path, query, rawBody, req, }) {
|
|
136
182
|
debug(`request: ${method} ${path}`);
|
|
137
183
|
debug(`headers: ${JSON.stringify(headers)}`);
|
|
@@ -1,32 +1,44 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
-
|
|
3
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- discovery walks directories rooted at basePath and uses Dirent-provided names. */
|
|
4
|
+
import { toForwardSlashPath, pathJoin } from "../util/forward-slash-path.js";
|
|
4
5
|
import { escapePathForWindows } from "../util/windows-escape.js";
|
|
5
6
|
const JS_EXTENSIONS = new Set(["cjs", "cts", "js", "mjs", "mts", "ts"]);
|
|
7
|
+
/**
|
|
8
|
+
* Recursively discovers JavaScript/TypeScript source files under a base
|
|
9
|
+
* directory.
|
|
10
|
+
*
|
|
11
|
+
* Only files with one of the following extensions are returned:
|
|
12
|
+
* `js`, `mjs`, `cjs`, `ts`, `mts`, `cts`.
|
|
13
|
+
*/
|
|
6
14
|
export class FileDiscovery {
|
|
7
15
|
basePath;
|
|
8
16
|
constructor(basePath) {
|
|
9
|
-
this.basePath = basePath
|
|
17
|
+
this.basePath = toForwardSlashPath(basePath);
|
|
10
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Returns an array of absolute file paths for all JS/TS files found
|
|
21
|
+
* recursively under `basePath/directory`.
|
|
22
|
+
*
|
|
23
|
+
* @param directory - Sub-directory relative to `basePath` to start from.
|
|
24
|
+
* Defaults to `""` (the base path itself).
|
|
25
|
+
* @throws When `basePath/directory` does not exist.
|
|
26
|
+
*/
|
|
11
27
|
async findFiles(directory = "") {
|
|
12
|
-
const fullDir =
|
|
13
|
-
.join(this.basePath, directory)
|
|
14
|
-
.replaceAll("\\", "/");
|
|
28
|
+
const fullDir = pathJoin(this.basePath, directory);
|
|
15
29
|
if (!existsSync(fullDir)) {
|
|
16
30
|
throw new Error(`Directory does not exist ${fullDir}`);
|
|
17
31
|
}
|
|
18
32
|
const entries = await fs.readdir(fullDir, { withFileTypes: true });
|
|
19
33
|
const results = await Promise.all(entries.map(async (entry) => {
|
|
20
34
|
if (entry.isDirectory()) {
|
|
21
|
-
return this.findFiles(
|
|
35
|
+
return this.findFiles(pathJoin(directory, entry.name));
|
|
22
36
|
}
|
|
23
37
|
const extension = entry.name.split(".").at(-1);
|
|
24
38
|
if (!JS_EXTENSIONS.has(extension ?? "")) {
|
|
25
39
|
return [];
|
|
26
40
|
}
|
|
27
|
-
const fullPath =
|
|
28
|
-
.join(this.basePath, directory, entry.name)
|
|
29
|
-
.replaceAll("\\", "/");
|
|
41
|
+
const fullPath = pathJoin(this.basePath, directory, entry.name);
|
|
30
42
|
return [escapePathForWindows(fullPath)];
|
|
31
43
|
}));
|
|
32
44
|
return results.flat();
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Determines whether a given request path should be forwarded to the upstream
|
|
3
|
+
* proxy.
|
|
4
|
+
*
|
|
5
|
+
* The check walks up the path hierarchy until it either finds an explicit
|
|
6
|
+
* `proxyPaths` entry or reaches the root (in which case the proxy is
|
|
7
|
+
* considered disabled).
|
|
8
|
+
*
|
|
9
|
+
* @param path - The request path to check (e.g. `"/pets/1"`).
|
|
10
|
+
* @param config - Object containing the `proxyPaths` map.
|
|
11
|
+
* @returns `true` when the request should be proxied.
|
|
12
|
+
*/
|
|
1
13
|
export function isProxyEnabledForPath(path, config) {
|
|
2
14
|
if (config.proxyPaths.has(path)) {
|
|
3
15
|
return config.proxyPaths.get(path) ?? false;
|
|
@@ -36,6 +36,16 @@ function objectToXml(json, schema, name) {
|
|
|
36
36
|
});
|
|
37
37
|
return `<${name}${attributes.join("")}>${String(xml.join(""))}</${name}>`;
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Converts a JSON value to an XML string using optional OpenAPI `xml` schema
|
|
41
|
+
* hints (element names, attributes, wrapping).
|
|
42
|
+
*
|
|
43
|
+
* @param json - The value to serialise.
|
|
44
|
+
* @param schema - Optional JSON Schema with an `xml` hint block.
|
|
45
|
+
* @param keyName - Fallback XML element name when the schema does not provide
|
|
46
|
+
* one. Defaults to `"root"`.
|
|
47
|
+
* @returns A well-formed XML string.
|
|
48
|
+
*/
|
|
39
49
|
export function jsonToXml(json, schema, keyName = "root") {
|
|
40
50
|
const name = schema?.xml?.name ?? keyName;
|
|
41
51
|
if (Array.isArray(json)) {
|
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import createDebug from "debug";
|
|
3
|
-
const debug = createDebug("counterfact:server:load-openapi-document");
|
|
1
|
+
import { OpenApiDocument } from "./openapi-document.js";
|
|
4
2
|
export async function loadOpenApiDocument(source) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
catch (error) {
|
|
9
|
-
debug("could not load OpenAPI document from %s: %o", source, error);
|
|
10
|
-
const details = error instanceof Error ? error.message : String(error);
|
|
11
|
-
throw new Error(`Could not load the OpenAPI spec from "${source}".\n${details}`, { cause: error });
|
|
12
|
-
}
|
|
3
|
+
const document = new OpenApiDocument(source);
|
|
4
|
+
await document.load();
|
|
5
|
+
return document;
|
|
13
6
|
}
|
|
@@ -2,6 +2,14 @@ import { dirname, resolve } from "node:path";
|
|
|
2
2
|
import createDebug from "debug";
|
|
3
3
|
import precinct from "precinct";
|
|
4
4
|
const debug = createDebug("counterfact:server:module-dependency-graph");
|
|
5
|
+
/**
|
|
6
|
+
* Tracks which route files depend on shared modules so that when a shared
|
|
7
|
+
* module changes, all dependent route files can be reloaded.
|
|
8
|
+
*
|
|
9
|
+
* Dependency edges are extracted using [precinct](https://npm.im/precinct)'s
|
|
10
|
+
* static analysis and are stored as a reverse map (`dependency → Set<dependent
|
|
11
|
+
* files>`).
|
|
12
|
+
*/
|
|
5
13
|
export class ModuleDependencyGraph {
|
|
6
14
|
dependents = new Map();
|
|
7
15
|
loadDependencies(path) {
|
|
@@ -18,6 +26,14 @@ export class ModuleDependencyGraph {
|
|
|
18
26
|
group.delete(path);
|
|
19
27
|
});
|
|
20
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* (Re-)indexes the dependency edges for `path`, replacing any previously
|
|
31
|
+
* recorded edges.
|
|
32
|
+
*
|
|
33
|
+
* Only relative imports are tracked; node_modules dependencies are ignored.
|
|
34
|
+
*
|
|
35
|
+
* @param path - Absolute path of the file to analyse.
|
|
36
|
+
*/
|
|
21
37
|
load(path) {
|
|
22
38
|
this.clearDependents(path);
|
|
23
39
|
for (const dependency of this.loadDependencies(path)) {
|
|
@@ -31,6 +47,15 @@ export class ModuleDependencyGraph {
|
|
|
31
47
|
this.dependents.get(key)?.add(path);
|
|
32
48
|
}
|
|
33
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Returns the transitive set of files that (directly or indirectly) import
|
|
52
|
+
* `path`.
|
|
53
|
+
*
|
|
54
|
+
* Uses a BFS traversal so each dependent is returned exactly once.
|
|
55
|
+
*
|
|
56
|
+
* @param path - Absolute path of the changed dependency.
|
|
57
|
+
* @returns A `Set` of absolute paths of all dependent files.
|
|
58
|
+
*/
|
|
34
59
|
dependentsOf(path) {
|
|
35
60
|
const marked = new Set();
|
|
36
61
|
const dependents = new Set();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { once } from "node:events";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
-
import nodePath, { basename
|
|
3
|
+
import nodePath, { basename } from "node:path";
|
|
4
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- readJson resolves paths against the current context directory before file access. */
|
|
4
5
|
import { watch } from "chokidar";
|
|
5
6
|
import createDebug from "debug";
|
|
6
7
|
import { CHOKIDAR_OPTIONS } from "./constants.js";
|
|
@@ -10,9 +11,23 @@ import { FileDiscovery } from "./file-discovery.js";
|
|
|
10
11
|
import { isContextModule, isMiddlewareModule, } from "./middleware-detector.js";
|
|
11
12
|
import { ModuleDependencyGraph } from "./module-dependency-graph.js";
|
|
12
13
|
import { uncachedImport } from "./uncached-import.js";
|
|
14
|
+
import { toForwardSlashPath, pathDirname, pathRelative, } from "../util/forward-slash-path.js";
|
|
13
15
|
import { unescapePathForWindows } from "../util/windows-escape.js";
|
|
14
16
|
const { uncachedRequire } = await import("./uncached-require.cjs");
|
|
15
17
|
const debug = createDebug("counterfact:server:module-loader");
|
|
18
|
+
/**
|
|
19
|
+
* Watches the compiled routes directory and dynamically loads/reloads route
|
|
20
|
+
* modules, context files, and middleware as files are added, changed, or
|
|
21
|
+
* removed.
|
|
22
|
+
*
|
|
23
|
+
* Loaded modules are registered in the {@link Registry} (route handlers) or
|
|
24
|
+
* the {@link ContextRegistry} (context files). An optional
|
|
25
|
+
* {@link ScenarioRegistry} receives scenario modules loaded from a separate
|
|
26
|
+
* `scenarios/` directory.
|
|
27
|
+
*
|
|
28
|
+
* Emits DOM-style events (`"add"`, `"remove"`) so callers can react to module
|
|
29
|
+
* lifecycle changes.
|
|
30
|
+
*/
|
|
16
31
|
export class ModuleLoader extends EventTarget {
|
|
17
32
|
basePath;
|
|
18
33
|
registry;
|
|
@@ -28,19 +43,29 @@ export class ModuleLoader extends EventTarget {
|
|
|
28
43
|
};
|
|
29
44
|
constructor(basePath, registry, contextRegistry = new ContextRegistry(), scenariosPath, scenarioRegistry) {
|
|
30
45
|
super();
|
|
31
|
-
this.basePath = basePath
|
|
46
|
+
this.basePath = toForwardSlashPath(basePath);
|
|
32
47
|
this.registry = registry;
|
|
33
48
|
this.contextRegistry = contextRegistry;
|
|
34
|
-
this.scenariosPath =
|
|
49
|
+
this.scenariosPath =
|
|
50
|
+
scenariosPath === undefined
|
|
51
|
+
? undefined
|
|
52
|
+
: toForwardSlashPath(scenariosPath);
|
|
35
53
|
this.scenarioRegistry = scenarioRegistry;
|
|
36
54
|
this.fileDiscovery = new FileDiscovery(this.basePath);
|
|
37
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Starts watching the routes directory (and optionally the scenarios
|
|
58
|
+
* directory) for file-system changes, loading or reloading modules on
|
|
59
|
+
* `"add"` and `"change"` events and deregistering them on `"unlink"`.
|
|
60
|
+
*
|
|
61
|
+
* Resolves once the initial directory scan is complete.
|
|
62
|
+
*/
|
|
38
63
|
async watch() {
|
|
39
64
|
this.watcher = watch(this.basePath, CHOKIDAR_OPTIONS).on("all", (eventName, pathNameOriginal) => {
|
|
40
65
|
const JS_EXTENSIONS = ["js", "mjs", "cjs", "ts", "mts", "cts"];
|
|
41
66
|
if (!JS_EXTENSIONS.some((extension) => pathNameOriginal.endsWith(`.${extension}`)))
|
|
42
67
|
return;
|
|
43
|
-
const pathName = pathNameOriginal
|
|
68
|
+
const pathName = toForwardSlashPath(pathNameOriginal);
|
|
44
69
|
if (pathName.includes("$.context") && eventName === "add") {
|
|
45
70
|
process.stdout.write(`\n\n!!! The file at ${pathName} needs a minor update.\n See https://github.com/pmcelhaney/counterfact/blob/main/docs/faq.md\n\n\n`);
|
|
46
71
|
return;
|
|
@@ -49,14 +74,12 @@ export class ModuleLoader extends EventTarget {
|
|
|
49
74
|
return;
|
|
50
75
|
}
|
|
51
76
|
const parts = nodePath.parse(pathName.replace(this.basePath, ""));
|
|
52
|
-
const url = unescapePathForWindows(`/${parts.dir}/${parts.name}`
|
|
53
|
-
.replaceAll("\\", "/")
|
|
54
|
-
.replaceAll(/\/+/gu, "/"));
|
|
77
|
+
const url = unescapePathForWindows(toForwardSlashPath(`/${parts.dir}/${parts.name}`).replaceAll(/\/+/gu, "/"));
|
|
55
78
|
if (eventName === "unlink") {
|
|
56
79
|
this.registry.remove(url);
|
|
57
80
|
this.dispatchEvent(new Event("remove"));
|
|
58
81
|
if (this.isContextFile(pathName)) {
|
|
59
|
-
this.contextRegistry.remove(unescapePathForWindows(parts.dir)
|
|
82
|
+
this.contextRegistry.remove(unescapePathForWindows(toForwardSlashPath(parts.dir)) || "/");
|
|
60
83
|
}
|
|
61
84
|
return;
|
|
62
85
|
}
|
|
@@ -75,7 +98,7 @@ export class ModuleLoader extends EventTarget {
|
|
|
75
98
|
return;
|
|
76
99
|
if (!["add", "change", "unlink"].includes(eventName))
|
|
77
100
|
return;
|
|
78
|
-
const pathName = pathNameOriginal
|
|
101
|
+
const pathName = toForwardSlashPath(pathNameOriginal);
|
|
79
102
|
if (eventName === "unlink") {
|
|
80
103
|
const fileKey = this.scenarioFileKey(pathName);
|
|
81
104
|
this.scenarioRegistry?.remove(fileKey);
|
|
@@ -86,6 +109,7 @@ export class ModuleLoader extends EventTarget {
|
|
|
86
109
|
await once(this.scenariosWatcher, "ready");
|
|
87
110
|
}
|
|
88
111
|
}
|
|
112
|
+
/** Closes both file-system watchers (routes and scenarios). */
|
|
89
113
|
async stopWatching() {
|
|
90
114
|
await this.watcher?.close();
|
|
91
115
|
await this.scenariosWatcher?.close();
|
|
@@ -93,6 +117,12 @@ export class ModuleLoader extends EventTarget {
|
|
|
93
117
|
isContextFile(pathName) {
|
|
94
118
|
return basename(pathName).startsWith("_.context.");
|
|
95
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Performs a one-shot load of all modules found under `directory` (relative
|
|
122
|
+
* to the configured base path) and all scenario files.
|
|
123
|
+
*
|
|
124
|
+
* @param directory - Sub-directory to load, defaults to the root (`""`).
|
|
125
|
+
*/
|
|
96
126
|
async load(directory = "") {
|
|
97
127
|
const files = await this.fileDiscovery.findFiles(directory);
|
|
98
128
|
await Promise.all(files.map((file) => this.loadEndpoint(file)));
|
|
@@ -115,12 +145,10 @@ export class ModuleLoader extends EventTarget {
|
|
|
115
145
|
}
|
|
116
146
|
}
|
|
117
147
|
scenarioFileKey(pathName) {
|
|
118
|
-
const normalizedScenariosPath = (this.scenariosPath ?? "")
|
|
119
|
-
const directory =
|
|
148
|
+
const normalizedScenariosPath = toForwardSlashPath(this.scenariosPath ?? "");
|
|
149
|
+
const directory = pathDirname(pathName.slice(normalizedScenariosPath.length));
|
|
120
150
|
const name = nodePath.parse(basename(pathName)).name;
|
|
121
|
-
const url = unescapePathForWindows(`/${nodePath.join(directory, name)}`
|
|
122
|
-
.replaceAll("\\", "/")
|
|
123
|
-
.replaceAll(/\/+/gu, "/"));
|
|
151
|
+
const url = unescapePathForWindows(toForwardSlashPath(`/${nodePath.join(directory, name)}`).replaceAll(/\/+/gu, "/"));
|
|
124
152
|
return url.slice(1); // strip leading "/"
|
|
125
153
|
}
|
|
126
154
|
async loadScenarioFile(pathName) {
|
|
@@ -142,10 +170,8 @@ export class ModuleLoader extends EventTarget {
|
|
|
142
170
|
}
|
|
143
171
|
async loadEndpoint(pathName) {
|
|
144
172
|
debug("importing module: %s", pathName);
|
|
145
|
-
const directory =
|
|
146
|
-
const url = unescapePathForWindows(`/${nodePath.join(directory, nodePath.parse(basename(pathName)).name)}`
|
|
147
|
-
.replaceAll("\\", "/")
|
|
148
|
-
.replaceAll(/\/+/gu, "/"));
|
|
173
|
+
const directory = pathDirname(pathName.slice(this.basePath.length));
|
|
174
|
+
const url = unescapePathForWindows(toForwardSlashPath(`/${nodePath.join(directory, nodePath.parse(basename(pathName)).name)}`).replaceAll(/\/+/gu, "/"));
|
|
149
175
|
debug(`loading pathName from dependencyGraph: ${pathName}`);
|
|
150
176
|
this.dependencyGraph.load(pathName);
|
|
151
177
|
try {
|
|
@@ -159,9 +185,14 @@ export class ModuleLoader extends EventTarget {
|
|
|
159
185
|
if (importError !== undefined) {
|
|
160
186
|
const isSyntaxError = importError instanceof SyntaxError ||
|
|
161
187
|
String(importError).startsWith("SyntaxError:");
|
|
162
|
-
const displayPath =
|
|
163
|
-
|
|
164
|
-
|
|
188
|
+
const displayPath = pathRelative(process.cwd(), unescapePathForWindows(pathName));
|
|
189
|
+
if (this.isContextFile(pathName)) {
|
|
190
|
+
const warning = isSyntaxError
|
|
191
|
+
? `Warning: There is a syntax error in the context file: ${displayPath}`
|
|
192
|
+
: `Warning: There was an error loading the context file: ${displayPath}`;
|
|
193
|
+
process.stdout.write(`\n${warning}\n`);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
165
196
|
const message = isSyntaxError
|
|
166
197
|
? `There is a syntax error in the route file: ${displayPath}`
|
|
167
198
|
: `There was an error loading the route file: ${displayPath}`;
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
function isDirectory(test) {
|
|
2
2
|
return test !== undefined;
|
|
3
3
|
}
|
|
4
|
+
/**
|
|
5
|
+
* Trie-based tree that maps URL path segments to route-handler modules.
|
|
6
|
+
*
|
|
7
|
+
* Each node in the tree represents one URL segment. Segments whose names are
|
|
8
|
+
* wrapped in curly braces (e.g. `{petId}`) are treated as wildcards and can
|
|
9
|
+
* match any value in that position.
|
|
10
|
+
*
|
|
11
|
+
* The tree supports:
|
|
12
|
+
* - **Exact matches** — literal URL segments take precedence over wildcards.
|
|
13
|
+
* - **Wildcard matches** — `{param}` segments capture the matched value as a
|
|
14
|
+
* path variable.
|
|
15
|
+
* - **Ambiguous-wildcard detection** — when multiple wildcards exist at the
|
|
16
|
+
* same level the match is flagged as `ambiguous` and an error is logged.
|
|
17
|
+
*/
|
|
4
18
|
export class ModuleTree {
|
|
5
19
|
root = {
|
|
6
20
|
directories: new Map(),
|
|
@@ -58,6 +72,12 @@ export class ModuleTree {
|
|
|
58
72
|
}
|
|
59
73
|
}
|
|
60
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Registers a module at the given URL pattern.
|
|
77
|
+
*
|
|
78
|
+
* @param url - The route URL pattern (e.g. `"/pets/{petId}"`).
|
|
79
|
+
* @param module - The route-handler module to associate with the URL.
|
|
80
|
+
*/
|
|
61
81
|
add(url, module) {
|
|
62
82
|
this.addModuleToDirectory(this.root, url.split("/").slice(1), module);
|
|
63
83
|
}
|
|
@@ -75,6 +95,11 @@ export class ModuleTree {
|
|
|
75
95
|
}
|
|
76
96
|
this.removeModuleFromDirectory(directory.directories.get(segment.toLowerCase()), remainingSegments);
|
|
77
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Removes the module registered at `url`.
|
|
100
|
+
*
|
|
101
|
+
* @param url - The route URL pattern to deregister.
|
|
102
|
+
*/
|
|
78
103
|
remove(url) {
|
|
79
104
|
const segments = url.split("/").slice(1);
|
|
80
105
|
this.removeModuleFromDirectory(this.root, segments);
|
|
@@ -168,9 +193,20 @@ export class ModuleTree {
|
|
|
168
193
|
}
|
|
169
194
|
return wildcardMatches[0];
|
|
170
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Finds the best-matching module for `url` and `method`.
|
|
198
|
+
*
|
|
199
|
+
* Traverses the trie, preferring exact matches over wildcards at each
|
|
200
|
+
* segment. Returns `undefined` when no match is found.
|
|
201
|
+
*
|
|
202
|
+
* @param url - The incoming request URL.
|
|
203
|
+
* @param method - The HTTP method (used to validate wildcard matches).
|
|
204
|
+
* @returns A {@link Match} object, or `undefined` when nothing matches.
|
|
205
|
+
*/
|
|
171
206
|
match(url, method) {
|
|
172
207
|
return this.matchWithinDirectory(this.root, url.split("/").slice(1), {}, "", method);
|
|
173
208
|
}
|
|
209
|
+
/** Returns all registered routes sorted alphabetically by path. */
|
|
174
210
|
get routes() {
|
|
175
211
|
const routes = [];
|
|
176
212
|
function traverse(directory, path) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { watch } from "chokidar";
|
|
2
|
+
import createDebug from "debug";
|
|
3
|
+
import { dereference } from "@apidevtools/json-schema-ref-parser";
|
|
4
|
+
import { waitForEvent } from "../util/wait-for-event.js";
|
|
5
|
+
import { CHOKIDAR_OPTIONS } from "./constants.js";
|
|
6
|
+
const debug = createDebug("counterfact:server:openapi-document");
|
|
7
|
+
/**
|
|
8
|
+
* Represents a loaded OpenAPI document. Knows the location of its source
|
|
9
|
+
* file, can read the file and initialize itself, can watch for file-system
|
|
10
|
+
* changes, and dispatches a `"reload"` event (via `EventTarget`) whenever
|
|
11
|
+
* the document is reloaded from disk.
|
|
12
|
+
*/
|
|
13
|
+
export class OpenApiDocument extends EventTarget {
|
|
14
|
+
/** The path or URL of the OpenAPI source file. */
|
|
15
|
+
source;
|
|
16
|
+
basePath;
|
|
17
|
+
paths = {};
|
|
18
|
+
produces;
|
|
19
|
+
watcher;
|
|
20
|
+
constructor(source) {
|
|
21
|
+
super();
|
|
22
|
+
this.source = source;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Reads the source file and populates the document's properties.
|
|
26
|
+
* Must be called at least once before the document data is accessible.
|
|
27
|
+
*/
|
|
28
|
+
async load() {
|
|
29
|
+
try {
|
|
30
|
+
const data = (await dereference(this.source));
|
|
31
|
+
this.basePath = data.basePath;
|
|
32
|
+
this.paths = data.paths;
|
|
33
|
+
this.produces = data.produces;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
debug("could not load OpenAPI document from %s: %o", this.source, error);
|
|
37
|
+
const details = error instanceof Error ? error.message : String(error);
|
|
38
|
+
throw new Error(`Could not load the OpenAPI spec from "${this.source}".\n${details}`, { cause: error });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Starts watching the source file for changes. When a change is detected
|
|
43
|
+
* the document reloads itself and dispatches a `"reload"` event.
|
|
44
|
+
*
|
|
45
|
+
* Has no effect when the source is `"_"` or a remote URL.
|
|
46
|
+
*/
|
|
47
|
+
async watch() {
|
|
48
|
+
if (this.source === "_" || this.source.startsWith("http")) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
this.watcher = watch(this.source, CHOKIDAR_OPTIONS).on("change", () => {
|
|
52
|
+
void (async () => {
|
|
53
|
+
try {
|
|
54
|
+
await this.load();
|
|
55
|
+
debug("reloaded OpenAPI document from %s", this.source);
|
|
56
|
+
this.dispatchEvent(new Event("reload"));
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
debug("failed to reload OpenAPI document from %s: %o", this.source, error);
|
|
60
|
+
}
|
|
61
|
+
})();
|
|
62
|
+
});
|
|
63
|
+
await waitForEvent(this.watcher, "ready");
|
|
64
|
+
}
|
|
65
|
+
/** Stops watching the source file. */
|
|
66
|
+
async stopWatching() {
|
|
67
|
+
await this.watcher?.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
package/dist/server/registry.js
CHANGED
|
@@ -11,6 +11,16 @@ const ALL_HTTP_METHODS = [
|
|
|
11
11
|
"PUT",
|
|
12
12
|
"TRACE",
|
|
13
13
|
];
|
|
14
|
+
/**
|
|
15
|
+
* Casts a string URL/header/query parameter value to the type declared in the
|
|
16
|
+
* OpenAPI spec.
|
|
17
|
+
*
|
|
18
|
+
* @param value - The raw parameter value (may already be the correct type when
|
|
19
|
+
* the HTTP framework has pre-parsed it).
|
|
20
|
+
* @param type - The OpenAPI primitive type string (`"integer"`, `"number"`,
|
|
21
|
+
* `"boolean"`, or anything else to leave as a string).
|
|
22
|
+
* @returns The value coerced to the appropriate JavaScript type.
|
|
23
|
+
*/
|
|
14
24
|
function castParameter(value, type) {
|
|
15
25
|
if (typeof value !== "string") {
|
|
16
26
|
return value;
|
|
@@ -26,6 +36,13 @@ function castParameter(value, type) {
|
|
|
26
36
|
}
|
|
27
37
|
return value;
|
|
28
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Applies {@link castParameter} to every value in a parameters map.
|
|
41
|
+
*
|
|
42
|
+
* @param parameters - Key/value map of raw parameter values.
|
|
43
|
+
* @param parameterTypes - Map from parameter name to its OpenAPI type string.
|
|
44
|
+
* @returns A new object with the same keys and cast values.
|
|
45
|
+
*/
|
|
29
46
|
function castParameters(parameters = {}, parameterTypes = new Map()) {
|
|
30
47
|
const copy = {};
|
|
31
48
|
Object.entries(parameters).forEach(([key, value]) => {
|
|
@@ -33,27 +50,70 @@ function castParameters(parameters = {}, parameterTypes = new Map()) {
|
|
|
33
50
|
});
|
|
34
51
|
return copy;
|
|
35
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Central route registry that maps URL patterns to route-handler modules.
|
|
55
|
+
*
|
|
56
|
+
* Routes are stored in a {@link ModuleTree} that supports wildcard path
|
|
57
|
+
* segments (e.g. `{petId}`). The registry also maintains an ordered chain of
|
|
58
|
+
* middleware functions that wrap every route handler execution.
|
|
59
|
+
*/
|
|
36
60
|
export class Registry {
|
|
37
61
|
moduleTree = new ModuleTree();
|
|
38
62
|
middlewares = new Map();
|
|
39
63
|
constructor() {
|
|
40
64
|
this.middlewares.set("", ($, respondTo) => respondTo($));
|
|
41
65
|
}
|
|
66
|
+
/** Returns all registered routes as a flat array of `{ path, methods }` objects. */
|
|
42
67
|
get routes() {
|
|
43
68
|
return this.moduleTree.routes;
|
|
44
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Registers (or replaces) the module for a URL pattern.
|
|
72
|
+
*
|
|
73
|
+
* @param url - The URL pattern (e.g. `/pets/{petId}`).
|
|
74
|
+
* @param module - The route-handler module exposing HTTP-method functions.
|
|
75
|
+
*/
|
|
45
76
|
add(url, module) {
|
|
46
77
|
this.moduleTree.add(url, module);
|
|
47
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Registers a middleware function that wraps every handler under `url`.
|
|
81
|
+
*
|
|
82
|
+
* Middleware receives `($, respondTo)` where `respondTo` is the next handler
|
|
83
|
+
* in the chain. Setting `url` to `"/"` makes the middleware global.
|
|
84
|
+
*
|
|
85
|
+
* @param url - The path prefix at which this middleware applies.
|
|
86
|
+
* @param callback - The middleware function.
|
|
87
|
+
*/
|
|
48
88
|
addMiddleware(url, callback) {
|
|
49
89
|
this.middlewares.set(url === "/" ? "" : url, callback);
|
|
50
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Removes the module registered at `url`.
|
|
93
|
+
*
|
|
94
|
+
* @param url - The URL pattern to deregister.
|
|
95
|
+
*/
|
|
51
96
|
remove(url) {
|
|
52
97
|
this.moduleTree.remove(url);
|
|
53
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Returns `true` when a handler for `method` is registered at `url`.
|
|
101
|
+
*
|
|
102
|
+
* @param method - HTTP method (e.g. `"GET"`).
|
|
103
|
+
* @param url - The request URL.
|
|
104
|
+
*/
|
|
54
105
|
exists(method, url) {
|
|
55
106
|
return Boolean(this.handler(url, method).module?.[method]);
|
|
56
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Finds the best-matching module and extracts path-variable bindings for a
|
|
110
|
+
* given URL and HTTP method.
|
|
111
|
+
*
|
|
112
|
+
* @param url - The incoming request URL.
|
|
113
|
+
* @param method - The HTTP method.
|
|
114
|
+
* @returns An object with `module`, `path` (variable bindings),
|
|
115
|
+
* `matchedPath`, and `ambiguous` flag.
|
|
116
|
+
*/
|
|
57
117
|
handler(url, method) {
|
|
58
118
|
const match = this.moduleTree.match(url, method);
|
|
59
119
|
return {
|
|
@@ -63,12 +123,41 @@ export class Registry {
|
|
|
63
123
|
path: match?.pathVariables ?? {},
|
|
64
124
|
};
|
|
65
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Returns `true` when the URL matches a registered module for at least one
|
|
128
|
+
* HTTP method other than `excludeMethod`.
|
|
129
|
+
*
|
|
130
|
+
* Used to decide whether to respond with 405 Method Not Allowed.
|
|
131
|
+
*
|
|
132
|
+
* @param url - The request URL.
|
|
133
|
+
* @param excludeMethod - The method to exclude from the check.
|
|
134
|
+
*/
|
|
66
135
|
pathExistsWithAnyMethod(url, excludeMethod) {
|
|
67
136
|
return ALL_HTTP_METHODS.filter((method) => method !== excludeMethod).some((method) => this.moduleTree.match(url, method) !== undefined);
|
|
68
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Returns a comma-separated list of HTTP methods that have a registered
|
|
140
|
+
* handler at `url`. Used to populate the `Allow` response header for 405
|
|
141
|
+
* responses.
|
|
142
|
+
*
|
|
143
|
+
* @param url - The request URL.
|
|
144
|
+
*/
|
|
69
145
|
allowedMethods(url) {
|
|
70
146
|
return ALL_HTTP_METHODS.filter((method) => Boolean(this.moduleTree.match(url, method)?.module?.[method])).join(", ");
|
|
71
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Returns an async function that executes the registered handler for
|
|
150
|
+
* `httpRequestMethod` at `url`, wrapped by all applicable middleware.
|
|
151
|
+
*
|
|
152
|
+
* Path, query, and header parameter values are cast to their declared types
|
|
153
|
+
* before being forwarded to the handler. The returned function always
|
|
154
|
+
* resolves to a {@link CounterfactResponseObject}.
|
|
155
|
+
*
|
|
156
|
+
* @param httpRequestMethod - The HTTP method to look up.
|
|
157
|
+
* @param url - The incoming request URL (before path-variable substitution).
|
|
158
|
+
* @param parameterTypes - Optional maps from parameter name to OpenAPI type
|
|
159
|
+
* for each of `header`, `path`, and `query`.
|
|
160
|
+
*/
|
|
72
161
|
endpoint(httpRequestMethod, url, parameterTypes = {}) {
|
|
73
162
|
const handler = this.handler(url, httpRequestMethod);
|
|
74
163
|
debug("handler for %s: %o", url, handler);
|