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,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("/")
|
|
@@ -36,7 +72,14 @@ export class Requirement {
|
|
|
36
72
|
// Unescape URL encoded characters (e.g. %20 -> " ")
|
|
37
73
|
// Technically we should not be unescaping, but it came up in https://github.com/pmcelhaney/counterfact/issues/1083
|
|
38
74
|
// and I can't think of a reason anyone would intentionally put a % in a key name.
|
|
39
|
-
.map(
|
|
75
|
+
.map((part) => {
|
|
76
|
+
try {
|
|
77
|
+
return decodeURIComponent(part);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return part;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
40
83
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
41
84
|
let result = this;
|
|
42
85
|
for (const part of parts) {
|
|
@@ -47,19 +90,42 @@ export class Requirement {
|
|
|
47
90
|
}
|
|
48
91
|
return result;
|
|
49
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
|
+
*/
|
|
50
99
|
forEach(callback) {
|
|
51
100
|
Object.keys(this.data).forEach((key) => {
|
|
52
101
|
callback(this.select(this.escapeJsonPointer(key)), key);
|
|
53
102
|
});
|
|
54
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Maps over all child properties and returns the collected results.
|
|
106
|
+
*
|
|
107
|
+
* @param callback - Transformation function called with `(child, key)`.
|
|
108
|
+
*/
|
|
55
109
|
map(callback) {
|
|
56
110
|
const result = [];
|
|
57
111
|
this.forEach((value, key) => result.push(callback(value, key)));
|
|
58
112
|
return result;
|
|
59
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
|
+
*/
|
|
60
120
|
flatMap(callback) {
|
|
61
121
|
return this.map(callback).flat();
|
|
62
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
|
+
*/
|
|
63
129
|
find(callback) {
|
|
64
130
|
let result;
|
|
65
131
|
this.forEach((value, key) => {
|
|
@@ -69,11 +135,21 @@ export class Requirement {
|
|
|
69
135
|
});
|
|
70
136
|
return result;
|
|
71
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Escapes a JSON Pointer token: `~` → `~0`, `/` → `~1`.
|
|
140
|
+
*
|
|
141
|
+
* @param value - The token to escape.
|
|
142
|
+
*/
|
|
72
143
|
escapeJsonPointer(value) {
|
|
73
144
|
if (typeof value !== "string")
|
|
74
145
|
return value;
|
|
75
146
|
return value.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
76
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Unescapes a JSON Pointer token: `~1` → `/`, `~0` → `~`.
|
|
150
|
+
*
|
|
151
|
+
* @param pointer - The token to unescape.
|
|
152
|
+
*/
|
|
77
153
|
unescapeJsonPointer(pointer) {
|
|
78
154
|
if (typeof pointer !== "string")
|
|
79
155
|
return pointer;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words
|
|
2
|
+
export const RESERVED_WORDS = new Set([
|
|
3
|
+
"break",
|
|
4
|
+
"case",
|
|
5
|
+
"catch",
|
|
6
|
+
"class",
|
|
7
|
+
"const",
|
|
8
|
+
"continue",
|
|
9
|
+
"debugger",
|
|
10
|
+
"default",
|
|
11
|
+
"delete",
|
|
12
|
+
"do",
|
|
13
|
+
"else",
|
|
14
|
+
"export",
|
|
15
|
+
"extends",
|
|
16
|
+
"false",
|
|
17
|
+
"finally",
|
|
18
|
+
"for",
|
|
19
|
+
"function",
|
|
20
|
+
"if",
|
|
21
|
+
"import",
|
|
22
|
+
"in",
|
|
23
|
+
"instanceof",
|
|
24
|
+
"new",
|
|
25
|
+
"null",
|
|
26
|
+
"return",
|
|
27
|
+
"static",
|
|
28
|
+
"super",
|
|
29
|
+
"switch",
|
|
30
|
+
"this",
|
|
31
|
+
"throw",
|
|
32
|
+
"true",
|
|
33
|
+
"try",
|
|
34
|
+
"typeof",
|
|
35
|
+
"var",
|
|
36
|
+
"void",
|
|
37
|
+
"while",
|
|
38
|
+
"with",
|
|
39
|
+
"yield",
|
|
40
|
+
"await",
|
|
41
|
+
"enum",
|
|
42
|
+
"implements",
|
|
43
|
+
"interface",
|
|
44
|
+
"let",
|
|
45
|
+
"package",
|
|
46
|
+
"private",
|
|
47
|
+
"protected",
|
|
48
|
+
"public",
|
|
49
|
+
"type",
|
|
50
|
+
]);
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import nodePath from "node:path";
|
|
4
|
+
import { watch } from "chokidar";
|
|
5
|
+
import { CHOKIDAR_OPTIONS } from "../server/constants.js";
|
|
6
|
+
import { pathRelative } from "../util/forward-slash-path.js";
|
|
7
|
+
import { waitForEvent } from "../util/wait-for-event.js";
|
|
8
|
+
async function collectContextFiles(destination) {
|
|
9
|
+
const routesDir = nodePath.join(destination, "routes");
|
|
10
|
+
const results = [];
|
|
11
|
+
if (!existsSync(routesDir)) {
|
|
12
|
+
return results;
|
|
13
|
+
}
|
|
14
|
+
await walkForContextFiles(routesDir, routesDir, results);
|
|
15
|
+
results.sort((a, b) => b.depth - a.depth);
|
|
16
|
+
return results;
|
|
17
|
+
}
|
|
18
|
+
async function walkForContextFiles(routesDir, currentDir, results) {
|
|
19
|
+
let entries;
|
|
20
|
+
try {
|
|
21
|
+
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
await walkForContextFiles(routesDir, nodePath.join(currentDir, entry.name), results);
|
|
29
|
+
}
|
|
30
|
+
else if (entry.name === "_.context.ts") {
|
|
31
|
+
const relDir = pathRelative(routesDir, currentDir);
|
|
32
|
+
const routePath = relDir === "" ? "/" : `/${relDir}`;
|
|
33
|
+
const depth = relDir === "" ? 0 : relDir.split("/").length;
|
|
34
|
+
const importPath = relDir === "" ? "../routes/_.context" : `../routes/${relDir}/_.context`;
|
|
35
|
+
const alias = routePathToAlias(routePath);
|
|
36
|
+
results.push({ importPath, alias, routePath, depth });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function routePathToAlias(routePath) {
|
|
41
|
+
if (routePath === "/") {
|
|
42
|
+
return "Context";
|
|
43
|
+
}
|
|
44
|
+
return (routePath
|
|
45
|
+
.split("/")
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.map((seg) => seg
|
|
48
|
+
.replace(/\{(.+?)\}/g, (_match, name) => name.replace(/[^a-z0-9]/gi, " "))
|
|
49
|
+
.replace(/[-_\s]([a-z])/g, (_match, c) => c.toUpperCase())
|
|
50
|
+
.replace(/^[a-z]/, (c) => c.toUpperCase())
|
|
51
|
+
.replace(/[^a-z0-9]/gi, ""))
|
|
52
|
+
.join("") + "Context");
|
|
53
|
+
}
|
|
54
|
+
const PARAM_SEGMENT_REGEX = /^\{.+\}$/u;
|
|
55
|
+
function buildLoadContextOverload(routePath, alias) {
|
|
56
|
+
if (routePath === "/") {
|
|
57
|
+
return ' loadContext(path: "/" | `/${string}`): ' + alias + ";";
|
|
58
|
+
}
|
|
59
|
+
const segments = routePath.split("/").filter(Boolean);
|
|
60
|
+
const hasParam = segments.some((seg) => PARAM_SEGMENT_REGEX.test(seg));
|
|
61
|
+
if (!hasParam) {
|
|
62
|
+
return ` loadContext(path: "${routePath}" | \`${routePath}/\${string}\`): ${alias};`;
|
|
63
|
+
}
|
|
64
|
+
const templatePath = `/${segments
|
|
65
|
+
.map((seg) => (PARAM_SEGMENT_REGEX.test(seg) ? "${string}" : seg))
|
|
66
|
+
.join("/")}`;
|
|
67
|
+
return ` loadContext(path: \`${templatePath}\`): ${alias};`;
|
|
68
|
+
}
|
|
69
|
+
function buildScenarioContextContent(contextFiles) {
|
|
70
|
+
const rootContext = contextFiles.find((f) => f.routePath === "/");
|
|
71
|
+
const contextType = rootContext
|
|
72
|
+
? rootContext.alias
|
|
73
|
+
: "Record<string, unknown>";
|
|
74
|
+
const importLines = contextFiles.map(({ importPath, alias }) => alias === "Context"
|
|
75
|
+
? `import type { Context } from "${importPath}";`
|
|
76
|
+
: `import type { Context as ${alias} } from "${importPath}";`);
|
|
77
|
+
const overloadLines = contextFiles.map(({ alias, routePath }) => buildLoadContextOverload(routePath, alias));
|
|
78
|
+
const parts = [
|
|
79
|
+
"// This file is generated by Counterfact. Do not edit manually.",
|
|
80
|
+
...importLines,
|
|
81
|
+
"",
|
|
82
|
+
"interface LoadContextDefinitions {",
|
|
83
|
+
" /* code generator adds additional signatures here */",
|
|
84
|
+
...overloadLines,
|
|
85
|
+
" loadContext(path: string): Record<string, unknown>;",
|
|
86
|
+
"}",
|
|
87
|
+
"",
|
|
88
|
+
"export interface Scenario$ {",
|
|
89
|
+
' /** Root context, same as loadContext("/") */',
|
|
90
|
+
` readonly context: ${contextType};`,
|
|
91
|
+
' readonly loadContext: LoadContextDefinitions["loadContext"];',
|
|
92
|
+
" /** Named route builders stored in the REPL execution context */",
|
|
93
|
+
" readonly routes: Record<string, unknown>;",
|
|
94
|
+
" /** Create a new route builder for a given path */",
|
|
95
|
+
" readonly route: (path: string) => unknown;",
|
|
96
|
+
"}",
|
|
97
|
+
"",
|
|
98
|
+
"/** A scenario function that receives the live REPL environment */",
|
|
99
|
+
"export type Scenario = ($: Scenario$) => Promise<void> | void;",
|
|
100
|
+
"",
|
|
101
|
+
"/** Interface for Context objects defined in _.context.ts files */",
|
|
102
|
+
"export interface Context$ {",
|
|
103
|
+
" /** Load a context object for a specific path */",
|
|
104
|
+
' readonly loadContext: LoadContextDefinitions["loadContext"];',
|
|
105
|
+
" /** Load a JSON file relative to this file's path */",
|
|
106
|
+
" readonly readJson: (relativePath: string) => Promise<unknown>;",
|
|
107
|
+
"}",
|
|
108
|
+
"",
|
|
109
|
+
];
|
|
110
|
+
return parts.join("\n");
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Writes the `types/_.context.ts` file, which exports the
|
|
114
|
+
* `Scenario$` interface used to type scenario functions.
|
|
115
|
+
*
|
|
116
|
+
* The interface is generated from all `_.context.ts` files found under the
|
|
117
|
+
* `routes/` directory, providing strongly typed `loadContext()` overloads for
|
|
118
|
+
* every route path that has a context file.
|
|
119
|
+
*
|
|
120
|
+
* @param destination - Root output directory.
|
|
121
|
+
*/
|
|
122
|
+
async function writeScenarioContextType(destination) {
|
|
123
|
+
const typesDir = nodePath.join(destination, "types");
|
|
124
|
+
const filePath = nodePath.join(typesDir, "_.context.ts");
|
|
125
|
+
const contextFiles = await collectContextFiles(destination);
|
|
126
|
+
const content = buildScenarioContextContent(contextFiles);
|
|
127
|
+
await fs.mkdir(typesDir, { recursive: true });
|
|
128
|
+
await fs.writeFile(filePath, content, "utf8");
|
|
129
|
+
}
|
|
130
|
+
const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/_.context.js";
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Scenario scripts are plain TypeScript functions that receive the live REPL
|
|
134
|
+
* environment and can read or mutate server state. Run them from the REPL with:
|
|
135
|
+
* .scenario <functionName>
|
|
136
|
+
*/
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read or mutate the root context (same object routes see as $.context):
|
|
140
|
+
* $.context.<property> = <value>;
|
|
141
|
+
*
|
|
142
|
+
* Load a context for a specific path:
|
|
143
|
+
* const petsCtx = $.loadContext("/pets");
|
|
144
|
+
*
|
|
145
|
+
* Store a pre-configured route builder for later use in the REPL:
|
|
146
|
+
* $.routes.myRequest = $.route("/pets").method("get");
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* startup() runs automatically when the server initializes, right before the
|
|
151
|
+
* REPL starts. Use it to seed dummy data so the server is ready to use
|
|
152
|
+
* immediately. It receives the same $ argument as all other scenario functions.
|
|
153
|
+
*
|
|
154
|
+
* Tip: delegate to other scenario functions and pass $ along so each function
|
|
155
|
+
* stays focused on a single concern. You can also pass additional arguments to
|
|
156
|
+
* configure them, e.g. addPets($, 20, "dog").
|
|
157
|
+
*
|
|
158
|
+
* If you don't need a startup scenario, delete this function or leave it empty.
|
|
159
|
+
*/
|
|
160
|
+
export const startup: Scenario = ($) => {
|
|
161
|
+
void $;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* An example scenario. To use it in the REPL, type:
|
|
166
|
+
* .scenario help
|
|
167
|
+
*/
|
|
168
|
+
export const help: Scenario = ($) => {
|
|
169
|
+
void $;
|
|
170
|
+
|
|
171
|
+
console.log(
|
|
172
|
+
[
|
|
173
|
+
"Scenarios are functions that populate the context object",
|
|
174
|
+
"and / or the REPL environment. They are intended to",
|
|
175
|
+
"populate your environment with specific data and",
|
|
176
|
+
"configurations for testing purposes.",
|
|
177
|
+
].join("\\n"),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
console.log(
|
|
181
|
+
"\\nScenarios (including this one) are defined in the ./scenarios directory.",
|
|
182
|
+
);
|
|
183
|
+
};
|
|
184
|
+
`;
|
|
185
|
+
async function writeDefaultScenariosIndex(destination) {
|
|
186
|
+
const scenariosDir = nodePath.join(destination, "scenarios");
|
|
187
|
+
const filePath = nodePath.join(scenariosDir, "index.ts");
|
|
188
|
+
if (existsSync(filePath)) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
await fs.mkdir(scenariosDir, { recursive: true });
|
|
192
|
+
await fs.writeFile(filePath, DEFAULT_SCENARIOS_INDEX, "utf8");
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Encapsulates the generation of scenario-related files:
|
|
196
|
+
* - `types/_.context.ts` — the typed `Scenario$` interface derived from all
|
|
197
|
+
* `_.context.ts` files found under `routes/`.
|
|
198
|
+
* - `scenarios/index.ts` — the default scenarios entry-point (created only if
|
|
199
|
+
* it does not already exist).
|
|
200
|
+
*
|
|
201
|
+
* When {@link watch} is called, a file-system watcher monitors the `routes/`
|
|
202
|
+
* directory for changes to `_.context.ts` files and automatically regenerates
|
|
203
|
+
* `types/_.context.ts`.
|
|
204
|
+
*/
|
|
205
|
+
export class ScenarioFileGenerator {
|
|
206
|
+
destination;
|
|
207
|
+
watcher;
|
|
208
|
+
constructor(destination) {
|
|
209
|
+
this.destination = destination;
|
|
210
|
+
}
|
|
211
|
+
/** Generates both scenario-related files once and resolves when complete. */
|
|
212
|
+
async generate() {
|
|
213
|
+
await writeScenarioContextType(this.destination);
|
|
214
|
+
await writeDefaultScenariosIndex(this.destination);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Starts watching the `routes/` directory for `_.context.ts` changes and
|
|
218
|
+
* regenerates `types/_.context.ts` on every change.
|
|
219
|
+
*
|
|
220
|
+
* Resolves once the watcher is ready.
|
|
221
|
+
*/
|
|
222
|
+
async watch() {
|
|
223
|
+
const routesDir = nodePath.join(this.destination, "routes");
|
|
224
|
+
this.watcher = watch(routesDir, CHOKIDAR_OPTIONS).on("all", (_event, filePath) => {
|
|
225
|
+
if (filePath.endsWith("_.context.ts")) {
|
|
226
|
+
void writeScenarioContextType(this.destination);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
await waitForEvent(this.watcher, "ready");
|
|
230
|
+
}
|
|
231
|
+
/** Closes the file-system watcher. */
|
|
232
|
+
async stopWatching() {
|
|
233
|
+
await this.watcher?.close();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -1,8 +1,18 @@
|
|
|
1
|
-
import nodePath from "node:path";
|
|
2
1
|
import createDebugger from "debug";
|
|
3
2
|
import { format } from "prettier";
|
|
4
3
|
import { escapePathForWindows } from "../util/windows-escape.js";
|
|
4
|
+
import { pathJoin, pathRelative, pathDirname, } from "../util/forward-slash-path.js";
|
|
5
5
|
const debug = createDebugger("counterfact:typescript-generator:script");
|
|
6
|
+
/**
|
|
7
|
+
* Represents a single TypeScript file being assembled by the code generator.
|
|
8
|
+
*
|
|
9
|
+
* A `Script` accumulates exports, imports, and external imports contributed by
|
|
10
|
+
* {@link Coder} instances. Once all coders have resolved, {@link contents}
|
|
11
|
+
* formats the result with Prettier and returns the final source text.
|
|
12
|
+
*
|
|
13
|
+
* Scripts are created and retrieved through a {@link Repository} so that the
|
|
14
|
+
* same module path always maps to the same `Script` instance.
|
|
15
|
+
*/
|
|
6
16
|
export class Script {
|
|
7
17
|
repository;
|
|
8
18
|
comments;
|
|
@@ -22,6 +32,10 @@ export class Script {
|
|
|
22
32
|
this.typeCache = new Map();
|
|
23
33
|
this.path = path;
|
|
24
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* A `"../"` path fragment that points from this script's directory back to
|
|
37
|
+
* the repository root, used to resolve relative import paths.
|
|
38
|
+
*/
|
|
25
39
|
get relativePathToBase() {
|
|
26
40
|
return this.path
|
|
27
41
|
.split("/")
|
|
@@ -29,6 +43,13 @@ export class Script {
|
|
|
29
43
|
.map(() => "..")
|
|
30
44
|
.join("/");
|
|
31
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Picks the first name from `coder.names()` that is not already used as an
|
|
48
|
+
* import in this script, ensuring export/import name uniqueness.
|
|
49
|
+
*
|
|
50
|
+
* @param coder - The coder needing a name.
|
|
51
|
+
* @throws When all 100 candidate names are already taken.
|
|
52
|
+
*/
|
|
32
53
|
firstUniqueName(coder) {
|
|
33
54
|
for (const name of coder.names()) {
|
|
34
55
|
if (!this.imports.has(name)) {
|
|
@@ -37,6 +58,17 @@ export class Script {
|
|
|
37
58
|
}
|
|
38
59
|
throw new Error(`could not find a unique name for ${coder.id}`);
|
|
39
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Registers an export for `coder` in this script and returns the export name.
|
|
63
|
+
*
|
|
64
|
+
* If the same coder has already been exported (cache hit), the previously
|
|
65
|
+
* assigned name is returned without creating a duplicate.
|
|
66
|
+
*
|
|
67
|
+
* @param coder - The coder to export.
|
|
68
|
+
* @param isType - Emit `export type` instead of `export const`.
|
|
69
|
+
* @param isDefault - Emit `export default` instead of a named export.
|
|
70
|
+
* @returns The name under which the coder is exported.
|
|
71
|
+
*/
|
|
40
72
|
export(coder, isType = false, isDefault = false) {
|
|
41
73
|
const cacheKey = isDefault ? "default" : `${coder.id}:${isType}`;
|
|
42
74
|
if (this.cache.has(cacheKey)) {
|
|
@@ -75,6 +107,16 @@ export class Script {
|
|
|
75
107
|
exportDefault(coder, isType = false) {
|
|
76
108
|
this.export(coder, isType, true);
|
|
77
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Registers an import of `coder` from its owning module and returns the
|
|
112
|
+
* local alias used in this script.
|
|
113
|
+
*
|
|
114
|
+
* The coder is also exported from its home module as a side effect.
|
|
115
|
+
*
|
|
116
|
+
* @param coder - The coder to import.
|
|
117
|
+
* @param isType - Use a `import type` declaration.
|
|
118
|
+
* @param isDefault - Import the default export rather than a named export.
|
|
119
|
+
*/
|
|
78
120
|
import(coder, isType = false, isDefault = false) {
|
|
79
121
|
debug("import coder: %s", coder.id);
|
|
80
122
|
const modulePath = coder.modulePath();
|
|
@@ -103,24 +145,42 @@ export class Script {
|
|
|
103
145
|
importDefault(coder, isType = false) {
|
|
104
146
|
return this.import(coder, isType, true);
|
|
105
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Registers an import from an external npm package or absolute module path
|
|
150
|
+
* (not managed by the repository).
|
|
151
|
+
*
|
|
152
|
+
* @param name - The local binding name.
|
|
153
|
+
* @param modulePath - The module specifier (e.g. `"counterfact-types/index"`).
|
|
154
|
+
* @param isType - Use a `import type` declaration.
|
|
155
|
+
* @returns `name` (for convenience in method chaining).
|
|
156
|
+
*/
|
|
106
157
|
importExternal(name, modulePath, isType = false) {
|
|
107
158
|
this.externalImport.set(name, { isType, modulePath });
|
|
108
159
|
return name;
|
|
109
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Convenience wrapper that calls {@link importExternal} with `isType = true`.
|
|
163
|
+
*/
|
|
110
164
|
importExternalType(name, modulePath) {
|
|
111
165
|
return this.importExternal(name, modulePath, true);
|
|
112
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Imports a type from the shared `counterfact-types/index.ts` module,
|
|
169
|
+
* resolving the path relative to this script's location in the repository.
|
|
170
|
+
*
|
|
171
|
+
* @param name - The type name to import (e.g. `"WideOperationArgument"`).
|
|
172
|
+
*/
|
|
113
173
|
importSharedType(name) {
|
|
114
|
-
return this.importExternal(name,
|
|
115
|
-
.join(this.relativePathToBase, "counterfact-types/index.ts")
|
|
116
|
-
.replaceAll("\\", "/"), true);
|
|
174
|
+
return this.importExternal(name, pathJoin(this.relativePathToBase, "counterfact-types/index.ts"), true);
|
|
117
175
|
}
|
|
118
176
|
exportType(coder) {
|
|
119
177
|
return this.export(coder, true);
|
|
120
178
|
}
|
|
179
|
+
/** `true` while at least one export promise is still pending. */
|
|
121
180
|
isInProgress() {
|
|
122
181
|
return Array.from(this.exports.values()).some((exportStatement) => !exportStatement.done);
|
|
123
182
|
}
|
|
183
|
+
/** Returns a promise that resolves when all pending export promises settle. */
|
|
124
184
|
finished() {
|
|
125
185
|
return Promise.all(Array.from(this.exports.values(), (value) => value.promise));
|
|
126
186
|
}
|
|
@@ -129,9 +189,7 @@ export class Script {
|
|
|
129
189
|
}
|
|
130
190
|
importStatements() {
|
|
131
191
|
return Array.from(this.imports, ([name, { isDefault, isType, script }]) => {
|
|
132
|
-
const resolvedPath = escapePathForWindows(
|
|
133
|
-
.relative(nodePath.dirname(this.path).replaceAll("\\", "/"), script.path.replace(/\.ts$/u, ".js"))
|
|
134
|
-
.replaceAll("\\", "/"));
|
|
192
|
+
const resolvedPath = escapePathForWindows(pathRelative(pathDirname(this.path), script.path.replace(/\.ts$/u, ".js")));
|
|
135
193
|
return `import${isType ? " type" : ""} ${isDefault ? name : `{ ${name} }`} from "${resolvedPath.includes("../") ? "" : "./"}${resolvedPath}";`;
|
|
136
194
|
});
|
|
137
195
|
}
|
|
@@ -150,6 +208,11 @@ export class Script {
|
|
|
150
208
|
return `${jsdoc}${beforeExport}export ${keyword} ${name ?? ""}${typeAnnotation} = ${code};`;
|
|
151
209
|
});
|
|
152
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* Formats the fully assembled script source with Prettier and returns it.
|
|
213
|
+
*
|
|
214
|
+
* All pending export promises are awaited before formatting.
|
|
215
|
+
*/
|
|
153
216
|
contents() {
|
|
154
217
|
return format([
|
|
155
218
|
this.comments.map((comment) => `// ${comment}`).join("\n"),
|
|
@@ -2,6 +2,14 @@ import { bundle } from "@apidevtools/json-schema-ref-parser";
|
|
|
2
2
|
import createDebug from "debug";
|
|
3
3
|
import { Requirement } from "./requirement.js";
|
|
4
4
|
const debug = createDebug("counterfact:typescript-generator:specification");
|
|
5
|
+
/**
|
|
6
|
+
* Represents a fully dereferenced OpenAPI specification as a navigable tree
|
|
7
|
+
* of {@link Requirement} nodes.
|
|
8
|
+
*
|
|
9
|
+
* Use {@link Specification.fromFile} to load a spec from disk or a URL; the
|
|
10
|
+
* static method bundles external `$ref` references into a single in-memory
|
|
11
|
+
* object before constructing the tree.
|
|
12
|
+
*/
|
|
5
13
|
export class Specification {
|
|
6
14
|
cache;
|
|
7
15
|
rootRequirement;
|
|
@@ -11,15 +19,34 @@ export class Specification {
|
|
|
11
19
|
this.rootRequirement = rootRequirement;
|
|
12
20
|
}
|
|
13
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Loads the OpenAPI document at `urlOrPath`, bundles all external `$ref`
|
|
24
|
+
* references, and returns a fully initialised {@link Specification}.
|
|
25
|
+
*
|
|
26
|
+
* @param urlOrPath - A local file path or HTTP(S) URL.
|
|
27
|
+
* @throws When the document cannot be found or parsed.
|
|
28
|
+
*/
|
|
14
29
|
static async fromFile(urlOrPath) {
|
|
15
30
|
const specification = new Specification();
|
|
16
31
|
await specification.load(urlOrPath);
|
|
17
32
|
return specification;
|
|
18
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Returns the {@link Requirement} at `url` (a JSON Pointer such as
|
|
36
|
+
* `"#/paths"`).
|
|
37
|
+
*
|
|
38
|
+
* @param url - A JSON Pointer string (must start with `"#/"`).
|
|
39
|
+
*/
|
|
19
40
|
getRequirement(url) {
|
|
20
41
|
debug("getting requirement at %s", url);
|
|
21
42
|
return this.rootRequirement.select(url.slice(2));
|
|
22
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Loads (or reloads) the specification from `urlOrPath`.
|
|
46
|
+
*
|
|
47
|
+
* @param urlOrPath - A local file path or HTTP(S) URL.
|
|
48
|
+
* @throws When the document cannot be found or parsed.
|
|
49
|
+
*/
|
|
23
50
|
async load(urlOrPath) {
|
|
24
51
|
try {
|
|
25
52
|
this.rootRequirement = new Requirement((await bundle(urlOrPath)), urlOrPath, this);
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import nodePath from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Synchronously ensures that the directory containing `filePath` exists,
|
|
5
|
+
* creating it (and any missing ancestors) if necessary.
|
|
6
|
+
*
|
|
7
|
+
* @param filePath - Path to a file; the *directory* part of this path is
|
|
8
|
+
* created, not the file itself.
|
|
9
|
+
*/
|
|
3
10
|
export function ensureDirectoryExists(filePath) {
|
|
4
11
|
const directory = nodePath.dirname(filePath);
|
|
5
12
|
try {
|