mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-28 21:12:16 +00:00
fix tag grouping in swagger docs
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
147
openbb_platform/core/tests/api/test_app_loader.py
Normal file
147
openbb_platform/core/tests/api/test_app_loader.py
Normal 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"
|
||||
Reference in New Issue
Block a user