hyapp-admin-platform/scripts/validate-admin-architecture.ts
2026-05-02 13:02:57 +08:00

130 lines
4.3 KiB
TypeScript

import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { MENU_CODE_VALUES, PERMISSION_CODES } from "../src/app/permissions";
import { fallbackNavigation } from "../src/app/navigation/menu";
import { adminRoutes } from "../src/app/router/routeConfig";
type OpenApiOperation = {
operationId?: string;
"x-permission"?: string;
"x-permissions"?: string[];
};
type OpenApiDocument = {
paths?: Record<string, Record<string, OpenApiOperation>>;
};
type NavItem = {
children?: NavItem[];
code?: string;
path?: string;
permissionCode?: string;
};
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const contract = JSON.parse(readFileSync(path.join(rootDir, "contracts/admin-openapi.json"), "utf8")) as OpenApiDocument;
const errors: string[] = [];
const knownPermissions = new Set<string>(PERMISSION_CODES);
const knownMenuCodes = new Set<string>(MENU_CODE_VALUES);
const routesByPath = new Map<string, (typeof adminRoutes)[number]>();
const routesByMenuCode = new Map<string, (typeof adminRoutes)[number]>();
const routePermissions = new Set<string>();
for (const route of adminRoutes) {
if (!route.path) {
errors.push(`Route ${route.pageKey} is missing path`);
}
if (!route.menuCode) {
errors.push(`Route ${route.path} is missing menuCode`);
}
if (!route.permission) {
errors.push(`Route ${route.path} is missing permission`);
}
if (route.menuCode && !knownMenuCodes.has(route.menuCode)) {
errors.push(`Route ${route.path} uses unknown menuCode: ${route.menuCode}`);
}
if (route.permission && !knownPermissions.has(route.permission)) {
errors.push(`Route ${route.path} uses unknown permission: ${route.permission}`);
}
if (routesByPath.has(route.path)) {
errors.push(`Duplicate route path: ${route.path}`);
}
if (routesByMenuCode.has(route.menuCode)) {
errors.push(`Duplicate route menuCode: ${route.menuCode}`);
}
routesByPath.set(route.path, route);
routesByMenuCode.set(route.menuCode, route);
routePermissions.add(route.permission);
}
for (const item of flattenNavItems(fallbackNavigation as NavItem[])) {
if (item.code && !knownMenuCodes.has(item.code)) {
errors.push(`Fallback menu uses unknown menu code: ${item.code}`);
}
if (!item.path) {
continue;
}
const route = item.code ? routesByMenuCode.get(item.code) : undefined;
if (!route) {
errors.push(`Fallback menu ${item.code || item.path} has no matching route`);
continue;
}
if (route.path !== item.path) {
errors.push(`Fallback menu ${item.code} path mismatch: ${item.path} !== ${route.path}`);
}
if (item.permissionCode !== route.permission) {
errors.push(`Fallback menu ${item.code} permission mismatch: ${item.permissionCode} !== ${route.permission}`);
}
}
const operationIds = new Set<string>();
const apiPermissions = new Set<string>();
const httpMethods = new Set(["delete", "get", "patch", "post", "put"]);
for (const [endpointPath, pathItem] of Object.entries(contract.paths || {})) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!httpMethods.has(method)) {
continue;
}
if (!operation.operationId) {
errors.push(`${method.toUpperCase()} ${endpointPath} is missing operationId`);
} else if (operationIds.has(operation.operationId)) {
errors.push(`Duplicate OpenAPI operationId: ${operation.operationId}`);
} else {
operationIds.add(operation.operationId);
}
const permissions = operation["x-permissions"]?.length
? operation["x-permissions"]
: operation["x-permission"]
? [operation["x-permission"]]
: [];
for (const permission of permissions) {
apiPermissions.add(permission);
if (!knownPermissions.has(permission)) {
errors.push(`${method.toUpperCase()} ${endpointPath} uses unknown x-permission: ${permission}`);
}
}
}
}
if (errors.length) {
console.error("Admin architecture validation failed:");
for (const error of errors) {
console.error(`- ${error}`);
}
process.exit(1);
}
console.log(
`Admin architecture validation passed: ${adminRoutes.length} routes, ${operationIds.size} operations, ${apiPermissions.size} protected permissions.`
);
function flattenNavItems(items: NavItem[]): NavItem[] {
return items.flatMap((item) => [item, ...flattenNavItems(item.children || [])]);
}