Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
assertNoDuplicateApiRoutes,
|
||||
formatApiRouteKeys,
|
||||
scanApiRoutesFromFile,
|
||||
scanApiRoutesFromSource,
|
||||
type ApiRouteKey,
|
||||
} from "./scan-api-routes";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const apiAppPath = path.join(repoRoot, "apps/api/src/app.ts");
|
||||
|
||||
const expectedCurrentRouteKeys: ApiRouteKey[] = [
|
||||
"GET /healthz",
|
||||
"GET /v1/health",
|
||||
"GET /readyz",
|
||||
"GET /v1/openapi.json",
|
||||
"GET /v1/docs",
|
||||
"GET /v1/session/current-user",
|
||||
"POST /v1/auth/login",
|
||||
"POST /v1/auth/refresh-token",
|
||||
"POST /v1/auth/logout",
|
||||
"POST /v1/api-keys/validate",
|
||||
"GET /v1/accounts",
|
||||
"GET /v1/accounts/:id",
|
||||
"POST /v1/analytics/events",
|
||||
"POST /v1/analytics/page-views",
|
||||
"GET /v1/analytics/public",
|
||||
"GET /v1/analytics/admin",
|
||||
"GET /v1/notifications/stream",
|
||||
"GET /v1/workspaces/:tenantId/auth/providers",
|
||||
"POST /v1/workspaces/:tenantId/context/revalidate",
|
||||
"GET /v1/workspaces/:tenantId/invites",
|
||||
"POST /v1/workspaces/:tenantId/invites",
|
||||
"POST /v1/workspaces/:tenantId/invites/:inviteId/accept",
|
||||
"POST /v1/workspaces/:tenantId/invites/:inviteId/revoke",
|
||||
"POST /v1/ai/upload",
|
||||
"POST /v1/support/inbound-email",
|
||||
"POST /v1/email-sequences/mailjet/webhooks",
|
||||
"POST /v1/events/webhooks/attempts/:id",
|
||||
"POST /v1/maintenance/export-retention/enqueue",
|
||||
"GET /v1/exports/:exportJobId/download-url",
|
||||
"GET /v1/exports/:exportJobId/progress",
|
||||
"GET /v1/entities/:entity/rows",
|
||||
"POST /v1/entities/:entity/rows",
|
||||
"GET /v1/entities/:entity/rows/:rowId",
|
||||
"PATCH /v1/entities/:entity/rows/:rowId",
|
||||
"DELETE /v1/entities/:entity/rows/:rowId",
|
||||
"GET /v1/entities/:entity",
|
||||
"POST /v1/relationships",
|
||||
"GET /v1/relationships/:id",
|
||||
"DELETE /v1/relationships/:id",
|
||||
"POST /v1/devices/register",
|
||||
"POST /v1/devices/revoke",
|
||||
"GET /status",
|
||||
"GET /metrics",
|
||||
"GET /v1/usage",
|
||||
"POST /v1/billing/stripe/webhooks",
|
||||
];
|
||||
|
||||
describe("scanApiRoutesFromSource", () => {
|
||||
it("returns the complete current route-key set from apps/api/src/app.ts and registered route modules", async () => {
|
||||
const result = await scanApiRoutesFromFile(apiAppPath);
|
||||
|
||||
expect(result.duplicateRoutes).toEqual([]);
|
||||
expect(result.unresolvedMountedRouters).toEqual([]);
|
||||
expect(formatApiRouteKeys(result.routes)).toEqual(expectedCurrentRouteKeys);
|
||||
});
|
||||
|
||||
it("finds multiline Hono app route registrations", () => {
|
||||
const result = scanApiRoutesFromSource(`
|
||||
app.post(
|
||||
"/v1/multiline",
|
||||
multilineHandler
|
||||
);
|
||||
app.delete(
|
||||
'/v1/multiline/:id',
|
||||
deleteHandler
|
||||
);
|
||||
app.put(
|
||||
"/v1/multiline-put",
|
||||
putHandler
|
||||
);
|
||||
app.options(
|
||||
"/v1/multiline-options",
|
||||
optionsHandler
|
||||
);
|
||||
app.all(
|
||||
"/api/v1/*",
|
||||
redirectHandler
|
||||
);
|
||||
`);
|
||||
|
||||
expect(formatApiRouteKeys(result.routes)).toEqual([
|
||||
"POST /v1/multiline",
|
||||
"DELETE /v1/multiline/:id",
|
||||
"PUT /v1/multiline-put",
|
||||
"OPTIONS /v1/multiline-options",
|
||||
"ALL /api/v1/*",
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds simple Hono subrouter registrations mounted through app.route", () => {
|
||||
const result = scanApiRoutesFromSource(`
|
||||
const workspaceRoutes = new Hono();
|
||||
workspaceRoutes.get("/workspaces/:tenantId/invites", listInvites);
|
||||
workspaceRoutes.post(
|
||||
"/workspaces/:tenantId/invites",
|
||||
createInvite
|
||||
);
|
||||
const ignoredRoutes = new Hono();
|
||||
ignoredRoutes.get("/not-mounted", handler);
|
||||
app.route("/v1", workspaceRoutes);
|
||||
`);
|
||||
|
||||
expect(formatApiRouteKeys(result.routes)).toEqual([
|
||||
"GET /v1/workspaces/:tenantId/invites",
|
||||
"POST /v1/workspaces/:tenantId/invites",
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds Hono app.on route registrations", () => {
|
||||
const result = scanApiRoutesFromSource(`
|
||||
app.on("GET", "/v1/on-single", handler);
|
||||
app.on(["POST", "DELETE"], "/v1/on-array/:id", handler);
|
||||
`);
|
||||
|
||||
expect(formatApiRouteKeys(result.routes)).toEqual(["GET /v1/on-single", "POST /v1/on-array/:id", "DELETE /v1/on-array/:id"]);
|
||||
});
|
||||
|
||||
it("reports mounted routers that cannot be resolved from the scanned source", () => {
|
||||
const result = scanApiRoutesFromSource(`
|
||||
import { userRoutes } from "./users";
|
||||
app.route("/v1/users", userRoutes);
|
||||
`);
|
||||
|
||||
expect(result.routes).toEqual([]);
|
||||
expect(result.unresolvedMountedRouters).toEqual([
|
||||
{
|
||||
receiver: "userRoutes",
|
||||
prefix: "/v1/users",
|
||||
location: { line: 3, column: 7 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports duplicate route keys without collapsing them through a Set", () => {
|
||||
const result = scanApiRoutesFromSource(`
|
||||
app.get("/v1/dup", firstHandler);
|
||||
app.get("/v1/dup", secondHandler);
|
||||
`);
|
||||
|
||||
expect(formatApiRouteKeys(result.routes)).toEqual(["GET /v1/dup", "GET /v1/dup"]);
|
||||
expect(result.duplicateRoutes).toEqual([
|
||||
{
|
||||
key: "GET /v1/dup",
|
||||
locations: [
|
||||
{ line: 2, column: 7 },
|
||||
{ line: 3, column: 7 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(() => assertNoDuplicateApiRoutes(result)).toThrow(/Duplicate API route keys found: GET \/v1\/dup/);
|
||||
});
|
||||
|
||||
it("ignores non-Hono-app source text", () => {
|
||||
const result = scanApiRoutesFromSource(`
|
||||
something.get("/v1/not-a-route", handler);
|
||||
router.post("/v1/not-a-route", handler);
|
||||
const text = 'app.delete("/v1/not-a-real-route")';
|
||||
const templateText = \`app.post("/v1/not-a-real-route")\`;
|
||||
const objectText = { value: "app.patch('/v1/not-a-real-route')" };
|
||||
`);
|
||||
|
||||
expect(result.routes).toEqual([]);
|
||||
expect(result.duplicateRoutes).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
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>;
|
||||
}
|
||||
Reference in new issue
Block a user