Skip to content

Commit ab30f3d

Browse files
authored
Force APIRouter.include_router to propagate Mount paths instead of dropping them. (#1)
This change ensures that mounted ASGI applications (such as Flask apps wrapped in WSGIMiddleware) are not dropped when `APIRouter.include_router` is called. Previously, while the initial load in `ExtensionLoader` worked as expected, the `Mount` objects were ignored by FastAPI's default inclusion logic. This caused the mounted elements to be lost during the final application assembly, resulting in 404 errors for the Flask routes. This fix overrides the inclusion logic to manually propagate `Mount` objects downstream.
1 parent 97d133c commit ab30f3d

File tree

4 files changed

+105
-4
lines changed

4 files changed

+105
-4
lines changed

openbb_platform/core/openbb_core/api/app_loader.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from openbb_core.app.router import RouterLoader
88
from openbb_core.provider.utils.errors import EmptyDataError, UnauthorizedError
99
from pydantic import ValidationError
10+
from starlette.routing import Mount
1011

1112

1213
class AppLoader:
@@ -15,8 +16,30 @@ class AppLoader:
1516
@staticmethod
1617
def add_routers(app: FastAPI, routers: list[APIRouter | None], prefix: str):
1718
"""Add routers."""
19+
20+
def _join_paths(p1: str, p2: str) -> str:
21+
if not p1:
22+
return p2 or "/"
23+
if not p2:
24+
return p1 or "/"
25+
joined = p1.rstrip("/") + "/" + p2.lstrip("/")
26+
return joined.rstrip("/") or "/"
27+
1828
for router in routers:
1929
if router:
30+
# FastAPI's include_router doesn't propagate Starlette Mount routes.
31+
# If an APIRouter contains mounted sub-apps (e.g. WSGIMiddleware for Flask),
32+
# mount them directly on the FastAPI app with the same prefix.
33+
for route in getattr(router, "routes", []):
34+
if not isinstance(route, Mount):
35+
continue
36+
mount_path = _join_paths(prefix, route.path)
37+
if any(
38+
isinstance(existing, Mount) and existing.path == mount_path
39+
for existing in app.router.routes
40+
):
41+
continue
42+
app.mount(mount_path, route.app, name=route.name)
2043
app.include_router(router=router, prefix=prefix)
2144

2245
@staticmethod

openbb_platform/core/openbb_core/api/router/commands.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from openbb_core.env import Env
2424
from openbb_core.provider.utils.helpers import to_snake_case
2525
from pydantic import BaseModel
26+
from starlette.routing import Mount
2627
from typing_extensions import ParamSpec
2728

2829
try:
@@ -349,7 +350,18 @@ def add_command_map(command_runner: CommandRunner, api_router: APIRouter) -> Non
349350
plugins_router = RouterLoader.from_extensions()
350351

351352
for route in plugins_router.api_router.routes:
352-
route.endpoint = build_api_wrapper(command_runner=command_runner, route=route) # type: ignore # noqa
353+
if isinstance(route, APIRoute):
354+
route.endpoint = build_api_wrapper(command_runner=command_runner, route=route) # type: ignore # noqa
355+
continue
356+
# Mounted sub-apps (e.g. WSGIMiddleware for Flask) are Starlette Mount routes.
357+
# APIRouter.include_router will not carry these over, so we mount them manually.
358+
if isinstance(route, Mount):
359+
if any(
360+
isinstance(existing, Mount) and existing.path == route.path
361+
for existing in api_router.routes
362+
):
363+
continue
364+
api_router.mount(route.path, route.app, name=route.name)
353365
api_router.include_router(router=plugins_router.api_router)
354366

355367

openbb_platform/core/openbb_core/app/router.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
)
1616

1717
from fastapi import APIRouter, Depends
18+
from fastapi.routing import APIRoute
1819
from openbb_core.app.deprecation import DeprecationSummary, OpenBBDeprecationWarning
1920
from openbb_core.app.extension_loader import ExtensionLoader
2021
from openbb_core.app.model.abstract.warning import OpenBBWarning
@@ -28,6 +29,7 @@
2829
)
2930
from openbb_core.env import Env
3031
from pydantic import BaseModel
32+
from starlette.routing import Mount
3133
from typing_extensions import ParamSpec
3234

