Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/check-api-inventory.ts
T

171 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
/* eslint-disable no-console */
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { scanApiRoutesFromFile, type ApiRouteKey } from "../tools/api-inventory/scan-api-routes.js";
export const DEPRECATED_API_ROUTE_KEYS = ["GET /v1/auth/verify-token", "GET /v1/auth/me", "POST /v1/ai/upload"] as const satisfies readonly ApiRouteKey[];
export type DeprecatedApiRouteKey = (typeof DEPRECATED_API_ROUTE_KEYS)[number];
export type ApiInventoryGuardOptions = {
appSourcePath?: string;
removedWebApiRoutesRoot?: string;
legacyRoutesRoot?: string;
};
export type ApiInventoryViolation = {
routeKey: DeprecatedApiRouteKey | "REMOVED /api web route" | "UNRESOLVED mounted router";
surface: "hono" | "removed-react-router-api" | "hono-route-mount";
sourcePath: string;
location?: {
line: number;
column: number;
};
detail?: string;
};
export type ApiInventoryGuardResult = {
ok: boolean;
violations: ApiInventoryViolation[];
missingRoots: string[];
};
const DEFAULT_APP_SOURCE_PATH = "apps/api/src/app.ts";
const DEFAULT_REMOVED_WEB_API_ROUTES_ROOT = "apps/web/app/routes/api";
const deprecatedRouteKeys = new Set<ApiRouteKey>(DEPRECATED_API_ROUTE_KEYS);
const legacyRouteFileNamesByRouteKey = new Map<DeprecatedApiRouteKey, readonly string[]>([
["GET /v1/auth/verify-token", [path.join("auth", "verify-token.ts"), path.join("auth", "verify-token.tsx")]],
["GET /v1/auth/me", [path.join("auth", "me.ts"), path.join("auth", "me.tsx")]],
]);
export async function validateApiInventory(options: ApiInventoryGuardOptions = {}): Promise<ApiInventoryGuardResult> {
const appSourcePath = options.appSourcePath ?? DEFAULT_APP_SOURCE_PATH;
const removedWebApiRoutesRoot = options.removedWebApiRoutesRoot ?? options.legacyRoutesRoot ?? DEFAULT_REMOVED_WEB_API_ROUTES_ROOT;
const violations: ApiInventoryViolation[] = [];
const missingRoots: string[] = [];
const scanResult = await scanApiRoutesFromFile(appSourcePath);
for (const mountedRouter of scanResult.unresolvedMountedRouters) {
violations.push({
routeKey: "UNRESOLVED mounted router",
surface: "hono-route-mount",
sourcePath: appSourcePath,
location: mountedRouter.location,
detail: `${mountedRouter.prefix} -> ${mountedRouter.receiver}`,
});
}
for (const route of scanResult.routes) {
if (!deprecatedRouteKeys.has(route.key)) continue;
violations.push({
routeKey: route.key as DeprecatedApiRouteKey,
surface: "hono",
sourcePath: appSourcePath,
location: route.location,
});
}
const removedWebApiRootExists = await pathExists(removedWebApiRoutesRoot);
const removedWebApiRouteFiles = removedWebApiRootExists ? await listFiles(removedWebApiRoutesRoot) : [];
const normalizedRemovedWebApiRouteFiles = new Set(removedWebApiRouteFiles.map((filePath) => normalizeRouteFilePath(filePath)));
for (const fileName of removedWebApiRouteFiles) {
violations.push({
routeKey: "REMOVED /api web route",
surface: "removed-react-router-api",
sourcePath: path.join(removedWebApiRoutesRoot, fileName),
});
}
for (const [routeKey, fileNames] of legacyRouteFileNamesByRouteKey.entries()) {
for (const fileName of fileNames) {
if (!normalizedRemovedWebApiRouteFiles.has(normalizeRouteFilePath(fileName))) continue;
violations.push({
routeKey,
surface: "removed-react-router-api",
sourcePath: path.join(removedWebApiRoutesRoot, fileName),
});
}
}
return { ok: violations.length === 0 && missingRoots.length === 0, violations, missingRoots };
}
export function renderApiInventoryGuardResult(result: ApiInventoryGuardResult): string {
if (result.ok) return "PASS api-inventory: no deprecated route keys found";
return [
"FAIL api-inventory: deprecated route keys or removed web API routes found",
...result.missingRoots.map((root) => `- missing scan root: ${root}`),
...result.violations.map(formatViolation),
].join("\n");
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
return true;
} catch (error) {
if (isNodeError(error) && error.code === "ENOENT") return false;
throw error;
}
}
async function listFiles(root: string, relativeDir = ""): Promise<string[]> {
const absoluteDir = path.join(root, relativeDir);
const entries = await readdir(absoluteDir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const relativePath = path.join(relativeDir, entry.name);
if (entry.isDirectory()) {
files.push(...(await listFiles(root, relativePath)));
} else if (entry.isFile()) {
files.push(relativePath);
}
}
return files;
}
function formatViolation(violation: ApiInventoryViolation): string {
const location = violation.location ? `:${violation.location.line}:${violation.location.column}` : "";
const detail = violation.detail ? ` (${violation.detail})` : "";
return `- ${violation.routeKey} [${violation.surface}] ${violation.sourcePath}${location}${detail}`;
}
function normalizeRouteFilePath(filePath: string): string {
return filePath.split(path.sep).join("/");
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return typeof error === "object" && error !== null && "code" in error;
}
async function main(args = process.argv.slice(2)): Promise<void> {
if (args.length > 0) {
console.error("FAIL api-inventory: unknown arguments");
for (const arg of args) console.error(`- ${arg}`);
process.exitCode = 1;
return;
}
const result = await validateApiInventory();
const output = renderApiInventoryGuardResult(result);
if (result.ok) {
console.log(output);
return;
}
console.error(output);
process.exitCode = 1;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
await main();
}