171 lines
5.8 KiB
JavaScript
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"] 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();
|
|
}
|