counterfact 2.5.1 → 2.7.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 +103 -140
- package/bin/README.md +25 -4
- package/bin/counterfact.js +208 -24
- package/bin/register-ts-loader.mjs +17 -0
- package/bin/ts-loader.mjs +31 -0
- package/dist/app.js +31 -21
- 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 +30 -10
- package/dist/repl/raw-http-client.js +14 -14
- package/dist/repl/repl.js +119 -4
- package/dist/repl/route-builder.js +270 -0
- package/dist/server/config.js +1 -1
- package/dist/server/context-registry.js +44 -4
- 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 -328
- 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 +1 -20
- package/dist/server/determine-module-kind.js +1 -1
- package/dist/server/dispatcher.js +39 -15
- package/dist/server/file-discovery.js +34 -0
- package/dist/server/json-to-xml.js +1 -1
- package/dist/server/koa-middleware.js +7 -1
- package/dist/server/load-openapi-document.js +13 -0
- package/dist/server/middleware-detector.js +8 -0
- package/dist/server/module-dependency-graph.js +4 -1
- package/dist/server/module-loader.js +81 -33
- package/dist/server/module-tree.js +26 -23
- package/dist/server/openapi-middleware.js +2 -2
- package/dist/server/openapi-watcher.js +35 -0
- package/dist/server/registry.js +2 -2
- package/dist/server/request-validator.js +57 -0
- package/dist/server/response-builder.js +3 -0
- package/dist/server/response-validator.js +58 -0
- package/dist/server/scenario-registry.js +29 -0
- package/dist/server/tools.js +2 -2
- package/dist/server/transpiler.js +13 -5
- package/dist/typescript-generator/coder.js +7 -2
- package/dist/typescript-generator/generate.js +155 -0
- package/dist/typescript-generator/jsdoc.js +45 -0
- package/dist/typescript-generator/operation-coder.js +1 -1
- package/dist/typescript-generator/operation-type-coder.js +5 -49
- package/dist/typescript-generator/parameters-type-coder.js +5 -1
- package/dist/typescript-generator/prune.js +2 -1
- package/dist/typescript-generator/read-only-comments.js +1 -1
- package/dist/typescript-generator/requirement.js +8 -1
- package/dist/typescript-generator/reserved-words.js +50 -0
- package/dist/typescript-generator/schema-type-coder.js +7 -1
- package/dist/typescript-generator/script.js +5 -3
- package/dist/typescript-generator/specification.js +7 -1
- package/dist/util/load-config-file.js +44 -0
- package/dist/util/runtime-can-execute-erasable-ts.js +22 -0
- package/package.json +12 -12
- 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
|
@@ -3,8 +3,8 @@ function isDirectory(test) {
|
|
|
3
3
|
}
|
|
4
4
|
export class ModuleTree {
|
|
5
5
|
root = {
|
|
6
|
-
directories:
|
|
7
|
-
files:
|
|
6
|
+
directories: new Map(),
|
|
7
|
+
files: new Map(),
|
|
8
8
|
isWildcard: false,
|
|
9
9
|
name: "",
|
|
10
10
|
rawName: "",
|
|
@@ -17,16 +17,19 @@ export class ModuleTree {
|
|
|
17
17
|
if (remainingSegments.length === 0) {
|
|
18
18
|
return directory;
|
|
19
19
|
}
|
|
20
|
-
const isNewDirectory = directory.directories
|
|
21
|
-
|
|
22
|
-
directories
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
20
|
+
const isNewDirectory = !directory.directories.has(segment.toLowerCase());
|
|
21
|
+
if (isNewDirectory) {
|
|
22
|
+
directory.directories.set(segment.toLowerCase(), {
|
|
23
|
+
directories: new Map(),
|
|
24
|
+
files: new Map(),
|
|
25
|
+
isWildcard: segment.startsWith("{"),
|
|
26
|
+
name: segment.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
|
|
27
|
+
rawName: segment,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const nextDirectory = directory.directories.get(segment.toLowerCase());
|
|
28
31
|
if (isNewDirectory && segment.startsWith("{")) {
|
|
29
|
-
const ambiguousWildcardDirectories =
|
|
32
|
+
const ambiguousWildcardDirectories = Array.from(directory.directories.values()).filter((subdirectory) => subdirectory.isWildcard);
|
|
30
33
|
if (ambiguousWildcardDirectories.length > 1) {
|
|
31
34
|
process.stderr.write(`[counterfact] ERROR: Ambiguous wildcard paths detected. Multiple wildcard directories exist at the same level: ${ambiguousWildcardDirectories.map((d) => d.rawName).join(", ")}. Requests may be routed unpredictably.\n`);
|
|
32
35
|
}
|
|
@@ -42,14 +45,14 @@ export class ModuleTree {
|
|
|
42
45
|
if (filename === undefined) {
|
|
43
46
|
throw new Error("The file name (the last segment of the URL) is undefined. This is theoretically impossible but TypeScript can't enforce it.");
|
|
44
47
|
}
|
|
45
|
-
targetDirectory.files
|
|
48
|
+
targetDirectory.files.set(filename, {
|
|
46
49
|
isWildcard: filename.startsWith("{"),
|
|
47
50
|
module,
|
|
48
51
|
name: filename.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
|
|
49
52
|
rawName: filename,
|
|
50
|
-
};
|
|
53
|
+
});
|
|
51
54
|
if (filename.startsWith("{")) {
|
|
52
|
-
const ambiguousWildcardFiles =
|
|
55
|
+
const ambiguousWildcardFiles = Array.from(targetDirectory.files.values()).filter((file) => file.isWildcard);
|
|
53
56
|
if (ambiguousWildcardFiles.length > 1) {
|
|
54
57
|
process.stderr.write(`[counterfact] ERROR: Ambiguous wildcard paths detected. Multiple wildcard files exist at the same path level: ${ambiguousWildcardFiles.map((f) => f.rawName).join(", ")}. Requests may be routed unpredictably.\n`);
|
|
55
58
|
}
|
|
@@ -67,10 +70,10 @@ export class ModuleTree {
|
|
|
67
70
|
return;
|
|
68
71
|
}
|
|
69
72
|
if (remainingSegments.length === 0) {
|
|
70
|
-
|
|
73
|
+
directory.files.delete(segment.toLowerCase());
|
|
71
74
|
return;
|
|
72
75
|
}
|
|
73
|
-
this.removeModuleFromDirectory(directory.directories
|
|
76
|
+
this.removeModuleFromDirectory(directory.directories.get(segment.toLowerCase()), remainingSegments);
|
|
74
77
|
}
|
|
75
78
|
remove(url) {
|
|
76
79
|
const segments = url.split("/").slice(1);
|
|
@@ -81,14 +84,14 @@ export class ModuleTree {
|
|
|
81
84
|
}
|
|
82
85
|
buildMatch(directory, segment, pathVariables, matchedPath, method) {
|
|
83
86
|
function normalizedSegment(segment, directory) {
|
|
84
|
-
for (const file
|
|
87
|
+
for (const file of directory.files.keys()) {
|
|
85
88
|
if (file.toLowerCase() === segment.toLowerCase()) {
|
|
86
89
|
return file;
|
|
87
90
|
}
|
|
88
91
|
}
|
|
89
92
|
return "";
|
|
90
93
|
}
|
|
91
|
-
const exactMatchFile = directory.files
|
|
94
|
+
const exactMatchFile = directory.files.get(normalizedSegment(segment, directory));
|
|
92
95
|
// If the URL segment literally matches a file key (e.g., requesting "/{x}"
|
|
93
96
|
// as a literal URL value), exactMatchFile may be a wildcard file. In that
|
|
94
97
|
// case, fall through to wildcard matching below.
|
|
@@ -99,7 +102,7 @@ export class ModuleTree {
|
|
|
99
102
|
pathVariables,
|
|
100
103
|
};
|
|
101
104
|
}
|
|
102
|
-
const wildcardFiles =
|
|
105
|
+
const wildcardFiles = Array.from(directory.files.values()).filter((file) => file.isWildcard && this.fileModuleDefined(file, method));
|
|
103
106
|
if (wildcardFiles.length > 1) {
|
|
104
107
|
const firstWildcard = wildcardFiles[0];
|
|
105
108
|
return {
|
|
@@ -144,11 +147,11 @@ export class ModuleTree {
|
|
|
144
147
|
(remainingSegments.length === 1 && remainingSegments[0] === "")) {
|
|
145
148
|
return this.buildMatch(directory, segment, pathVariables, matchedPath, method);
|
|
146
149
|
}
|
|
147
|
-
const exactMatch = directory.directories
|
|
150
|
+
const exactMatch = directory.directories.get(segment.toLowerCase());
|
|
148
151
|
if (isDirectory(exactMatch)) {
|
|
149
152
|
return this.matchWithinDirectory(exactMatch, remainingSegments, pathVariables, `${matchedPath}/${segment}`, method);
|
|
150
153
|
}
|
|
151
|
-
const wildcardDirectories =
|
|
154
|
+
const wildcardDirectories = Array.from(directory.directories.values()).filter((subdirectory) => subdirectory.isWildcard);
|
|
152
155
|
const wildcardMatches = [];
|
|
153
156
|
for (const wildcardDirectory of wildcardDirectories) {
|
|
154
157
|
const wildcardMatch = this.matchWithinDirectory(wildcardDirectory, remainingSegments, {
|
|
@@ -171,10 +174,10 @@ export class ModuleTree {
|
|
|
171
174
|
get routes() {
|
|
172
175
|
const routes = [];
|
|
173
176
|
function traverse(directory, path) {
|
|
174
|
-
|
|
177
|
+
directory.directories.forEach((subdirectory) => {
|
|
175
178
|
traverse(subdirectory, `${path}/${subdirectory.rawName}`);
|
|
176
179
|
});
|
|
177
|
-
|
|
180
|
+
directory.files.forEach((file) => {
|
|
178
181
|
const methods = Object.entries(file.module).map(([method, implementation]) => [method, String(implementation)]);
|
|
179
182
|
routes.push({
|
|
180
183
|
methods: Object.fromEntries(methods),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { bundle } from "@apidevtools/json-schema-ref-parser";
|
|
2
|
-
import
|
|
2
|
+
import { dump } from "js-yaml";
|
|
3
3
|
export function openapiMiddleware(openApiPath, url) {
|
|
4
4
|
return async (ctx, next) => {
|
|
5
5
|
if (ctx.URL.pathname === "/counterfact/openapi") {
|
|
@@ -11,7 +11,7 @@ export function openapiMiddleware(openApiPath, url) {
|
|
|
11
11
|
});
|
|
12
12
|
// OpenApi 2 support:
|
|
13
13
|
openApiDocument.host = url;
|
|
14
|
-
ctx.body =
|
|
14
|
+
ctx.body = dump(openApiDocument);
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
17
|
await next();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { watch } from "chokidar";
|
|
2
|
+
import createDebug from "debug";
|
|
3
|
+
import { waitForEvent } from "../util/wait-for-event.js";
|
|
4
|
+
import { CHOKIDAR_OPTIONS } from "./constants.js";
|
|
5
|
+
import { loadOpenApiDocument } from "./load-openapi-document.js";
|
|
6
|
+
const debug = createDebug("counterfact:server:openapi-watcher");
|
|
7
|
+
export class OpenApiWatcher {
|
|
8
|
+
openApiPath;
|
|
9
|
+
dispatcher;
|
|
10
|
+
watcher;
|
|
11
|
+
constructor(openApiPath, dispatcher) {
|
|
12
|
+
this.openApiPath = openApiPath;
|
|
13
|
+
this.dispatcher = dispatcher;
|
|
14
|
+
}
|
|
15
|
+
async watch() {
|
|
16
|
+
if (this.openApiPath === "_" || this.openApiPath.startsWith("http")) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
this.watcher = watch(this.openApiPath, CHOKIDAR_OPTIONS).on("change", () => {
|
|
20
|
+
void (async () => {
|
|
21
|
+
try {
|
|
22
|
+
this.dispatcher.openApiDocument = await loadOpenApiDocument(this.openApiPath);
|
|
23
|
+
debug("reloaded OpenAPI document from %s", this.openApiPath);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
debug("failed to reload OpenAPI document from %s: %o", this.openApiPath, error);
|
|
27
|
+
}
|
|
28
|
+
})();
|
|
29
|
+
});
|
|
30
|
+
await waitForEvent(this.watcher, "ready");
|
|
31
|
+
}
|
|
32
|
+
async stopWatching() {
|
|
33
|
+
await this.watcher?.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
package/dist/server/registry.js
CHANGED
|
@@ -26,10 +26,10 @@ function castParameter(value, type) {
|
|
|
26
26
|
}
|
|
27
27
|
return value;
|
|
28
28
|
}
|
|
29
|
-
function castParameters(parameters = {}, parameterTypes =
|
|
29
|
+
function castParameters(parameters = {}, parameterTypes = new Map()) {
|
|
30
30
|
const copy = {};
|
|
31
31
|
Object.entries(parameters).forEach(([key, value]) => {
|
|
32
|
-
copy[key] = castParameter(value, parameterTypes
|
|
32
|
+
copy[key] = castParameter(value, parameterTypes.get(key) ?? "string");
|
|
33
33
|
});
|
|
34
34
|
return copy;
|
|
35
35
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import Ajv from "ajv";
|
|
2
|
+
const ajv = new Ajv({
|
|
3
|
+
allErrors: true,
|
|
4
|
+
strict: false,
|
|
5
|
+
coerceTypes: false,
|
|
6
|
+
});
|
|
7
|
+
function findMissingRequired(parameters, location, values) {
|
|
8
|
+
return parameters
|
|
9
|
+
.filter((p) => p.in === location && p.required === true)
|
|
10
|
+
.filter((p) => !(p.name in values) || values[p.name] === undefined)
|
|
11
|
+
.map((p) => `${location} parameter '${p.name}' is required`);
|
|
12
|
+
}
|
|
13
|
+
export function validateRequest(operation, request) {
|
|
14
|
+
if (!operation) {
|
|
15
|
+
return { errors: [], valid: true };
|
|
16
|
+
}
|
|
17
|
+
const errors = [];
|
|
18
|
+
const parameters = operation.parameters ?? [];
|
|
19
|
+
// For query and header parameters, HTTP always delivers values as strings.
|
|
20
|
+
// Only check that required parameters are present; type coercion is handled
|
|
21
|
+
// by the registry before the route handler is called.
|
|
22
|
+
errors.push(...findMissingRequired(parameters, "query", request.query));
|
|
23
|
+
errors.push(...findMissingRequired(parameters, "header", request.headers));
|
|
24
|
+
// Validate request body (OpenAPI 3.x requestBody)
|
|
25
|
+
if (operation.requestBody?.content !== undefined) {
|
|
26
|
+
const schema = operation.requestBody.content["application/json"]?.schema ??
|
|
27
|
+
operation.requestBody.content["application/x-www-form-urlencoded"]
|
|
28
|
+
?.schema;
|
|
29
|
+
if (schema !== undefined) {
|
|
30
|
+
const valid = ajv.validate(schema, request.body);
|
|
31
|
+
if (!valid && ajv.errors) {
|
|
32
|
+
for (const error of ajv.errors) {
|
|
33
|
+
const path = error.instancePath ?? "";
|
|
34
|
+
errors.push(`body${path} ${error.message ?? "is invalid"}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else if (operation.requestBody.required === true && !request.body) {
|
|
39
|
+
errors.push("body is required");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Validate request body (OpenAPI 2.x body parameter)
|
|
43
|
+
const bodyParam = parameters.find((p) => p.in === "body");
|
|
44
|
+
if (bodyParam?.schema !== undefined) {
|
|
45
|
+
const valid = ajv.validate(bodyParam.schema, request.body);
|
|
46
|
+
if (!valid && ajv.errors) {
|
|
47
|
+
for (const error of ajv.errors) {
|
|
48
|
+
const path = error.instancePath ?? "";
|
|
49
|
+
errors.push(`body${path} ${error.message ?? "is invalid"}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
errors,
|
|
55
|
+
valid: errors.length === 0,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import Ajv from "ajv";
|
|
2
|
+
const ajv = new Ajv({
|
|
3
|
+
allErrors: true,
|
|
4
|
+
strict: false,
|
|
5
|
+
coerceTypes: false,
|
|
6
|
+
});
|
|
7
|
+
export function validateResponse(operation, response) {
|
|
8
|
+
if (!operation) {
|
|
9
|
+
return { errors: [], valid: true };
|
|
10
|
+
}
|
|
11
|
+
const errors = [];
|
|
12
|
+
const statusKey = response.status !== undefined ? String(response.status) : undefined;
|
|
13
|
+
const responseSpec = (statusKey !== undefined ? operation.responses[statusKey] : undefined) ??
|
|
14
|
+
operation.responses.default;
|
|
15
|
+
if (!responseSpec) {
|
|
16
|
+
return { errors: [], valid: true };
|
|
17
|
+
}
|
|
18
|
+
const specHeaders = responseSpec.headers ?? {};
|
|
19
|
+
const actualHeaders = response.headers ?? {};
|
|
20
|
+
for (const [name, headerSpec] of Object.entries(specHeaders)) {
|
|
21
|
+
const actualValue = actualHeaders[name] ?? actualHeaders[name.toLowerCase()];
|
|
22
|
+
if (headerSpec.required === true && actualValue === undefined) {
|
|
23
|
+
errors.push(`response header '${name}' is required`);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (actualValue !== undefined && headerSpec.schema !== undefined) {
|
|
27
|
+
const coercedValue = typeof actualValue === "string"
|
|
28
|
+
? coerceHeaderValue(actualValue, headerSpec.schema)
|
|
29
|
+
: actualValue;
|
|
30
|
+
const valid = ajv.validate(headerSpec.schema, coercedValue);
|
|
31
|
+
if (!valid && ajv.errors) {
|
|
32
|
+
for (const error of ajv.errors) {
|
|
33
|
+
const path = error.instancePath ?? "";
|
|
34
|
+
errors.push(`response header '${name}'${path} ${error.message ?? "is invalid"}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
errors,
|
|
41
|
+
valid: errors.length === 0,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function coerceHeaderValue(value, schema) {
|
|
45
|
+
const type = schema.type;
|
|
46
|
+
if (type === "integer" || type === "number") {
|
|
47
|
+
const num = Number(value);
|
|
48
|
+
return Number.isNaN(num) ? value : num;
|
|
49
|
+
}
|
|
50
|
+
if (type === "boolean") {
|
|
51
|
+
if (value === "true")
|
|
52
|
+
return true;
|
|
53
|
+
if (value === "false")
|
|
54
|
+
return false;
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class ScenarioRegistry {
|
|
2
|
+
modules = new Map();
|
|
3
|
+
add(key, module) {
|
|
4
|
+
this.modules.set(key, module);
|
|
5
|
+
}
|
|
6
|
+
remove(key) {
|
|
7
|
+
this.modules.delete(key);
|
|
8
|
+
}
|
|
9
|
+
getModule(fileKey) {
|
|
10
|
+
return this.modules.get(fileKey);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Returns the names of all exported functions for the given file key.
|
|
14
|
+
* Used for tab completion.
|
|
15
|
+
*/
|
|
16
|
+
getExportedFunctionNames(fileKey) {
|
|
17
|
+
const module = this.modules.get(fileKey);
|
|
18
|
+
if (!module)
|
|
19
|
+
return [];
|
|
20
|
+
return Object.keys(module).filter((k) => typeof module[k] === "function");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Returns all loaded file keys (e.g. "index", "myscript", "sub/script").
|
|
24
|
+
* Used for tab completion to enumerate available scenario files.
|
|
25
|
+
*/
|
|
26
|
+
getFileKeys() {
|
|
27
|
+
return [...this.modules.keys()];
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dist/server/tools.js
CHANGED
|
@@ -8,13 +8,13 @@ export class Tools {
|
|
|
8
8
|
return array[Math.floor(Math.random() * array.length)];
|
|
9
9
|
}
|
|
10
10
|
accepts(contentType) {
|
|
11
|
-
const acceptHeader = this.headers.
|
|
11
|
+
const acceptHeader = Object.entries(this.headers).find(([key]) => key.toLowerCase() === "accept")?.[1];
|
|
12
12
|
if (acceptHeader === "" || acceptHeader === undefined) {
|
|
13
13
|
return true;
|
|
14
14
|
}
|
|
15
15
|
const acceptTypes = String(acceptHeader).split(",");
|
|
16
16
|
return acceptTypes.some((acceptType) => {
|
|
17
|
-
const [type, subtype] = acceptType.split("/");
|
|
17
|
+
const [type, subtype] = acceptType.trim().split("/");
|
|
18
18
|
return ((type === "*" || type === contentType.split("/")[0]) &&
|
|
19
19
|
(subtype === "*" || subtype === contentType.split("/")[1]));
|
|
20
20
|
});
|
|
@@ -67,12 +67,20 @@ export class Transpiler extends EventTarget {
|
|
|
67
67
|
async transpileFile(eventName, sourcePath, destinationPath) {
|
|
68
68
|
ensureDirectoryExists(destinationPath);
|
|
69
69
|
const source = await fs.readFile(sourcePath, "utf8");
|
|
70
|
-
const
|
|
70
|
+
const transpileOutput = ts.transpileModule(source, {
|
|
71
71
|
compilerOptions: {
|
|
72
72
|
module: ts.ModuleKind[this.moduleKind.toLowerCase() === "module" ? "ES2022" : "CommonJS"],
|
|
73
73
|
target: ts.ScriptTarget.ES2015,
|
|
74
74
|
},
|
|
75
|
-
|
|
75
|
+
reportDiagnostics: true,
|
|
76
|
+
});
|
|
77
|
+
if (transpileOutput.diagnostics?.length) {
|
|
78
|
+
for (const diagnostic of transpileOutput.diagnostics) {
|
|
79
|
+
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
|
|
80
|
+
debug("TypeScript diagnostic in %s: %s", sourcePath, message);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const result = transpileOutput.outputText;
|
|
76
84
|
const fullDestination = nodePath
|
|
77
85
|
.join(sourcePath
|
|
78
86
|
.replace(this.sourcePath, this.destinationPath)
|
|
@@ -82,10 +90,10 @@ export class Transpiler extends EventTarget {
|
|
|
82
90
|
try {
|
|
83
91
|
await fs.writeFile(fullDestination, resultWithTransformedFileExtensions);
|
|
84
92
|
}
|
|
85
|
-
catch {
|
|
86
|
-
debug("error
|
|
93
|
+
catch (error) {
|
|
94
|
+
debug("error writing transpiled output to %s: %o", fullDestination, error);
|
|
87
95
|
this.dispatchEvent(new Event("error"));
|
|
88
|
-
throw new Error("could not transpile");
|
|
96
|
+
throw new Error("could not transpile", { cause: error });
|
|
89
97
|
}
|
|
90
98
|
this.dispatchEvent(new Event("write"));
|
|
91
99
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RESERVED_WORDS } from "./reserved-words.js";
|
|
1
2
|
export class Coder {
|
|
2
3
|
requirement;
|
|
3
4
|
constructor(requirement) {
|
|
@@ -12,6 +13,9 @@ export class Coder {
|
|
|
12
13
|
beforeExport(_path) {
|
|
13
14
|
return "";
|
|
14
15
|
}
|
|
16
|
+
jsdoc() {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
15
19
|
write(script) {
|
|
16
20
|
if (this.requirement.isReference) {
|
|
17
21
|
return script.import(this);
|
|
@@ -32,12 +36,13 @@ export class Coder {
|
|
|
32
36
|
const name = rawName
|
|
33
37
|
.replace(/^\d/u, (digit) => `_${digit}`)
|
|
34
38
|
.replaceAll(/[^\w$]/gu, "_");
|
|
35
|
-
|
|
39
|
+
const baseName = RESERVED_WORDS.has(name) ? `${name}_` : name;
|
|
40
|
+
yield baseName;
|
|
36
41
|
let index = 1;
|
|
37
42
|
const MAX_NAMES_TO_GENERATE_BEFORE_GIVING_UP = 100;
|
|
38
43
|
while (index < MAX_NAMES_TO_GENERATE_BEFORE_GIVING_UP) {
|
|
39
44
|
index += 1;
|
|
40
|
-
yield
|
|
45
|
+
yield baseName + index;
|
|
41
46
|
}
|
|
42
47
|
}
|
|
43
48
|
typeDeclaration(_namespace, _script) {
|
|
@@ -60,4 +60,159 @@ export async function generate(source, destination, generateOptions, repository
|
|
|
60
60
|
debug("telling the repository to write the files to %s", destination);
|
|
61
61
|
await repository.writeFiles(destination, generateOptions);
|
|
62
62
|
debug("finished writing the files");
|
|
63
|
+
if (generateOptions.types) {
|
|
64
|
+
await writeApplyContextType(destination);
|
|
65
|
+
await writeDefaultScenariosIndex(destination);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function collectContextFiles(destination) {
|
|
69
|
+
const routesDir = nodePath.join(destination, "routes");
|
|
70
|
+
const results = [];
|
|
71
|
+
if (!existsSync(routesDir)) {
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
74
|
+
await walkForContextFiles(routesDir, routesDir, results);
|
|
75
|
+
results.sort((a, b) => b.depth - a.depth);
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
78
|
+
async function walkForContextFiles(routesDir, currentDir, results) {
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (entry.isDirectory()) {
|
|
88
|
+
await walkForContextFiles(routesDir, nodePath.join(currentDir, entry.name), results);
|
|
89
|
+
}
|
|
90
|
+
else if (entry.name === "_.context.ts") {
|
|
91
|
+
const relDir = nodePath
|
|
92
|
+
.relative(routesDir, currentDir)
|
|
93
|
+
.replaceAll("\\", "/");
|
|
94
|
+
const routePath = relDir === "" ? "/" : `/${relDir}`;
|
|
95
|
+
const depth = relDir === "" ? 0 : relDir.split("/").length;
|
|
96
|
+
const importPath = relDir === "" ? "../routes/_.context" : `../routes/${relDir}/_.context`;
|
|
97
|
+
const alias = routePathToAlias(routePath);
|
|
98
|
+
results.push({ importPath, alias, routePath, depth });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function routePathToAlias(routePath) {
|
|
103
|
+
if (routePath === "/") {
|
|
104
|
+
return "Context";
|
|
105
|
+
}
|
|
106
|
+
return (routePath
|
|
107
|
+
.split("/")
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.map((seg) => seg
|
|
110
|
+
.replace(/\{(.+?)\}/g, (_match, name) => name.replace(/[^a-z0-9]/gi, " "))
|
|
111
|
+
.replace(/[-_\s]([a-z])/g, (_match, c) => c.toUpperCase())
|
|
112
|
+
.replace(/^[a-z]/, (c) => c.toUpperCase())
|
|
113
|
+
.replace(/[^a-z0-9]/gi, ""))
|
|
114
|
+
.join("") + "Context");
|
|
115
|
+
}
|
|
116
|
+
const PARAM_SEGMENT_REGEX = /^\{.+\}$/u;
|
|
117
|
+
function buildLoadContextOverload(routePath, alias) {
|
|
118
|
+
if (routePath === "/") {
|
|
119
|
+
return ' loadContext(path: "/" | `/${string}`): ' + alias + ";";
|
|
120
|
+
}
|
|
121
|
+
const segments = routePath.split("/").filter(Boolean);
|
|
122
|
+
const hasParam = segments.some((seg) => PARAM_SEGMENT_REGEX.test(seg));
|
|
123
|
+
if (!hasParam) {
|
|
124
|
+
return ` loadContext(path: "${routePath}" | \`${routePath}/\${string}\`): ${alias};`;
|
|
125
|
+
}
|
|
126
|
+
const templatePath = `/${segments
|
|
127
|
+
.map((seg) => (PARAM_SEGMENT_REGEX.test(seg) ? "${string}" : seg))
|
|
128
|
+
.join("/")}`;
|
|
129
|
+
return ` loadContext(path: \`${templatePath}\`): ${alias};`;
|
|
130
|
+
}
|
|
131
|
+
function buildApplyContextContent(contextFiles) {
|
|
132
|
+
const rootContext = contextFiles.find((f) => f.routePath === "/");
|
|
133
|
+
const contextType = rootContext
|
|
134
|
+
? rootContext.alias
|
|
135
|
+
: "Record<string, unknown>";
|
|
136
|
+
const importLines = contextFiles.map(({ importPath, alias }) => alias === "Context"
|
|
137
|
+
? `import type { Context } from "${importPath}";`
|
|
138
|
+
: `import type { Context as ${alias} } from "${importPath}";`);
|
|
139
|
+
const overloadLines = contextFiles.map(({ alias, routePath }) => buildLoadContextOverload(routePath, alias));
|
|
140
|
+
const parts = [
|
|
141
|
+
"// This file is generated by Counterfact. Do not edit manually.",
|
|
142
|
+
...importLines,
|
|
143
|
+
"",
|
|
144
|
+
"export interface ApplyContext {",
|
|
145
|
+
' /** Root context, same as loadContext("/") */',
|
|
146
|
+
` context: ${contextType};`,
|
|
147
|
+
" /** Load a context object for a specific path */",
|
|
148
|
+
...overloadLines,
|
|
149
|
+
" loadContext(path: string): Record<string, unknown>;",
|
|
150
|
+
" /** Named route builders stored in the REPL execution context */",
|
|
151
|
+
" routes: Record<string, unknown>;",
|
|
152
|
+
" /** Create a new route builder for a given path */",
|
|
153
|
+
" route: (path: string) => unknown;",
|
|
154
|
+
"}",
|
|
155
|
+
"",
|
|
156
|
+
"/** A scenario function that receives the live REPL environment */",
|
|
157
|
+
"export type Scenario = ($: ApplyContext) => Promise<void> | void;",
|
|
158
|
+
"",
|
|
159
|
+
];
|
|
160
|
+
return parts.join("\n");
|
|
161
|
+
}
|
|
162
|
+
export async function writeApplyContextType(destination) {
|
|
163
|
+
const typesDir = nodePath.join(destination, "types");
|
|
164
|
+
const filePath = nodePath.join(typesDir, "scenario-context.ts");
|
|
165
|
+
const contextFiles = await collectContextFiles(destination);
|
|
166
|
+
const content = buildApplyContextContent(contextFiles);
|
|
167
|
+
await fs.mkdir(typesDir, { recursive: true });
|
|
168
|
+
await fs.writeFile(filePath, content, "utf8");
|
|
169
|
+
}
|
|
170
|
+
const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/scenario-context.js";
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Scenario scripts are plain TypeScript functions that receive the live REPL
|
|
174
|
+
* environment and can read or mutate server state. Run them from the REPL with:
|
|
175
|
+
* .apply <functionName>
|
|
176
|
+
*/
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Read or mutate the root context (same object routes see as $.context):
|
|
180
|
+
* $.context.<property> = <value>;
|
|
181
|
+
*
|
|
182
|
+
* Load a context for a specific path:
|
|
183
|
+
* const petsCtx = $.loadContext("/pets");
|
|
184
|
+
*
|
|
185
|
+
* Store a pre-configured route builder for later use in the REPL:
|
|
186
|
+
* $.routes.myRequest = $.route("/pets").method("get");
|
|
187
|
+
*/
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* An example scenario. To use it in the REPL, type:
|
|
191
|
+
* .apply help
|
|
192
|
+
*/
|
|
193
|
+
export const help: Scenario = ($) => {
|
|
194
|
+
void $;
|
|
195
|
+
|
|
196
|
+
console.log(
|
|
197
|
+
[
|
|
198
|
+
"Scenarios are functions that populate the context object",
|
|
199
|
+
"and / or the REPL environment. They are intended to",
|
|
200
|
+
"populate your environment with specific data and",
|
|
201
|
+
"configurations for testing purposes.",
|
|
202
|
+
].join("\\n"),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
console.log(
|
|
206
|
+
"\\nScenarios (including this one) are defined in the ./scenarios directory.",
|
|
207
|
+
);
|
|
208
|
+
};
|
|
209
|
+
`;
|
|
210
|
+
async function writeDefaultScenariosIndex(destination) {
|
|
211
|
+
const scenariosDir = nodePath.join(destination, "scenarios");
|
|
212
|
+
const filePath = nodePath.join(scenariosDir, "index.ts");
|
|
213
|
+
if (existsSync(filePath)) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
await fs.mkdir(scenariosDir, { recursive: true });
|
|
217
|
+
await fs.writeFile(filePath, DEFAULT_SCENARIOS_INDEX, "utf8");
|
|
63
218
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a JSDoc comment string from OpenAPI schema metadata.
|
|
3
|
+
* Returns an empty string if there is no relevant metadata.
|
|
4
|
+
*/
|
|
5
|
+
export function buildJsDoc(data) {
|
|
6
|
+
const lines = [];
|
|
7
|
+
const description = data["description"];
|
|
8
|
+
const summary = data["summary"];
|
|
9
|
+
const example = data["example"];
|
|
10
|
+
const examples = data["examples"];
|
|
11
|
+
const defaultValue = data["default"];
|
|
12
|
+
const format = data["format"];
|
|
13
|
+
const deprecated = data["deprecated"];
|
|
14
|
+
const mainText = description ?? summary;
|
|
15
|
+
if (mainText) {
|
|
16
|
+
// Escape */ to prevent prematurely closing the JSDoc block
|
|
17
|
+
const escaped = String(mainText).replace(/\*\//gu, "* /");
|
|
18
|
+
const textLines = escaped.split("\n");
|
|
19
|
+
for (const line of textLines) {
|
|
20
|
+
lines.push(` * ${line}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (format !== undefined) {
|
|
24
|
+
lines.push(` * @format ${format}`);
|
|
25
|
+
}
|
|
26
|
+
if (defaultValue !== undefined) {
|
|
27
|
+
lines.push(` * @default ${JSON.stringify(defaultValue)}`);
|
|
28
|
+
}
|
|
29
|
+
// Use scalar `example`, or fall back to the first value from `examples`
|
|
30
|
+
const exampleValue = example !== undefined
|
|
31
|
+
? example
|
|
32
|
+
: examples !== undefined
|
|
33
|
+
? Object.values(examples)[0]?.value
|
|
34
|
+
: undefined;
|
|
35
|
+
if (exampleValue !== undefined) {
|
|
36
|
+
lines.push(` * @example ${JSON.stringify(exampleValue)}`);
|
|
37
|
+
}
|
|
38
|
+
if (deprecated === true) {
|
|
39
|
+
lines.push(` * @deprecated`);
|
|
40
|
+
}
|
|
41
|
+
if (lines.length === 0) {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
return `/**\n${lines.join("\n")}\n */\n`;
|
|
45
|
+
}
|
|
@@ -22,7 +22,7 @@ export class OperationCoder extends Coder {
|
|
|
22
22
|
if (firstResponse === undefined ||
|
|
23
23
|
!("content" in firstResponse || "schema" in firstResponse)) {
|
|
24
24
|
return `async ($) => {
|
|
25
|
-
return $.response[${firstStatusCode === "default" ? 200 : firstStatusCode}];
|
|
25
|
+
return $.response[${firstStatusCode === "default" ? 200 : firstStatusCode}].empty();
|
|
26
26
|
}`;
|
|
27
27
|
}
|
|
28
28
|
return `async ($) => {
|