3335
P = ParamSpec("P")
@@ -182,6 +184,29 @@ def include_router(
182184
prefix=prefix,
183185
tags=tags, # type: ignore
184186
)
187+
188+
# FastAPI's APIRouter.include_router only includes APIRoute instances.
189+
# Starlette Mount routes (used by .mount, e.g. for WSGIMiddleware) must
190+
# be manually propagated, otherwise mounted apps silently disappear.
191+
def _join_paths(p1: str, p2: str) -> str:
192+
if not p1:
193+
return p2 or "/"
194+
if not p2:
195+
return p1 or "/"
196+
joined = p1.rstrip("/") + "/" + p2.lstrip("/")
197+
return joined.rstrip("/") or "/"
198+
199+
for route in router.api_router.routes:
200+
if not isinstance(route, Mount):
201+
continue
202+
mount_path = _join_paths(prefix, route.path)
203+
if any(
204+
isinstance(existing, Mount) and existing.path == mount_path
205+
for existing in self._api_router.routes
206+
):
207+
continue
208+
self._api_router.mount(mount_path, route.app, name=route.name)
209+
185210
name = prefix if prefix else router.prefix
186211
self._routers[name.strip("/")] = router
187212

@@ -426,7 +451,11 @@ def get_command_map(
426451
) -> dict[str, Callable]:
427452
"""Get command map."""
428453
api_router = router.api_router
429-
command_map = {route.path: route.endpoint for route in api_router.routes} # type: ignore
454+
command_map = {
455+
route.path: route.endpoint
456+
for route in api_router.routes
457+
if isinstance(route, APIRoute)
458+
}
430459
return command_map
431460

432461
@staticmethod
@@ -440,6 +469,8 @@ def get_provider_coverage(
440469

441470
coverage_map: dict[Any, Any] = {}
442471
for route in api_router.routes:
472+
if not isinstance(route, APIRoute):
473+
continue
443474
openapi_extra = getattr(route, "openapi_extra", None)
444475
if openapi_extra:
445476
model = openapi_extra.get("model", None)
@@ -471,7 +502,9 @@ def get_command_coverage(
471502

472503
coverage_map: dict[Any, Any] = {}
473504
for route in api_router.routes:
474-
openapi_extra = getattr(route, "openapi_extra")
505+
if not isinstance(route, APIRoute):
506+
continue
507+
openapi_extra = getattr(route, "openapi_extra", None)
475508
if openapi_extra:
476509
model = openapi_extra.get("model", None)
477510
if model:
@@ -493,7 +526,9 @@ def get_commands_model(router: Router, sep: str | None = None) -> dict[str, str]
493526

494527
coverage_map: dict[Any, Any] = {}
495528
for route in api_router.routes:
496-
openapi_extra = getattr(route, "openapi_extra")
529+
if not isinstance(route, APIRoute):
530+
continue
531+
openapi_extra = getattr(route, "openapi_extra", None)
497532
if openapi_extra:
498533
model = openapi_extra.get("model", None)
499534
if model and hasattr(route, "path"):

openbb_platform/core/tests/app/test_platform_router.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
SignatureInspector,
1919
)
2020
from pydantic import BaseModel, ConfigDict
21+
from starlette.applications import Starlette
22+
from starlette.responses import PlainTextResponse
23+
from starlette.routing import Mount
2124

2225

2326
class MockBaseModel(BaseModel):
@@ -53,6 +56,34 @@ def test_include_router(router):
5356
assert router.include_router(some_router) is None
5457

5558

59+
def test_include_router_propagates_mount() -> None:
60+
"""Mounted sub-app routes should survive Router.include_router."""
61+
child = Router()
62+
child.api_router.mount(
63+
"/",
64+
Starlette(
65+
routes=[
66+
# A simple mounted route to prove the mount exists.
67+
# If Mount isn't propagated, it silently disappears.
68+
# (We don't need to issue HTTP requests here.)
69+
# noqa: E501
70+
],
71+
),
72+
)
73+
74+
# Mount an app that has at least one route.
75+
mounted_app = Starlette()
76+
mounted_app.add_route("/health", lambda *_: PlainTextResponse("ok"))
77+
child.api_router.routes.clear()
78+
child.api_router.mount("/", mounted_app)
79+
80+
parent = Router()
81+
parent.include_router(child, prefix="/flask")
82+
83+
mount_paths = [r.path for r in parent.api_router.routes if isinstance(r, Mount)]
84+
assert "/flask" in mount_paths
85+
86+
5687
@pytest.fixture(scope="module")
5788
def router_loader():
5889
"""Set up router_loader."""

0 commit comments

Comments
 (0)