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
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { pathJoin } from "../util/forward-slash-path.js";
|
|
2
2
|
import { Coder } from "./coder.js";
|
|
3
3
|
import { OperationTypeCoder, } from "./operation-type-coder.js";
|
|
4
|
+
/**
|
|
5
|
+
* Generates the default route handler stub for a single OpenAPI operation.
|
|
6
|
+
*
|
|
7
|
+
* The generated stub calls `$.response[statusCode].random()` (or `.empty()`)
|
|
8
|
+
* for the first response defined in the spec. It is only written when no
|
|
9
|
+
* handler file exists yet — users are expected to replace it with real logic.
|
|
10
|
+
*
|
|
11
|
+
* The corresponding TypeScript type is emitted by {@link OperationTypeCoder}
|
|
12
|
+
* into the `types/paths/…` tree.
|
|
13
|
+
*/
|
|
4
14
|
export class OperationCoder extends Coder {
|
|
5
15
|
requestMethod;
|
|
6
16
|
securitySchemes;
|
|
@@ -38,8 +48,6 @@ export class OperationCoder extends Coder {
|
|
|
38
48
|
.split("/")
|
|
39
49
|
.at(-2)
|
|
40
50
|
.replaceAll("~1", "/");
|
|
41
|
-
return `${
|
|
42
|
-
.join("routes", pathString)
|
|
43
|
-
.replaceAll("\\", "/")}.types.ts`;
|
|
51
|
+
return `${pathJoin("routes", pathString)}.types.ts`;
|
|
44
52
|
}
|
|
45
53
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { pathJoin } from "../util/forward-slash-path.js";
|
|
2
2
|
import { CONTEXT_FILE_TOKEN } from "./context-file-token.js";
|
|
3
3
|
import { buildJsDoc } from "./jsdoc.js";
|
|
4
4
|
import { ParameterExportTypeCoder } from "./parameter-export-type-coder.js";
|
|
@@ -24,6 +24,15 @@ function sanitizeIdentifier(value) {
|
|
|
24
24
|
}
|
|
25
25
|
return result || "_";
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Generates the TypeScript type for a single OpenAPI operation.
|
|
29
|
+
*
|
|
30
|
+
* The emitted type describes the function signature that a Counterfact route
|
|
31
|
+
* handler must satisfy, including strongly-typed `query`, `path`, `headers`,
|
|
32
|
+
* `cookie`, `body`, `context`, `response`, and `user` arguments.
|
|
33
|
+
*
|
|
34
|
+
* Output is written to `types/paths/<route>.types.ts`.
|
|
35
|
+
*/
|
|
27
36
|
export class OperationTypeCoder extends TypeCoder {
|
|
28
37
|
requestMethod;
|
|
29
38
|
securitySchemes;
|
|
@@ -35,6 +44,10 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
35
44
|
this.requestMethod = requestMethod;
|
|
36
45
|
this.securitySchemes = securitySchemes;
|
|
37
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Returns the base identifier for this operation, derived from its
|
|
49
|
+
* `operationId` (sanitised) or falling back to `HTTP_<METHOD>`.
|
|
50
|
+
*/
|
|
38
51
|
getOperationBaseName() {
|
|
39
52
|
const operationId = this.requirement.get("operationId")?.data;
|
|
40
53
|
return operationId
|
|
@@ -47,6 +60,19 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
47
60
|
names() {
|
|
48
61
|
return super.names(this.getOperationBaseName());
|
|
49
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Generates and exports a named parameter type (e.g. `ListPets_Query`) from
|
|
65
|
+
* `modulePath` and returns the exported type name.
|
|
66
|
+
*
|
|
67
|
+
* Returns `"never"` without creating an export when `inlineType` is
|
|
68
|
+
* `"never"`.
|
|
69
|
+
*
|
|
70
|
+
* @param script - The script being assembled.
|
|
71
|
+
* @param parameterKind - `"query"`, `"path"`, `"headers"`, or `"cookie"`.
|
|
72
|
+
* @param inlineType - The inline TypeScript type string to export.
|
|
73
|
+
* @param baseName - The base identifier prefix for the exported type name.
|
|
74
|
+
* @param modulePath - The repository-relative path of the type file.
|
|
75
|
+
*/
|
|
50
76
|
exportParameterType(script, parameterKind, inlineType, baseName, modulePath) {
|
|
51
77
|
if (inlineType === "never") {
|
|
52
78
|
return "never";
|
|
@@ -57,6 +83,11 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
57
83
|
coder._modulePath = modulePath;
|
|
58
84
|
return script.export(coder, true);
|
|
59
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Returns the union of all possible response type shapes for this operation.
|
|
88
|
+
*
|
|
89
|
+
* @param script - The script being assembled (used to resolve imports).
|
|
90
|
+
*/
|
|
60
91
|
responseTypes(script) {
|
|
61
92
|
return this.requirement
|
|
62
93
|
.get("responses")
|
|
@@ -96,10 +127,14 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
96
127
|
.split("/")
|
|
97
128
|
.at(-2)
|
|
98
129
|
.replaceAll("~1", "/");
|
|
99
|
-
return `${
|
|
100
|
-
.join("types/paths", pathString === "/" ? "/index" : pathString)
|
|
101
|
-
.replaceAll("\\", "/")}.types.ts`;
|
|
130
|
+
return `${pathJoin("types/paths", pathString === "/" ? "/index" : pathString)}.types.ts`;
|
|
102
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Returns the TypeScript type for the `user` argument.
|
|
134
|
+
*
|
|
135
|
+
* When the operation is protected by HTTP Basic auth, the type is
|
|
136
|
+
* `{username?: string, password?: string}`. Otherwise it is `"never"`.
|
|
137
|
+
*/
|
|
103
138
|
userType() {
|
|
104
139
|
if (this.securitySchemes.some(({ scheme, type }) => type === "http" && scheme === "basic")) {
|
|
105
140
|
return "{username?: string, password?: string}";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { pathJoin } from "../util/forward-slash-path.js";
|
|
2
2
|
import { buildJsDoc } from "./jsdoc.js";
|
|
3
3
|
import { SchemaTypeCoder } from "./schema-type-coder.js";
|
|
4
4
|
import { TypeCoder } from "./type-coder.js";
|
|
@@ -39,8 +39,6 @@ export class ParametersTypeCoder extends TypeCoder {
|
|
|
39
39
|
.split("/")
|
|
40
40
|
.at(-2)
|
|
41
41
|
.replaceAll("~1", "/");
|
|
42
|
-
return `${
|
|
43
|
-
.join("parameters", pathString)
|
|
44
|
-
.replaceAll("\\", "/")}.types.ts`;
|
|
42
|
+
return `${pathJoin("parameters", pathString)}.types.ts`;
|
|
45
43
|
}
|
|
46
44
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import nodePath from "node:path";
|
|
3
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- pruning only traverses and removes files under destination/routes. */
|
|
3
4
|
import createDebug from "debug";
|
|
5
|
+
import { toForwardSlashPath } from "../util/forward-slash-path.js";
|
|
4
6
|
const debug = createDebug("counterfact:typescript-generator:prune");
|
|
5
7
|
/**
|
|
6
8
|
* Collects all .ts route files in a directory recursively.
|
|
@@ -87,7 +89,7 @@ export async function pruneRoutes(destination, openApiPaths) {
|
|
|
87
89
|
debug("actual route files: %o", actualFiles);
|
|
88
90
|
let prunedCount = 0;
|
|
89
91
|
for (const file of actualFiles) {
|
|
90
|
-
const normalizedFile = file
|
|
92
|
+
const normalizedFile = toForwardSlashPath(file);
|
|
91
93
|
if (!expectedFiles.has(normalizedFile)) {
|
|
92
94
|
const fullPath = nodePath.join(routesDir, file);
|
|
93
95
|
debug("pruning %s", fullPath);
|
|
@@ -2,19 +2,36 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import nodePath, { dirname } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- repository writes and stats generated files only inside destination output directories. */
|
|
5
6
|
import createDebug from "debug";
|
|
6
7
|
import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
|
|
8
|
+
import { toForwardSlashPath, pathJoin, pathRelative, pathDirname, } from "../util/forward-slash-path.js";
|
|
7
9
|
import { CONTEXT_FILE_TOKEN } from "./context-file-token.js";
|
|
8
10
|
import { Script } from "./script.js";
|
|
9
11
|
import { escapePathForWindows } from "../util/windows-escape.js";
|
|
10
12
|
const debug = createDebug("counterfact:server:repository");
|
|
11
|
-
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
13
|
+
const __dirname = toForwardSlashPath(dirname(fileURLToPath(import.meta.url)));
|
|
12
14
|
debug("dirname is %s", __dirname);
|
|
15
|
+
/**
|
|
16
|
+
* Collection of {@link Script} objects keyed by their repository-relative
|
|
17
|
+
* path.
|
|
18
|
+
*
|
|
19
|
+
* Coders call {@link get} to obtain (or create) the script where they should
|
|
20
|
+
* export their generated TypeScript. After all coders have been registered,
|
|
21
|
+
* {@link writeFiles} waits for every script to finish and writes the output to
|
|
22
|
+
* disk.
|
|
23
|
+
*/
|
|
13
24
|
export class Repository {
|
|
14
25
|
scripts;
|
|
15
26
|
constructor() {
|
|
16
27
|
this.scripts = new Map();
|
|
17
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Returns the {@link Script} for `path`, creating it if it does not yet
|
|
31
|
+
* exist.
|
|
32
|
+
*
|
|
33
|
+
* @param path - Repository-relative path (e.g. `"routes/pets.ts"`).
|
|
34
|
+
*/
|
|
18
35
|
get(path) {
|
|
19
36
|
debug("getting script at %s", path);
|
|
20
37
|
if (this.scripts.has(path)) {
|
|
@@ -26,12 +43,22 @@ export class Repository {
|
|
|
26
43
|
this.scripts.set(path, script);
|
|
27
44
|
return script;
|
|
28
45
|
}
|
|
46
|
+
/** Waits until all scripts have resolved all of their pending export promises. */
|
|
29
47
|
async finished() {
|
|
30
48
|
while (Array.from(this.scripts.values()).some((script) => script.isInProgress())) {
|
|
31
49
|
debug("waiting for %i scripts to finish", this.scripts.size);
|
|
32
50
|
await Promise.all(Array.from(this.scripts.values(), (script) => script.finished()));
|
|
33
51
|
}
|
|
34
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Copies the compiled `counterfact-types` directory from the Counterfact
|
|
55
|
+
* distribution into the generated output tree.
|
|
56
|
+
*
|
|
57
|
+
* Returns `false` when the source directory does not exist (e.g. running
|
|
58
|
+
* from source without a prior build).
|
|
59
|
+
*
|
|
60
|
+
* @param destination - The root of the generated output tree.
|
|
61
|
+
*/
|
|
35
62
|
async copyCoreFiles(destination) {
|
|
36
63
|
const sourcePath = nodePath.join(__dirname, "../../dist/server/counterfact-types");
|
|
37
64
|
const destinationPath = nodePath.join(destination, "counterfact-types");
|
|
@@ -40,13 +67,23 @@ export class Repository {
|
|
|
40
67
|
}
|
|
41
68
|
return fs.cp(sourcePath, destinationPath, { recursive: true });
|
|
42
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Waits for all scripts to finish, then writes each one to disk.
|
|
72
|
+
*
|
|
73
|
+
* Route files (`routes/…`) are never overwritten if they already exist on
|
|
74
|
+
* disk, preserving user edits. Type files (`types/…`) are always
|
|
75
|
+
* overwritten.
|
|
76
|
+
*
|
|
77
|
+
* @param destination - Absolute path to the output root directory.
|
|
78
|
+
* @param options - Controls which artefacts are written.
|
|
79
|
+
*/
|
|
43
80
|
async writeFiles(destination, { routes, types }) {
|
|
44
81
|
debug("waiting for %i or more scripts to finish before writing files", this.scripts.size);
|
|
45
82
|
await this.finished();
|
|
46
83
|
debug("all %i scripts are finished", this.scripts.size);
|
|
47
84
|
const writeFiles = Array.from(this.scripts.entries(), async ([path, script]) => {
|
|
48
85
|
const contents = await script.contents();
|
|
49
|
-
const fullPath = escapePathForWindows(
|
|
86
|
+
const fullPath = escapePathForWindows(pathJoin(destination, path));
|
|
50
87
|
await ensureDirectoryExists(fullPath);
|
|
51
88
|
const shouldWriteRoutes = routes && path.startsWith("routes");
|
|
52
89
|
const shouldWriteTypes = types && !path.startsWith("routes");
|
|
@@ -70,37 +107,57 @@ export class Repository {
|
|
|
70
107
|
await this.createDefaultContextFile(destination);
|
|
71
108
|
}
|
|
72
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Creates the default `routes/_.context.ts` file if it does not already
|
|
112
|
+
* exist.
|
|
113
|
+
*
|
|
114
|
+
* @param destination - Absolute path to the output root directory.
|
|
115
|
+
*/
|
|
73
116
|
async createDefaultContextFile(destination) {
|
|
74
117
|
const contextFilePath = nodePath.join(destination, "routes", "_.context.ts");
|
|
75
118
|
if (existsSync(contextFilePath)) {
|
|
76
119
|
return;
|
|
77
120
|
}
|
|
78
121
|
await ensureDirectoryExists(contextFilePath);
|
|
79
|
-
await fs.writeFile(contextFilePath,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
|
|
89
|
-
|
|
122
|
+
await fs.writeFile(contextFilePath, `import type { Context$ } from "../types/_.context.js";
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* This is the default context for Counterfact.
|
|
126
|
+
*
|
|
127
|
+
* It defines the context object in the REPL
|
|
128
|
+
* and the $.context object in the code.
|
|
129
|
+
*
|
|
130
|
+
* Add properties and methods to suit your needs.
|
|
131
|
+
*
|
|
132
|
+
* See https://github.com/counterfact/api-simulator/blob/main/docs/features/state.md
|
|
133
|
+
*/
|
|
90
134
|
|
|
135
|
+
export class Context {
|
|
136
|
+
constructor($: Context$) {
|
|
137
|
+
void $;
|
|
138
|
+
}
|
|
91
139
|
}
|
|
92
140
|
`);
|
|
93
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Returns the path of the `_.context.ts` file that is nearest to `path` in
|
|
144
|
+
* the directory hierarchy, relative to the script's output directory.
|
|
145
|
+
*
|
|
146
|
+
* @param destination - Output root directory.
|
|
147
|
+
* @param path - Repository-relative path of the script being generated.
|
|
148
|
+
*/
|
|
94
149
|
findContextPath(destination, path) {
|
|
95
|
-
return nodePath
|
|
96
|
-
.relative(nodePath.join(destination, nodePath.dirname(path)), this.nearestContextFile(destination, path))
|
|
97
|
-
.replaceAll("\\", "/");
|
|
150
|
+
return pathRelative(nodePath.join(destination, nodePath.dirname(path)), this.nearestContextFile(destination, path));
|
|
98
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Walks up the directory tree from `path` to find the nearest
|
|
154
|
+
* `_.context.ts` file, falling back to `routes/_.context.ts` at the root.
|
|
155
|
+
*
|
|
156
|
+
* @param destination - Output root directory.
|
|
157
|
+
* @param path - Repository-relative path to start from.
|
|
158
|
+
*/
|
|
99
159
|
nearestContextFile(destination, path) {
|
|
100
|
-
const directory =
|
|
101
|
-
.dirname(path)
|
|
102
|
-
.replaceAll("\\", "/")
|
|
103
|
-
.replace("types/paths", "routes");
|
|
160
|
+
const directory = pathDirname(path).replace("types/paths", "routes");
|
|
104
161
|
const candidate = nodePath.join(destination, directory, "_.context.ts");
|
|
105
162
|
if (directory.length <= 1) {
|
|
106
163
|
// No _context.ts was found so import the one that should be in the root
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A node in the dereferenced OpenAPI spec tree.
|
|
3
|
+
*
|
|
4
|
+
* A `Requirement` wraps a raw JSON object (`data`) together with its location
|
|
5
|
+
* (`url`, a JSON Pointer) and a back-reference to the owning
|
|
6
|
+
* {@link Specification}. Navigation methods (`get`, `select`, `find`, …)
|
|
7
|
+
* transparently follow `$ref` pointers.
|
|
8
|
+
*/
|
|
1
9
|
export class Requirement {
|
|
2
10
|
data;
|
|
3
11
|
url;
|
|
@@ -7,18 +15,36 @@ export class Requirement {
|
|
|
7
15
|
this.url = url;
|
|
8
16
|
this.specification = specification;
|
|
9
17
|
}
|
|
18
|
+
/** `true` when this node is a JSON Reference (`$ref`) rather than inline data. */
|
|
10
19
|
get isReference() {
|
|
11
20
|
return this.data["$ref"] !== undefined;
|
|
12
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolves the `$ref` and returns the target {@link Requirement}.
|
|
24
|
+
*
|
|
25
|
+
* @throws When `isReference` is `false` or the specification is not set.
|
|
26
|
+
*/
|
|
13
27
|
reference() {
|
|
14
28
|
return this.specification.getRequirement(this.data["$ref"]);
|
|
15
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns `true` when this node has a child property named `item`.
|
|
32
|
+
*
|
|
33
|
+
* Transparently follows `$ref` references.
|
|
34
|
+
*
|
|
35
|
+
* @param item - The property key to check.
|
|
36
|
+
*/
|
|
16
37
|
has(item) {
|
|
17
38
|
if (this.isReference) {
|
|
18
39
|
return this.reference().has(item);
|
|
19
40
|
}
|
|
20
41
|
return item in this.data;
|
|
21
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Returns the child {@link Requirement} for `item`, or `undefined`.
|
|
45
|
+
*
|
|
46
|
+
* @param item - The property key (string) or array index (number).
|
|
47
|
+
*/
|
|
22
48
|
get(item) {
|
|
23
49
|
if (this.isReference) {
|
|
24
50
|
return this.reference().get(item);
|
|
@@ -29,6 +55,16 @@ export class Requirement {
|
|
|
29
55
|
}
|
|
30
56
|
return new Requirement(this.data[key], `${this.url}/${this.escapeJsonPointer(key)}`, this.specification);
|
|
31
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Navigates to a descendant node using a slash-delimited JSON Pointer path.
|
|
60
|
+
*
|
|
61
|
+
* Tilde-escaped characters (`~0` → `~`, `~1` → `/`) and percent-encoded
|
|
62
|
+
* characters are unescaped during traversal.
|
|
63
|
+
*
|
|
64
|
+
* @param path - A slash-delimited path (e.g. `"responses/200/content"`).
|
|
65
|
+
* @returns The target {@link Requirement}, or `undefined` if the path does
|
|
66
|
+
* not exist.
|
|
67
|
+
*/
|
|
32
68
|
select(path) {
|
|
33
69
|
const parts = path
|
|
34
70
|
.split("/")
|
|
@@ -54,19 +90,42 @@ export class Requirement {
|
|
|
54
90
|
}
|
|
55
91
|
return result;
|
|
56
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Iterates over all child properties and calls `callback` with each child
|
|
95
|
+
* requirement and its key.
|
|
96
|
+
*
|
|
97
|
+
* @param callback - Called for each child with `(child, key)`.
|
|
98
|
+
*/
|
|
57
99
|
forEach(callback) {
|
|
58
100
|
Object.keys(this.data).forEach((key) => {
|
|
59
101
|
callback(this.select(this.escapeJsonPointer(key)), key);
|
|
60
102
|
});
|
|
61
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Maps over all child properties and returns the collected results.
|
|
106
|
+
*
|
|
107
|
+
* @param callback - Transformation function called with `(child, key)`.
|
|
108
|
+
*/
|
|
62
109
|
map(callback) {
|
|
63
110
|
const result = [];
|
|
64
111
|
this.forEach((value, key) => result.push(callback(value, key)));
|
|
65
112
|
return result;
|
|
66
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Maps and flattens over all child properties.
|
|
116
|
+
*
|
|
117
|
+
* @param callback - Transformation function that may return a value or an
|
|
118
|
+
* array of values.
|
|
119
|
+
*/
|
|
67
120
|
flatMap(callback) {
|
|
68
121
|
return this.map(callback).flat();
|
|
69
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Returns the first child for which `callback` returns `true`, or
|
|
125
|
+
* `undefined` when nothing matches.
|
|
126
|
+
*
|
|
127
|
+
* @param callback - Predicate called with `(child, key)`.
|
|
128
|
+
*/
|
|
70
129
|
find(callback) {
|
|
71
130
|
let result;
|
|
72
131
|
this.forEach((value, key) => {
|
|
@@ -76,11 +135,21 @@ export class Requirement {
|
|
|
76
135
|
});
|
|
77
136
|
return result;
|
|
78
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Escapes a JSON Pointer token: `~` → `~0`, `/` → `~1`.
|
|
140
|
+
*
|
|
141
|
+
* @param value - The token to escape.
|
|
142
|
+
*/
|
|
79
143
|
escapeJsonPointer(value) {
|
|
80
144
|
if (typeof value !== "string")
|
|
81
145
|
return value;
|
|
82
146
|
return value.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
83
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Unescapes a JSON Pointer token: `~1` → `/`, `~0` → `~`.
|
|
150
|
+
*
|
|
151
|
+
* @param pointer - The token to unescape.
|
|
152
|
+
*/
|
|
84
153
|
unescapeJsonPointer(pointer) {
|
|
85
154
|
if (typeof pointer !== "string")
|
|
86
155
|
return pointer;
|
|
@@ -1,70 +1,11 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import nodePath from "node:path";
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { Specification } from "./specification.js";
|
|
10
|
-
const debug = createDebug("counterfact:typescript-generator:generate");
|
|
11
|
-
async function buildCacheDirectory(destination) {
|
|
12
|
-
const gitignorePath = nodePath.join(destination, ".gitignore");
|
|
13
|
-
const cacheReadmePath = nodePath.join(destination, ".cache", "README.md");
|
|
14
|
-
debug("ensuring the directory containing .gitgnore exists");
|
|
15
|
-
await ensureDirectoryExists(gitignorePath);
|
|
16
|
-
debug("creating the .gitignore file if it doesn't already exist");
|
|
17
|
-
if (!existsSync(gitignorePath)) {
|
|
18
|
-
await fs.writeFile(gitignorePath, ".cache\n", "utf8");
|
|
19
|
-
}
|
|
20
|
-
debug("creating the .cache/README.md file");
|
|
21
|
-
ensureDirectoryExists(cacheReadmePath);
|
|
22
|
-
await fs.writeFile(cacheReadmePath, "This directory contains compiled JS files from the paths directory. Do not edit these files directly.\n", "utf8");
|
|
23
|
-
}
|
|
24
|
-
async function getPathsFromSpecification(specification) {
|
|
25
|
-
try {
|
|
26
|
-
return specification.getRequirement("#/paths") ?? new Set();
|
|
27
|
-
}
|
|
28
|
-
catch (error) {
|
|
29
|
-
process.stderr.write(`Could not find #/paths in the specification.\n${error}\n`);
|
|
30
|
-
return undefined;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
export async function generate(source, destination, generateOptions, repository = new Repository()) {
|
|
34
|
-
debug("generating code from %s to %s", source, destination);
|
|
35
|
-
debug("initializing the .cache directory");
|
|
36
|
-
await buildCacheDirectory(destination);
|
|
37
|
-
debug("done initializing the .cache directory");
|
|
38
|
-
debug("creating specification from %s", source);
|
|
39
|
-
const specification = await Specification.fromFile(source);
|
|
40
|
-
debug("created specification: $o", specification);
|
|
41
|
-
debug("reading the #/paths from the specification");
|
|
42
|
-
const paths = await getPathsFromSpecification(specification);
|
|
43
|
-
debug("got %i paths", paths?.map?.length ?? 0);
|
|
44
|
-
if (generateOptions.prune && generateOptions.routes) {
|
|
45
|
-
debug("pruning defunct route files");
|
|
46
|
-
await pruneRoutes(destination, paths.map((_v, key) => key));
|
|
47
|
-
debug("done pruning");
|
|
48
|
-
}
|
|
49
|
-
const securityRequirement = specification.getRequirement("#/components/securitySchemes");
|
|
50
|
-
const securitySchemes = Object.values(securityRequirement?.data ?? {});
|
|
51
|
-
paths.forEach((pathDefinition, key) => {
|
|
52
|
-
debug("processing path %s", key);
|
|
53
|
-
const path = key === "/" ? "/index" : key;
|
|
54
|
-
pathDefinition.forEach((operation, requestMethod) => {
|
|
55
|
-
repository
|
|
56
|
-
.get(`routes${path}.ts`)
|
|
57
|
-
.export(new OperationCoder(operation, requestMethod, securitySchemes));
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
debug("telling the repository to write the files to %s", destination);
|
|
61
|
-
await repository.writeFiles(destination, generateOptions);
|
|
62
|
-
debug("finished writing the files");
|
|
63
|
-
if (generateOptions.types) {
|
|
64
|
-
await writeApplyContextType(destination);
|
|
65
|
-
await writeDefaultScenariosIndex(destination);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
4
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- scenario files are discovered and generated under the configured destination tree. */
|
|
5
|
+
import { watch } from "chokidar";
|
|
6
|
+
import { CHOKIDAR_OPTIONS } from "../server/constants.js";
|
|
7
|
+
import { pathRelative } from "../util/forward-slash-path.js";
|
|
8
|
+
import { waitForEvent } from "../util/wait-for-event.js";
|
|
68
9
|
async function collectContextFiles(destination) {
|
|
69
10
|
const routesDir = nodePath.join(destination, "routes");
|
|
70
11
|
const results = [];
|
|
@@ -88,9 +29,7 @@ async function walkForContextFiles(routesDir, currentDir, results) {
|
|
|
88
29
|
await walkForContextFiles(routesDir, nodePath.join(currentDir, entry.name), results);
|
|
89
30
|
}
|
|
90
31
|
else if (entry.name === "_.context.ts") {
|
|
91
|
-
const relDir =
|
|
92
|
-
.relative(routesDir, currentDir)
|
|
93
|
-
.replaceAll("\\", "/");
|
|
32
|
+
const relDir = pathRelative(routesDir, currentDir);
|
|
94
33
|
const routePath = relDir === "" ? "/" : `/${relDir}`;
|
|
95
34
|
const depth = relDir === "" ? 0 : relDir.split("/").length;
|
|
96
35
|
const importPath = relDir === "" ? "../routes/_.context" : `../routes/${relDir}/_.context`;
|
|
@@ -128,7 +67,7 @@ function buildLoadContextOverload(routePath, alias) {
|
|
|
128
67
|
.join("/")}`;
|
|
129
68
|
return ` loadContext(path: \`${templatePath}\`): ${alias};`;
|
|
130
69
|
}
|
|
131
|
-
function
|
|
70
|
+
function buildScenarioContextContent(contextFiles) {
|
|
132
71
|
const rootContext = contextFiles.find((f) => f.routePath === "/");
|
|
133
72
|
const contextType = rootContext
|
|
134
73
|
? rootContext.alias
|
|
@@ -141,38 +80,60 @@ function buildApplyContextContent(contextFiles) {
|
|
|
141
80
|
"// This file is generated by Counterfact. Do not edit manually.",
|
|
142
81
|
...importLines,
|
|
143
82
|
"",
|
|
144
|
-
"
|
|
145
|
-
|
|
146
|
-
` context: ${contextType};`,
|
|
147
|
-
" /** Load a context object for a specific path */",
|
|
83
|
+
"interface LoadContextDefinitions {",
|
|
84
|
+
" /* code generator adds additional signatures here */",
|
|
148
85
|
...overloadLines,
|
|
149
86
|
" loadContext(path: string): Record<string, unknown>;",
|
|
87
|
+
"}",
|
|
88
|
+
"",
|
|
89
|
+
"export interface Scenario$ {",
|
|
90
|
+
' /** Root context, same as loadContext("/") */',
|
|
91
|
+
` readonly context: ${contextType};`,
|
|
92
|
+
' readonly loadContext: LoadContextDefinitions["loadContext"];',
|
|
150
93
|
" /** Named route builders stored in the REPL execution context */",
|
|
151
|
-
" routes: Record<string, unknown>;",
|
|
94
|
+
" readonly routes: Record<string, unknown>;",
|
|
152
95
|
" /** Create a new route builder for a given path */",
|
|
153
|
-
" route: (path: string) => unknown;",
|
|
96
|
+
" readonly route: (path: string) => unknown;",
|
|
154
97
|
"}",
|
|
155
98
|
"",
|
|
156
99
|
"/** A scenario function that receives the live REPL environment */",
|
|
157
|
-
"export type Scenario = ($:
|
|
100
|
+
"export type Scenario = ($: Scenario$) => Promise<void> | void;",
|
|
101
|
+
"",
|
|
102
|
+
"/** Interface for Context objects defined in _.context.ts files */",
|
|
103
|
+
"export interface Context$ {",
|
|
104
|
+
" /** Load a context object for a specific path */",
|
|
105
|
+
' readonly loadContext: LoadContextDefinitions["loadContext"];',
|
|
106
|
+
" /** Load a JSON file relative to this file's path */",
|
|
107
|
+
" readonly readJson: (relativePath: string) => Promise<unknown>;",
|
|
108
|
+
"}",
|
|
158
109
|
"",
|
|
159
110
|
];
|
|
160
111
|
return parts.join("\n");
|
|
161
112
|
}
|
|
162
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Writes the `types/_.context.ts` file, which exports the
|
|
115
|
+
* `Scenario$` interface used to type scenario functions.
|
|
116
|
+
*
|
|
117
|
+
* The interface is generated from all `_.context.ts` files found under the
|
|
118
|
+
* `routes/` directory, providing strongly typed `loadContext()` overloads for
|
|
119
|
+
* every route path that has a context file.
|
|
120
|
+
*
|
|
121
|
+
* @param destination - Root output directory.
|
|
122
|
+
*/
|
|
123
|
+
async function writeScenarioContextType(destination) {
|
|
163
124
|
const typesDir = nodePath.join(destination, "types");
|
|
164
|
-
const filePath = nodePath.join(typesDir, "
|
|
125
|
+
const filePath = nodePath.join(typesDir, "_.context.ts");
|
|
165
126
|
const contextFiles = await collectContextFiles(destination);
|
|
166
|
-
const content =
|
|
127
|
+
const content = buildScenarioContextContent(contextFiles);
|
|
167
128
|
await fs.mkdir(typesDir, { recursive: true });
|
|
168
129
|
await fs.writeFile(filePath, content, "utf8");
|
|
169
130
|
}
|
|
170
|
-
const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/
|
|
131
|
+
const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/_.context.js";
|
|
171
132
|
|
|
172
133
|
/**
|
|
173
134
|
* Scenario scripts are plain TypeScript functions that receive the live REPL
|
|
174
135
|
* environment and can read or mutate server state. Run them from the REPL with:
|
|
175
|
-
* .
|
|
136
|
+
* .scenario <functionName>
|
|
176
137
|
*/
|
|
177
138
|
|
|
178
139
|
/**
|
|
@@ -186,9 +147,24 @@ const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/scenari
|
|
|
186
147
|
* $.routes.myRequest = $.route("/pets").method("get");
|
|
187
148
|
*/
|
|
188
149
|
|
|
150
|
+
/**
|
|
151
|
+
* startup() runs automatically when the server initializes, right before the
|
|
152
|
+
* REPL starts. Use it to seed dummy data so the server is ready to use
|
|
153
|
+
* immediately. It receives the same $ argument as all other scenario functions.
|
|
154
|
+
*
|
|
155
|
+
* Tip: delegate to other scenario functions and pass $ along so each function
|
|
156
|
+
* stays focused on a single concern. You can also pass additional arguments to
|
|
157
|
+
* configure them, e.g. addPets($, 20, "dog").
|
|
158
|
+
*
|
|
159
|
+
* If you don't need a startup scenario, delete this function or leave it empty.
|
|
160
|
+
*/
|
|
161
|
+
export const startup: Scenario = ($) => {
|
|
162
|
+
void $;
|
|
163
|
+
};
|
|
164
|
+
|
|
189
165
|
/**
|
|
190
166
|
* An example scenario. To use it in the REPL, type:
|
|
191
|
-
* .
|
|
167
|
+
* .scenario help
|
|
192
168
|
*/
|
|
193
169
|
export const help: Scenario = ($) => {
|
|
194
170
|
void $;
|
|
@@ -216,3 +192,45 @@ async function writeDefaultScenariosIndex(destination) {
|
|
|
216
192
|
await fs.mkdir(scenariosDir, { recursive: true });
|
|
217
193
|
await fs.writeFile(filePath, DEFAULT_SCENARIOS_INDEX, "utf8");
|
|
218
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Encapsulates the generation of scenario-related files:
|
|
197
|
+
* - `types/_.context.ts` — the typed `Scenario$` interface derived from all
|
|
198
|
+
* `_.context.ts` files found under `routes/`.
|
|
199
|
+
* - `scenarios/index.ts` — the default scenarios entry-point (created only if
|
|
200
|
+
* it does not already exist).
|
|
201
|
+
*
|
|
202
|
+
* When {@link watch} is called, a file-system watcher monitors the `routes/`
|
|
203
|
+
* directory for changes to `_.context.ts` files and automatically regenerates
|
|
204
|
+
* `types/_.context.ts`.
|
|
205
|
+
*/
|
|
206
|
+
export class ScenarioFileGenerator {
|
|
207
|
+
destination;
|
|
208
|
+
watcher;
|
|
209
|
+
constructor(destination) {
|
|
210
|
+
this.destination = destination;
|
|
211
|
+
}
|
|
212
|
+
/** Generates both scenario-related files once and resolves when complete. */
|
|
213
|
+
async generate() {
|
|
214
|
+
await writeScenarioContextType(this.destination);
|
|
215
|
+
await writeDefaultScenariosIndex(this.destination);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Starts watching the `routes/` directory for `_.context.ts` changes and
|
|
219
|
+
* regenerates `types/_.context.ts` on every change.
|
|
220
|
+
*
|
|
221
|
+
* Resolves once the watcher is ready.
|
|
222
|
+
*/
|
|
223
|
+
async watch() {
|
|
224
|
+
const routesDir = nodePath.join(this.destination, "routes");
|
|
225
|
+
this.watcher = watch(routesDir, CHOKIDAR_OPTIONS).on("all", (_event, filePath) => {
|
|
226
|
+
if (filePath.endsWith("_.context.ts")) {
|
|
227
|
+
void writeScenarioContextType(this.destination);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
await waitForEvent(this.watcher, "ready");
|
|
231
|
+
}
|
|
232
|
+
/** Closes the file-system watcher. */
|
|
233
|
+
async stopWatching() {
|
|
234
|
+
await this.watcher?.close();
|
|
235
|
+
}
|
|
236
|
+
}
|