hyapp-admin-platform/scripts/sync-openapi-from-backend.mjs
2026-05-02 13:02:57 +08:00

152 lines
5.4 KiB
JavaScript

import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultBackendDir = path.resolve(rootDir, "../hyapp-server/server/admin");
const backendDir = process.env.HYAPP_ADMIN_SERVER_DIR
? path.resolve(process.env.HYAPP_ADMIN_SERVER_DIR)
: defaultBackendDir;
const contractPath = path.join(rootDir, "contracts/admin-openapi.json");
const methods = ["get", "post", "put", "patch", "delete"];
const contract = JSON.parse(await readFile(contractPath, "utf8"));
const routes = await readBackendRoutes(path.join(backendDir, "internal/modules"));
for (const route of routes) {
contract.paths[route.path] = contract.paths[route.path] || {};
const operation = contract.paths[route.path][route.method] || {};
operation.operationId = route.operationId;
operation.responses = operation.responses || { "200": { "$ref": "#/components/responses/EmptyResponse" } };
const pathParams = [...route.path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
if (pathParams.length && !operation.parameters) {
operation.parameters = pathParams.map((name) =>
name === "id"
? { "$ref": "#/components/parameters/Id" }
: {
in: "path",
name,
required: true,
schema: { type: "integer" }
}
);
}
if (route.permissions.length) {
operation["x-permission"] = operation["x-permission"] || route.permissions[0];
operation["x-permissions"] = route.permissions;
}
contract.paths[route.path][route.method] = operation;
}
contract.paths = sortObject(contract.paths, (pathItem) => sortObject(pathItem, undefined, methods));
await writeFile(contractPath, `${JSON.stringify(contract, null, 2)}\n`);
console.log(`Synced ${routes.length} backend routes into ${path.relative(rootDir, contractPath)}`);
async function readBackendRoutes(modulesDir) {
const modules = await readdir(modulesDir, { withFileTypes: true });
const routes = [];
for (const moduleEntry of modules.filter((entry) => entry.isDirectory())) {
const moduleDir = path.join(modulesDir, moduleEntry.name);
const files = await readdir(moduleDir, { withFileTypes: true });
for (const fileEntry of files.filter((entry) => entry.isFile() && entry.name.endsWith(".go"))) {
const source = await readFile(path.join(moduleDir, fileEntry.name), "utf8");
if (!source.includes("RegisterRoutes")) {
continue;
}
routes.push(...parseRoutes(source, moduleEntry.name));
}
}
return routes.sort((left, right) => `${left.path}:${left.method}`.localeCompare(`${right.path}:${right.method}`));
}
function parseRoutes(source, moduleName) {
const groupPrefixes = new Map([
["api", ""],
["protected", ""]
]);
const routes = [];
for (const line of source.split("\n")) {
const groupMatch = line.match(/^\s*(\w+)\s*:=\s*(\w+)\.Group\("([^"]+)"\)/);
if (groupMatch) {
const [, groupName, parentName, prefix] = groupMatch;
groupPrefixes.set(groupName, `${groupPrefixes.get(parentName) || ""}${prefix}`);
continue;
}
const routeMatch = line.match(/^\s*(\w+)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"(?:,\s*(.*))?\)/);
if (!routeMatch) {
continue;
}
const [, groupName, method, routePath, args = ""] = routeMatch;
const handlerMatch = args.match(/h\.([A-Za-z0-9_]+)/g);
const handlerName = handlerMatch?.at(-1)?.replace("h.", "") || `${method}${routePath}`;
const permissions = readPermissions(args);
routes.push({
method: method.toLowerCase(),
operationId: operationIdForHandler(moduleName, handlerName, routePath, method),
path: toOpenApiPath(`${groupPrefixes.get(groupName) || ""}${routePath}`),
permissions
});
}
return routes;
}
function readPermissions(args) {
const anyMatch = args.match(/RequireAnyPermission\(([^)]*)\)/);
if (anyMatch) {
return [...anyMatch[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
}
const match = args.match(/RequirePermission\("([^"]+)"\)/);
return match ? [match[1]] : [];
}
function toOpenApiPath(routePath) {
return routePath.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
}
function lowerFirst(value) {
return value.charAt(0).toLowerCase() + value.slice(1);
}
function operationIdForHandler(moduleName, handlerName, routePath, method) {
if (moduleName === "appuser") {
return `app${handlerName}`;
}
if (moduleName === "countryregion" && handlerName === "DisableCountry" && method === "DELETE") {
return "deleteCountry";
}
if (moduleName === "hostorg" && handlerName === "SetBDStatus" && routePath.includes("bd-leaders")) {
return "setBDLeaderStatus";
}
if (moduleName === "resource" && handlerName === "UpdateResourceGroup" && routePath.includes("/items")) {
return "updateResourceGroupItems";
}
return lowerFirst(handlerName);
}
function sortObject(object, mapValue = (value) => value, preferredOrder = []) {
return Object.fromEntries(
Object.entries(object)
.sort(([left], [right]) => {
const leftIndex = preferredOrder.indexOf(left);
const rightIndex = preferredOrder.indexOf(right);
if (leftIndex >= 0 || rightIndex >= 0) {
return (leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex) - (rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex);
}
return left.localeCompare(right);
})
.map(([key, value]) => [key, mapValue(value)])
);
}