diff --git a/bin/cli/commands/openapi.mjs b/bin/cli/commands/openapi.mjs index 443a0910bb..0b1ced838e 100644 --- a/bin/cli/commands/openapi.mjs +++ b/bin/cli/commands/openapi.mjs @@ -41,11 +41,83 @@ function toYaml(obj, indent = 0) { .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.openapi && !spec.swagger) throw new Error("missing openapi/swagger version field"); if (!spec.info) throw new Error("missing info object"); - if (!spec.paths) throw new Error("missing paths 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 = [ @@ -132,20 +204,7 @@ export function registerOpenapi(program) { process.exit(1); } const spec = await res.json(); - const rows = []; - for (const [path, methods] of Object.entries(spec.paths ?? {})) { - for (const [method, def] of Object.entries(methods)) { - if (["parameters", "summary"].includes(method)) continue; - const summary = def.summary ?? def.description ?? ""; - if ( - opts.search && - !path.includes(opts.search) && - !summary.toLowerCase().includes(opts.search.toLowerCase()) - ) - continue; - rows.push({ method: method.toUpperCase(), path, summary, operationId: def.operationId }); - } - } + const rows = extractEndpoints(spec).filter((row) => matchesSearch(row, opts.search)); emit(rows, cmd.optsWithGlobals(), endpointSchema); }); @@ -159,9 +218,8 @@ export function registerOpenapi(program) { process.exit(1); } const spec = await res.json(); - const paths = Object.keys(spec.paths ?? {}).sort(); emit( - paths.map((p) => ({ path: p })), + extractPaths(spec).map((p) => ({ path: p })), cmd.optsWithGlobals() ); }); diff --git a/tests/unit/cli-openapi-endpoints-shape-10082.test.ts b/tests/unit/cli-openapi-endpoints-shape-10082.test.ts new file mode 100644 index 0000000000..72e6d514cd --- /dev/null +++ b/tests/unit/cli-openapi-endpoints-shape-10082.test.ts @@ -0,0 +1,88 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #10082 — `GET /api/openapi/spec` answers with a compact catalog +// ({ info, servers, tags, endpoints[], schemas }), not an OpenAPI document. +// The CLI read `spec.paths`, so `openapi endpoints` and `openapi paths` printed +// "(empty)" against a live server and `openapi validate` reported the API as +// invalid, even though 318 endpoints were sitting in `spec.endpoints`. + +const { extractEndpoints, extractPaths } = await import("../../bin/cli/commands/openapi.mjs"); + +const CATALOG_SHAPE = { + info: { title: "OmniRoute API", version: "3.8.50" }, + servers: [{ url: "http://localhost:20128" }], + tags: ["Playground"], + endpoints: [ + { + method: "POST", + path: "/api/playground/improve-prompt", + tags: ["Playground"], + summary: "Improve prompt via LLM", + }, + { method: "GET", path: "/api/providers", summary: "List provider connections" }, + { path: "/api/health", description: "Health probe" }, + ], + schemas: {}, +}; + +const SPEC_SHAPE = { + openapi: "3.1.0", + info: { title: "OmniRoute API", version: "3.8.50" }, + paths: { + "/api/providers": { + // Path Item fields that sit alongside operations must not be treated as one. + parameters: [{ name: "limit", in: "query" }], + summary: "Provider connections", + get: { summary: "List provider connections", operationId: "listProviders" }, + post: { description: "Create connection", operationId: "createProvider" }, + }, + "/api/health": { get: { summary: "Health probe" } }, + }, +}; + +test("extractEndpoints reads the compact catalog shape served by /api/openapi/spec", () => { + const rows = extractEndpoints(CATALOG_SHAPE); + + assert.equal(rows.length, 3); + assert.deepEqual(rows[0], { + method: "POST", + path: "/api/playground/improve-prompt", + summary: "Improve prompt via LLM", + operationId: undefined, + }); + // description is used when summary is absent, and method defaults to GET. + assert.deepEqual(rows[2], { + method: "GET", + path: "/api/health", + summary: "Health probe", + operationId: undefined, + }); +}); + +test("extractEndpoints still reads a real OpenAPI document", () => { + const rows = extractEndpoints(SPEC_SHAPE); + + assert.equal(rows.length, 3); + assert.ok(rows.every((r) => r.method !== "PARAMETERS" && r.method !== "SUMMARY")); + const get = rows.find((r) => r.path === "/api/providers" && r.method === "GET"); + assert.equal(get?.operationId, "listProviders"); + assert.equal(get?.summary, "List provider connections"); +}); + +test("extractPaths returns sorted, de-duplicated paths for both shapes", () => { + assert.deepEqual(extractPaths(CATALOG_SHAPE), [ + "/api/health", + "/api/playground/improve-prompt", + "/api/providers", + ]); + // /api/providers has two operations but must appear once. + assert.deepEqual(extractPaths(SPEC_SHAPE), ["/api/health", "/api/providers"]); +}); + +test("extractEndpoints degrades to an empty list on junk instead of throwing", () => { + assert.deepEqual(extractEndpoints(null), []); + assert.deepEqual(extractEndpoints({}), []); + assert.deepEqual(extractEndpoints({ info: {}, endpoints: "nope" }), []); + assert.deepEqual(extractEndpoints({ paths: { "/a": null } }), []); +});