From 4fe1bbcb94f7e5a73a861ff84e5f88c515b7ceac Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:37:09 -0700 Subject: [PATCH] fix tag grouping in swagger docs --- .../core/openbb_core/api/app_loader.py | 58 ++++++- .../core/openbb_core/api/rest_api.py | 4 + .../core/tests/api/test_app_loader.py | 147 ++++++++++++++++++ 3 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 openbb_platform/core/tests/api/test_app_loader.py diff --git a/openbb_platform/core/openbb_core/api/app_loader.py b/openbb_platform/core/openbb_core/api/app_loader.py index 1d8bcb37f75..42d561bc640 100644 --- a/openbb_platform/core/openbb_core/api/app_loader.py +++ b/openbb_platform/core/openbb_core/api/app_loader.py @@ -1,5 +1,7 @@ """App loader module.""" +from typing import Any + from fastapi import APIRouter, FastAPI from fastapi.exceptions import ResponseValidationError from pydantic import ValidationError @@ -24,7 +26,7 @@ class AppLoader: @staticmethod def add_openapi_tags(app: FastAPI): - """Add openapi tags.""" + """Declare extension tags and collapse per-level route tags into one.""" main_router = RouterLoader.from_extensions() # Add tag data for each router in the main router app.openapi_tags = [ @@ -35,6 +37,60 @@ class AppLoader: for r in main_router.routers ] + base_openapi = app.openapi + extension_names = set(main_router.routers) + + def _order(group: str, name: str) -> tuple[int, str]: + """Sort data extensions first; the core's own groups last.""" + return (0 if group in extension_names else 1, name.lower()) + + def _describe(levels: list[str]) -> str: + """Return the router description registered for a tag's full path.""" + return main_router.get_attr("/" + "/".join(levels), "description") or "" + + def openapi() -> dict[str, Any]: + """Build the schema, then rewrite its tags into the nested shape.""" + if app.openapi_schema: + return app.openapi_schema + + schema = base_openapi() + composites: dict[str, list[str]] = {} + + for path_item in schema.get("paths", {}).values(): + for operation in path_item.values(): + if not isinstance(operation, dict): + continue + levels = operation.get("tags") or [] + if not levels: + continue + composite = "/".join(levels) + composites[composite] = levels + operation["tags"] = [composite] + + if composites: + schema["tags"] = [ + {"name": name, "description": _describe(levels)} + for name, levels in sorted( + composites.items(), key=lambda kv: _order(kv[1][0], kv[0]) + ) + ] + groups: dict[str, list[str]] = {} + for name, levels in composites.items(): + groups.setdefault(levels[0], []).append(name) + schema["x-tagGroups"] = [ + {"name": group, "tags": sorted(names, key=str.lower)} + for group, names in sorted( + groups.items(), key=lambda kv: _order(kv[0], kv[0]) + ) + ] + + app.openapi_schema = schema + return schema + + # Shadowing the bound method with a plain function on the instance is + # the documented FastAPI hook for customising the schema. + app.openapi = openapi # type: ignore[method-assign] # ty: ignore[invalid-assignment] + @staticmethod def add_exception_handlers(app: FastAPI): """Add exception handlers.""" diff --git a/openbb_platform/core/openbb_core/api/rest_api.py b/openbb_platform/core/openbb_core/api/rest_api.py index 4bb4ec873ae..0a823f9858d 100644 --- a/openbb_platform/core/openbb_core/api/rest_api.py +++ b/openbb_platform/core/openbb_core/api/rest_api.py @@ -66,6 +66,10 @@ app = FastAPI( ], lifespan=lifespan, strict_content_type=False, + # Swagger UI defaults ``docExpansion`` to "list", which opens every tag + # section on load - hundreds of endpoint rows before the reader has picked + # anything. "none" renders the tag cards collapsed. + swagger_ui_parameters={"docExpansion": "none"}, ) app.add_middleware( CORSMiddleware, diff --git a/openbb_platform/core/tests/api/test_app_loader.py b/openbb_platform/core/tests/api/test_app_loader.py new file mode 100644 index 00000000000..810374d22b4 --- /dev/null +++ b/openbb_platform/core/tests/api/test_app_loader.py @@ -0,0 +1,147 @@ +"""Tests for ``AppLoader``'s OpenAPI tag rewriting.""" + +from fastapi import FastAPI + +from openbb_core.api.app_loader import AppLoader + + +def _app_with_base_schema(schema: dict) -> FastAPI: + """Return an app whose base ``openapi()`` yields ``schema``.""" + app = FastAPI() + app.openapi = lambda: schema # type: ignore[method-assign] + AppLoader.add_openapi_tags(app) + return app + + +def test_openapi_collapses_nested_tags_into_one_composite(): + """A route tagged per nesting level collapses to a single composite tag. + + Swagger renders an operation under every tag it carries, so leaving + ``["eia", "coal"]`` in place lists the endpoint twice, in two sections. + """ + schema = { + "paths": { + "/a": { + "get": {"tags": ["eia", "coal"]}, + # A path-level ``parameters`` list is not an operation. + "parameters": [], + }, + "/b": {"get": {"tags": ["eia"]}}, + "/c": {"get": {}}, + } + } + app = _app_with_base_schema(schema) + out = app.openapi() + + assert out["paths"]["/a"]["get"]["tags"] == ["eia/coal"] + assert out["paths"]["/b"]["get"]["tags"] == ["eia"] + # Untagged operations are left alone. + assert "tags" not in out["paths"]["/c"]["get"] + # No operation carries more than one tag, so nothing is listed twice. + assert all( + len(op.get("tags", [])) <= 1 + for item in out["paths"].values() + for op in item.values() + if isinstance(op, dict) + ) + + +def test_openapi_emits_redoc_tag_groups(): + """The first level becomes a ReDoc ``x-tagGroups`` group.""" + schema = { + "paths": { + "/a": {"get": {"tags": ["eia", "petroleum", "prices"]}}, + "/b": {"get": {"tags": ["eia", "coal"]}}, + "/c": {"get": {"tags": ["sec"]}}, + } + } + out = _app_with_base_schema(schema).openapi() + + assert out["x-tagGroups"] == [ + {"name": "eia", "tags": ["eia/coal", "eia/petroleum/prices"]}, + {"name": "sec", "tags": ["sec"]}, + ] + assert [t["name"] for t in out["tags"]] == [ + "eia/coal", + "eia/petroleum/prices", + "sec", + ] + + +def test_openapi_unknown_tag_path_gets_empty_description(): + """A tag with no matching router resolves to an empty description.""" + out = _app_with_base_schema( + {"paths": {"/a": {"get": {"tags": ["not_a_router_xyz"]}}}} + ).openapi() + assert out["tags"] == [{"name": "not_a_router_xyz", "description": ""}] + + +def test_openapi_without_tagged_operations_adds_nothing(): + """With no tagged operation there is no tag list or group list to add.""" + out = _app_with_base_schema({"paths": {"/a": {"get": {}}}}).openapi() + assert "x-tagGroups" not in out + assert "tags" not in out + + +def test_openapi_returns_cached_schema(): + """A built schema is returned as-is on later calls.""" + app = _app_with_base_schema({"paths": {}}) + app.openapi_schema = {"already": "built"} + assert app.openapi() == {"already": "built"} + + +def test_core_groups_sort_after_data_extensions(monkeypatch): + """``Coverage`` and friends sort last, below the data extensions. + + They come from routers the core mounts itself, so they are absent from + ``main_router.routers``. Plain alphabetical order floats them to the top + (``"C" < "e"`` in ASCII), burying what the API is actually for. + """ + + class _FakeRouter: + routers = {"eia": object(), "sec": object()} + + @staticmethod + def get_attr(_path, _attr): + return "" + + monkeypatch.setattr( + "openbb_core.api.app_loader.RouterLoader.from_extensions", + _FakeRouter, + ) + + schema = { + "paths": { + "/a": {"get": {"tags": ["Coverage"]}}, + "/b": {"get": {"tags": ["System"]}}, + "/c": {"get": {"tags": ["sec"]}}, + "/d": {"get": {"tags": ["eia", "coal"]}}, + "/e": {"get": {"tags": ["eia"]}}, + } + } + out = _app_with_base_schema(schema).openapi() + + assert [t["name"] for t in out["tags"]] == [ + "eia", + "eia/coal", + "sec", + "Coverage", + "System", + ] + assert [g["name"] for g in out["x-tagGroups"]] == [ + "eia", + "sec", + "Coverage", + "System", + ] + + +def test_swagger_ui_opens_with_every_tag_collapsed(): + """``docExpansion="none"`` keeps the tag cards closed on load. + + Swagger UI's own default is "list", which expands every section and dumps + hundreds of endpoint rows on the reader before they pick anything. + """ + from openbb_core.api.rest_api import app + + assert (app.swagger_ui_parameters or {}).get("docExpansion") == "none"