counterfact 2.7.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -160
- package/bin/README.md +39 -14
- package/bin/counterfact.js +18 -539
- package/bin/ts-loader.mjs +1 -0
- package/dist/api-runner.js +202 -0
- package/dist/app.js +102 -114
- package/dist/cli/banner.js +81 -0
- package/dist/cli/check-for-updates.js +45 -0
- package/dist/cli/run.js +304 -0
- package/dist/cli/telemetry.js +50 -0
- package/dist/migrate/paths-to-routes.js +1 -0
- package/dist/migrate/update-route-types.js +3 -3
- package/dist/msw.js +78 -0
- package/dist/repl/raw-http-client.js +22 -1
- package/dist/repl/repl.js +250 -63
- package/dist/repl/route-builder.js +68 -0
- package/dist/server/constants.js +8 -0
- package/dist/server/context-registry.js +54 -1
- package/dist/server/determine-module-kind.js +14 -0
- package/dist/server/dispatcher.js +46 -0
- package/dist/server/file-discovery.js +21 -9
- package/dist/server/is-proxy-enabled-for-path.js +12 -0
- package/dist/server/json-to-xml.js +10 -0
- package/dist/server/load-openapi-document.js +4 -11
- package/dist/server/module-dependency-graph.js +25 -0
- package/dist/server/module-loader.js +52 -21
- package/dist/server/module-tree.js +36 -0
- package/dist/server/openapi-document.js +69 -0
- package/dist/server/registry.js +89 -0
- package/dist/server/response-builder.js +15 -0
- package/dist/server/scenario-registry.js +26 -0
- package/dist/server/tools.js +27 -0
- package/dist/server/transpiler.js +24 -9
- package/dist/server/{admin-api-middleware.js → web-server/admin-api-middleware.js} +19 -9
- package/dist/server/web-server/create-koa-app.js +68 -0
- package/dist/server/web-server/openapi-middleware.js +34 -0
- package/dist/server/{koa-middleware.js → web-server/routes-middleware.js} +26 -6
- package/dist/typescript-generator/code-generator.js +118 -4
- package/dist/typescript-generator/coder.js +76 -0
- package/dist/typescript-generator/operation-coder.js +12 -4
- package/dist/typescript-generator/operation-type-coder.js +39 -4
- package/dist/typescript-generator/parameters-type-coder.js +2 -4
- package/dist/typescript-generator/prune.js +3 -1
- package/dist/typescript-generator/repository.js +77 -20
- package/dist/typescript-generator/requirement.js +69 -0
- package/dist/typescript-generator/{generate.js → scenario-file-generator.js} +99 -81
- package/dist/typescript-generator/script.js +70 -7
- package/dist/typescript-generator/specification.js +27 -0
- package/dist/util/ensure-directory-exists.js +8 -0
- package/dist/util/forward-slash-path.js +63 -0
- package/dist/util/load-config-file.js +2 -2
- package/dist/util/read-file.js +27 -2
- package/dist/util/runtime-can-execute-erasable-ts.js +12 -0
- package/dist/util/windows-escape.js +18 -0
- package/package.json +5 -4
- package/dist/server/create-koa-app.js +0 -42
- package/dist/server/openapi-middleware.js +0 -19
|
@@ -56,6 +56,21 @@ function unknownStatusCodeResponse(statusCode) {
|
|
|
56
56
|
status: 500,
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Creates the `$.response` builder proxy made available to route handlers.
|
|
61
|
+
*
|
|
62
|
+
* The proxy maps HTTP status codes to a fluent builder object. Example usage
|
|
63
|
+
* in a route handler:
|
|
64
|
+
*
|
|
65
|
+
* ```ts
|
|
66
|
+
* return $.response[200].json({ id: 1, name: "Fluffy" });
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* @param operation - The OpenAPI operation descriptor used for schema-driven
|
|
70
|
+
* random generation and example resolution.
|
|
71
|
+
* @param config - Server configuration (e.g. `alwaysFakeOptionals`).
|
|
72
|
+
* @returns A {@link ResponseBuilder} proxy keyed by HTTP status code.
|
|
73
|
+
*/
|
|
59
74
|
export function createResponseBuilder(operation, config) {
|
|
60
75
|
return new Proxy({}, {
|
|
61
76
|
get: (target, statusCode) => ({
|
|
@@ -1,11 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of loaded scenario modules.
|
|
3
|
+
*
|
|
4
|
+
* Scenario modules are plain JavaScript/TypeScript files that export named
|
|
5
|
+
* functions. Each module is keyed by a slash-delimited path relative to the
|
|
6
|
+
* `scenarios/` directory (e.g. `"index"`, `"sub/reset"`).
|
|
7
|
+
*
|
|
8
|
+
* The registry is used by the REPL's `.scenario` command and by tab-completion
|
|
9
|
+
* to enumerate available scenarios and their exported function names.
|
|
10
|
+
*/
|
|
1
11
|
export class ScenarioRegistry {
|
|
2
12
|
modules = new Map();
|
|
13
|
+
/**
|
|
14
|
+
* Registers (or replaces) a scenario module.
|
|
15
|
+
*
|
|
16
|
+
* @param key - Slash-delimited file key (e.g. `"index"`, `"auth/setup"`).
|
|
17
|
+
* @param module - The module's exported values.
|
|
18
|
+
*/
|
|
3
19
|
add(key, module) {
|
|
4
20
|
this.modules.set(key, module);
|
|
5
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Removes the scenario module for `key`.
|
|
24
|
+
*
|
|
25
|
+
* @param key - The file key to remove.
|
|
26
|
+
*/
|
|
6
27
|
remove(key) {
|
|
7
28
|
this.modules.delete(key);
|
|
8
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns the module for `fileKey`, or `undefined` if not registered.
|
|
32
|
+
*
|
|
33
|
+
* @param fileKey - The file key to look up.
|
|
34
|
+
*/
|
|
9
35
|
getModule(fileKey) {
|
|
10
36
|
return this.modules.get(fileKey);
|
|
11
37
|
}
|
package/dist/server/tools.js
CHANGED
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
import { generate } from "json-schema-faker";
|
|
2
|
+
/**
|
|
3
|
+
* A collection of utility helpers made available to route handlers via
|
|
4
|
+
* `$.tools`.
|
|
5
|
+
*
|
|
6
|
+
* Provides random selection, content-type acceptance checking, and
|
|
7
|
+
* schema-based random data generation.
|
|
8
|
+
*/
|
|
2
9
|
export class Tools {
|
|
3
10
|
headers;
|
|
4
11
|
constructor({ headers = {}, } = {}) {
|
|
5
12
|
this.headers = headers;
|
|
6
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Returns a randomly selected element from `array`.
|
|
16
|
+
*
|
|
17
|
+
* @param array - The array to pick from.
|
|
18
|
+
* @returns A random element.
|
|
19
|
+
*/
|
|
7
20
|
oneOf(array) {
|
|
8
21
|
return array[Math.floor(Math.random() * array.length)];
|
|
9
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Returns `true` when the request's `Accept` header is compatible with
|
|
25
|
+
* `contentType`.
|
|
26
|
+
*
|
|
27
|
+
* A missing or empty `Accept` header is treated as `*/*` (accepts anything).
|
|
28
|
+
*
|
|
29
|
+
* @param contentType - The MIME type to check (e.g. `"application/json"`).
|
|
30
|
+
*/
|
|
10
31
|
accepts(contentType) {
|
|
11
32
|
const acceptHeader = Object.entries(this.headers).find(([key]) => key.toLowerCase() === "accept")?.[1];
|
|
12
33
|
if (acceptHeader === "" || acceptHeader === undefined) {
|
|
@@ -19,6 +40,12 @@ export class Tools {
|
|
|
19
40
|
(subtype === "*" || subtype === contentType.split("/")[1]));
|
|
20
41
|
});
|
|
21
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Generates a random value that satisfies `schema` using json-schema-faker.
|
|
45
|
+
*
|
|
46
|
+
* @param schema - A JSON Schema object.
|
|
47
|
+
* @returns A promise that resolves to a generated value.
|
|
48
|
+
*/
|
|
22
49
|
randomFromSchema(schema) {
|
|
23
50
|
return generate(schema, { useExamplesValue: true, fillProperties: false });
|
|
24
51
|
}
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
// Stryker disable all
|
|
2
2
|
import { once } from "node:events";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
|
-
|
|
4
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- transpiler consumes watched source files and writes paired outputs under configured directories. */
|
|
5
5
|
import { watch as chokidarWatch } from "chokidar";
|
|
6
6
|
import createDebug from "debug";
|
|
7
7
|
import ts from "typescript";
|
|
8
8
|
import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
|
|
9
|
+
import { toForwardSlashPath, pathJoin } from "../util/forward-slash-path.js";
|
|
9
10
|
import { CHOKIDAR_OPTIONS } from "./constants.js";
|
|
10
11
|
import { convertFileExtensionsToCjs } from "./convert-js-extensions-to-cjs.js";
|
|
11
12
|
const debug = createDebug("counterfact:server:transpiler");
|
|
13
|
+
/**
|
|
14
|
+
* Watches TypeScript source files in `sourcePath` and compiles them to
|
|
15
|
+
* JavaScript in `destinationPath` using the TypeScript compiler API.
|
|
16
|
+
*
|
|
17
|
+
* Used when the runtime cannot execute TypeScript natively (i.e. Node.js
|
|
18
|
+
* without the `--experimental-strip-types` flag). Each file is compiled
|
|
19
|
+
* independently (no type-checking) for maximum speed.
|
|
20
|
+
*
|
|
21
|
+
* Emits DOM-style events: `"write"` after a successful transpile, `"delete"`
|
|
22
|
+
* after a source file is removed, and `"error"` on write or compilation errors.
|
|
23
|
+
*/
|
|
12
24
|
export class Transpiler extends EventTarget {
|
|
13
25
|
sourcePath;
|
|
14
26
|
destinationPath;
|
|
@@ -23,6 +35,11 @@ export class Transpiler extends EventTarget {
|
|
|
23
35
|
get extension() {
|
|
24
36
|
return this.moduleKind.toLowerCase() === "commonjs" ? ".cjs" : ".js";
|
|
25
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Starts the file-system watcher and transpiles all existing files in the
|
|
40
|
+
* source path. Resolves once the initial scan and all pending transpiles
|
|
41
|
+
* are complete.
|
|
42
|
+
*/
|
|
26
43
|
async watch() {
|
|
27
44
|
debug("transpiler: watch");
|
|
28
45
|
this.watcher = chokidarWatch(this.sourcePath, {
|
|
@@ -36,11 +53,10 @@ export class Transpiler extends EventTarget {
|
|
|
36
53
|
const JS_EXTENSIONS = ["js", "mjs", "ts", "mts"];
|
|
37
54
|
if (!JS_EXTENSIONS.some((extension) => sourcePathOriginal.endsWith(`.${extension}`)))
|
|
38
55
|
return;
|
|
39
|
-
const sourcePath = sourcePathOriginal
|
|
40
|
-
const destinationPath = sourcePath
|
|
56
|
+
const sourcePath = toForwardSlashPath(sourcePathOriginal);
|
|
57
|
+
const destinationPath = toForwardSlashPath(sourcePath
|
|
41
58
|
.replace(this.sourcePath, this.destinationPath)
|
|
42
|
-
.
|
|
43
|
-
.replace(".ts", this.extension);
|
|
59
|
+
.replace(".ts", this.extension));
|
|
44
60
|
if (["add", "change"].includes(eventName)) {
|
|
45
61
|
transpiles.push(this.transpileFile(eventName, sourcePath, destinationPath));
|
|
46
62
|
}
|
|
@@ -61,6 +77,7 @@ export class Transpiler extends EventTarget {
|
|
|
61
77
|
await once(this.watcher, "ready");
|
|
62
78
|
await Promise.all(transpiles);
|
|
63
79
|
}
|
|
80
|
+
/** Closes the file-system watcher. */
|
|
64
81
|
async stopWatching() {
|
|
65
82
|
await this.watcher?.close();
|
|
66
83
|
}
|
|
@@ -81,11 +98,9 @@ export class Transpiler extends EventTarget {
|
|
|
81
98
|
}
|
|
82
99
|
}
|
|
83
100
|
const result = transpileOutput.outputText;
|
|
84
|
-
const fullDestination =
|
|
85
|
-
.join(sourcePath
|
|
101
|
+
const fullDestination = pathJoin(sourcePath
|
|
86
102
|
.replace(this.sourcePath, this.destinationPath)
|
|
87
|
-
.replace(".ts", this.extension))
|
|
88
|
-
.replaceAll("\\", "/");
|
|
103
|
+
.replace(".ts", this.extension));
|
|
89
104
|
const resultWithTransformedFileExtensions = convertFileExtensionsToCjs(result);
|
|
90
105
|
try {
|
|
91
106
|
await fs.writeFile(fullDestination, resultWithTransformedFileExtensions);
|
|
@@ -24,16 +24,25 @@ function isLoopbackIp(ip) {
|
|
|
24
24
|
/**
|
|
25
25
|
* Admin API middleware for programmatic access to Counterfact internals.
|
|
26
26
|
* Exposes context management, proxy configuration, and route discovery
|
|
27
|
-
* through HTTP endpoints at
|
|
27
|
+
* through HTTP endpoints at the given path prefix.
|
|
28
28
|
*
|
|
29
29
|
* This enables AI agents and external tools to interact with the mock server
|
|
30
30
|
* in the same way the REPL does, but via HTTP requests.
|
|
31
|
+
*
|
|
32
|
+
* @param pathPrefix - The URL path prefix at which the admin API is mounted,
|
|
33
|
+
* e.g. `"/_counterfact/api"`. Requests to paths that do not start with this
|
|
34
|
+
* prefix fall through to the next middleware.
|
|
35
|
+
* @param registry - The route registry used to list available routes.
|
|
36
|
+
* @param contextRegistry - The context registry used to read and update
|
|
37
|
+
* per-path context objects.
|
|
38
|
+
* @param config - Server configuration (proxy settings, port, etc.).
|
|
39
|
+
* @returns A Koa middleware function.
|
|
31
40
|
*/
|
|
32
|
-
export function adminApiMiddleware(registry, contextRegistry, config) {
|
|
41
|
+
export function adminApiMiddleware(pathPrefix, registry, contextRegistry, config) {
|
|
33
42
|
return async (ctx, next) => {
|
|
34
43
|
const { pathname } = ctx.URL;
|
|
35
|
-
// Only handle admin API routes
|
|
36
|
-
if (!pathname.startsWith(
|
|
44
|
+
// Only handle admin API routes (exact prefix or paths beneath it)
|
|
45
|
+
if (pathname !== pathPrefix && !pathname.startsWith(`${pathPrefix}/`)) {
|
|
37
46
|
return await next();
|
|
38
47
|
}
|
|
39
48
|
// ===== Admin API Access Guard =====
|
|
@@ -72,9 +81,10 @@ export function adminApiMiddleware(registry, contextRegistry, config) {
|
|
|
72
81
|
}
|
|
73
82
|
}
|
|
74
83
|
debug("Admin API request: %s %s", ctx.method, pathname);
|
|
75
|
-
// Extract
|
|
76
|
-
const
|
|
77
|
-
const
|
|
84
|
+
// Extract the resource path after the prefix
|
|
85
|
+
const subPath = pathname.slice(pathPrefix.length);
|
|
86
|
+
const parts = subPath.split("/").filter(Boolean);
|
|
87
|
+
const [resource, ...rest] = parts;
|
|
78
88
|
try {
|
|
79
89
|
// ===== Health Check =====
|
|
80
90
|
if (resource === "health" && ctx.method === "GET") {
|
|
@@ -83,7 +93,7 @@ export function adminApiMiddleware(registry, contextRegistry, config) {
|
|
|
83
93
|
port: config.port,
|
|
84
94
|
uptime: process.uptime(),
|
|
85
95
|
basePath: config.basePath,
|
|
86
|
-
|
|
96
|
+
prefix: config.prefix,
|
|
87
97
|
};
|
|
88
98
|
return;
|
|
89
99
|
}
|
|
@@ -155,7 +165,7 @@ export function adminApiMiddleware(registry, contextRegistry, config) {
|
|
|
155
165
|
openApiPath: config.openApiPath,
|
|
156
166
|
port: config.port,
|
|
157
167
|
proxyUrl: config.proxyUrl,
|
|
158
|
-
|
|
168
|
+
prefix: config.prefix,
|
|
159
169
|
startAdminApi: config.startAdminApi,
|
|
160
170
|
startRepl: config.startRepl,
|
|
161
171
|
startServer: config.startServer,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import createDebug from "debug";
|
|
2
|
+
import Koa from "koa";
|
|
3
|
+
import bodyParser from "koa-bodyparser";
|
|
4
|
+
import { koaSwagger } from "koa2-swagger-ui";
|
|
5
|
+
import { adminApiMiddleware } from "./admin-api-middleware.js";
|
|
6
|
+
import { routesMiddleware } from "./routes-middleware.js";
|
|
7
|
+
import { openapiMiddleware } from "./openapi-middleware.js";
|
|
8
|
+
const debug = createDebug("counterfact:server:create-koa-app");
|
|
9
|
+
/**
|
|
10
|
+
* Builds and configures the Koa application with all built-in middleware.
|
|
11
|
+
*
|
|
12
|
+
* The middleware stack (in order) is:
|
|
13
|
+
* 1. Per runner: OpenAPI document serving at `/counterfact/openapi${runner.subdirectory}`
|
|
14
|
+
* 2. Per runner: Swagger UI at `/counterfact/swagger${runner.subdirectory}`
|
|
15
|
+
* 3. Per runner: Admin API (when `config.startAdminApi` is `true`) at `/_counterfact/api${runner.subdirectory}`
|
|
16
|
+
* 4. Redirect `/counterfact` → `/counterfact/swagger`
|
|
17
|
+
* 5. Body parser
|
|
18
|
+
* 6. JSON serialisation of object bodies
|
|
19
|
+
* 7. Per runner: Route-dispatching middleware at `runner.prefix`
|
|
20
|
+
*
|
|
21
|
+
* @param runners - The ApiRunner instances, one per API spec.
|
|
22
|
+
* @param config - Server configuration.
|
|
23
|
+
* @returns A configured Koa application (not yet listening).
|
|
24
|
+
*/
|
|
25
|
+
export function createKoaApp({ runners, config, }) {
|
|
26
|
+
const app = new Koa();
|
|
27
|
+
for (const runner of runners) {
|
|
28
|
+
app.use(openapiMiddleware(`/counterfact/openapi${runner.subdirectory}`, {
|
|
29
|
+
path: runner.openApiPath,
|
|
30
|
+
baseUrl: `//localhost:${config.port}${runner.prefix}`,
|
|
31
|
+
}));
|
|
32
|
+
app.use(koaSwagger({
|
|
33
|
+
routePrefix: `/counterfact/swagger${runner.subdirectory}`,
|
|
34
|
+
swaggerOptions: {
|
|
35
|
+
url: `/counterfact/openapi${runner.subdirectory}`,
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
if (config.startAdminApi) {
|
|
39
|
+
app.use(adminApiMiddleware(`/_counterfact/api${runner.subdirectory}`, runner.registry, runner.contextRegistry, config));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
debug("basePath: %s", config.basePath);
|
|
43
|
+
app.use(async (ctx, next) => {
|
|
44
|
+
if (ctx.URL.pathname === "/counterfact") {
|
|
45
|
+
ctx.redirect("/counterfact/swagger");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
await next();
|
|
49
|
+
});
|
|
50
|
+
app.use(bodyParser());
|
|
51
|
+
app.use(async (ctx, next) => {
|
|
52
|
+
await next();
|
|
53
|
+
if (ctx.body !== null &&
|
|
54
|
+
ctx.body !== undefined &&
|
|
55
|
+
typeof ctx.body === "object" &&
|
|
56
|
+
!Buffer.isBuffer(ctx.body)) {
|
|
57
|
+
ctx.body = JSON.stringify(ctx.body, null, 2);
|
|
58
|
+
ctx.type = "application/json";
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
for (const runner of runners) {
|
|
62
|
+
app.use(routesMiddleware(runner.prefix, runner.dispatcher, {
|
|
63
|
+
proxyPaths: config.proxyPaths,
|
|
64
|
+
proxyUrl: config.proxyUrl,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
return app;
|
|
68
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { bundle } from "@apidevtools/json-schema-ref-parser";
|
|
2
|
+
import { dump } from "js-yaml";
|
|
3
|
+
/**
|
|
4
|
+
* Returns a Koa middleware that serves a bundled OpenAPI document as YAML at
|
|
5
|
+
* the given `pathPrefix`.
|
|
6
|
+
*
|
|
7
|
+
* The served document is augmented with a `servers` entry (OpenAPI 3.x) and a
|
|
8
|
+
* `host` field (OpenAPI 2.x / Swagger) so that the Swagger UI can send
|
|
9
|
+
* requests to the running Counterfact instance.
|
|
10
|
+
*
|
|
11
|
+
* @param pathPrefix - The URL path at which to serve the document, e.g.
|
|
12
|
+
* `"/counterfact/openapi"`. Requests to any other path fall through to the
|
|
13
|
+
* next middleware.
|
|
14
|
+
* @param document - Descriptor providing `path` (file path or URL to the
|
|
15
|
+
* source OpenAPI document) and `baseUrl` (the base URL to inject, e.g.
|
|
16
|
+
* `"//localhost:3100/api"`).
|
|
17
|
+
* @returns A Koa middleware function.
|
|
18
|
+
*/
|
|
19
|
+
export function openapiMiddleware(pathPrefix, document) {
|
|
20
|
+
return async (ctx, next) => {
|
|
21
|
+
if (ctx.URL.pathname !== pathPrefix) {
|
|
22
|
+
return await next();
|
|
23
|
+
}
|
|
24
|
+
const openApiDocument = (await bundle(document.path));
|
|
25
|
+
openApiDocument.servers ??= [];
|
|
26
|
+
openApiDocument.servers.unshift({
|
|
27
|
+
description: "Counterfact",
|
|
28
|
+
url: document.baseUrl,
|
|
29
|
+
});
|
|
30
|
+
// OpenApi 2 support:
|
|
31
|
+
openApiDocument.host = document.baseUrl;
|
|
32
|
+
ctx.body = dump(openApiDocument);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import createDebug from "debug";
|
|
2
2
|
import koaProxy from "koa-proxies";
|
|
3
|
-
import { isProxyEnabledForPath } from "
|
|
3
|
+
import { isProxyEnabledForPath } from "../is-proxy-enabled-for-path.js";
|
|
4
4
|
const debug = createDebug("counterfact:server:create-koa-app");
|
|
5
5
|
const HTTP_STATUS_CODE_OK = 200;
|
|
6
6
|
const HEADERS_TO_DROP = new Set([
|
|
@@ -40,17 +40,37 @@ function getAuthObject(ctx) {
|
|
|
40
40
|
const [username, password] = user.split(":");
|
|
41
41
|
return { password, username };
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Builds the Koa middleware function that bridges Koa's request context with
|
|
45
|
+
* the Counterfact {@link Dispatcher}.
|
|
46
|
+
*
|
|
47
|
+
* Responsibilities:
|
|
48
|
+
* - Respects `prefix` — requests outside the prefix are passed to `next`.
|
|
49
|
+
* - Adds CORS headers to every response.
|
|
50
|
+
* - Handles `OPTIONS` pre-flight requests (200 with CORS headers, no body).
|
|
51
|
+
* - Proxies the request upstream when proxy is enabled for the path.
|
|
52
|
+
* - Forwards the request to the dispatcher and maps the response back onto
|
|
53
|
+
* the Koa context.
|
|
54
|
+
*
|
|
55
|
+
* @param prefix - The URL path prefix that this middleware handles, e.g.
|
|
56
|
+
* `"/api/v1"`. Requests to paths that do not start with this prefix fall
|
|
57
|
+
* through to the next middleware.
|
|
58
|
+
* @param dispatcher - The {@link Dispatcher} instance that handles requests.
|
|
59
|
+
* @param config - Server configuration (proxy settings, etc.).
|
|
60
|
+
* @param proxy - Proxy factory; injectable for testing.
|
|
61
|
+
* @returns A Koa middleware function.
|
|
62
|
+
*/
|
|
63
|
+
export function routesMiddleware(prefix, dispatcher, config, proxy = koaProxy) {
|
|
44
64
|
return async function middleware(ctx, next) {
|
|
45
|
-
const { proxyUrl
|
|
65
|
+
const { proxyUrl } = config;
|
|
46
66
|
debug("middleware running for path: %s", ctx.request.path);
|
|
47
|
-
debug("
|
|
48
|
-
if (!ctx.request.path.startsWith(
|
|
67
|
+
debug("prefix: %s", prefix);
|
|
68
|
+
if (!ctx.request.path.startsWith(prefix)) {
|
|
49
69
|
return await next();
|
|
50
70
|
}
|
|
51
71
|
const auth = getAuthObject(ctx);
|
|
52
72
|
const { body, headers, query, rawBody } = ctx.request;
|
|
53
|
-
const path = ctx.request.path.slice(
|
|
73
|
+
const path = ctx.request.path.slice(prefix.length);
|
|
54
74
|
const method = ctx.request.method;
|
|
55
75
|
if (isProxyEnabledForPath(path, config) && proxyUrl) {
|
|
56
76
|
return proxy("/", { changeOrigin: true, target: proxyUrl })(ctx, next);
|
|
@@ -1,7 +1,24 @@
|
|
|
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 -- generated files are written under the caller-provided destination tree. */
|
|
1
5
|
import { watch } from "chokidar";
|
|
6
|
+
import createDebug from "debug";
|
|
2
7
|
import { CHOKIDAR_OPTIONS } from "../server/constants.js";
|
|
8
|
+
import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
|
|
3
9
|
import { waitForEvent } from "../util/wait-for-event.js";
|
|
4
|
-
import {
|
|
10
|
+
import { OperationCoder } from "./operation-coder.js";
|
|
11
|
+
import { pruneRoutes } from "./prune.js";
|
|
12
|
+
import { Repository } from "./repository.js";
|
|
13
|
+
import { Specification } from "./specification.js";
|
|
14
|
+
const debug = createDebug("counterfact:typescript-generator:generate");
|
|
15
|
+
/**
|
|
16
|
+
* Orchestrates the code-generation pipeline and optional file-system watching.
|
|
17
|
+
*
|
|
18
|
+
* When {@link watch} is called, Counterfact watches the source OpenAPI document
|
|
19
|
+
* for changes and re-runs code generation automatically. `"generate"` and
|
|
20
|
+
* `"failed"` events are emitted after each attempt.
|
|
21
|
+
*/
|
|
5
22
|
export class CodeGenerator extends EventTarget {
|
|
6
23
|
openapiPath;
|
|
7
24
|
destination;
|
|
@@ -13,15 +30,111 @@ export class CodeGenerator extends EventTarget {
|
|
|
13
30
|
this.destination = destination;
|
|
14
31
|
this.generateOptions = generateOptions;
|
|
15
32
|
}
|
|
16
|
-
|
|
17
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Initialises the `.cache` directory that holds compiled JS output.
|
|
35
|
+
*
|
|
36
|
+
* Creates a `.gitignore` file that excludes the `.cache` sub-directory and a
|
|
37
|
+
* `README.md` inside `.cache` that explains its purpose.
|
|
38
|
+
*
|
|
39
|
+
* @param destination - The root output directory.
|
|
40
|
+
*/
|
|
41
|
+
async buildCacheDirectory(destination) {
|
|
42
|
+
const gitignorePath = nodePath.join(destination, ".gitignore");
|
|
43
|
+
const cacheReadmePath = nodePath.join(destination, ".cache", "README.md");
|
|
44
|
+
debug("ensuring the directory containing .gitgnore exists");
|
|
45
|
+
await ensureDirectoryExists(gitignorePath);
|
|
46
|
+
debug("creating the .gitignore file if it doesn't already exist");
|
|
47
|
+
if (!existsSync(gitignorePath)) {
|
|
48
|
+
await fs.writeFile(gitignorePath, ".cache\n", "utf8");
|
|
49
|
+
}
|
|
50
|
+
debug("creating the .cache/README.md file");
|
|
51
|
+
ensureDirectoryExists(cacheReadmePath);
|
|
52
|
+
await fs.writeFile(cacheReadmePath, "This directory contains compiled JS files from the paths directory. Do not edit these files directly.\n", "utf8");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Reads and returns the `#/paths` requirement from `specification`.
|
|
56
|
+
*
|
|
57
|
+
* Writes a diagnostic message to stderr and returns an empty set when the
|
|
58
|
+
* `paths` key is missing or cannot be read.
|
|
59
|
+
*
|
|
60
|
+
* @param specification - The loaded OpenAPI specification.
|
|
61
|
+
*/
|
|
62
|
+
async getPathsFromSpecification(specification) {
|
|
63
|
+
try {
|
|
64
|
+
return specification.getRequirement("#/paths") ?? new Set();
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
process.stderr.write(`Could not find #/paths in the specification.\n${error}\n`);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Runs the main code-generation pipeline once and resolves when complete.
|
|
73
|
+
*
|
|
74
|
+
* Loads the OpenAPI spec from `openapiPath`, optionally prunes defunct route
|
|
75
|
+
* files, registers all path operations as {@link OperationCoder} exports, and
|
|
76
|
+
* writes the resulting TypeScript files to `destination`.
|
|
77
|
+
*
|
|
78
|
+
* @param repository - Injectable repository instance; defaults to a fresh one
|
|
79
|
+
* (primarily useful in tests).
|
|
80
|
+
*/
|
|
81
|
+
async generate(repository = new Repository()) {
|
|
82
|
+
const { destination } = this;
|
|
83
|
+
debug("generating code from %s to %s", this.openapiPath, destination);
|
|
84
|
+
debug("initializing the .cache directory");
|
|
85
|
+
await this.buildCacheDirectory(destination);
|
|
86
|
+
debug("done initializing the .cache directory");
|
|
87
|
+
debug("creating specification from %s", this.openapiPath);
|
|
88
|
+
const specification = await Specification.fromFile(this.openapiPath);
|
|
89
|
+
debug("created specification: $o", specification);
|
|
90
|
+
debug("reading the #/paths from the specification");
|
|
91
|
+
const paths = await this.getPathsFromSpecification(specification);
|
|
92
|
+
debug("got %i paths", paths?.map?.length ?? 0);
|
|
93
|
+
if (this.generateOptions.prune && this.generateOptions.routes) {
|
|
94
|
+
debug("pruning defunct route files");
|
|
95
|
+
await pruneRoutes(destination, paths.map((_v, key) => key));
|
|
96
|
+
debug("done pruning");
|
|
97
|
+
}
|
|
98
|
+
const securityRequirement = specification.getRequirement("#/components/securitySchemes");
|
|
99
|
+
const securitySchemes = Object.values(securityRequirement?.data ?? {});
|
|
100
|
+
const HTTP_VERBS = new Set([
|
|
101
|
+
"get",
|
|
102
|
+
"put",
|
|
103
|
+
"post",
|
|
104
|
+
"delete",
|
|
105
|
+
"options",
|
|
106
|
+
"head",
|
|
107
|
+
"patch",
|
|
108
|
+
"trace",
|
|
109
|
+
]);
|
|
110
|
+
paths.forEach((pathDefinition, key) => {
|
|
111
|
+
debug("processing path %s", key);
|
|
112
|
+
const path = key === "/" ? "/index" : key;
|
|
113
|
+
pathDefinition.forEach((operation, requestMethod) => {
|
|
114
|
+
if (!HTTP_VERBS.has(requestMethod)) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
repository
|
|
118
|
+
.get(`routes${path}.ts`)
|
|
119
|
+
.export(new OperationCoder(operation, requestMethod, securitySchemes));
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
debug("telling the repository to write the files to %s", destination);
|
|
123
|
+
await repository.writeFiles(destination, this.generateOptions);
|
|
124
|
+
debug("finished writing the files");
|
|
18
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Starts watching the OpenAPI document for changes.
|
|
128
|
+
*
|
|
129
|
+
* Has no effect when `openApiPath` is a URL (HTTP sources are not watched).
|
|
130
|
+
* Resolves once the watcher is ready.
|
|
131
|
+
*/
|
|
19
132
|
async watch() {
|
|
20
133
|
if (this.openapiPath.startsWith("http")) {
|
|
21
134
|
return;
|
|
22
135
|
}
|
|
23
136
|
this.watcher = watch(this.openapiPath, CHOKIDAR_OPTIONS).on("change", () => {
|
|
24
|
-
void generate(
|
|
137
|
+
void this.generate().then(() => {
|
|
25
138
|
this.dispatchEvent(new Event("generate"));
|
|
26
139
|
return true;
|
|
27
140
|
}, () => {
|
|
@@ -31,6 +144,7 @@ export class CodeGenerator extends EventTarget {
|
|
|
31
144
|
});
|
|
32
145
|
await waitForEvent(this.watcher, "ready");
|
|
33
146
|
}
|
|
147
|
+
/** Closes the file-system watcher. */
|
|
34
148
|
async stopWatching() {
|
|
35
149
|
await this.watcher?.close();
|
|
36
150
|
}
|
|
@@ -1,30 +1,84 @@
|
|
|
1
1
|
import { RESERVED_WORDS } from "./reserved-words.js";
|
|
2
|
+
/**
|
|
3
|
+
* Base class for all code-generation helpers in the TypeScript generator.
|
|
4
|
+
*
|
|
5
|
+
* A `Coder` wraps a single {@link Requirement} node from the OpenAPI spec and
|
|
6
|
+
* knows how to emit TypeScript code for it. Subclasses override
|
|
7
|
+
* {@link writeCode} to produce the actual source text.
|
|
8
|
+
*
|
|
9
|
+
* Coders are used by {@link Script} and {@link Repository} to lazily generate
|
|
10
|
+
* exports and imports, resolving `$ref` references through the
|
|
11
|
+
* {@link Specification} before writing.
|
|
12
|
+
*/
|
|
2
13
|
export class Coder {
|
|
3
14
|
requirement;
|
|
4
15
|
constructor(requirement) {
|
|
5
16
|
this.requirement = requirement;
|
|
6
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* A stable cache key for this coder, composed of the constructor name and
|
|
20
|
+
* either the `$ref` value (for references) or the requirement URL.
|
|
21
|
+
*/
|
|
7
22
|
get id() {
|
|
8
23
|
if (this.requirement.isReference) {
|
|
9
24
|
return `${this.constructor.name}@${this.requirement.data["$ref"]}`;
|
|
10
25
|
}
|
|
11
26
|
return `${this.constructor.name}@${this.requirement.url}`;
|
|
12
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Optional preamble emitted before the `export` keyword.
|
|
30
|
+
*
|
|
31
|
+
* Subclasses can return a string (e.g. a type alias) that must appear in the
|
|
32
|
+
* output before this coder's export statement.
|
|
33
|
+
*
|
|
34
|
+
* @param _path - The path of the script being written (unused in base class).
|
|
35
|
+
*/
|
|
13
36
|
beforeExport(_path) {
|
|
14
37
|
return "";
|
|
15
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Returns a JSDoc comment block to be placed immediately before the export.
|
|
41
|
+
*
|
|
42
|
+
* Returns `""` by default; subclasses override this to surface OpenAPI
|
|
43
|
+
* metadata (description, summary, examples, etc.).
|
|
44
|
+
*/
|
|
16
45
|
jsdoc() {
|
|
17
46
|
return "";
|
|
18
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Writes this coder's contribution to `script`.
|
|
50
|
+
*
|
|
51
|
+
* When the requirement is a `$ref`, delegates to {@link Script.import} so
|
|
52
|
+
* the reference target is exported from its own module. Otherwise calls
|
|
53
|
+
* {@link writeCode}.
|
|
54
|
+
*
|
|
55
|
+
* @param script - The script being assembled.
|
|
56
|
+
* @returns The TypeScript source text for this coder's export value.
|
|
57
|
+
*/
|
|
19
58
|
write(script) {
|
|
20
59
|
if (this.requirement.isReference) {
|
|
21
60
|
return script.import(this);
|
|
22
61
|
}
|
|
23
62
|
return this.writeCode(script);
|
|
24
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Generates the TypeScript source text for this coder's value.
|
|
66
|
+
*
|
|
67
|
+
* This method is abstract — subclasses **must** override it.
|
|
68
|
+
*
|
|
69
|
+
* @param _script - The script being assembled.
|
|
70
|
+
* @throws Always — callers should never reach the base implementation.
|
|
71
|
+
*/
|
|
25
72
|
writeCode(_script) {
|
|
26
73
|
throw new Error("write() is abstract and should be overwritten by a subclass");
|
|
27
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Resolves `$ref` references by returning the target coder.
|
|
77
|
+
*
|
|
78
|
+
* When this coder's requirement is not a reference, returns `this`.
|
|
79
|
+
* Otherwise loads the referenced requirement and wraps it in an instance of
|
|
80
|
+
* the same concrete coder class.
|
|
81
|
+
*/
|
|
28
82
|
async delegate() {
|
|
29
83
|
if (!this.requirement.isReference) {
|
|
30
84
|
return this;
|
|
@@ -32,6 +86,15 @@ export class Coder {
|
|
|
32
86
|
const requirement = await this.requirement.reference();
|
|
33
87
|
return new this.constructor(requirement);
|
|
34
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Generator that yields candidate export names for this coder.
|
|
91
|
+
*
|
|
92
|
+
* The first name is derived from the last path segment of the requirement
|
|
93
|
+
* URL, sanitised to be a valid TypeScript identifier. Subsequent names have
|
|
94
|
+
* an incrementing numeric suffix to resolve collisions.
|
|
95
|
+
*
|
|
96
|
+
* @param rawName - Override the starting name (used by subclasses).
|
|
97
|
+
*/
|
|
35
98
|
*names(rawName = this.requirement.url.split("/").at(-1)) {
|
|
36
99
|
const name = rawName
|
|
37
100
|
.replace(/^\d/u, (digit) => `_${digit}`)
|
|
@@ -45,9 +108,22 @@ export class Coder {
|
|
|
45
108
|
yield baseName + index;
|
|
46
109
|
}
|
|
47
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Returns an optional TypeScript type annotation string to be placed between
|
|
113
|
+
* the export name and its value (`export const name: <type> = value`).
|
|
114
|
+
*
|
|
115
|
+
* Returns `""` by default.
|
|
116
|
+
*/
|
|
48
117
|
typeDeclaration(_namespace, _script) {
|
|
49
118
|
return "";
|
|
50
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Returns the repository-relative path of the script where this coder's
|
|
122
|
+
* export should live when imported by another script.
|
|
123
|
+
*
|
|
124
|
+
* Subclasses override this to place type exports in `types/paths/…` and
|
|
125
|
+
* route exports in `routes/…`.
|
|
126
|
+
*/
|
|
51
127
|
modulePath() {
|
|
52
128
|
return "did-not-override-coder-modulePath.ts";
|
|
53
129
|
}
|