mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 18:52:18 +03:00
GET /api/openapi/spec answers with a compact catalog
({ info, servers, tags, endpoints[], schemas }) rather than an OpenAPI document,
while dist/docs/openapi.yaml is a real spec. The CLI only read spec.paths, so
against a live server 'openapi endpoints' and 'openapi paths' printed nothing
and 'openapi validate' reported 'missing openapi/swagger version field' — with
318 endpoints sitting in spec.endpoints.
Normalize both shapes through extractEndpoints()/extractPaths() and let
validateBasic() accept a catalog that carries endpoints[] instead of a version
field. Path Item members that are not operations (parameters, summary,
description, servers, $ref) are no longer emitted as fake operations.
Closes #10082
227 lines
7.5 KiB
JavaScript
227 lines
7.5 KiB
JavaScript
import { readFileSync, writeFileSync } from "node:fs";
|
|
import { apiFetch } from "../api.mjs";
|
|
import { emit } from "../output.mjs";
|
|
import { t } from "../i18n.mjs";
|
|
|
|
function truncate(v, max = 40) {
|
|
if (!v) return "-";
|
|
const s = String(v);
|
|
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
|
}
|
|
|
|
function toYaml(obj, indent = 0) {
|
|
const pad = " ".repeat(indent);
|
|
if (obj === null || obj === undefined) return "null";
|
|
if (typeof obj === "boolean") return String(obj);
|
|
if (typeof obj === "number") return String(obj);
|
|
if (typeof obj === "string") {
|
|
if (/[\n:#{}[\],&*?|<>=!%@`]/.test(obj) || obj.trim() !== obj) {
|
|
return JSON.stringify(obj);
|
|
}
|
|
return obj || '""';
|
|
}
|
|
if (Array.isArray(obj)) {
|
|
if (obj.length === 0) return "[]";
|
|
return obj.map((v) => `\n${pad}- ${toYaml(v, indent + 1)}`).join("");
|
|
}
|
|
const entries = Object.entries(obj);
|
|
if (entries.length === 0) return "{}";
|
|
return entries
|
|
.map(([k, v]) => {
|
|
const safeKey = /[^a-zA-Z0-9_-]/.test(k) ? JSON.stringify(k) : k;
|
|
if (v !== null && typeof v === "object") {
|
|
const nested = toYaml(v, indent + 1);
|
|
if (Array.isArray(v) && v.length > 0) return `\n${pad}${safeKey}:${nested}`;
|
|
if (!Array.isArray(v) && Object.keys(v).length > 0) return `\n${pad}${safeKey}:\n${nested}`;
|
|
return `\n${pad}${safeKey}: ${nested}`;
|
|
}
|
|
return `\n${pad}${safeKey}: ${toYaml(v, indent + 1)}`;
|
|
})
|
|
.join("")
|
|
.trimStart();
|
|
}
|
|
|
|
// Keys that live alongside operations inside a Path Item Object but are not
|
|
// themselves operations (OpenAPI 3.x Path Item fields).
|
|
const NON_OPERATION_PATH_KEYS = new Set([
|
|
"parameters",
|
|
"summary",
|
|
"description",
|
|
"servers",
|
|
"$ref",
|
|
]);
|
|
|
|
/**
|
|
* `GET /api/openapi/spec` answers with a compact catalog
|
|
* (`{ info, servers, tags, endpoints[], schemas }`) rather than an OpenAPI
|
|
* document with a `paths` object, while `dist/docs/openapi.yaml` is a real
|
|
* spec. Normalize either shape into the flat rows the CLI renders so the
|
|
* commands work against both instead of silently printing nothing.
|
|
*/
|
|
export function extractEndpoints(spec) {
|
|
if (!spec || typeof spec !== "object") return [];
|
|
|
|
if (spec.paths && typeof spec.paths === "object") {
|
|
const rows = [];
|
|
for (const [path, pathItem] of Object.entries(spec.paths)) {
|
|
if (!pathItem || typeof pathItem !== "object") continue;
|
|
for (const [method, def] of Object.entries(pathItem)) {
|
|
if (NON_OPERATION_PATH_KEYS.has(method)) continue;
|
|
if (!def || typeof def !== "object") continue;
|
|
rows.push({
|
|
method: method.toUpperCase(),
|
|
path,
|
|
summary: def.summary ?? def.description ?? "",
|
|
operationId: def.operationId,
|
|
});
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
if (Array.isArray(spec.endpoints)) {
|
|
return spec.endpoints
|
|
.filter((entry) => entry && typeof entry === "object" && entry.path)
|
|
.map((entry) => ({
|
|
method: String(entry.method ?? "GET").toUpperCase(),
|
|
path: entry.path,
|
|
summary: entry.summary ?? entry.description ?? "",
|
|
operationId: entry.operationId,
|
|
}));
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/** Sorted, de-duplicated list of paths across either shape. */
|
|
export function extractPaths(spec) {
|
|
return [...new Set(extractEndpoints(spec).map((row) => row.path))].sort();
|
|
}
|
|
|
|
function matchesSearch(row, query) {
|
|
if (!query) return true;
|
|
const needle = query.toLowerCase();
|
|
return row.path.includes(query) || String(row.summary).toLowerCase().includes(needle);
|
|
}
|
|
|
|
function validateBasic(spec) {
|
|
if (!spec || typeof spec !== "object") throw new Error("spec is not an object");
|
|
if (!spec.info) throw new Error("missing info object");
|
|
|
|
// A real OpenAPI document must carry a version field and a paths object.
|
|
if (spec.openapi || spec.swagger) {
|
|
if (!spec.paths) throw new Error("missing paths object");
|
|
return;
|
|
}
|
|
|
|
// The compact catalog served by /api/openapi/spec carries endpoints[] instead.
|
|
if (Array.isArray(spec.endpoints)) return;
|
|
|
|
throw new Error("missing openapi/swagger version field and no endpoints[] catalog");
|
|
}
|
|
|
|
const endpointSchema = [
|
|
{ key: "method", header: "Method", width: 8 },
|
|
{ key: "path", header: "Path", width: 45 },
|
|
{ key: "operationId", header: "Operation ID", width: 25 },
|
|
{ key: "summary", header: "Summary", width: 40, formatter: (v) => truncate(v, 40) },
|
|
];
|
|
|
|
export function registerOpenapi(program) {
|
|
const api = program.command("openapi").description(t("openapi.description"));
|
|
|
|
api
|
|
.command("dump")
|
|
.description(t("openapi.dump.description"))
|
|
.option("--format <f>", t("openapi.dump.format"), "yaml")
|
|
.option("--out <path>", t("openapi.dump.out"))
|
|
.action(async (opts, cmd) => {
|
|
const res = await apiFetch("/api/openapi/spec");
|
|
if (!res.ok) {
|
|
process.stderr.write(`Error: ${res.status}\n`);
|
|
process.exit(1);
|
|
}
|
|
const data = await res.json();
|
|
const serialized =
|
|
opts.format === "yaml" ? toYaml(data) + "\n" : JSON.stringify(data, null, 2);
|
|
if (opts.out) {
|
|
writeFileSync(opts.out, serialized);
|
|
process.stdout.write(`Saved to ${opts.out}\n`);
|
|
} else {
|
|
process.stdout.write(serialized);
|
|
}
|
|
});
|
|
|
|
api
|
|
.command("validate")
|
|
.description(t("openapi.validate.description"))
|
|
.action(async (opts, cmd) => {
|
|
const res = await apiFetch("/api/openapi/spec");
|
|
if (!res.ok) {
|
|
process.stderr.write(`Error: ${res.status}\n`);
|
|
process.exit(1);
|
|
}
|
|
const spec = await res.json();
|
|
try {
|
|
validateBasic(spec);
|
|
process.stdout.write("Spec is valid\n");
|
|
} catch (err) {
|
|
process.stderr.write(`Invalid: ${err.message}\n`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
api
|
|
.command("try <path>")
|
|
.description(t("openapi.try.description"))
|
|
.option("--method <m>", t("openapi.try.method"), "GET")
|
|
.option("--body <file>", t("openapi.try.body"))
|
|
.option("--query <kv>", t("openapi.try.query"), (v, prev = []) => [...prev, v.split("=")], [])
|
|
.option("--header <kv>", t("openapi.try.header"), (v, prev = []) => [...prev, v.split("=")], [])
|
|
.action(async (path, opts, cmd) => {
|
|
const body = opts.body ? JSON.parse(readFileSync(opts.body, "utf8")) : undefined;
|
|
const query = Object.fromEntries(opts.query ?? []);
|
|
const headers = Object.fromEntries(opts.header ?? []);
|
|
const res = await apiFetch("/api/openapi/try", {
|
|
method: "POST",
|
|
body: { path, method: opts.method, body, query, headers },
|
|
});
|
|
if (!res.ok) {
|
|
process.stderr.write(`Error: ${res.status}\n`);
|
|
process.exit(1);
|
|
}
|
|
emit(await res.json(), cmd.optsWithGlobals());
|
|
});
|
|
|
|
api
|
|
.command("endpoints")
|
|
.description(t("openapi.endpoints.description"))
|
|
.option("--search <q>", t("openapi.endpoints.search"))
|
|
.action(async (opts, cmd) => {
|
|
const res = await apiFetch("/api/openapi/spec");
|
|
if (!res.ok) {
|
|
process.stderr.write(`Error: ${res.status}\n`);
|
|
process.exit(1);
|
|
}
|
|
const spec = await res.json();
|
|
const rows = extractEndpoints(spec).filter((row) => matchesSearch(row, opts.search));
|
|
emit(rows, cmd.optsWithGlobals(), endpointSchema);
|
|
});
|
|
|
|
api
|
|
.command("paths")
|
|
.description(t("openapi.paths.description"))
|
|
.action(async (opts, cmd) => {
|
|
const res = await apiFetch("/api/openapi/spec");
|
|
if (!res.ok) {
|
|
process.stderr.write(`Error: ${res.status}\n`);
|
|
process.exit(1);
|
|
}
|
|
const spec = await res.json();
|
|
emit(
|
|
extractPaths(spec).map((p) => ({ path: p })),
|
|
cmd.optsWithGlobals()
|
|
);
|
|
});
|
|
}
|