diff --git a/openbb_platform/extensions/platform_api/openbb_platform_api/utils/merge_widgets.py b/openbb_platform/extensions/platform_api/openbb_platform_api/utils/merge_widgets.py index 151a5bce61f..768f55f67c5 100644 --- a/openbb_platform/extensions/platform_api/openbb_platform_api/utils/merge_widgets.py +++ b/openbb_platform/extensions/platform_api/openbb_platform_api/utils/merge_widgets.py @@ -47,67 +47,38 @@ async def get_additional_widgets(app: FastAPI) -> dict: def fix_router_widgets(path, widgets): - """Append the API prefix and path to the function, if necessary.""" + """Restore the full API route path on every router-attached widget.""" updated_widgets: dict = {} + + def _restore(value: str) -> str: + if not value or "://" in value or value.startswith(path): + return value + return path + value.lstrip("/") + for widget_id, widget in widgets.items(): if not isinstance(widget, dict) or widget_id.endswith("/widgets.json"): continue new_widget: dict = widget.copy() - params = widget.get("params", []) - if (endpoint := widget.get("endpoint", "")) and not endpoint.startswith(path): - new_widget["endpoint"] = ( - path + endpoint[1:] if endpoint.startswith("/") else endpoint - ) - - if ( - (ws_endpoint := widget.get("wsEndpoint", "")) - and "://" not in ws_endpoint - and not ws_endpoint.startswith(path) - ): - new_widget["wsEndpoint"] = ( - path + ws_endpoint[1:] if ws_endpoint.startswith("/") else ws_endpoint - ) - - if ( - (img_url := widget.get("imgUrl", "")) - and "://" not in img_url - and not img_url.startswith(path) - ): - new_widget["imgUrl"] = ( - path + img_url[1:] if img_url.startswith("/") else img_url - ) + if endpoint := widget.get("endpoint", ""): + new_widget["endpoint"] = _restore(endpoint) + if ws_endpoint := widget.get("wsEndpoint", ""): + new_widget["wsEndpoint"] = _restore(ws_endpoint) + if img_url := widget.get("imgUrl", ""): + new_widget["imgUrl"] = _restore(img_url) new_params: list = [] - - for param in params: + for param in widget.get("params", []): new_param: dict = param.copy() - - if ( - (endpoint := param.get("endpoint", "")) - and "://" not in endpoint - and not endpoint.startswith(path) - ): - new_param["endpoint"] = ( - path + endpoint[1:] if endpoint.startswith("/") else endpoint - ) - - if ( - (opt_endpoint := param.get("optionsEndpoint", "")) - and "://" not in opt_endpoint - and not opt_endpoint.startswith(path) - ): - new_param["optionsEndpoint"] = ( - path + opt_endpoint[1:] - if opt_endpoint.startswith("/") - else opt_endpoint - ) - + if endpoint := param.get("endpoint", ""): + new_param["endpoint"] = _restore(endpoint) + if opt_endpoint := param.get("optionsEndpoint", ""): + new_param["optionsEndpoint"] = _restore(opt_endpoint) new_params.append(new_param) new_widget["params"] = new_params - updated_widgets[new_widget.get("widgetId", new_widget["endpoint"])] = new_widget + updated_widgets[new_widget.get("widgetId", widget_id)] = new_widget return updated_widgets diff --git a/openbb_platform/extensions/platform_api/tests/utils/test_merge_widgets.py b/openbb_platform/extensions/platform_api/tests/utils/test_merge_widgets.py index e911c1ad35a..63b401f51cb 100644 --- a/openbb_platform/extensions/platform_api/tests/utils/test_merge_widgets.py +++ b/openbb_platform/extensions/platform_api/tests/utils/test_merge_widgets.py @@ -186,6 +186,80 @@ def test_fix_router_widgets_updates_nested_paths_without_mutating_source(): assert widget["params"][1]["endpoint"] == "http://external/api" +def test_fix_router_widgets_prefixes_bare_relative_endpoints(): + """Bare (non-slash) endpoints from a standalone router get the full path. + + Per-district routers serve their widgets with bare endpoints so the router + can be added to the Workspace as a standalone backend; merging them into the + root widgets.json must restore the full route path for those too, not only + ``/``-prefixed endpoints. + """ + widgets = { + "kc_natural_rate": { + "widgetId": "kc_natural_rate", + "endpoint": "natural_rate", + "params": [{"name": "report", "optionsEndpoint": "natural_rate_choices"}], + } + } + updated = fix_router_widgets("/federal_reserve/kc/", widgets) + assert list(updated.keys()) == ["kc_natural_rate"] + widget = updated["kc_natural_rate"] + assert widget["endpoint"] == "/federal_reserve/kc/natural_rate" + assert ( + widget["params"][0]["optionsEndpoint"] + == "/federal_reserve/kc/natural_rate_choices" + ) + + +def test_fix_router_widgets_falls_back_to_source_key_without_widget_id(): + """A widget without a widgetId keeps its source key rather than the endpoint.""" + widgets = {"custom_report": {"endpoint": "report"}} + updated = fix_router_widgets("/federal_reserve/sf/", widgets) + assert list(updated.keys()) == ["custom_report"] + assert updated["custom_report"]["endpoint"] == "/federal_reserve/sf/report" + + +@pytest.mark.asyncio +async def test_get_and_fix_widget_paths_carries_configured_api_prefix(): + """A router mounted under an API prefix yields prefixed merged endpoints. + + The merge restores the *full* route path discovered from the app, so whatever + prefix the platform is mounted under (e.g. ``/api/v1`` or whatever + ``api_settings.prefix`` dictates) flows into every merged endpoint and options + endpoint. The router's own widgets.json stays relative (bare) so it still + works as a standalone backend. + """ + app = FastAPI() + + @app.get("/widgets.json") + async def root_widgets(): + return {"root": {"widgetId": "root", "endpoint": "/root"}} + + sub = APIRouter() + + @sub.get("/widgets.json") + async def sub_widgets(): + return { + "kc_natural_rate": { + "widgetId": "kc_natural_rate", + "endpoint": "natural_rate", + "params": [ + {"paramName": "x", "optionsEndpoint": "natural_rate_choices"} + ], + } + } + + app.include_router(sub, prefix="/api/v1/federal_reserve/kc") + result = await get_and_fix_widget_paths(app) + + widget = result["/api/v1/federal_reserve/kc/"]["kc_natural_rate"] + assert widget["endpoint"] == "/api/v1/federal_reserve/kc/natural_rate" + assert ( + widget["params"][0]["optionsEndpoint"] + == "/api/v1/federal_reserve/kc/natural_rate_choices" + ) + + def test_fix_router_widgets_leaves_prefixed_and_missing_fields_untouched(): """Already-prefixed or absent endpoint/wsEndpoint/imgUrl are left as-is.""" widgets = {