counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
169 lines (168 loc) • 7.28 kB
JavaScript
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import nodePath from "node:path";
/* eslint-disable security/detect-non-literal-fs-filename -- generated files are written under the caller-provided destination tree. */
import { watch } from "chokidar";
import createDebug from "debug";
import { CHOKIDAR_OPTIONS } from "../server/constants.js";
import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
import { waitForEvent } from "../util/wait-for-event.js";
import { OperationCoder } from "./operation-coder.js";
import { pruneRoutes } from "./prune.js";
import { Repository } from "./repository.js";
import { Specification } from "./specification.js";
const debug = createDebug("counterfact:typescript-generator:generate");
/**
* Orchestrates the code-generation pipeline and optional file-system watching.
*
* When {@link watch} is called, Counterfact watches the source OpenAPI document
* for changes and re-runs code generation automatically. `"generate"` and
* `"failed"` events are emitted after each attempt.
*/
export class CodeGenerator extends EventTarget {
openapiPath;
destination;
version;
overlays;
generateOptions;
watcher;
constructor(openApiPath, destination, generateOptions, version = "", overlays = []) {
super();
this.openapiPath = openApiPath;
this.destination = destination;
this.version = version;
this.overlays = overlays;
this.generateOptions = generateOptions;
}
/**
* Initialises the `.cache` directory that holds compiled JS output.
*
* Creates a `.gitignore` file that excludes the `.cache` sub-directory and a
* `README.md` inside `.cache` that explains its purpose.
*
* @param destination - The root output directory.
*/
async buildCacheDirectory(destination) {
const gitignorePath = nodePath.join(destination, ".gitignore");
const cacheReadmePath = nodePath.join(destination, ".cache", "README.md");
debug("ensuring the directory containing .gitgnore exists");
await ensureDirectoryExists(gitignorePath);
debug("creating the .gitignore file if it doesn't already exist");
if (!existsSync(gitignorePath)) {
await fs.writeFile(gitignorePath, ".cache\n", "utf8");
}
debug("creating the .cache/README.md file");
ensureDirectoryExists(cacheReadmePath);
await fs.writeFile(cacheReadmePath, "This directory contains compiled JS files from the paths directory. Do not edit these files directly.\n", "utf8");
}
/**
* Reads and returns the `#/paths` requirement from `specification`.
*
* Writes a diagnostic message to stderr and returns an empty set when the
* `paths` key is missing or cannot be read.
*
* @param specification - The loaded OpenAPI specification.
*/
async getPathsFromSpecification(specification) {
try {
return specification.getRequirement("#/paths") ?? new Set();
}
catch (error) {
process.stderr.write(`Could not find #/paths in the specification.\n${error}\n`);
return undefined;
}
}
/**
* Runs the main code-generation pipeline once and resolves when complete.
*
* Loads the OpenAPI spec from `openapiPath`, optionally prunes defunct route
* files, registers all path operations as {@link OperationCoder} exports, and
* writes the resulting TypeScript files to `destination`.
*
* @param repository - Injectable repository instance; defaults to a fresh one
* (primarily useful in tests).
*/
async generate(repository = new Repository()) {
const { destination } = this;
debug("generating code from %s to %s", this.openapiPath, destination);
debug("initializing the .cache directory");
await this.buildCacheDirectory(destination);
debug("done initializing the .cache directory");
debug("creating specification from %s", this.openapiPath);
const specification = await Specification.fromFile(this.openapiPath, this.overlays);
debug("created specification: $o", specification);
debug("reading the #/paths from the specification");
const paths = await this.getPathsFromSpecification(specification);
debug("got %i paths", paths?.map?.length ?? 0);
if (this.generateOptions.prune && this.generateOptions.routes) {
debug("pruning defunct route files");
await pruneRoutes(destination, paths.map((_v, key) => key));
debug("done pruning");
}
const securityRequirement = specification.getRequirement("#/components/securitySchemes");
const securitySchemes = Object.values(securityRequirement?.data ?? {});
const HTTP_VERBS = new Set([
"get",
"put",
"post",
"delete",
"options",
"head",
"patch",
"trace",
]);
const operationMethodsForPath = (pathDefinition) => pathDefinition.flatMap((operation, requestMethod) => {
if (requestMethod === "additionalOperations") {
return operation.map((additionalOperation, additionalMethod) => [
additionalOperation,
additionalMethod.toLowerCase(),
]);
}
if (!HTTP_VERBS.has(requestMethod)) {
return [];
}
return [[operation, requestMethod]];
});
paths.forEach((pathDefinition, key) => {
debug("processing path %s", key);
const path = key === "/" ? "/index" : key;
operationMethodsForPath(pathDefinition).forEach(([operation, requestMethod]) => {
repository
.get(`routes${path}.ts`)
.export(new OperationCoder(operation, this.version, requestMethod, securitySchemes));
});
});
debug("telling the repository to write the files to %s", destination);
await repository.writeFiles(destination, this.generateOptions);
debug("finished writing the files");
}
/**
* Starts watching the OpenAPI document and any overlay files for changes.
*
* Has no effect when neither source is watchable (for example, an HTTP
* OpenAPI source with no local overlays).
* Resolves once the watcher is ready.
*/
async watch() {
const watchablePaths = this.openapiPath.startsWith("http")
? [...this.overlays]
: [this.openapiPath, ...this.overlays];
if (watchablePaths.length === 0) {
return;
}
this.watcher = watch(watchablePaths, CHOKIDAR_OPTIONS).on("change", () => {
void this.generate().then(() => {
this.dispatchEvent(new Event("generate"));
return true;
}, () => {
this.dispatchEvent(new Event("failed"));
return false;
});
});
await waitForEvent(this.watcher, "ready");
}
/** Closes the file-system watcher. */
async stopWatching() {
await this.watcher?.close();
}
}