fix merge widgets

This commit is contained in:
Danglewood
2026-07-02 11:10:50 -07:00
parent f6867c2b18
commit 768e52c635
2 changed files with 93 additions and 48 deletions

View File

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

View File

@@ -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 = {