307 lines
9.9 KiB
TypeScript
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>;
|
|
}
|