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
|
@@ -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
|
+
}
|
|
@@ -1,16 +1,45 @@
|
|
|
1
1
|
import { bundle } from "@apidevtools/json-schema-ref-parser";
|
|
2
2
|
import { dump } from "js-yaml";
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Returns a Koa middleware that serves bundled OpenAPI documents as YAML.
|
|
5
|
+
*
|
|
6
|
+
* When `documents` has exactly one entry the document is served at
|
|
7
|
+
* `/counterfact/openapi` (backward-compatible behaviour).
|
|
8
|
+
*
|
|
9
|
+
* When `documents` has more than one entry each document is served at
|
|
10
|
+
* `/counterfact/openapi/{id}` where `id` comes from the corresponding entry.
|
|
11
|
+
*
|
|
12
|
+
* Every served document is augmented with a `servers` entry (OpenAPI 3.x) and
|
|
13
|
+
* a `host` field (OpenAPI 2.x / Swagger) so that the Swagger UI can send
|
|
14
|
+
* requests to the running Counterfact instance.
|
|
15
|
+
*
|
|
16
|
+
* @param documents - Array of document descriptors. Each entry must provide
|
|
17
|
+
* `path` (file path or URL to the source OpenAPI document) and `baseUrl`
|
|
18
|
+
* (the base URL to inject, e.g. `"//localhost:3100/api"`). An optional `id`
|
|
19
|
+
* string is used to build the per-document URL when more than one document
|
|
20
|
+
* is present.
|
|
21
|
+
* @returns A Koa middleware function.
|
|
22
|
+
*/
|
|
23
|
+
export function openapiMiddleware(documents) {
|
|
4
24
|
return async (ctx, next) => {
|
|
5
|
-
|
|
6
|
-
|
|
25
|
+
let matched;
|
|
26
|
+
if (documents.length === 1) {
|
|
27
|
+
if (ctx.URL.pathname === "/counterfact/openapi") {
|
|
28
|
+
matched = documents[0];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
matched = documents.find((doc) => ctx.URL.pathname === `/counterfact/openapi/${doc.id}`);
|
|
33
|
+
}
|
|
34
|
+
if (matched) {
|
|
35
|
+
const openApiDocument = (await bundle(matched.path));
|
|
7
36
|
openApiDocument.servers ??= [];
|
|
8
37
|
openApiDocument.servers.unshift({
|
|
9
38
|
description: "Counterfact",
|
|
10
|
-
url,
|
|
39
|
+
url: matched.baseUrl,
|
|
11
40
|
});
|
|
12
41
|
// OpenApi 2 support:
|
|
13
|
-
openApiDocument.host =
|
|
42
|
+
openApiDocument.host = matched.baseUrl;
|
|
14
43
|
ctx.body = dump(openApiDocument);
|
|
15
44
|
return;
|
|
16
45
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { watch } from "chokidar";
|
|
2
|
+
import createDebug from "debug";
|
|
3
|
+
import { waitForEvent } from "../util/wait-for-event.js";
|
|
4
|
+
import { CHOKIDAR_OPTIONS } from "./constants.js";
|
|
5
|
+
import { loadOpenApiDocument } from "./load-openapi-document.js";
|
|
6
|
+
const debug = createDebug("counterfact:server:openapi-watcher");
|
|
7
|
+
export class OpenApiWatcher {
|
|
8
|
+
openApiPath;
|
|
9
|
+
dispatcher;
|
|
10
|
+
watcher;
|
|
11
|
+
constructor(openApiPath, dispatcher) {
|
|
12
|
+
this.openApiPath = openApiPath;
|
|
13
|
+
this.dispatcher = dispatcher;
|
|
14
|
+
}
|
|
15
|
+
async watch() {
|
|
16
|
+
if (this.openApiPath === "_" || this.openApiPath.startsWith("http")) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
this.watcher = watch(this.openApiPath, CHOKIDAR_OPTIONS).on("change", () => {
|
|
20
|
+
void (async () => {
|
|
21
|
+
try {
|
|
22
|
+
this.dispatcher.openApiDocument = await loadOpenApiDocument(this.openApiPath);
|
|
23
|
+
debug("reloaded OpenAPI document from %s", this.openApiPath);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
debug("failed to reload OpenAPI document from %s: %o", this.openApiPath, error);
|
|
27
|
+
}
|
|
28
|
+
})();
|
|
29
|
+
});
|
|
30
|
+
await waitForEvent(this.watcher, "ready");
|
|
31
|
+
}
|
|
32
|
+
async stopWatching() {
|
|
33
|
+
await this.watcher?.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
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);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Ajv from "ajv";
|
|
2
2
|
const ajv = new Ajv({
|
|
3
3
|
allErrors: true,
|
|
4
|
-
|
|
4
|
+
strict: false,
|
|
5
5
|
coerceTypes: false,
|
|
6
6
|
});
|
|
7
7
|
function findMissingRequired(parameters, location, values) {
|
|
@@ -30,9 +30,7 @@ export function validateRequest(operation, request) {
|
|
|
30
30
|
const valid = ajv.validate(schema, request.body);
|
|
31
31
|
if (!valid && ajv.errors) {
|
|
32
32
|
for (const error of ajv.errors) {
|
|
33
|
-
const path = error.instancePath ??
|
|
34
|
-
error.dataPath ??
|
|
35
|
-
"";
|
|
33
|
+
const path = error.instancePath ?? "";
|
|
36
34
|
errors.push(`body${path} ${error.message ?? "is invalid"}`);
|
|
37
35
|
}
|
|
38
36
|
}
|
|
@@ -47,9 +45,7 @@ export function validateRequest(operation, request) {
|
|
|
47
45
|
const valid = ajv.validate(bodyParam.schema, request.body);
|
|
48
46
|
if (!valid && ajv.errors) {
|
|
49
47
|
for (const error of ajv.errors) {
|
|
50
|
-
const path = error.instancePath ??
|
|
51
|
-
error.dataPath ??
|
|
52
|
-
"";
|
|
48
|
+
const path = error.instancePath ?? "";
|
|
53
49
|
errors.push(`body${path} ${error.message ?? "is invalid"}`);
|
|
54
50
|
}
|
|
55
51
|
}
|
|
@@ -56,6 +56,21 @@ function unknownStatusCodeResponse(statusCode) {
|
|
|
56
56
|
status: 500,
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Creates the `$.response` builder proxy made available to route handlers.
|
|
61
|
+
*
|
|
62
|
+
* The proxy maps HTTP status codes to a fluent builder object. Example usage
|
|
63
|
+
* in a route handler:
|
|
64
|
+
*
|
|
65
|
+
* ```ts
|
|
66
|
+
* return $.response[200].json({ id: 1, name: "Fluffy" });
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* @param operation - The OpenAPI operation descriptor used for schema-driven
|
|
70
|
+
* random generation and example resolution.
|
|
71
|
+
* @param config - Server configuration (e.g. `alwaysFakeOptionals`).
|
|
72
|
+
* @returns A {@link ResponseBuilder} proxy keyed by HTTP status code.
|
|
73
|
+
*/
|
|
59
74
|
export function createResponseBuilder(operation, config) {
|
|
60
75
|
return new Proxy({}, {
|
|
61
76
|
get: (target, statusCode) => ({
|
|
@@ -112,6 +127,9 @@ export function createResponseBuilder(operation, config) {
|
|
|
112
127
|
],
|
|
113
128
|
};
|
|
114
129
|
},
|
|
130
|
+
empty() {
|
|
131
|
+
return { ...this, content: undefined };
|
|
132
|
+
},
|
|
115
133
|
example(name) {
|
|
116
134
|
if (operation.produces) {
|
|
117
135
|
return unknownStatusCodeResponse(this.status);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import Ajv from "ajv";
|
|
2
|
+
const ajv = new Ajv({
|
|
3
|
+
allErrors: true,
|
|
4
|
+
strict: false,
|
|
5
|
+
coerceTypes: false,
|
|
6
|
+
});
|
|
7
|
+
export function validateResponse(operation, response) {
|
|
8
|
+
if (!operation) {
|
|
9
|
+
return { errors: [], valid: true };
|
|
10
|
+
}
|
|
11
|
+
const errors = [];
|
|
12
|
+
const statusKey = response.status !== undefined ? String(response.status) : undefined;
|
|
13
|
+
const responseSpec = (statusKey !== undefined ? operation.responses[statusKey] : undefined) ??
|
|
14
|
+
operation.responses.default;
|
|
15
|
+
if (!responseSpec) {
|
|
16
|
+
return { errors: [], valid: true };
|
|
17
|
+
}
|
|
18
|
+
const specHeaders = responseSpec.headers ?? {};
|
|
19
|
+
const actualHeaders = response.headers ?? {};
|
|
20
|
+
for (const [name, headerSpec] of Object.entries(specHeaders)) {
|
|
21
|
+
const actualValue = actualHeaders[name] ?? actualHeaders[name.toLowerCase()];
|
|
22
|
+
if (headerSpec.required === true && actualValue === undefined) {
|
|
23
|
+
errors.push(`response header '${name}' is required`);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (actualValue !== undefined && headerSpec.schema !== undefined) {
|
|
27
|
+
const coercedValue = typeof actualValue === "string"
|
|
28
|
+
? coerceHeaderValue(actualValue, headerSpec.schema)
|
|
29
|
+
: actualValue;
|
|
30
|
+
const valid = ajv.validate(headerSpec.schema, coercedValue);
|
|
31
|
+
if (!valid && ajv.errors) {
|
|
32
|
+
for (const error of ajv.errors) {
|
|
33
|
+
const path = error.instancePath ?? "";
|
|
34
|
+
errors.push(`response header '${name}'${path} ${error.message ?? "is invalid"}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
errors,
|
|
41
|
+
valid: errors.length === 0,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function coerceHeaderValue(value, schema) {
|
|
45
|
+
const type = schema.type;
|
|
46
|
+
if (type === "integer" || type === "number") {
|
|
47
|
+
const num = Number(value);
|
|
48
|
+
return Number.isNaN(num) ? value : num;
|
|
49
|
+
}
|
|
50
|
+
if (type === "boolean") {
|
|
51
|
+
if (value === "true")
|
|
52
|
+
return true;
|
|
53
|
+
if (value === "false")
|
|
54
|
+
return false;
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of loaded scenario modules.
|
|
3
|
+
*
|
|
4
|
+
* Scenario modules are plain JavaScript/TypeScript files that export named
|
|
5
|
+
* functions. Each module is keyed by a slash-delimited path relative to the
|
|
6
|
+
* `scenarios/` directory (e.g. `"index"`, `"sub/reset"`).
|
|
7
|
+
*
|
|
8
|
+
* The registry is used by the REPL's `.scenario` command and by tab-completion
|
|
9
|
+
* to enumerate available scenarios and their exported function names.
|
|
10
|
+
*/
|
|
11
|
+
export class ScenarioRegistry {
|
|
12
|
+
modules = new Map();
|
|
13
|
+
/**
|
|
14
|
+
* Registers (or replaces) a scenario module.
|
|
15
|
+
*
|
|
16
|
+
* @param key - Slash-delimited file key (e.g. `"index"`, `"auth/setup"`).
|
|
17
|
+
* @param module - The module's exported values.
|
|
18
|
+
*/
|
|
19
|
+
add(key, module) {
|
|
20
|
+
this.modules.set(key, module);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Removes the scenario module for `key`.
|
|
24
|
+
*
|
|
25
|
+
* @param key - The file key to remove.
|
|
26
|
+
*/
|
|
27
|
+
remove(key) {
|
|
28
|
+
this.modules.delete(key);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns the module for `fileKey`, or `undefined` if not registered.
|
|
32
|
+
*
|
|
33
|
+
* @param fileKey - The file key to look up.
|
|
34
|
+
*/
|
|
35
|
+
getModule(fileKey) {
|
|
36
|
+
return this.modules.get(fileKey);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Returns the names of all exported functions for the given file key.
|
|
40
|
+
* Used for tab completion.
|
|
41
|
+
*/
|
|
42
|
+
getExportedFunctionNames(fileKey) {
|
|
43
|
+
const module = this.modules.get(fileKey);
|
|
44
|
+
if (!module)
|
|
45
|
+
return [];
|
|
46
|
+
return Object.keys(module).filter((k) => typeof module[k] === "function");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Returns all loaded file keys (e.g. "index", "myscript", "sub/script").
|
|
50
|
+
* Used for tab completion to enumerate available scenario files.
|
|
51
|
+
*/
|
|
52
|
+
getFileKeys() {
|
|
53
|
+
return [...this.modules.keys()];
|
|
54
|
+
}
|
|
55
|
+
}
|
package/dist/server/tools.js
CHANGED
|
@@ -1,24 +1,51 @@
|
|
|
1
1
|
import { generate } from "json-schema-faker";
|
|
2
|
+
/**
|
|
3
|
+
* A collection of utility helpers made available to route handlers via
|
|
4
|
+
* `$.tools`.
|
|
5
|
+
*
|
|
6
|
+
* Provides random selection, content-type acceptance checking, and
|
|
7
|
+
* schema-based random data generation.
|
|
8
|
+
*/
|
|
2
9
|
export class Tools {
|
|
3
10
|
headers;
|
|
4
11
|
constructor({ headers = {}, } = {}) {
|
|
5
12
|
this.headers = headers;
|
|
6
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Returns a randomly selected element from `array`.
|
|
16
|
+
*
|
|
17
|
+
* @param array - The array to pick from.
|
|
18
|
+
* @returns A random element.
|
|
19
|
+
*/
|
|
7
20
|
oneOf(array) {
|
|
8
21
|
return array[Math.floor(Math.random() * array.length)];
|
|
9
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Returns `true` when the request's `Accept` header is compatible with
|
|
25
|
+
* `contentType`.
|
|
26
|
+
*
|
|
27
|
+
* A missing or empty `Accept` header is treated as `*/*` (accepts anything).
|
|
28
|
+
*
|
|
29
|
+
* @param contentType - The MIME type to check (e.g. `"application/json"`).
|
|
30
|
+
*/
|
|
10
31
|
accepts(contentType) {
|
|
11
|
-
const acceptHeader = this.headers.
|
|
32
|
+
const acceptHeader = Object.entries(this.headers).find(([key]) => key.toLowerCase() === "accept")?.[1];
|
|
12
33
|
if (acceptHeader === "" || acceptHeader === undefined) {
|
|
13
34
|
return true;
|
|
14
35
|
}
|
|
15
36
|
const acceptTypes = String(acceptHeader).split(",");
|
|
16
37
|
return acceptTypes.some((acceptType) => {
|
|
17
|
-
const [type, subtype] = acceptType.split("/");
|
|
38
|
+
const [type, subtype] = acceptType.trim().split("/");
|
|
18
39
|
return ((type === "*" || type === contentType.split("/")[0]) &&
|
|
19
40
|
(subtype === "*" || subtype === contentType.split("/")[1]));
|
|
20
41
|
});
|
|
21
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Generates a random value that satisfies `schema` using json-schema-faker.
|
|
45
|
+
*
|
|
46
|
+
* @param schema - A JSON Schema object.
|
|
47
|
+
* @returns A promise that resolves to a generated value.
|
|
48
|
+
*/
|
|
22
49
|
randomFromSchema(schema) {
|
|
23
50
|
return generate(schema, { useExamplesValue: true, fillProperties: false });
|
|
24
51
|
}
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
// Stryker disable all
|
|
2
2
|
import { once } from "node:events";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
|
-
import nodePath from "node:path";
|
|
5
4
|
import { watch as chokidarWatch } from "chokidar";
|
|
6
5
|
import createDebug from "debug";
|
|
7
6
|
import ts from "typescript";
|
|
8
7
|
import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
|
|
8
|
+
import { toForwardSlashPath, pathJoin } from "../util/forward-slash-path.js";
|
|
9
9
|
import { CHOKIDAR_OPTIONS } from "./constants.js";
|
|
10
10
|
import { convertFileExtensionsToCjs } from "./convert-js-extensions-to-cjs.js";
|
|
11
11
|
const debug = createDebug("counterfact:server:transpiler");
|
|
12
|
+
/**
|
|
13
|
+
* Watches TypeScript source files in `sourcePath` and compiles them to
|
|
14
|
+
* JavaScript in `destinationPath` using the TypeScript compiler API.
|
|
15
|
+
*
|
|
16
|
+
* Used when the runtime cannot execute TypeScript natively (i.e. Node.js
|
|
17
|
+
* without the `--experimental-strip-types` flag). Each file is compiled
|
|
18
|
+
* independently (no type-checking) for maximum speed.
|
|
19
|
+
*
|
|
20
|
+
* Emits DOM-style events: `"write"` after a successful transpile, `"delete"`
|
|
21
|
+
* after a source file is removed, and `"error"` on write or compilation errors.
|
|
22
|
+
*/
|
|
12
23
|
export class Transpiler extends EventTarget {
|
|
13
24
|
sourcePath;
|
|
14
25
|
destinationPath;
|
|
@@ -23,6 +34,11 @@ export class Transpiler extends EventTarget {
|
|
|
23
34
|
get extension() {
|
|
24
35
|
return this.moduleKind.toLowerCase() === "commonjs" ? ".cjs" : ".js";
|
|
25
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Starts the file-system watcher and transpiles all existing files in the
|
|
39
|
+
* source path. Resolves once the initial scan and all pending transpiles
|
|
40
|
+
* are complete.
|
|
41
|
+
*/
|
|
26
42
|
async watch() {
|
|
27
43
|
debug("transpiler: watch");
|
|
28
44
|
this.watcher = chokidarWatch(this.sourcePath, {
|
|
@@ -36,11 +52,10 @@ export class Transpiler extends EventTarget {
|
|
|
36
52
|
const JS_EXTENSIONS = ["js", "mjs", "ts", "mts"];
|
|
37
53
|
if (!JS_EXTENSIONS.some((extension) => sourcePathOriginal.endsWith(`.${extension}`)))
|
|
38
54
|
return;
|
|
39
|
-
const sourcePath = sourcePathOriginal
|
|
40
|
-
const destinationPath = sourcePath
|
|
55
|
+
const sourcePath = toForwardSlashPath(sourcePathOriginal);
|
|
56
|
+
const destinationPath = toForwardSlashPath(sourcePath
|
|
41
57
|
.replace(this.sourcePath, this.destinationPath)
|
|
42
|
-
.
|
|
43
|
-
.replace(".ts", this.extension);
|
|
58
|
+
.replace(".ts", this.extension));
|
|
44
59
|
if (["add", "change"].includes(eventName)) {
|
|
45
60
|
transpiles.push(this.transpileFile(eventName, sourcePath, destinationPath));
|
|
46
61
|
}
|
|
@@ -61,6 +76,7 @@ export class Transpiler extends EventTarget {
|
|
|
61
76
|
await once(this.watcher, "ready");
|
|
62
77
|
await Promise.all(transpiles);
|
|
63
78
|
}
|
|
79
|
+
/** Closes the file-system watcher. */
|
|
64
80
|
async stopWatching() {
|
|
65
81
|
await this.watcher?.close();
|
|
66
82
|
}
|
|
@@ -81,11 +97,9 @@ export class Transpiler extends EventTarget {
|
|
|
81
97
|
}
|
|
82
98
|
}
|
|
83
99
|
const result = transpileOutput.outputText;
|
|
84
|
-
const fullDestination =
|
|
85
|
-
.join(sourcePath
|
|
100
|
+
const fullDestination = pathJoin(sourcePath
|
|
86
101
|
.replace(this.sourcePath, this.destinationPath)
|
|
87
|
-
.replace(".ts", this.extension))
|
|
88
|
-
.replaceAll("\\", "/");
|
|
102
|
+
.replace(".ts", this.extension));
|
|
89
103
|
const resultWithTransformedFileExtensions = convertFileExtensionsToCjs(result);
|
|
90
104
|
try {
|
|
91
105
|
await fs.writeFile(fullDestination, resultWithTransformedFileExtensions);
|