fix tag grouping in swagger docs

This commit is contained in:
Danglewood
2026-09-18 19:37:09 -07:00
parent 99e5c8618e
commit 4fe1bbcb94
3 changed files with 208 additions and 1 deletions

View File

@@ -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."""

View File

@@ -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,

View File

@@ -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"