UNPKG

counterfact

Version:

Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.

323 lines (322 loc) 12.7 kB
import createDebugger from "debug"; import { format } from "prettier"; import { escapePathForWindows } from "../util/windows-escape.js"; import { pathJoin, pathRelative, pathDirname, } from "../util/forward-slash-path.js"; const debug = createDebugger("counterfact:typescript-generator:script"); /** * Represents a single TypeScript file being assembled by the code generator. * * A `Script` accumulates exports, imports, and external imports contributed by * {@link Coder} instances. Once all coders have resolved, {@link contents} * formats the result with Prettier and returns the final source text. * * Scripts are created and retrieved through a {@link Repository} so that the * same module path always maps to the same `Script` instance. */ export class Script { repository; comments; exports; versions; versionFormatters; imports; externalImport; cache; typeCache; path; constructor(repository, path) { this.repository = repository; this.comments = []; this.exports = new Map(); this.versions = new Map(); this.versionFormatters = new Map(); this.imports = new Map(); this.externalImport = new Map(); this.cache = new Map(); this.typeCache = new Map(); this.path = path; } /** * A `"../"` path fragment that points from this script's directory back to * the repository root, used to resolve relative import paths. */ get relativePathToBase() { return this.path .split("/") .slice(0, -1) .map(() => "..") .join("/"); } /** * Picks the first name from `coder.names()` that is not already used as an * import in this script, ensuring export/import name uniqueness. * * @param coder - The coder needing a name. * @throws When all 100 candidate names are already taken. */ firstUniqueName(coder) { for (const name of coder.names()) { if (!this.imports.has(name)) { return name; } } throw new Error(`could not find a unique name for ${coder.id}`); } /** * Registers an export for `coder` in this script and returns the export name. * * If the same coder has already been exported (cache hit), the previously * assigned name is returned without creating a duplicate. * * @param coder - The coder to export. * @param isType - Emit `export type` instead of `export const`. * @param isDefault - Emit `export default` instead of a named export. * @returns The name under which the coder is exported. */ export(coder, isType = false, isDefault = false) { const cacheKey = isDefault ? "default" : `${coder.id}:${isType}`; if (this.cache.has(cacheKey)) { return this.cache.get(cacheKey); } const name = this.firstUniqueName(coder); this.cache.set(cacheKey, name); const exportStatement = { beforeExport: coder.beforeExport(this.path), done: false, id: coder.id, isDefault, isType, jsdoc: "", typeDeclaration: coder.typeDeclaration(this.exports, this), }; exportStatement.promise = coder .delegate() .then((availableCoder) => { exportStatement.name = name; exportStatement.code = availableCoder.write(this); exportStatement.jsdoc = availableCoder.jsdoc(); return availableCoder; }) .catch((error) => { exportStatement.code = `{/* error creating export "${name}" for ${this.path}: ${error.stack} */}`; exportStatement.error = error; return undefined; }) .finally(() => { exportStatement.done = true; }); this.exports.set(name, exportStatement); return name; } exportDefault(coder, isType = false) { this.export(coder, isType, true); } /** * Registers an import of `coder` from its owning module and returns the * local alias used in this script. * * The coder is also exported from its home module as a side effect. * * @param coder - The coder to import. * @param isType - Use a `import type` declaration. * @param isDefault - Import the default export rather than a named export. */ import(coder, isType = false, isDefault = false) { debug("import coder: %s", coder.id); const modulePath = coder.modulePath(); const cacheKey = `${coder.id}@${modulePath}:${isType}:${isDefault}`; debug("cache key: %s", cacheKey); if (this.cache.has(cacheKey)) { debug("cache hit: %s", cacheKey); return this.cache.get(cacheKey); } debug("cache miss: %s", cacheKey); const name = this.firstUniqueName(coder); this.cache.set(cacheKey, name); const scriptFromWhichToExport = this.repository.get(modulePath); const exportedName = scriptFromWhichToExport.export(coder, isType, isDefault); this.imports.set(name, { isDefault, isType, name: exportedName, script: scriptFromWhichToExport, }); return name; } importType(coder) { return this.import(coder, true); } importDefault(coder, isType = false) { return this.import(coder, isType, true); } /** * Registers an import from an external npm package or absolute module path * (not managed by the repository). * * @param name - The local binding name. * @param modulePath - The module specifier (e.g. `"counterfact-types/index"`). * @param isType - Use a `import type` declaration. * @returns `name` (for convenience in method chaining). */ importExternal(name, modulePath, isType = false) { this.externalImport.set(name, { isType, modulePath }); return name; } /** * Convenience wrapper that calls {@link importExternal} with `isType = true`. */ importExternalType(name, modulePath) { return this.importExternal(name, modulePath, true); } /** * Imports a type from the shared `counterfact-types/index.ts` module, * resolving the path relative to this script's location in the repository. * * @param name - The type name to import (e.g. `"WideOperationArgument"`). */ importSharedType(name) { return this.importExternal(name, pathJoin(this.relativePathToBase, "counterfact-types/index.ts"), true); } /** * Imports a type from the generated `types/versions.ts` module, * resolving the path relative to this script's location in the repository. * * @param name - The type name to import (e.g. `"Versioned"`). */ importVersionsType(name) { return this.importExternal(name, pathJoin(this.relativePathToBase, "types/versions.ts"), true); } exportType(coder) { return this.export(coder, true); } /** * Registers a formatter function for the merged versioned type emitted under * `name` by {@link versionsTypeStatements}. * * When a formatter is present for a name, `versionsTypeStatements` delegates * the entire type declaration to it instead of generating the default * `Versions` object type. The formatter receives a `Map<version, importAlias>` * and must return the complete TypeScript source for that operation type. */ setVersionFormatter(name, formatter) { this.versionFormatters.set(name, formatter); } declareVersion(coder, name) { const version = coder.version; const versions = this.versions.get(name) ?? new Map(); this.versions.set(name, versions); if (versions.has(version)) { return; } const versionStatement = { beforeExport: "", done: false, id: coder.id, isDefault: false, isType: true, jsdoc: "", typeDeclaration: "", }; versionStatement.promise = coder .delegate() .then((availableCoder) => { versionStatement.code = availableCoder.write(this); return availableCoder; }) .catch((error) => { versionStatement.code = `unknown /* error declaring version "${name}" (${version}) for ${this.path}: ${error.message} */`; versionStatement.error = error; return undefined; }) .finally(() => { versionStatement.done = true; }); versions.set(version, versionStatement); } /** `true` while at least one export promise is still pending. */ isInProgress() { return (Array.from(this.exports.values()).some((exportStatement) => !exportStatement.done) || Array.from(this.versions.values()) .flatMap((versions) => Array.from(versions.values())) .some((versionStatement) => !versionStatement.done)); } /** Returns a promise that resolves when all pending export promises settle. */ finished() { return Promise.all([ ...Array.from(this.exports.values(), (value) => value.promise), ...Array.from(this.versions.values()) .flatMap((versions) => Array.from(versions.values())) .map((value) => value.promise), ]); } externalImportStatements() { return Array.from(this.externalImport, ([name, { isDefault, isType, modulePath }]) => `import${isType ? " type" : ""} ${isDefault ? name : `{ ${name} }`} from "${modulePath}";`); } importStatements() { return Array.from(this.imports, ([name, { isDefault, isType, script }]) => { const resolvedPath = escapePathForWindows(pathRelative(pathDirname(this.path), script.path.replace(/\.ts$/u, ".js"))); return `import${isType ? " type" : ""} ${isDefault ? name : `{ ${name} }`} from "${resolvedPath.includes("../") ? "" : "./"}${resolvedPath}";`; }); } exportStatements() { return Array.from(this.exports.values(), ({ beforeExport, code, isDefault, isType, jsdoc, name, typeDeclaration, }) => { if (typeof code === "object" && code !== null && "raw" in code) { return code.raw; } if (isDefault) { return `${jsdoc}${beforeExport}export default ${code};`; } const keyword = isType ? "type" : "const"; const typeAnnotation = (typeDeclaration ?? "").length === 0 ? "" : `:${typeDeclaration ?? ""}`; return `${jsdoc}${beforeExport}export ${keyword} ${name ?? ""}${typeAnnotation} = ${code};`; }); } versionsTypeStatements() { if (this.versions.size === 0) { return []; } const statements = []; const unformatted = []; for (const [name, versions] of this.versions) { const formatter = this.versionFormatters.get(name); if (formatter) { const versionCodes = new Map(Array.from(versions, ([version, stmt]) => [ version, stmt.code, ])); statements.push(formatter(versionCodes)); } else { unformatted.push([name, versions]); } } if (unformatted.length > 0) { const names = unformatted.map(([name, versions]) => { const mappedVersions = Array.from(versions, ([version, versionStatement]) => `"${version}": ${versionStatement.code}`); return `"${name}": { ${mappedVersions.join(", ")} }`; }); statements.push(`export type Versions = { ${names.join(", ")} };`); } return statements; } /** * Formats the fully assembled script source with Prettier and returns it. * * All pending export promises are awaited before formatting. */ async contents() { await this.finished(); return format([ this.comments.map((comment) => `// ${comment}`).join("\n"), this.comments.length > 0 ? "\n\n" : "", this.externalImportStatements().join("\n"), this.importStatements().join("\n"), "\n\n", this.versionsTypeStatements().join("\n"), this.versions.size > 0 ? "\n\n" : "", this.exportStatements().join("\n\n"), ].join(""), { parser: "typescript" }); } }