/** * OpenAPI bulk generator (WS5-01) * * Generates generic-but-valid spec path entries for route files that are * still undocumented, and appends them to src/lib/openapi.ts as an * `autoGeneratedPaths` block merged into `paths`. * * Hand-tuned entries (auth, pulls, issues core flows) always win: this * script only fills gaps. * * Usage: bun scripts/openapi-generate.ts */ import { readdirSync, statSync, readFileSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; import { openApiSpec } from "@/lib/openapi"; const rootDir = join(import.meta.dirname, ".."); const API_ROOT = join(rootDir, "src", "pages", "api"); const OPENAPI_FILE = join(rootDir, "src", "lib", "openapi.ts"); function collectRouteFiles(dir: string): string[] { const files: string[] = []; const stack = [dir]; while (stack.length > 0) { const current = stack.pop()!; for (const entry of readdirSync(current)) { const full = join(current, entry); const stats = statSync(full); if (stats.isDirectory()) stack.push(full); else if (entry.endsWith(".ts") && !entry.endsWith(".test.ts")) files.push(full); } } return files; } function routeToSpecPath(routeFile: string): string { const relativePath = relative(API_ROOT, routeFile).replace(/\.ts$/, ""); const segments = relativePath.split("/"); let path = "/" + segments.map((s) => s.replace(/\[([^\]]+)\]/g, "{$1}")).join("/"); if (path.endsWith("/index")) path = path.slice(0, -"/index".length); if (path === "") path = "/"; return path; } function detectMethods(routeFile: string): string[] { const content = readFileSync(routeFile, "utf8"); const methods: string[] = []; for (const m of ["GET", "POST", "PATCH", "PUT", "DELETE"]) { // Match "export const GET: APIRoute" or "export const GET =" or "export const GET:" const re = new RegExp(`export\\s+const\\s+${m}\\s*:?\\s*(APIRoute)?\\s*=`); if (re.test(content)) methods.push(m.toLowerCase()); } if (methods.length === 0) methods.push("get"); // default return methods; } function extractParams(specPath: string): Array<{ name: string; in: "path" }> { const matches = [...specPath.matchAll(/\{([^}]+)\}/g)]; return matches.map((m) => ({ name: m[1], in: "path" as const })); } function tagFor(path: string): string { const seg = path.split("/").filter(Boolean)[0] || "API"; return seg.charAt(0).toUpperCase() + seg.slice(1); } const routeFiles = collectRouteFiles(API_ROOT); const currentPaths = openApiSpec.paths ?? {}; // Hand-tuned paths = spec keys that are NOT in the current auto-generated // block. Parse the block's JSON from the file text so the generator can // regenerate the FULL auto set (not just the delta) idempotently. function currentAutoKeys(): Set { try { const text = readFileSync(OPENAPI_FILE, "utf8"); const decl = text.indexOf("const autoGeneratedPaths = {"); if (decl === -1) return new Set(); const braceStart = text.indexOf("{", decl); // brace-count to the matching close — robust against any `};` noise let depth = 0; let end = -1; for (let i = braceStart; i < text.length; i++) { if (text[i] === "{") depth++; else if (text[i] === "}") { depth--; if (depth === 0) { end = i; break; } } } if (end === -1) return new Set(); const parsed = JSON.parse(text.slice(braceStart, end + 1)); return new Set(Object.keys(parsed)); } catch { return new Set(); } } const autoKeys = currentAutoKeys(); const handTuned = new Set( Object.keys(currentPaths).filter((p) => !autoKeys.has(p)), ); const generated: Record = {}; for (const file of routeFiles) { const specPath = routeToSpecPath(file); if (handTuned.has(specPath)) continue; // hand-tuned — keep richer entries const params = extractParams(specPath); const methods = detectMethods(file); const ops: Record = {}; for (const method of methods) { ops[method] = { tags: [tagFor(specPath)], summary: `${method.toUpperCase()} ${specPath}`, description: "Auto-generated OpenAPI entry for this route.", security: [{ bearerAuth: [] }], parameters: params, responses: { 200: { description: "Success" }, 400: { description: "Invalid request", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, }, }; } generated[specPath] = ops; } if (Object.keys(generated).length === 0) { console.log("No missing routes — nothing to generate."); process.exit(0); } // Render as a TS block: `const autoGeneratedPaths = {...};` merged into paths const snippet = ` // ───────────────────────────────────────────────────────────── // AUTO-GENERATED path entries (scripts/openapi-generate.ts) // Generic documentation for routes without hand-tuned entries. // Re-run the generator after adding routes; refine entries here. // ───────────────────────────────────────────────────────────── const autoGeneratedPaths = ${JSON.stringify(generated, null, 4)};`; let content = readFileSync(OPENAPI_FILE, "utf8"); // Remove any previous auto-generated block to keep the generator // idempotent. The block starts at the marker comment and ends at the // first `};` after `const autoGeneratedPaths = {`. function stripAutoGenerated(text: string): string { const marker = "// AUTO-GENERATED path entries (scripts/openapi-generate.ts)"; const start = text.indexOf(marker); if (start === -1) return text; // back up to the dashes line above the marker (it starts the block) const lineStart = text.lastIndexOf("\n", start - 1) + 1; let blockStart = lineStart; const above = text.slice(0, lineStart).trimEnd(); const dashLine = above.lastIndexOf("\n// ─"); if (dashLine !== -1) { blockStart = dashLine + 1; } else if (/\/\/ ─+$/.test(above)) { // dashes line is the very first line of the file blockStart = 0; } // brace-count to the declaration's matching close (robust against `};` noise) const declIdx = text.indexOf("const autoGeneratedPaths = {", start); if (declIdx === -1) return text; const braceStart = text.indexOf("{", declIdx); let depth = 0; let closeIdx = -1; for (let i = braceStart; i < text.length; i++) { if (text[i] === "{") depth++; else if (text[i] === "}") { depth--; if (depth === 0) { closeIdx = i; break; } } } if (closeIdx === -1) return text; let end = closeIdx + 1; if (text.slice(end, end + 1) === ";") end += 1; let result = text.slice(0, blockStart) + text.slice(end); // clean any leading dashes/blank remnants from previous runs result = result.replace(/^(?:\s*\n)*\/\/ ─+[^\n]*\n(?:\s*\n)*/, ""); result = result.replace(/^\s*\n+/, ""); return result; } content = stripAutoGenerated(content); // Insert the block BEFORE `export const openApiSpec` so the spread in // `paths: { ...autoGeneratedPaths,` resolves without TDZ errors content = content.replace( /(export const openApiSpec = \{)/, `${snippet.replace(/^\n/, "")}\n\n$1`, ); // Merge into the paths object if (!content.includes("...autoGeneratedPaths,")) { content = content.replace( "paths: {", "paths: {\n ...autoGeneratedPaths,", ); } writeFileSync(OPENAPI_FILE, content); console.log(`Generated ${Object.keys(generated).length} spec entries into ${relative(rootDir, OPENAPI_FILE)}`);