mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 18:52:18 +03:00
fix(cli): openapi endpoints/paths/validate accept the served catalog shape (#10091)
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
This commit is contained in:
@@ -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()
|
||||
);
|
||||
});
|
||||
|
||||
88
tests/unit/cli-openapi-endpoints-shape-10082.test.ts
Normal file
88
tests/unit/cli-openapi-endpoints-shape-10082.test.ts
Normal file
@@ -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 } }), []);
|
||||
});
|
||||
Reference in New Issue
Block a user