@counterfact/generator 0.1.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/License.md +21 -0
- package/README.md +18 -0
- package/dist/code-generator.d.ts +60 -0
- package/dist/code-generator.js +182 -0
- package/dist/coder.d.ts +107 -0
- package/dist/coder.js +132 -0
- package/dist/context-file-token.d.ts +1 -0
- package/dist/context-file-token.js +1 -0
- package/dist/ensure-directory-exists.d.ts +1 -0
- package/dist/ensure-directory-exists.js +12 -0
- package/dist/forward-slash-path.d.ts +9 -0
- package/dist/forward-slash-path.js +13 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/jsdoc.d.ts +5 -0
- package/dist/jsdoc.js +49 -0
- package/dist/openapi-path.d.ts +11 -0
- package/dist/openapi-path.js +23 -0
- package/dist/operation-coder.d.ts +23 -0
- package/dist/operation-coder.js +73 -0
- package/dist/operation-type-coder.d.ts +140 -0
- package/dist/operation-type-coder.js +387 -0
- package/dist/operation-type-name-mapping.d.ts +7 -0
- package/dist/operation-type-name-mapping.js +53 -0
- package/dist/parameter-export-type-coder.d.ts +13 -0
- package/dist/parameter-export-type-coder.js +27 -0
- package/dist/parameters-type-coder.d.ts +10 -0
- package/dist/parameters-type-coder.js +44 -0
- package/dist/printers.d.ts +2 -0
- package/dist/printers.js +10 -0
- package/dist/prune.d.ts +20 -0
- package/dist/prune.js +146 -0
- package/dist/read-only-comments.d.ts +1 -0
- package/dist/read-only-comments.js +5 -0
- package/dist/repository.d.ts +87 -0
- package/dist/repository.js +264 -0
- package/dist/requirement.d.ts +104 -0
- package/dist/requirement.js +191 -0
- package/dist/reserved-words.d.ts +1 -0
- package/dist/reserved-words.js +50 -0
- package/dist/response-type-coder.d.ts +16 -0
- package/dist/response-type-coder.js +104 -0
- package/dist/responses-type-coder.d.ts +11 -0
- package/dist/responses-type-coder.js +43 -0
- package/dist/scenario-file-generator.d.ts +31 -0
- package/dist/scenario-file-generator.js +368 -0
- package/dist/schema-coder.d.ts +10 -0
- package/dist/schema-coder.js +49 -0
- package/dist/schema-type-coder.d.ts +20 -0
- package/dist/schema-type-coder.js +121 -0
- package/dist/script.d.ts +130 -0
- package/dist/script.js +322 -0
- package/dist/specification.d.ts +40 -0
- package/dist/specification.js +58 -0
- package/dist/templates/counterfact-types/cookie-options.ts +14 -0
- package/dist/templates/counterfact-types/counterfact-response.ts +15 -0
- package/dist/templates/counterfact-types/example-names.ts +13 -0
- package/dist/templates/counterfact-types/example.ts +14 -0
- package/dist/templates/counterfact-types/generic-response-builder.ts +167 -0
- package/dist/templates/counterfact-types/http-status-code.ts +62 -0
- package/dist/templates/counterfact-types/if-has-key.ts +19 -0
- package/dist/templates/counterfact-types/index.ts +30 -0
- package/dist/templates/counterfact-types/maybe-promise.ts +6 -0
- package/dist/templates/counterfact-types/media-type.ts +6 -0
- package/dist/templates/counterfact-types/middleware.ts +87 -0
- package/dist/templates/counterfact-types/omit-all.ts +11 -0
- package/dist/templates/counterfact-types/omit-value-when-never.ts +11 -0
- package/dist/templates/counterfact-types/open-api-content.ts +8 -0
- package/dist/templates/counterfact-types/open-api-header.ts +4 -0
- package/dist/templates/counterfact-types/open-api-operation.ts +36 -0
- package/dist/templates/counterfact-types/open-api-parameters.ts +26 -0
- package/dist/templates/counterfact-types/open-api-response.ts +22 -0
- package/dist/templates/counterfact-types/random-function.ts +9 -0
- package/dist/templates/counterfact-types/response-builder-factory.ts +16 -0
- package/dist/templates/counterfact-types/response-builder.ts +36 -0
- package/dist/templates/counterfact-types/wide-operation-argument.ts +20 -0
- package/dist/templates/counterfact-types/wide-response-builder.ts +28 -0
- package/dist/type-coder.d.ts +5 -0
- package/dist/type-coder.js +9 -0
- package/dist/versions-ts-generator.d.ts +10 -0
- package/dist/versions-ts-generator.js +82 -0
- package/dist/wait-for-event.d.ts +2 -0
- package/dist/wait-for-event.js +17 -0
- package/dist/watch-options.d.ts +5 -0
- package/dist/watch-options.js +5 -0
- package/dist/windows-escape.d.ts +1 -0
- package/dist/windows-escape.js +9 -0
- package/examples/generate-routes.mjs +40 -0
- package/package.json +61 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { STREAMING_CONTENT_TYPES } from "@counterfact/types/streaming-content-types";
|
|
2
|
+
import { pathJoin } from "./forward-slash-path.js";
|
|
3
|
+
import { printObject } from "./printers.js";
|
|
4
|
+
import { SchemaTypeCoder } from "./schema-type-coder.js";
|
|
5
|
+
import { TypeCoder } from "./type-coder.js";
|
|
6
|
+
export class ResponseTypeCoder extends TypeCoder {
|
|
7
|
+
openApi2MediaTypes;
|
|
8
|
+
constructor(requirement, version = "", openApi2MediaTypes = []) {
|
|
9
|
+
super(requirement, version);
|
|
10
|
+
this.openApi2MediaTypes = openApi2MediaTypes;
|
|
11
|
+
}
|
|
12
|
+
names() {
|
|
13
|
+
return super.names(this.requirement.refUrl.split("/").at(-1));
|
|
14
|
+
}
|
|
15
|
+
buildContentObjectType(script, response) {
|
|
16
|
+
if (response.has("content")) {
|
|
17
|
+
return response
|
|
18
|
+
.get("content")
|
|
19
|
+
.map((content, mediaType) => {
|
|
20
|
+
let schemaType;
|
|
21
|
+
if (content.has("itemSchema") &&
|
|
22
|
+
STREAMING_CONTENT_TYPES.has(mediaType)) {
|
|
23
|
+
schemaType = `AsyncIterable<${new SchemaTypeCoder(content.get("itemSchema"), this.version).write(script)}>`;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
schemaType = content.has("schema")
|
|
27
|
+
? new SchemaTypeCoder(content.get("schema"), this.version).write(script)
|
|
28
|
+
: "unknown";
|
|
29
|
+
}
|
|
30
|
+
return [
|
|
31
|
+
mediaType,
|
|
32
|
+
`{
|
|
33
|
+
schema: ${schemaType}
|
|
34
|
+
}`,
|
|
35
|
+
];
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return this.openApi2MediaTypes.map((mediaType) => [
|
|
39
|
+
mediaType,
|
|
40
|
+
`{
|
|
41
|
+
schema: ${new SchemaTypeCoder(response.get("schema"), this.version).write(script)}
|
|
42
|
+
}`,
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
printContentObjectType(script, response) {
|
|
46
|
+
if (response.has("content") || response.has("schema")) {
|
|
47
|
+
return printObject(this.buildContentObjectType(script, response));
|
|
48
|
+
}
|
|
49
|
+
return "never";
|
|
50
|
+
}
|
|
51
|
+
buildHeaders(script, response) {
|
|
52
|
+
return response
|
|
53
|
+
.get("headers")
|
|
54
|
+
.map((value, name) => [
|
|
55
|
+
name,
|
|
56
|
+
`{ schema: ${new SchemaTypeCoder(value.get("schema") ?? value, this.version).write(script)}}`,
|
|
57
|
+
]);
|
|
58
|
+
}
|
|
59
|
+
printHeaders(script, response) {
|
|
60
|
+
if (!response.has("headers")) {
|
|
61
|
+
return "never";
|
|
62
|
+
}
|
|
63
|
+
return printObject(this.buildHeaders(script, response));
|
|
64
|
+
}
|
|
65
|
+
printRequiredHeaders(response) {
|
|
66
|
+
const requiredHeaders = (response.get("headers")?.map((value, name) => ({
|
|
67
|
+
name,
|
|
68
|
+
required: value.data.required,
|
|
69
|
+
})) ?? [])
|
|
70
|
+
.filter(({ required }) => required)
|
|
71
|
+
.map(({ name }) => `"${name}"`);
|
|
72
|
+
return requiredHeaders.length === 0 ? "never" : requiredHeaders.join(" | ");
|
|
73
|
+
}
|
|
74
|
+
buildExamplesObjectType(response) {
|
|
75
|
+
if (!response.has("content")) {
|
|
76
|
+
return "{}";
|
|
77
|
+
}
|
|
78
|
+
const exampleNames = [];
|
|
79
|
+
response.get("content").forEach((content) => {
|
|
80
|
+
if (content.has("examples")) {
|
|
81
|
+
content.get("examples").forEach((_, name) => {
|
|
82
|
+
if (!exampleNames.includes(name)) {
|
|
83
|
+
exampleNames.push(name);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
if (exampleNames.length === 0) {
|
|
89
|
+
return "{}";
|
|
90
|
+
}
|
|
91
|
+
return printObject(exampleNames.map((name) => [name, "unknown"]));
|
|
92
|
+
}
|
|
93
|
+
modulePath() {
|
|
94
|
+
return pathJoin("types", this.version, this.requirement.refUrl + ".ts");
|
|
95
|
+
}
|
|
96
|
+
writeCode(script) {
|
|
97
|
+
return `{
|
|
98
|
+
headers: ${this.printHeaders(script, this.requirement)};
|
|
99
|
+
requiredHeaders: ${this.printRequiredHeaders(this.requirement)};
|
|
100
|
+
content: ${this.printContentObjectType(script, this.requirement)};
|
|
101
|
+
examples: ${this.buildExamplesObjectType(this.requirement)};
|
|
102
|
+
}`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { TypeCoder } from "./type-coder.js";
|
|
2
|
+
import type { Requirement } from "./requirement.js";
|
|
3
|
+
import type { Script } from "./script.js";
|
|
4
|
+
export declare class ResponsesTypeCoder extends TypeCoder {
|
|
5
|
+
openApi2MediaTypes: string[];
|
|
6
|
+
constructor(requirement: Requirement, version?: string, openApi2MediaTypes?: string[]);
|
|
7
|
+
typeForDefaultStatusCode(listedStatusCodes: string[]): string;
|
|
8
|
+
normalizeStatusCode(statusCode: string): string;
|
|
9
|
+
buildResponseObjectType(script: Script): string;
|
|
10
|
+
writeCode(script: Script): string;
|
|
11
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { printObjectWithoutQuotes } from "./printers.js";
|
|
2
|
+
import { ResponseTypeCoder } from "./response-type-coder.js";
|
|
3
|
+
import { TypeCoder } from "./type-coder.js";
|
|
4
|
+
export class ResponsesTypeCoder extends TypeCoder {
|
|
5
|
+
openApi2MediaTypes;
|
|
6
|
+
constructor(requirement, version = "", openApi2MediaTypes = []) {
|
|
7
|
+
super(requirement, version);
|
|
8
|
+
this.openApi2MediaTypes = openApi2MediaTypes;
|
|
9
|
+
}
|
|
10
|
+
typeForDefaultStatusCode(listedStatusCodes) {
|
|
11
|
+
const definedStatusCodes = listedStatusCodes.filter((key) => key !== "default");
|
|
12
|
+
if (definedStatusCodes.length === 0) {
|
|
13
|
+
return "[statusCode in HttpStatusCode]";
|
|
14
|
+
}
|
|
15
|
+
return `[statusCode in Exclude<HttpStatusCode, ${definedStatusCodes.join(" | ")}>]`;
|
|
16
|
+
}
|
|
17
|
+
normalizeStatusCode(statusCode) {
|
|
18
|
+
if (statusCode === "default") {
|
|
19
|
+
return this.typeForDefaultStatusCode(Object.keys(this.requirement.data));
|
|
20
|
+
}
|
|
21
|
+
return statusCode;
|
|
22
|
+
}
|
|
23
|
+
buildResponseObjectType(script) {
|
|
24
|
+
const entries = this.requirement.map((response, responseCode) => [
|
|
25
|
+
this.normalizeStatusCode(responseCode),
|
|
26
|
+
new ResponseTypeCoder(response, this.version, this.openApi2MediaTypes).write(script),
|
|
27
|
+
]);
|
|
28
|
+
const explicitEntries = entries.filter(([key]) => !key.startsWith("["));
|
|
29
|
+
const mappedEntries = entries.filter(([key]) => key.startsWith("["));
|
|
30
|
+
if (explicitEntries.length > 0 && mappedEntries.length > 0) {
|
|
31
|
+
return `${printObjectWithoutQuotes(explicitEntries)} & ${printObjectWithoutQuotes(mappedEntries)}`;
|
|
32
|
+
}
|
|
33
|
+
return printObjectWithoutQuotes(entries);
|
|
34
|
+
}
|
|
35
|
+
writeCode(script) {
|
|
36
|
+
script.importSharedType("ResponseBuilderFactory");
|
|
37
|
+
const text = `ResponseBuilderFactory<${this.buildResponseObjectType(script)}>`;
|
|
38
|
+
if (text.includes("HttpStatusCode")) {
|
|
39
|
+
script.importSharedType("HttpStatusCode");
|
|
40
|
+
}
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encapsulates the generation of scenario-related files:
|
|
3
|
+
* - `types/_.context.ts` — the typed `Scenario$` interface derived from all
|
|
4
|
+
* `_.context.ts` files found under `routes/`.
|
|
5
|
+
* - `scenarios/index.ts` — the default scenarios entry-point (created only if
|
|
6
|
+
* it does not already exist).
|
|
7
|
+
*
|
|
8
|
+
* When {@link watch} is called, file-system watchers monitor the `routes/`
|
|
9
|
+
* directory for changes to `_.context.ts` files and the application root's
|
|
10
|
+
* optional `_.store.ts`, automatically regenerating `types/_.context.ts` and
|
|
11
|
+
* the mirrored `_.middleware.ts` files under `types/`.
|
|
12
|
+
*/
|
|
13
|
+
export declare class ScenarioFileGenerator {
|
|
14
|
+
private readonly destination;
|
|
15
|
+
private readonly rootDestination;
|
|
16
|
+
private watcher;
|
|
17
|
+
private storeWatcher;
|
|
18
|
+
constructor(destination: string, rootDestination?: string);
|
|
19
|
+
/** Generates both scenario-related files once and resolves when complete. */
|
|
20
|
+
generate(): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Starts watching route context files and the exact application-root store
|
|
23
|
+
* path, regenerating `types/_.context.ts` and the mirrored `_.middleware.ts`
|
|
24
|
+
* files under `types/` on every relevant change.
|
|
25
|
+
*
|
|
26
|
+
* Resolves once the watcher is ready.
|
|
27
|
+
*/
|
|
28
|
+
watch(): Promise<void>;
|
|
29
|
+
/** Closes the file-system watcher. */
|
|
30
|
+
stopWatching(): Promise<void>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import nodePath from "node:path";
|
|
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 { pathRelative } from "./forward-slash-path.js";
|
|
7
|
+
import { waitForEvent } from "./wait-for-event.js";
|
|
8
|
+
import { CHOKIDAR_OPTIONS } from "./watch-options.js";
|
|
9
|
+
const GENERATED_FILE_COMMENT = "// This file is generated by Counterfact. Do not edit manually.";
|
|
10
|
+
async function collectContextFiles(destination) {
|
|
11
|
+
const routesDir = nodePath.join(destination, "routes");
|
|
12
|
+
const results = [];
|
|
13
|
+
if (!existsSync(routesDir)) {
|
|
14
|
+
return results;
|
|
15
|
+
}
|
|
16
|
+
await walkForContextFiles(routesDir, routesDir, results);
|
|
17
|
+
results.sort((a, b) => b.depth - a.depth);
|
|
18
|
+
return results;
|
|
19
|
+
}
|
|
20
|
+
async function walkForContextFiles(routesDir, currentDir, results) {
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
await walkForContextFiles(routesDir, nodePath.join(currentDir, entry.name), results);
|
|
31
|
+
}
|
|
32
|
+
else if (entry.name === "_.context.ts") {
|
|
33
|
+
const relDir = pathRelative(routesDir, currentDir);
|
|
34
|
+
const routePath = relDir === "" ? "/" : `/${relDir}`;
|
|
35
|
+
const depth = relDir === "" ? 0 : relDir.split("/").length;
|
|
36
|
+
const importPath = relDir === "" ? "../routes/_.context" : `../routes/${relDir}/_.context`;
|
|
37
|
+
const alias = routePathToAlias(routePath);
|
|
38
|
+
results.push({ importPath, alias, routePath, depth });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function collectRouteDirectories(destination) {
|
|
43
|
+
const routesDir = nodePath.join(destination, "routes");
|
|
44
|
+
if (!existsSync(routesDir)) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const results = [];
|
|
48
|
+
await walkRouteDirectories(routesDir, routesDir, undefined, results);
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
51
|
+
async function walkRouteDirectories(routesDir, currentDir, inheritedContextFile, results) {
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const localContextFile = entries.some((entry) => entry.isFile() && entry.name === "_.context.ts")
|
|
60
|
+
? nodePath.join(currentDir, "_.context.ts")
|
|
61
|
+
: inheritedContextFile;
|
|
62
|
+
results.push({
|
|
63
|
+
contextFile: localContextFile,
|
|
64
|
+
relativePath: pathRelative(routesDir, currentDir),
|
|
65
|
+
});
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
await walkRouteDirectories(routesDir, nodePath.join(currentDir, entry.name), localContextFile, results);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function routePathToAlias(routePath) {
|
|
73
|
+
if (routePath === "/") {
|
|
74
|
+
return "Context";
|
|
75
|
+
}
|
|
76
|
+
return (routePath
|
|
77
|
+
.split("/")
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.map((seg) => seg
|
|
80
|
+
.replace(/\{(.+?)\}/g, (_match, name) => name.replace(/[^a-z0-9]/gi, " "))
|
|
81
|
+
.replace(/[-_\s]([a-z])/g, (_match, c) => c.toUpperCase())
|
|
82
|
+
.replace(/^[a-z]/, (c) => c.toUpperCase())
|
|
83
|
+
.replace(/[^a-z0-9]/gi, ""))
|
|
84
|
+
.join("") + "Context");
|
|
85
|
+
}
|
|
86
|
+
const PARAM_SEGMENT_REGEX = /^\{.+\}$/u;
|
|
87
|
+
function buildLoadContextOverload(routePath, alias) {
|
|
88
|
+
if (routePath === "/") {
|
|
89
|
+
return ' loadContext(path: "/" | `/${string}`): ' + alias + ";";
|
|
90
|
+
}
|
|
91
|
+
const segments = routePath.split("/").filter(Boolean);
|
|
92
|
+
const hasParam = segments.some((seg) => PARAM_SEGMENT_REGEX.test(seg));
|
|
93
|
+
if (!hasParam) {
|
|
94
|
+
return ` loadContext(path: "${routePath}" | \`${routePath}/\${string}\`): ${alias};`;
|
|
95
|
+
}
|
|
96
|
+
const templatePath = `/${segments
|
|
97
|
+
.map((seg) => (PARAM_SEGMENT_REGEX.test(seg) ? "${string}" : seg))
|
|
98
|
+
.join("/")}`;
|
|
99
|
+
return ` loadContext(path: \`${templatePath}\`): ${alias};`;
|
|
100
|
+
}
|
|
101
|
+
function buildScenarioContextContent(contextFiles, storeImportPath) {
|
|
102
|
+
const rootContext = contextFiles.find((f) => f.routePath === "/");
|
|
103
|
+
const contextType = rootContext
|
|
104
|
+
? rootContext.alias
|
|
105
|
+
: "Record<string, unknown>";
|
|
106
|
+
const importLines = contextFiles.map(({ importPath, alias }) => alias === "Context"
|
|
107
|
+
? `import type { Context } from "${importPath}";`
|
|
108
|
+
: `import type { Context as ${alias} } from "${importPath}";`);
|
|
109
|
+
if (storeImportPath !== undefined) {
|
|
110
|
+
importLines.push(`import type { Store } from "${storeImportPath}";`);
|
|
111
|
+
}
|
|
112
|
+
const overloadLines = contextFiles.map(({ alias, routePath }) => buildLoadContextOverload(routePath, alias));
|
|
113
|
+
const parts = [
|
|
114
|
+
GENERATED_FILE_COMMENT,
|
|
115
|
+
...importLines,
|
|
116
|
+
"",
|
|
117
|
+
"interface LoadContextDefinitions {",
|
|
118
|
+
" /* code generator adds additional signatures here */",
|
|
119
|
+
...overloadLines,
|
|
120
|
+
" loadContext(path: string): Record<string, unknown>;",
|
|
121
|
+
"}",
|
|
122
|
+
"",
|
|
123
|
+
"export interface Scenario$ {",
|
|
124
|
+
' /** Root context, same as loadContext("/") */',
|
|
125
|
+
` readonly context: ${contextType};`,
|
|
126
|
+
' readonly loadContext: LoadContextDefinitions["loadContext"];',
|
|
127
|
+
" /** Named route builders stored in the REPL execution context */",
|
|
128
|
+
" readonly routes: Record<string, unknown>;",
|
|
129
|
+
" /** Create a new route builder for a given path */",
|
|
130
|
+
" readonly route: (path: string) => unknown;",
|
|
131
|
+
"}",
|
|
132
|
+
"",
|
|
133
|
+
"/** A scenario function that receives the live REPL environment */",
|
|
134
|
+
"export type Scenario = ($: Scenario$) => Promise<void> | void;",
|
|
135
|
+
"",
|
|
136
|
+
"/** Interface for Context objects defined in _.context.ts files */",
|
|
137
|
+
"export interface Context$ {",
|
|
138
|
+
...(storeImportPath === undefined
|
|
139
|
+
? []
|
|
140
|
+
: [
|
|
141
|
+
" /** Application-level store shared by every API group */",
|
|
142
|
+
" readonly store: Store;",
|
|
143
|
+
]),
|
|
144
|
+
" /** Load a context object for a specific path */",
|
|
145
|
+
' readonly loadContext: LoadContextDefinitions["loadContext"];',
|
|
146
|
+
" /** Load a JSON file relative to this file's path */",
|
|
147
|
+
" readonly readJson: (relativePath: string) => Promise<unknown>;",
|
|
148
|
+
"}",
|
|
149
|
+
"",
|
|
150
|
+
];
|
|
151
|
+
return parts.join("\n");
|
|
152
|
+
}
|
|
153
|
+
function ensureRelativeImportPath(path) {
|
|
154
|
+
return path.startsWith(".") ? path : `./${path}`;
|
|
155
|
+
}
|
|
156
|
+
function buildMiddlewareTypeContent(destination, routeDirectory) {
|
|
157
|
+
const typesDirectory = nodePath.join(destination, "types", routeDirectory.relativePath);
|
|
158
|
+
const sharedTypePath = ensureRelativeImportPath(pathRelative(typesDirectory, nodePath.join(destination, "counterfact-types", "index.js")));
|
|
159
|
+
const lines = [
|
|
160
|
+
GENERATED_FILE_COMMENT,
|
|
161
|
+
`import type { Middleware as MiddlewareFunction } from "${sharedTypePath}";`,
|
|
162
|
+
];
|
|
163
|
+
if (routeDirectory.contextFile === undefined) {
|
|
164
|
+
lines.push("", "export type Middleware = MiddlewareFunction;");
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
const contextImportPath = ensureRelativeImportPath(pathRelative(typesDirectory, routeDirectory.contextFile).replace(/\.ts$/u, ".js"));
|
|
168
|
+
lines.push(`import type { Context } from "${contextImportPath}";`, "", "export type Middleware = MiddlewareFunction<Context>;");
|
|
169
|
+
}
|
|
170
|
+
return `${lines.join("\n")}\n`;
|
|
171
|
+
}
|
|
172
|
+
async function collectGeneratedMiddlewareTypeFiles(directory) {
|
|
173
|
+
let entries;
|
|
174
|
+
try {
|
|
175
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
const files = [];
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
const path = nodePath.join(directory, entry.name);
|
|
183
|
+
if (entry.isDirectory()) {
|
|
184
|
+
files.push(...(await collectGeneratedMiddlewareTypeFiles(path)));
|
|
185
|
+
}
|
|
186
|
+
else if (entry.name === "_.middleware.ts") {
|
|
187
|
+
const content = await fs.readFile(path, "utf8");
|
|
188
|
+
if (content.startsWith(GENERATED_FILE_COMMENT)) {
|
|
189
|
+
files.push(path);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return files;
|
|
194
|
+
}
|
|
195
|
+
async function writeMiddlewareTypes(destination) {
|
|
196
|
+
const routeDirectories = await collectRouteDirectories(destination);
|
|
197
|
+
const expectedFiles = new Set();
|
|
198
|
+
for (const routeDirectory of routeDirectories) {
|
|
199
|
+
const filePath = nodePath.join(destination, "types", routeDirectory.relativePath, "_.middleware.ts");
|
|
200
|
+
expectedFiles.add(nodePath.resolve(filePath));
|
|
201
|
+
await fs.mkdir(nodePath.dirname(filePath), { recursive: true });
|
|
202
|
+
await fs.writeFile(filePath, buildMiddlewareTypeContent(destination, routeDirectory), "utf8");
|
|
203
|
+
}
|
|
204
|
+
const generatedFiles = await collectGeneratedMiddlewareTypeFiles(nodePath.join(destination, "types"));
|
|
205
|
+
await Promise.all(generatedFiles
|
|
206
|
+
.filter((file) => !expectedFiles.has(nodePath.resolve(file)))
|
|
207
|
+
.map((file) => fs.rm(file)));
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Writes the `types/_.context.ts` file, which exports the
|
|
211
|
+
* `Scenario$` interface used to type scenario functions.
|
|
212
|
+
*
|
|
213
|
+
* The interface is generated from all `_.context.ts` files found under the
|
|
214
|
+
* `routes/` directory, providing strongly typed `loadContext()` overloads for
|
|
215
|
+
* every route path that has a context file.
|
|
216
|
+
*
|
|
217
|
+
* @param destination - Root output directory.
|
|
218
|
+
* @param rootDestination - Application root containing the optional store.
|
|
219
|
+
*/
|
|
220
|
+
async function writeScenarioContextType(destination, rootDestination) {
|
|
221
|
+
const typesDir = nodePath.join(destination, "types");
|
|
222
|
+
const filePath = nodePath.join(typesDir, "_.context.ts");
|
|
223
|
+
const storePath = nodePath.join(rootDestination, "_.store.ts");
|
|
224
|
+
const contextFiles = await collectContextFiles(destination);
|
|
225
|
+
const relativeStorePath = pathRelative(typesDir, storePath);
|
|
226
|
+
const storeImportPath = existsSync(storePath)
|
|
227
|
+
? `${relativeStorePath.startsWith(".") ? "" : "./"}${relativeStorePath.replace(/\.ts$/u, ".js")}`
|
|
228
|
+
: undefined;
|
|
229
|
+
const content = buildScenarioContextContent(contextFiles, storeImportPath);
|
|
230
|
+
await fs.mkdir(typesDir, { recursive: true });
|
|
231
|
+
await fs.writeFile(filePath, content, "utf8");
|
|
232
|
+
}
|
|
233
|
+
const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/_.context.js";
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Scenario scripts are plain TypeScript functions that receive the live REPL
|
|
237
|
+
* environment and can read or mutate server state. Run them from the REPL with:
|
|
238
|
+
* .scenario <functionName>
|
|
239
|
+
*/
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Read or mutate the root context (same object routes see as $.context):
|
|
243
|
+
* $.context.<property> = <value>;
|
|
244
|
+
*
|
|
245
|
+
* Load a context for a specific path:
|
|
246
|
+
* const petsCtx = $.loadContext("/pets");
|
|
247
|
+
*
|
|
248
|
+
* Store a pre-configured route builder for later use in the REPL:
|
|
249
|
+
* $.routes.myRequest = $.route("/pets").method("get");
|
|
250
|
+
*/
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* startup() runs automatically when the server initializes, right before the
|
|
254
|
+
* REPL starts. Use it to seed dummy data so the server is ready to use
|
|
255
|
+
* immediately. It receives the same $ argument as all other scenario functions.
|
|
256
|
+
*
|
|
257
|
+
* Tip: delegate to other scenario functions and pass $ along so each function
|
|
258
|
+
* stays focused on a single concern. You can also pass additional arguments to
|
|
259
|
+
* configure them, e.g. addPets($, 20, "dog").
|
|
260
|
+
*
|
|
261
|
+
* If you don't need a startup scenario, delete this function or leave it empty.
|
|
262
|
+
*/
|
|
263
|
+
export const startup: Scenario = ($) => {
|
|
264
|
+
void $;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* An example scenario. To use it in the REPL, type:
|
|
269
|
+
* .scenario help
|
|
270
|
+
*/
|
|
271
|
+
export const help: Scenario = ($) => {
|
|
272
|
+
void $;
|
|
273
|
+
|
|
274
|
+
console.log(
|
|
275
|
+
[
|
|
276
|
+
"Scenarios are functions that populate the context object",
|
|
277
|
+
"and / or the REPL environment. They are intended to",
|
|
278
|
+
"populate your environment with specific data and",
|
|
279
|
+
"configurations for testing purposes.",
|
|
280
|
+
].join("\\n"),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
console.log(
|
|
284
|
+
"\\nScenarios (including this one) are defined in the ./scenarios directory.",
|
|
285
|
+
);
|
|
286
|
+
};
|
|
287
|
+
`;
|
|
288
|
+
async function writeDefaultScenariosIndex(destination) {
|
|
289
|
+
const scenariosDir = nodePath.join(destination, "scenarios");
|
|
290
|
+
const filePath = nodePath.join(scenariosDir, "index.ts");
|
|
291
|
+
if (existsSync(filePath)) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
await fs.mkdir(scenariosDir, { recursive: true });
|
|
295
|
+
await fs.writeFile(filePath, DEFAULT_SCENARIOS_INDEX, "utf8");
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Encapsulates the generation of scenario-related files:
|
|
299
|
+
* - `types/_.context.ts` — the typed `Scenario$` interface derived from all
|
|
300
|
+
* `_.context.ts` files found under `routes/`.
|
|
301
|
+
* - `scenarios/index.ts` — the default scenarios entry-point (created only if
|
|
302
|
+
* it does not already exist).
|
|
303
|
+
*
|
|
304
|
+
* When {@link watch} is called, file-system watchers monitor the `routes/`
|
|
305
|
+
* directory for changes to `_.context.ts` files and the application root's
|
|
306
|
+
* optional `_.store.ts`, automatically regenerating `types/_.context.ts` and
|
|
307
|
+
* the mirrored `_.middleware.ts` files under `types/`.
|
|
308
|
+
*/
|
|
309
|
+
export class ScenarioFileGenerator {
|
|
310
|
+
destination;
|
|
311
|
+
rootDestination;
|
|
312
|
+
watcher;
|
|
313
|
+
storeWatcher;
|
|
314
|
+
constructor(destination, rootDestination = destination) {
|
|
315
|
+
this.destination = destination;
|
|
316
|
+
this.rootDestination = rootDestination;
|
|
317
|
+
}
|
|
318
|
+
/** Generates both scenario-related files once and resolves when complete. */
|
|
319
|
+
async generate() {
|
|
320
|
+
await writeScenarioContextType(this.destination, this.rootDestination);
|
|
321
|
+
await writeMiddlewareTypes(this.destination);
|
|
322
|
+
await writeDefaultScenariosIndex(this.destination);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Starts watching route context files and the exact application-root store
|
|
326
|
+
* path, regenerating `types/_.context.ts` and the mirrored `_.middleware.ts`
|
|
327
|
+
* files under `types/` on every relevant change.
|
|
328
|
+
*
|
|
329
|
+
* Resolves once the watcher is ready.
|
|
330
|
+
*/
|
|
331
|
+
async watch() {
|
|
332
|
+
const routesDir = nodePath.join(this.destination, "routes");
|
|
333
|
+
const storePath = nodePath.join(this.rootDestination, "_.store.ts");
|
|
334
|
+
const regenerate = () => {
|
|
335
|
+
void Promise.all([
|
|
336
|
+
writeScenarioContextType(this.destination, this.rootDestination),
|
|
337
|
+
writeMiddlewareTypes(this.destination),
|
|
338
|
+
]).catch((error) => {
|
|
339
|
+
console.error("Failed to regenerate scenario files:", error);
|
|
340
|
+
});
|
|
341
|
+
};
|
|
342
|
+
this.watcher = watch(routesDir, CHOKIDAR_OPTIONS).on("all", (event, filePath) => {
|
|
343
|
+
if (filePath.endsWith("_.context.ts") ||
|
|
344
|
+
event === "addDir" ||
|
|
345
|
+
event === "unlinkDir") {
|
|
346
|
+
regenerate();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
// Polling lets chokidar observe creation of the exact conventional store
|
|
350
|
+
// path even when the file is absent when the watcher starts.
|
|
351
|
+
this.storeWatcher = watch(storePath, {
|
|
352
|
+
...CHOKIDAR_OPTIONS,
|
|
353
|
+
usePolling: true,
|
|
354
|
+
}).on("all", (_event, filePath) => {
|
|
355
|
+
if (nodePath.resolve(filePath) === nodePath.resolve(storePath)) {
|
|
356
|
+
regenerate();
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
await Promise.all([
|
|
360
|
+
waitForEvent(this.watcher, "ready"),
|
|
361
|
+
waitForEvent(this.storeWatcher, "ready"),
|
|
362
|
+
]);
|
|
363
|
+
}
|
|
364
|
+
/** Closes the file-system watcher. */
|
|
365
|
+
async stopWatching() {
|
|
366
|
+
await Promise.all([this.watcher?.close(), this.storeWatcher?.close()]);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Coder } from "./coder.js";
|
|
2
|
+
import type { Script } from "./script.js";
|
|
3
|
+
export declare class SchemaCoder extends Coder {
|
|
4
|
+
names(): Generator<string>;
|
|
5
|
+
objectSchema(script: Script): string;
|
|
6
|
+
arraySchema(script: Script): string;
|
|
7
|
+
typeDeclaration(_namespace: Map<string, unknown> | undefined, script: Script): string;
|
|
8
|
+
modulePath(): string;
|
|
9
|
+
writeCode(script: Script): string;
|
|
10
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Coder } from "./coder.js";
|
|
2
|
+
function scrubSchema(schema) {
|
|
3
|
+
// remove properties that are not valid in JSON Schema 6 and not useful anyway
|
|
4
|
+
const cleaned = { ...schema };
|
|
5
|
+
delete cleaned["example"];
|
|
6
|
+
delete cleaned["xml"];
|
|
7
|
+
return cleaned;
|
|
8
|
+
}
|
|
9
|
+
export class SchemaCoder extends Coder {
|
|
10
|
+
names() {
|
|
11
|
+
return super.names(`${this.requirement.refUrl.split("/").at(-1)}Schema`);
|
|
12
|
+
}
|
|
13
|
+
objectSchema(script) {
|
|
14
|
+
const { properties, required } = this.requirement.data;
|
|
15
|
+
const propertyLines = Object.keys(properties ?? {}).map((name) => {
|
|
16
|
+
const schemaCoder = new SchemaCoder(this.requirement.select(`properties/${name}`), this.version);
|
|
17
|
+
return `"${name}": ${schemaCoder.write(script)}`;
|
|
18
|
+
});
|
|
19
|
+
return `
|
|
20
|
+
{
|
|
21
|
+
type: "object",
|
|
22
|
+
required: ${JSON.stringify(required ?? [])},
|
|
23
|
+
properties: { ${propertyLines.join(", ")} }
|
|
24
|
+
}
|
|
25
|
+
`;
|
|
26
|
+
}
|
|
27
|
+
arraySchema(script) {
|
|
28
|
+
return `{
|
|
29
|
+
type: "array",
|
|
30
|
+
items: ${new SchemaCoder(this.requirement.get("items"), this.version).write(script)}
|
|
31
|
+
}`;
|
|
32
|
+
}
|
|
33
|
+
typeDeclaration(_namespace, script) {
|
|
34
|
+
return script.importExternalType("JSONSchema6", "json-schema");
|
|
35
|
+
}
|
|
36
|
+
modulePath() {
|
|
37
|
+
return `types/${this.requirement.refUrl}.ts`;
|
|
38
|
+
}
|
|
39
|
+
writeCode(script) {
|
|
40
|
+
const { type } = this.requirement.data;
|
|
41
|
+
if (type === "object") {
|
|
42
|
+
return this.objectSchema(script);
|
|
43
|
+
}
|
|
44
|
+
if (type === "array") {
|
|
45
|
+
return this.arraySchema(script);
|
|
46
|
+
}
|
|
47
|
+
return JSON.stringify(scrubSchema(this.requirement.data));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { TypeCoder } from "./type-coder.js";
|
|
2
|
+
import { Requirement } from "./requirement.js";
|
|
3
|
+
import type { Script } from "./script.js";
|
|
4
|
+
export declare class SchemaTypeCoder extends TypeCoder {
|
|
5
|
+
names(): Generator<string>;
|
|
6
|
+
jsdoc(): string;
|
|
7
|
+
additionalPropertiesType(script: Script): string;
|
|
8
|
+
objectSchema(script: Script): string;
|
|
9
|
+
arraySchema(script: Script): string;
|
|
10
|
+
writePrimitive(value: unknown): string;
|
|
11
|
+
writeType(script: Script, type: unknown): string;
|
|
12
|
+
writeGroup(script: Script, { allOf, anyOf, oneOf, }: {
|
|
13
|
+
allOf?: unknown[];
|
|
14
|
+
anyOf?: unknown[];
|
|
15
|
+
oneOf?: unknown[];
|
|
16
|
+
}): string;
|
|
17
|
+
writeEnum(_script: Script, requirement: Requirement): string;
|
|
18
|
+
modulePath(): string;
|
|
19
|
+
writeCode(script: Script): string;
|
|
20
|
+
}
|