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)]) ); }