Browse Source

Fix mounting sub-applications under APIRouter (#10180)

Sub-apps mounted on an APIRouter were silently dropped when the router
was included into a FastAPI app via include_router(), causing 404s for
any path under the mount (e.g. GET /api/subapi/sub).

Two changes:

1. APIRouter.mount() override — prepends self.prefix to the path before
   delegating to Router.mount(), matching the behaviour of add_api_route
   which already does `self.prefix + path`. This ensures the stored
   route.path is consistent whether the mount is registered on the router
   or on the app directly.

2. include_router() Mount branch — a new `elif isinstance(route, routing.Mount)`
   case forwards mounted sub-applications with `prefix + route.path`,
   exactly as is done for every other route type (Route, APIRoute,
   WebSocketRoute).

Both prefixes compose correctly: router-level prefix and include_router
prefix stack in the same way as for ordinary endpoints.

Fixes #10180
pull/15246/head
Sai Tarrun Pitta 4 months ago
parent
commit
0d5ecfae67
  1. 14
      fastapi/routing.py
  2. 139
      tests/test_router_mount.py

14
fastapi/routing.py

@ -1499,6 +1499,18 @@ class APIRouter(routing.Router):
)
self.routes.append(route)
def mount(self, path: str, app: ASGIApp, name: str | None = None) -> None:
"""
Mount a sub-application at the given path.
Overrides `Router.mount` so that the router's own prefix is applied,
matching the behaviour of `add_api_route`. The stored route path will
be `self.prefix + path`, which is then further prefixed by any
`prefix` argument passed to `include_router` when this router is
included into another router or application.
"""
super().mount(self.prefix + path, app=app, name=name)
def websocket(
self,
path: Annotated[
@ -1794,6 +1806,8 @@ class APIRouter(routing.Router):
self.strict_content_type,
),
)
elif isinstance(route, routing.Mount):
self.mount(prefix + route.path, route.app, route.name)
elif isinstance(route, routing.Route):
methods = list(route.methods or [])
self.add_route(

139
tests/test_router_mount.py

@ -0,0 +1,139 @@
"""
Tests for issue #10180: mounting sub-applications under APIRouter.
Sub-apps mounted on an APIRouter (with a prefix) must be reachable via the
combined prefix + mount-path after include_router().
"""
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
def make_sub_app() -> FastAPI:
sub = FastAPI()
@sub.get("/sub")
def read_sub():
return {"message": "Hello World from sub API"}
return sub
# ---------------------------------------------------------------------------
# Router prefix baked into APIRouter(prefix=...) itself
# ---------------------------------------------------------------------------
def test_router_prefix_mount_routing():
"""Request to /api/subapi/sub must return 200 (issue #10180 exact repro)."""
router = APIRouter(prefix="/api")
@router.get("/app")
def read_app():
return {"message": "Hello World from main app"}
router.mount("/subapi", make_sub_app())
app = FastAPI()
app.include_router(router)
client = TestClient(app)
assert client.get("/api/app").status_code == 200
assert client.get("/api/subapi/sub").status_code == 200
assert client.get("/api/subapi/sub").json() == {
"message": "Hello World from sub API"
}
# ---------------------------------------------------------------------------
# Prefix supplied via include_router(prefix=...)
# ---------------------------------------------------------------------------
def test_include_router_prefix_mount_routing():
"""Prefix from include_router() is applied to mounted sub-apps."""
router = APIRouter()
router.mount("/subapi", make_sub_app())
app = FastAPI()
app.include_router(router, prefix="/api")
client = TestClient(app)
assert client.get("/api/subapi/sub").status_code == 200
assert client.get("/api/subapi/sub").json() == {
"message": "Hello World from sub API"
}
# ---------------------------------------------------------------------------
# Combination: both router prefix AND include_router prefix
# ---------------------------------------------------------------------------
def test_combined_prefix_mount_routing():
"""Router prefix and include_router prefix are both applied to mounts."""
router = APIRouter(prefix="/api")
router.mount("/subapi", make_sub_app())
app = FastAPI()
app.include_router(router, prefix="/v1")
client = TestClient(app)
assert client.get("/v1/api/subapi/sub").status_code == 200
# ---------------------------------------------------------------------------
# Nested routers
# ---------------------------------------------------------------------------
def test_nested_router_mount_routing():
"""Mounts survive two levels of include_router nesting."""
inner = APIRouter(prefix="/inner")
inner.mount("/subapi", make_sub_app())
outer = APIRouter(prefix="/outer")
outer.include_router(inner)
app = FastAPI()
app.include_router(outer)
client = TestClient(app)
assert client.get("/outer/inner/subapi/sub").status_code == 200
# ---------------------------------------------------------------------------
# Sub-app openapi.json is reachable at the correct path
# ---------------------------------------------------------------------------
def test_router_prefix_mount_openapi():
"""Sub-app's OpenAPI spec is accessible at the combined prefix path."""
router = APIRouter(prefix="/api")
router.mount("/subapi", make_sub_app())
app = FastAPI()
app.include_router(router)
client = TestClient(app)
response = client.get("/api/subapi/openapi.json")
assert response.status_code == 200
data = response.json()
assert data["openapi"] == "3.1.0"
assert "/sub" in data["paths"]
# root_path is reflected in the servers list
assert any("/api/subapi" in s["url"] for s in data.get("servers", []))
# ---------------------------------------------------------------------------
# Mounts directly on FastAPI app are unaffected
# ---------------------------------------------------------------------------
def test_direct_app_mount_unaffected():
"""Existing behaviour: mounting directly on FastAPI still works."""
app = FastAPI()
app.mount("/subapi", make_sub_app())
client = TestClient(app)
assert client.get("/subapi/sub").status_code == 200
Loading…
Cancel
Save