frontend/scripts/generate-openapi-index.js
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
// Generates a compact TypeScript index from the OpenAPI spec.
// Run: pnpm --dir frontend run prepare
const fs = require("fs");
const path = require("path");
const yaml = require("js-yaml");
const YAML_FILE = path.join(
__dirname,
"../../backend/api/mcp/gen/openapi.yaml"
);
const OUTPUT_FILE = path.join(
__dirname,
"../src/modules/agent/logic/tools/gen/openapi-index.ts"
);
// --- Helpers ---
// Extract the last segment of a $ref string.
// "#/components/schemas/bytebase.v1.Foo" -> "bytebase.v1.Foo"
function refName(ref) {
if (!ref) return "";
const parts = ref.split("/");
return parts[parts.length - 1];
}
function isObjectSchema(schemaDef) {
return (
!!schemaDef &&
typeof schemaDef === "object" &&
(schemaDef.type === "object" ||
!!schemaDef.properties ||
Array.isArray(schemaDef.allOf) ||
Array.isArray(schemaDef.oneOf) ||
Array.isArray(schemaDef.anyOf))
);
}
function mergeObjectShape(schemaDef, mergedProperties, mergedRequired, options) {
if (!isObjectSchema(schemaDef)) {
return;
}
if (schemaDef.properties) {
Object.assign(mergedProperties, schemaDef.properties);
}
if (options.includeRequired && Array.isArray(schemaDef.required)) {
for (const requiredName of schemaDef.required) {
mergedRequired.add(requiredName);
}
}
if (Array.isArray(schemaDef.allOf)) {
for (const part of schemaDef.allOf) {
mergeObjectShape(part, mergedProperties, mergedRequired, options);
}
}
// A proto oneof renders as `oneOf` or `anyOf` depending on the generator
// version, and both mean the same thing here: every branch's fields belong
// to the message, none of them required. Walking only one spelling silently
// drops whole branches — including their bytes fields, which then never get
// base64-encoded on the way out.
for (const key of ["oneOf", "anyOf"]) {
const branches = schemaDef[key];
if (!Array.isArray(branches)) continue;
for (const branch of branches) {
mergeObjectShape(branch, mergedProperties, mergedRequired, {
includeRequired: false,
});
}
}
}
function collectObjectShape(schemaDef) {
const mergedProperties = {};
const mergedRequired = new Set();
mergeObjectShape(schemaDef, mergedProperties, mergedRequired, {
includeRequired: true,
});
return {
properties: mergedProperties,
required: mergedRequired,
};
}
// Extract property metadata from a schema property object.
function extractPropertyInfo(propName, propDef, requiredSet) {
const info = {
name: propName,
type: "object",
};
if (requiredSet.has(propName)) {
info.required = true;
}
if (!propDef) {
return info;
}
// A message-typed field renders either as a bare `$ref` or as a one-branch
// `allOf` wrapping it, depending on the generator version — the wrapper is
// how OpenAPI 3.0 attaches a title and description to a reference. Unwrap it,
// or the field's type degrades to a bare object and nothing downstream knows
// what it points at.
if (Array.isArray(propDef.allOf)) {
const branches = propDef.allOf.filter(Boolean);
if (branches.length === 1) {
const unwrapped = { ...branches[0] };
if (propDef.description && !unwrapped.description) {
unwrapped.description = propDef.description;
}
return extractPropertyInfo(propName, unwrapped, requiredSet);
}
}
const propBranches = Array.isArray(propDef.oneOf)
? propDef.oneOf
: propDef.anyOf;
if (Array.isArray(propBranches)) {
const nonNullBranches = propBranches.filter(
(branch) => branch && branch.type !== "null"
);
if (nonNullBranches.length === 1) {
const unionDef = { ...nonNullBranches[0] };
if (propDef.description && !unionDef.description) {
unionDef.description = propDef.description;
}
return extractPropertyInfo(propName, unionDef, requiredSet);
}
}
if (propDef.$ref) {
info.type = refName(propDef.$ref);
if (propDef.description) info.description = propDef.description;
return info;
}
const rawType = Array.isArray(propDef.type)
? propDef.type.find((t) => t !== "null")
: propDef.type;
if (rawType) {
info.type = rawType;
}
if (propDef.format) {
info.format = propDef.format;
}
if (propDef.description) {
info.description = propDef.description;
}
if (info.type === "array" && propDef.items) {
info.items = extractPropertyInfo("item", propDef.items, new Set());
info.type = `array<${info.items.type}>`;
}
if (
info.type === "object" &&
propDef.additionalProperties &&
typeof propDef.additionalProperties === "object"
) {
info.additionalProperties = extractPropertyInfo(
"value",
propDef.additionalProperties,
new Set()
);
}
return info;
}
// --- Main ---
const yamlContent = fs.readFileSync(YAML_FILE, "utf8");
const spec = yaml.load(yamlContent);
// 1. Extract endpoints
const endpoints = [];
const paths = spec.paths || {};
for (const [pathStr, pathItem] of Object.entries(paths)) {
const op = pathItem && pathItem.post;
if (!op) continue;
// Deprecated operations (e.g. compatibility aliases kept for one release)
// still serve, but agents should only discover the canonical surface.
if (op.deprecated) continue;
// /bytebase.v1.SQLService/Query -> service=SQLService, method=Query
const trimmed = pathStr.replace(/^\/bytebase\.v1\./, "");
const slashIdx = trimmed.indexOf("/");
if (slashIdx < 0) continue;
const service = trimmed.slice(0, slashIdx);
const method = trimmed.slice(slashIdx + 1);
let requestSchemaRef = "";
const reqBody = op.requestBody;
if (
reqBody &&
reqBody.content &&
reqBody.content["application/json"] &&
reqBody.content["application/json"].schema &&
reqBody.content["application/json"].schema.$ref
) {
requestSchemaRef = reqBody.content["application/json"].schema.$ref;
}
let responseSchemaRef = "";
const resp200 =
op.responses && (op.responses["200"] || op.responses[200]);
if (
resp200 &&
resp200.content &&
resp200.content["application/json"] &&
resp200.content["application/json"].schema &&
resp200.content["application/json"].schema.$ref
) {
responseSchemaRef = resp200.content["application/json"].schema.$ref;
}
endpoints.push({
operationId: op.operationId || "",
path: pathStr,
service,
method,
summary: op.summary || "",
description: op.description || "",
requestSchemaRef,
responseSchemaRef,
});
}
// 2. Extract schemas
const schemas = {};
const componentSchemas =
(spec.components && spec.components.schemas) || {};
for (const [schemaName, schemaDef] of Object.entries(componentSchemas)) {
if (!schemaDef) continue;
// Handle enum types
if (schemaDef.enum && Array.isArray(schemaDef.enum)) {
schemas[schemaName] = {
type: "enum",
values: schemaDef.enum.filter((v) => v != null && v !== ""),
description: schemaDef.description || "",
};
continue;
}
const { properties: mergedProperties, required: mergedRequired } =
collectObjectShape(schemaDef);
// Handle object types with properties
if (!mergedProperties || Object.keys(mergedProperties).length === 0) continue;
const requiredSet = mergedRequired;
const properties = [];
for (const [propName, propDef] of Object.entries(mergedProperties)) {
properties.push(extractPropertyInfo(propName, propDef, requiredSet));
}
// Sort properties by name for consistent output
properties.sort((a, b) => a.name.localeCompare(b.name));
schemas[schemaName] = {
type: "object",
properties,
description: schemaDef.description || "",
};
}
// 3. Generate TypeScript output
const lines = [];
lines.push(
"// Auto-generated from openapi.yaml. DO NOT EDIT manually.",
"// Run 'pnpm --dir frontend run prepare' to regenerate.",
""
);
// Interfaces
lines.push(
"export interface EndpointInfo {",
" operationId: string;",
" path: string;",
" service: string;",
" method: string;",
" summary: string;",
" description: string;",
" requestSchemaRef: string;",
" responseSchemaRef: string;",
"}",
"",
"export interface PropertyInfo {",
" name: string;",
" type: string;",
" description?: string;",
" required?: boolean;",
" format?: string;",
" items?: PropertyInfo;",
" additionalProperties?: PropertyInfo;",
"}",
"",
"export interface SchemaInfo {",
" type: \"object\" | \"enum\";",
" description: string;",
" properties?: PropertyInfo[];",
" values?: Array<string | number>;",
"}",
""
);
// Endpoints array
lines.push(
`export const endpoints: EndpointInfo[] = ${JSON.stringify(endpoints, null, 2)};`,
""
);
// Schemas record
lines.push(
`export const schemas: Record<string, SchemaInfo> = ${JSON.stringify(schemas, null, 2)};`,
""
);
// Write-then-rename: the frontend gate regenerates this while tsc and the
// bundler are already reading it, and an in-place write is observable
// half-finished. rename(2) within a directory is atomic, so a reader sees
// either the old file or the new one.
const tempFile = `${OUTPUT_FILE}.${process.pid}.tmp`;
fs.writeFileSync(tempFile, lines.join("\n"), "utf8");
fs.renameSync(tempFile, OUTPUT_FILE);
const schemaCount = Object.keys(schemas).length;
const enumCount = Object.values(schemas).filter(
(s) => s.type === "enum"
).length;
const objectCount = schemaCount - enumCount;
console.log(`Generated ${OUTPUT_FILE}`);
console.log(
` Endpoints: ${endpoints.length}, Schemas: ${schemaCount} (${objectCount} objects, ${enumCount} enums)`
);