UNPKG

counterfact

Version:

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

283 lines (282 loc) 11.6 kB
import { promises as fs } from "node:fs"; import path from "node:path"; /* eslint-disable security/detect-non-literal-fs-filename -- migration reads/writes discovered route files under the configured basePath/routes tree. */ import createDebug from "debug"; import { toForwardSlashPath } from "../util/forward-slash-path.js"; import { OperationTypeCoder, } from "../typescript-generator/operation-type-coder.js"; import { Specification } from "../typescript-generator/specification.js"; const debug = createDebug("counterfact:migrate:update-route-types"); const HTTP_METHODS = [ "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "QUERY", ]; // Pre-compile regex patterns derived from HTTP_METHODS const HTTP_METHOD_ALTERNATION = HTTP_METHODS.join("|"); // eslint-disable-next-line security/detect-non-literal-regexp const NEEDS_MIGRATION_REGEX = new RegExp(`import\\s+type\\s+\\{[^}]*HTTP_(?:${HTTP_METHOD_ALTERNATION})[^}]*\\}`, "iu"); // eslint-disable-next-line security/detect-non-literal-regexp const HTTP_TYPE_NAME_REGEX = new RegExp(`^HTTP_(?<method>${HTTP_METHOD_ALTERNATION})$`, "u"); // Pre-build import/export replacement patterns for each HTTP method type name const IMPORT_REPLACE_PATTERNS = new Map(HTTP_METHODS.map((method) => [ `HTTP_${method}`, // eslint-disable-next-line security/detect-non-literal-regexp new RegExp(`(import\\s+type\\s+\\{[^}]*\\b)HTTP_${method}(\\b[^}]*\\}\\s+from)`, "g"), ])); const EXPORT_REPLACE_PATTERNS = new Map(HTTP_METHODS.map((method) => [ `HTTP_${method}`, // eslint-disable-next-line security/detect-non-literal-regexp new RegExp(`(export\\s+const\\s+${method}\\s*:\\s*)HTTP_${method}(\\b)`, "g"), ])); /** * Converts an OpenAPI path to a file system path * e.g., "/hello/{name}" -> "hello/{name}" */ function openApiPathToFilePath(openApiPath) { if (openApiPath === "/") { return "index"; } return openApiPath.startsWith("/") ? openApiPath.slice(1) : openApiPath; } /** * Builds a mapping of route file paths to their operation type names per method * @param specification - The OpenAPI specification * @returns Map of filePath -> Map of method -> typeName */ async function buildTypeNameMapping(specification) { debug("building type name mapping from specification"); const mapping = new Map(); try { const paths = specification.getRequirement("#/paths"); if (!paths) { debug("no paths found in specification"); return mapping; } const securityRequirement = specification.getRequirement("#/components/securitySchemes"); const securitySchemes = Object.values(securityRequirement?.data ?? {}); paths.forEach((pathDefinition, openApiPath) => { const filePath = openApiPathToFilePath(openApiPath); const methodMap = new Map(); pathDefinition.forEach((operation, requestMethod) => { // Skip if not a standard HTTP method if (!HTTP_METHODS.includes(requestMethod.toUpperCase())) { return; } // Create the type coder to get the correct type name const typeCoder = new OperationTypeCoder(operation, "", requestMethod, securitySchemes); // Get the type name (first from the names generator) const typeName = typeCoder.names().next().value; methodMap.set(requestMethod.toUpperCase(), typeName); debug("mapped %s %s -> %s", requestMethod.toUpperCase(), openApiPath, typeName); }); if (methodMap.size > 0) { mapping.set(filePath, methodMap); } }); debug("built mapping for %d routes", mapping.size); } catch (error) { debug("error building type name mapping: %o", error); throw error; } return mapping; } /** * Checks if a route file needs migration by looking for old-style HTTP_ imports * @param content - The file content */ function needsMigration(content) { return NEEDS_MIGRATION_REGEX.test(content); } /** * Updates a single route file with the correct type names * @param filePath - Absolute path to the route file * @param methodToTypeName - Map of HTTP method to type name * @returns True if file was updated */ async function updateRouteFile(filePath, methodToTypeName) { debug("processing route file: %s", filePath); let content = await fs.readFile(filePath, "utf8"); // Check if migration is needed if (!needsMigration(content)) { debug("file does not need migration: %s", filePath); return false; } let modified = false; // Build a map of old type names to new type names found in this file const replacements = new Map(); // Find all import statements with HTTP_ patterns const importRegex = /import\s+type\s+\{(?<types>[^}]+)\}\s+from\s+["'][^"']+["'];?/gu; let importMatch; while ((importMatch = importRegex.exec(content)) !== null) { const importedTypes = (importMatch.groups?.["types"] ?? "") .split(",") .map((t) => t.trim()) .filter((t) => t.length > 0); for (const importedType of importedTypes) { // Check if this is an HTTP_ type const httpMethodMatch = importedType.match(HTTP_TYPE_NAME_REGEX); if (httpMethodMatch) { const method = httpMethodMatch.groups?.["method"] ?? ""; const newTypeName = methodToTypeName.get(method); if (newTypeName && newTypeName !== importedType) { replacements.set(importedType, newTypeName); debug("will replace %s with %s", importedType, newTypeName); } } } } if (replacements.size === 0) { debug("no replacements needed for: %s", filePath); return false; } // Apply replacements for (const [oldName, newName] of replacements.entries()) { // Replace in import statement const importPattern = IMPORT_REPLACE_PATTERNS.get(oldName); if (importPattern) { importPattern.lastIndex = 0; content = content.replace(importPattern, `$1${newName}$2`); } // Replace in export statement (e.g., "export const GET: HTTP_GET") // Match the method from the old type name const methodMatch = oldName.match(HTTP_TYPE_NAME_REGEX); if (methodMatch) { const exportPattern = EXPORT_REPLACE_PATTERNS.get(oldName); if (exportPattern) { exportPattern.lastIndex = 0; content = content.replace(exportPattern, `$1${newName}$2`); } } modified = true; } if (modified) { await fs.writeFile(filePath, content, "utf8"); debug("updated file: %s", filePath); } return modified; } /** * Recursively processes route files in a directory * @param routesDir - Path to routes directory * @param currentPath - Current subdirectory being processed * @param mapping - Type name mapping * @returns Number of files updated */ async function processRouteDirectory(routesDir, currentPath, mapping) { let updatedCount = 0; try { const entries = await fs.readdir(path.join(routesDir, currentPath), { withFileTypes: true, }); for (const entry of entries) { const relativePath = path.join(currentPath, entry.name); const absolutePath = path.join(routesDir, relativePath); if (entry.isDirectory()) { // Recursively process subdirectories updatedCount += await processRouteDirectory(routesDir, relativePath, mapping); } else if (entry.name.endsWith(".ts") && entry.name !== "_.context.ts") { // Process TypeScript route files (skip context files) const routePath = toForwardSlashPath(relativePath.replace(/\.ts$/, "")); const methodMap = mapping.get(routePath); if (methodMap) { const wasUpdated = await updateRouteFile(absolutePath, methodMap); if (wasUpdated) { updatedCount++; } } } } } catch (error) { if (error.code !== "ENOENT") { debug("error processing directory %s: %o", currentPath, error); } } return updatedCount; } /** * Checks if any route files need migration * @param routesDir - Path to routes directory */ async function checkIfMigrationNeeded(routesDir) { try { const entries = await fs.readdir(routesDir, { withFileTypes: true }); for (const entry of entries) { if (entry.name.endsWith(".ts") && entry.name !== "_.context.ts") { const content = await fs.readFile(path.join(routesDir, entry.name), "utf8"); if (needsMigration(content)) { return true; } } else if (entry.isDirectory() && entry.name !== "node_modules") { // Recursively check subdirectories const subDirPath = path.join(routesDir, entry.name); const found = await checkIfMigrationNeeded(subDirPath); if (found) { return true; } } } } catch (error) { debug("error checking for migration need: %o", error); } return false; } /** * Main migration function - updates route type imports to use new naming convention * @param basePath - Base path where routes and types are located * @param openApiPath - Path or URL to OpenAPI specification * @returns True if migration was performed */ export async function updateRouteTypes(basePath, openApiPath) { debug("starting route type migration for base path: %s", basePath); // Skip if running without OpenAPI spec if (openApiPath === "_") { debug("skipping migration - no OpenAPI spec provided"); return false; } const routesDir = path.join(basePath, "routes"); try { // Check if routes directory exists await fs.access(routesDir); } catch { debug("routes directory does not exist: %s", routesDir); return false; } // Quick check if migration is needed const migrationNeeded = await checkIfMigrationNeeded(routesDir); if (!migrationNeeded) { debug("no migration needed - no old-style HTTP_ imports found"); return false; } try { // Load the OpenAPI specification debug("loading OpenAPI specification from: %s", openApiPath); const specification = await Specification.fromFile(openApiPath); // Build the mapping of paths to type names const mapping = await buildTypeNameMapping(specification); if (mapping.size === 0) { debug("no routes found in specification"); return false; } // Process all route files debug("processing route files in: %s", routesDir); const updatedCount = await processRouteDirectory(routesDir, "", mapping); debug("migration complete - updated %d files", updatedCount); return updatedCount > 0; } catch (error) { debug("error during migration: %o", error); process.stderr.write(`Warning: Could not migrate route types: ${error.message}\n`); return false; } }