Files
HiveGrid-Wireframes/hivegrid-cloud/tools/api-inventory/scan-api-routes.ts
T

307 lines
9.9 KiB
TypeScript

import { readFile } from "node:fs/promises";
import path from "node:path";
export const API_ROUTE_METHODS = ["get", "post", "put", "patch", "delete", "options", "all"] as const;
export type ApiRouteMethod = (typeof API_ROUTE_METHODS)[number];
export type ApiRouteKey = `${Uppercase<ApiRouteMethod>} ${string}`;
export type ApiRouteLocation = {
line: number;
column: number;
};
export type ApiRoute = {
method: Uppercase<ApiRouteMethod>;
path: string;
key: ApiRouteKey;
location: ApiRouteLocation;
};
export type DuplicateApiRoute = {
key: ApiRouteKey;
locations: ApiRouteLocation[];
};
export type UnresolvedMountedRouter = {
receiver: string;
prefix: string;
location: ApiRouteLocation;
};
export type ApiRouteScanResult = {
routes: ApiRoute[];
duplicateRoutes: DuplicateApiRoute[];
unresolvedMountedRouters: UnresolvedMountedRouter[];
};
const API_ROUTE_METHOD_PATTERN = API_ROUTE_METHODS.join("|");
const ROUTE_CALL_PATTERN = new RegExp(
String.raw`\b([A-Za-z_$][\w$]*)\s*\.\s*(${API_ROUTE_METHOD_PATTERN})\s*\(\s*(["'\`])([^"'\`]+)\3`,
"gi"
);
const ON_SINGLE_ROUTE_CALL_PATTERN = /\b([A-Za-z_$][\w$]*)\s*\.\s*on\s*\(\s*(["'`])([A-Za-z]+)\2\s*,\s*(["'`])([^"'`]+)\4/g;
const ON_ARRAY_ROUTE_CALL_PATTERN = /\b([A-Za-z_$][\w$]*)\s*\.\s*on\s*\(\s*\[([\s\S]*?)\]\s*,\s*(["'`])([^"'`]+)\3/g;
const HONO_INSTANCE_PATTERN = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+Hono\b/g;
const APP_ROUTE_PATTERN = /\bapp\s*\.\s*route\s*\(\s*(["'`])([^"'`]+)\1\s*,\s*([A-Za-z_$][\w$]*)\s*\)/g;
const ROUTE_REGISTRATION_IMPORT_PATTERN = /import\s+\{\s*(register[A-Za-z0-9_$]+)\s*\}\s+from\s+(["'`])(.+?)\2/g;
const ROUTE_REGISTRATION_CALL_PATTERN = /\b(register[A-Za-z0-9_$]+)\s*\(\s*app\s*\)/g;
export function scanApiRoutesFromSource(source: string): ApiRouteScanResult {
const { routePrefixesByReceiver, unresolvedMountedRouters } = discoverRoutePrefixesByReceiver(source);
const searchableSource = maskNonRouteStringLiterals(source, new Set(routePrefixesByReceiver.keys()));
const routes: ApiRoute[] = [];
for (const match of searchableSource.matchAll(ROUTE_CALL_PATTERN)) {
const receiver = match[1];
const prefix = routePrefixesByReceiver.get(receiver);
if (prefix === undefined) {
continue;
}
const method = match[2].toUpperCase() as Uppercase<ApiRouteMethod>;
const path = joinApiRoutePath(prefix, match[4]);
const location = getSourceLocation(source, match.index ?? 0);
routes.push({
method,
path,
key: `${method} ${path}` as ApiRouteKey,
location,
});
}
for (const match of searchableSource.matchAll(ON_SINGLE_ROUTE_CALL_PATTERN)) {
const receiver = match[1];
const prefix = routePrefixesByReceiver.get(receiver);
const method = normalizeOnRouteMethod(match[3]);
if (prefix === undefined || !method) {
continue;
}
const path = joinApiRoutePath(prefix, match[5]);
const location = getSourceLocation(source, match.index ?? 0);
routes.push({
method,
path,
key: `${method} ${path}` as ApiRouteKey,
location,
});
}
for (const match of searchableSource.matchAll(ON_ARRAY_ROUTE_CALL_PATTERN)) {
const receiver = match[1];
const prefix = routePrefixesByReceiver.get(receiver);
if (prefix === undefined) {
continue;
}
const path = joinApiRoutePath(prefix, match[4]);
const location = getSourceLocation(source, match.index ?? 0);
for (const method of extractOnRouteMethods(match[2])) {
routes.push({
method,
path,
key: `${method} ${path}` as ApiRouteKey,
location,
});
}
}
return {
routes,
duplicateRoutes: findDuplicateApiRoutes(routes),
unresolvedMountedRouters,
};
}
export async function scanApiRoutesFromFile(filePath: string): Promise<ApiRouteScanResult> {
const source = await readFile(filePath, "utf8");
return scanApiRoutesFromSource(await expandRegisteredApiRouteModules(filePath, source));
}
async function expandRegisteredApiRouteModules(filePath: string, source: string) {
if (path.basename(filePath) !== "app.ts") {
return source;
}
const imports = new Map<string, string>();
for (const match of source.matchAll(ROUTE_REGISTRATION_IMPORT_PATTERN)) {
imports.set(match[1], match[3]);
}
const chunks: string[] = [];
for (const match of source.matchAll(ROUTE_REGISTRATION_CALL_PATTERN)) {
const importPath = imports.get(match[1]);
if (!importPath?.startsWith("./routes/")) {
continue;
}
const routeModulePath = path.resolve(path.dirname(filePath), importPath.replace(/\.js$/, ".ts"));
chunks.push(await readFile(routeModulePath, "utf8"));
}
chunks.push(source);
return chunks.join("\n");
}
export function formatApiRouteKeys(routes: readonly ApiRoute[]): ApiRouteKey[] {
return routes.map((route) => route.key);
}
export function assertNoDuplicateApiRoutes(result: ApiRouteScanResult): void {
if (result.duplicateRoutes.length === 0) {
return;
}
const duplicates = result.duplicateRoutes
.map((duplicate) => {
const locations = duplicate.locations.map((location) => `${location.line}:${location.column}`).join(", ");
return `${duplicate.key} (${locations})`;
})
.join("; ");
throw new Error(`Duplicate API route keys found: ${duplicates}`);
}
function findDuplicateApiRoutes(routes: readonly ApiRoute[]): DuplicateApiRoute[] {
const locationsByKey = new Map<ApiRouteKey, ApiRouteLocation[]>();
for (const route of routes) {
const locations = locationsByKey.get(route.key) ?? [];
locations.push(route.location);
locationsByKey.set(route.key, locations);
}
return Array.from(locationsByKey.entries())
.filter(([, locations]) => locations.length > 1)
.map(([key, locations]) => ({ key, locations }));
}
function getSourceLocation(source: string, index: number): ApiRouteLocation {
const prefix = source.slice(0, index);
const lines = prefix.split("\n");
const line = lines.length;
const column = lines[lines.length - 1].length + 1;
return { line, column };
}
function discoverRoutePrefixesByReceiver(source: string): {
routePrefixesByReceiver: Map<string, string>;
unresolvedMountedRouters: UnresolvedMountedRouter[];
} {
const routePrefixesByReceiver = new Map<string, string>([["app", ""]]);
const honoReceivers = new Set<string>();
const unresolvedMountedRouters: UnresolvedMountedRouter[] = [];
for (const match of source.matchAll(HONO_INSTANCE_PATTERN)) {
honoReceivers.add(match[1]);
}
for (const match of source.matchAll(APP_ROUTE_PATTERN)) {
const [, , prefix, receiver] = match;
if (honoReceivers.has(receiver)) {
routePrefixesByReceiver.set(receiver, joinApiRoutePath(routePrefixesByReceiver.get(receiver) ?? "", prefix));
} else {
unresolvedMountedRouters.push({
receiver,
prefix,
location: getSourceLocation(source, match.index ?? 0),
});
}
}
return { routePrefixesByReceiver, unresolvedMountedRouters };
}
function joinApiRoutePath(prefix: string, path: string): string {
const normalizedPrefix = prefix === "/" ? "" : prefix.replace(/\/+$/, "");
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${normalizedPrefix}${normalizedPath}` || "/";
}
function maskNonRouteStringLiterals(source: string, routeReceivers: ReadonlySet<string>): string {
const mutableSource = Array.from(source);
let quote: '"' | "'" | "`" | null = null;
let literalStart = -1;
let escaped = false;
for (let index = 0; index < mutableSource.length; index += 1) {
const character = mutableSource[index];
if (quote === null) {
if (character === '"' || character === "'" || character === "`") {
quote = character;
literalStart = index;
}
continue;
}
if (escaped) {
escaped = false;
continue;
}
if (character === "\\") {
escaped = true;
continue;
}
if (character === quote) {
if (!isRoutePathLiteral(source, literalStart, routeReceivers)) {
maskLiteralInterior(mutableSource, literalStart, index);
}
quote = null;
literalStart = -1;
}
}
return mutableSource.join("");
}
function isRoutePathLiteral(source: string, literalStart: number, routeReceivers: ReadonlySet<string>): boolean {
const prefix = source.slice(0, literalStart);
const routeCallMatch = new RegExp(String.raw`\b([A-Za-z_$][\w$]*)\s*\.\s*(?:${API_ROUTE_METHOD_PATTERN})\s*\(\s*$`, "i").exec(prefix);
if (routeCallMatch && routeReceivers.has(routeCallMatch[1])) {
return true;
}
const onMethodLiteralMatch = /\b([A-Za-z_$][\w$]*)\s*\.\s*on\s*\(\s*(?:\[[\s\S]*)?$/i.exec(prefix);
if (onMethodLiteralMatch && routeReceivers.has(onMethodLiteralMatch[1])) {
return true;
}
const onSingleRouteCallMatch = /\b([A-Za-z_$][\w$]*)\s*\.\s*on\s*\(\s*(["'`])([A-Za-z]+)\2\s*,\s*$/i.exec(prefix);
if (onSingleRouteCallMatch && routeReceivers.has(onSingleRouteCallMatch[1])) {
return true;
}
const onArrayRouteCallMatch = /\b([A-Za-z_$][\w$]*)\s*\.\s*on\s*\(\s*\[[\s\S]*?\]\s*,\s*$/i.exec(prefix);
if (onArrayRouteCallMatch && routeReceivers.has(onArrayRouteCallMatch[1])) {
return true;
}
return /\bapp\s*\.\s*route\s*\(\s*$/i.test(prefix);
}
function maskLiteralInterior(source: string[], literalStart: number, literalEnd: number): void {
for (let index = literalStart + 1; index < literalEnd; index += 1) {
source[index] = source[index] === "\n" ? "\n" : " ";
}
}
function extractOnRouteMethods(methodsSource: string): Array<Uppercase<ApiRouteMethod>> {
return [...methodsSource.matchAll(/(["'`])([A-Za-z]+)\1/g)]
.map((match) => normalizeOnRouteMethod(match[2]))
.filter((method): method is Uppercase<ApiRouteMethod> => Boolean(method));
}
function normalizeOnRouteMethod(method: string): Uppercase<ApiRouteMethod> | null {
const normalized = method.toLowerCase();
if (!(API_ROUTE_METHODS as readonly string[]).includes(normalized)) {
return null;
}
return normalized.toUpperCase() as Uppercase<ApiRouteMethod>;
}