counterfact 2.7.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 +4 -159
- package/bin/counterfact.js +10 -2
- package/dist/app.js +74 -20
- package/dist/migrate/update-route-types.js +2 -3
- package/dist/repl/raw-http-client.js +19 -0
- package/dist/repl/repl.js +26 -7
- 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/create-koa-app.js +27 -4
- package/dist/server/determine-module-kind.js +13 -0
- package/dist/server/dispatcher.js +46 -0
- 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 +10 -0
- package/dist/server/koa-middleware.js +18 -1
- package/dist/server/load-openapi-document.js +4 -11
- package/dist/server/module-dependency-graph.js +25 -0
- package/dist/server/module-loader.js +44 -21
- 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/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 +23 -9
- package/dist/typescript-generator/code-generator.js +117 -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 +2 -1
- package/dist/typescript-generator/repository.js +76 -20
- package/dist/typescript-generator/requirement.js +69 -0
- package/dist/typescript-generator/{generate.js → scenario-file-generator.js} +98 -81
- 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/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 +4 -4
|
@@ -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
|
}
|
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);
|
|
@@ -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) => ({
|
|
@@ -1,11 +1,37 @@
|
|
|
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
|
+
*/
|
|
1
11
|
export class ScenarioRegistry {
|
|
2
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
|
+
*/
|
|
3
19
|
add(key, module) {
|
|
4
20
|
this.modules.set(key, module);
|
|
5
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Removes the scenario module for `key`.
|
|
24
|
+
*
|
|
25
|
+
* @param key - The file key to remove.
|
|
26
|
+
*/
|
|
6
27
|
remove(key) {
|
|
7
28
|
this.modules.delete(key);
|
|
8
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns the module for `fileKey`, or `undefined` if not registered.
|
|
32
|
+
*
|
|
33
|
+
* @param fileKey - The file key to look up.
|
|
34
|
+
*/
|
|
9
35
|
getModule(fileKey) {
|
|
10
36
|
return this.modules.get(fileKey);
|
|
11
37
|
}
|
package/dist/server/tools.js
CHANGED
|
@@ -1,12 +1,33 @@
|
|
|
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
32
|
const acceptHeader = Object.entries(this.headers).find(([key]) => key.toLowerCase() === "accept")?.[1];
|
|
12
33
|
if (acceptHeader === "" || acceptHeader === undefined) {
|
|
@@ -19,6 +40,12 @@ export class Tools {
|
|
|
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);
|
|
@@ -1,7 +1,23 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import nodePath from "node:path";
|
|
1
4
|
import { watch } from "chokidar";
|
|
5
|
+
import createDebug from "debug";
|
|
2
6
|
import { CHOKIDAR_OPTIONS } from "../server/constants.js";
|
|
7
|
+
import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
|
|
3
8
|
import { waitForEvent } from "../util/wait-for-event.js";
|
|
4
|
-
import {
|
|
9
|
+
import { OperationCoder } from "./operation-coder.js";
|
|
10
|
+
import { pruneRoutes } from "./prune.js";
|
|
11
|
+
import { Repository } from "./repository.js";
|
|
12
|
+
import { Specification } from "./specification.js";
|
|
13
|
+
const debug = createDebug("counterfact:typescript-generator:generate");
|
|
14
|
+
/**
|
|
15
|
+
* Orchestrates the code-generation pipeline and optional file-system watching.
|
|
16
|
+
*
|
|
17
|
+
* When {@link watch} is called, Counterfact watches the source OpenAPI document
|
|
18
|
+
* for changes and re-runs code generation automatically. `"generate"` and
|
|
19
|
+
* `"failed"` events are emitted after each attempt.
|
|
20
|
+
*/
|
|
5
21
|
export class CodeGenerator extends EventTarget {
|
|
6
22
|
openapiPath;
|
|
7
23
|
destination;
|
|
@@ -13,15 +29,111 @@ export class CodeGenerator extends EventTarget {
|
|
|
13
29
|
this.destination = destination;
|
|
14
30
|
this.generateOptions = generateOptions;
|
|
15
31
|
}
|
|
16
|
-
|
|
17
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Initialises the `.cache` directory that holds compiled JS output.
|
|
34
|
+
*
|
|
35
|
+
* Creates a `.gitignore` file that excludes the `.cache` sub-directory and a
|
|
36
|
+
* `README.md` inside `.cache` that explains its purpose.
|
|
37
|
+
*
|
|
38
|
+
* @param destination - The root output directory.
|
|
39
|
+
*/
|
|
40
|
+
async buildCacheDirectory(destination) {
|
|
41
|
+
const gitignorePath = nodePath.join(destination, ".gitignore");
|
|
42
|
+
const cacheReadmePath = nodePath.join(destination, ".cache", "README.md");
|
|
43
|
+
debug("ensuring the directory containing .gitgnore exists");
|
|
44
|
+
await ensureDirectoryExists(gitignorePath);
|
|
45
|
+
debug("creating the .gitignore file if it doesn't already exist");
|
|
46
|
+
if (!existsSync(gitignorePath)) {
|
|
47
|
+
await fs.writeFile(gitignorePath, ".cache\n", "utf8");
|
|
48
|
+
}
|
|
49
|
+
debug("creating the .cache/README.md file");
|
|
50
|
+
ensureDirectoryExists(cacheReadmePath);
|
|
51
|
+
await fs.writeFile(cacheReadmePath, "This directory contains compiled JS files from the paths directory. Do not edit these files directly.\n", "utf8");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Reads and returns the `#/paths` requirement from `specification`.
|
|
55
|
+
*
|
|
56
|
+
* Writes a diagnostic message to stderr and returns an empty set when the
|
|
57
|
+
* `paths` key is missing or cannot be read.
|
|
58
|
+
*
|
|
59
|
+
* @param specification - The loaded OpenAPI specification.
|
|
60
|
+
*/
|
|
61
|
+
async getPathsFromSpecification(specification) {
|
|
62
|
+
try {
|
|
63
|
+
return specification.getRequirement("#/paths") ?? new Set();
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
process.stderr.write(`Could not find #/paths in the specification.\n${error}\n`);
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Runs the main code-generation pipeline once and resolves when complete.
|
|
72
|
+
*
|
|
73
|
+
* Loads the OpenAPI spec from `openapiPath`, optionally prunes defunct route
|
|
74
|
+
* files, registers all path operations as {@link OperationCoder} exports, and
|
|
75
|
+
* writes the resulting TypeScript files to `destination`.
|
|
76
|
+
*
|
|
77
|
+
* @param repository - Injectable repository instance; defaults to a fresh one
|
|
78
|
+
* (primarily useful in tests).
|
|
79
|
+
*/
|
|
80
|
+
async generate(repository = new Repository()) {
|
|
81
|
+
const { destination } = this;
|
|
82
|
+
debug("generating code from %s to %s", this.openapiPath, destination);
|
|
83
|
+
debug("initializing the .cache directory");
|
|
84
|
+
await this.buildCacheDirectory(destination);
|
|
85
|
+
debug("done initializing the .cache directory");
|
|
86
|
+
debug("creating specification from %s", this.openapiPath);
|
|
87
|
+
const specification = await Specification.fromFile(this.openapiPath);
|
|
88
|
+
debug("created specification: $o", specification);
|
|
89
|
+
debug("reading the #/paths from the specification");
|
|
90
|
+
const paths = await this.getPathsFromSpecification(specification);
|
|
91
|
+
debug("got %i paths", paths?.map?.length ?? 0);
|
|
92
|
+
if (this.generateOptions.prune && this.generateOptions.routes) {
|
|
93
|
+
debug("pruning defunct route files");
|
|
94
|
+
await pruneRoutes(destination, paths.map((_v, key) => key));
|
|
95
|
+
debug("done pruning");
|
|
96
|
+
}
|
|
97
|
+
const securityRequirement = specification.getRequirement("#/components/securitySchemes");
|
|
98
|
+
const securitySchemes = Object.values(securityRequirement?.data ?? {});
|
|
99
|
+
const HTTP_VERBS = new Set([
|
|
100
|
+
"get",
|
|
101
|
+
"put",
|
|
102
|
+
"post",
|
|
103
|
+
"delete",
|
|
104
|
+
"options",
|
|
105
|
+
"head",
|
|
106
|
+
"patch",
|
|
107
|
+
"trace",
|
|
108
|
+
]);
|
|
109
|
+
paths.forEach((pathDefinition, key) => {
|
|
110
|
+
debug("processing path %s", key);
|
|
111
|
+
const path = key === "/" ? "/index" : key;
|
|
112
|
+
pathDefinition.forEach((operation, requestMethod) => {
|
|
113
|
+
if (!HTTP_VERBS.has(requestMethod)) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
repository
|
|
117
|
+
.get(`routes${path}.ts`)
|
|
118
|
+
.export(new OperationCoder(operation, requestMethod, securitySchemes));
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
debug("telling the repository to write the files to %s", destination);
|
|
122
|
+
await repository.writeFiles(destination, this.generateOptions);
|
|
123
|
+
debug("finished writing the files");
|
|
18
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Starts watching the OpenAPI document for changes.
|
|
127
|
+
*
|
|
128
|
+
* Has no effect when `openApiPath` is a URL (HTTP sources are not watched).
|
|
129
|
+
* Resolves once the watcher is ready.
|
|
130
|
+
*/
|
|
19
131
|
async watch() {
|
|
20
132
|
if (this.openapiPath.startsWith("http")) {
|
|
21
133
|
return;
|
|
22
134
|
}
|
|
23
135
|
this.watcher = watch(this.openapiPath, CHOKIDAR_OPTIONS).on("change", () => {
|
|
24
|
-
void generate(
|
|
136
|
+
void this.generate().then(() => {
|
|
25
137
|
this.dispatchEvent(new Event("generate"));
|
|
26
138
|
return true;
|
|
27
139
|
}, () => {
|
|
@@ -31,6 +143,7 @@ export class CodeGenerator extends EventTarget {
|
|
|
31
143
|
});
|
|
32
144
|
await waitForEvent(this.watcher, "ready");
|
|
33
145
|
}
|
|
146
|
+
/** Closes the file-system watcher. */
|
|
34
147
|
async stopWatching() {
|
|
35
148
|
await this.watcher?.close();
|
|
36
149
|
}
|