UNPKG

counterfact

Version:

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

104 lines (103 loc) 3.96 kB
import fs from "node:fs/promises"; import nodePath from "node:path"; /* eslint-disable security/detect-non-literal-fs-filename -- pruning only traverses and removes files under destination/routes. */ import createDebug from "debug"; import { toForwardSlashPath } from "../util/forward-slash-path.js"; const debug = createDebug("counterfact:typescript-generator:prune"); /** * Collects all .ts route files in a directory recursively. * Context files (_.context.ts) are excluded. * @param routesDir - Path to routes directory * @param currentPath - Current subdirectory being processed (relative to routesDir) * @returns Array of relative paths (using forward slashes) */ async function collectRouteFiles(routesDir, currentPath = "") { const files = []; try { const fullDir = currentPath ? nodePath.join(routesDir, currentPath) : routesDir; const entries = await fs.readdir(fullDir, { withFileTypes: true }); for (const entry of entries) { const relativePath = currentPath ? `${currentPath}/${entry.name}` : entry.name; if (entry.isDirectory()) { files.push(...(await collectRouteFiles(routesDir, relativePath))); } else if (entry.name.endsWith(".ts") && entry.name !== "_.context.ts") { files.push(relativePath); } } } catch (error) { if (error.code !== "ENOENT") { throw error; } } return files; } /** * Recursively removes empty directories under rootDir, but not rootDir itself. * @param dir - Directory to check * @param rootDir - Root directory that should never be removed */ async function removeEmptyDirectories(dir, rootDir) { let entries; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch (error) { debug("could not read directory %s: %o", dir, error); return; } for (const entry of entries) { if (entry.isDirectory()) { await removeEmptyDirectories(nodePath.join(dir, entry.name), rootDir); } } if (nodePath.resolve(dir) === nodePath.resolve(rootDir)) { return; } const remaining = await fs.readdir(dir); if (remaining.length === 0) { await fs.rmdir(dir); debug("removed empty directory: %s", dir); } } /** * Converts an OpenAPI path to the expected route file path (relative to routesDir). * e.g. "/pet/{id}" -> "pet/{id}.ts", "/" -> "index.ts" * @param openApiPath - The OpenAPI path string */ function openApiPathToRouteFile(openApiPath) { const filePath = openApiPath === "/" ? "index" : openApiPath.slice(1); return `${filePath}.ts`; } /** * Prunes route files that no longer correspond to any path in the OpenAPI spec. * Context files (_.context.ts) are never pruned. * @param destination - Base destination directory (contains the routes/ sub-directory) * @param openApiPaths - Iterable of OpenAPI path strings (e.g. "/pet/{id}") * @returns Number of files removed */ export async function pruneRoutes(destination, openApiPaths) { const routesDir = nodePath.join(destination, "routes"); const expectedFiles = new Set(Array.from(openApiPaths).map(openApiPathToRouteFile)); debug("expected route files: %o", Array.from(expectedFiles)); const actualFiles = await collectRouteFiles(routesDir); debug("actual route files: %o", actualFiles); let prunedCount = 0; for (const file of actualFiles) { const normalizedFile = toForwardSlashPath(file); if (!expectedFiles.has(normalizedFile)) { const fullPath = nodePath.join(routesDir, file); debug("pruning %s", fullPath); await fs.rm(fullPath); prunedCount++; } } await removeEmptyDirectories(routesDir, routesDir); debug("pruned %d files", prunedCount); return prunedCount; }