fix: enable mounting sub-applications under APIRouter (#10180)

Added support for mounting sub-applications on APIRouter with proper
prefix handling:

1. Override mount() in APIRouter to automatically apply the router's
   prefix to the mount path

2. Handle Mount routes in include_router() to apply the include prefix

This allows patterns like:
    router = APIRouter(prefix='/api')
    router.mount('/subapp', FastAPI())
    app.include_router(router)

Which correctly routes requests to /api/subapp/...

Note: Mounts must be added before include_router() is called, as
include_router copies routes at call time. This is consistent with
how other routes work.

Fixes #10180
This commit is contained in:
Jonathan Fulton 2026-01-31 18:57:11 -05:00
parent 08924400c2
commit b6412448bc
2 changed files with 150 additions and 0 deletions

View File

@ -928,6 +928,20 @@ class APIRouter(routing.Router):
self.default_response_class = default_response_class
self.generate_unique_id_function = generate_unique_id_function
def mount(
self,
path: str,
app: ASGIApp,
name: Optional[str] = None,
) -> None:
"""
Mount a sub-application or ASGI app at the given path.
The router's prefix is automatically applied to the mount path.
"""
# Apply the router's prefix to the mount path
super().mount(self.prefix + path, app, name=name)
def route(
self,
path: str,
@ -1423,6 +1437,9 @@ class APIRouter(routing.Router):
self.add_websocket_route(
prefix + route.path, route.endpoint, name=route.name
)
elif isinstance(route, Mount):
# Handle mounted sub-applications by re-mounting with the prefix
self.mount(prefix + route.path, route.app, name=route.name)
for handler in router.on_startup:
self.add_event_handler("startup", handler)
for handler in router.on_shutdown:

133
tests/test_router_mount.py Normal file
View File

@ -0,0 +1,133 @@
"""Test mounting sub-applications under APIRouter."""
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
def test_router_mount_basic():
"""Sub-applications mounted on a router should work when included in the app."""
app = FastAPI()
api_router = APIRouter(prefix="/api")
@api_router.get("/main")
def read_main():
return {"message": "Hello from main"}
subapi = FastAPI()
@subapi.get("/sub")
def read_sub():
return {"message": "Hello from sub"}
# Mount BEFORE include_router
api_router.mount("/subapi", subapi)
app.include_router(api_router)
client = TestClient(app)
# Main route should work
response = client.get("/api/main")
assert response.status_code == 200
assert response.json() == {"message": "Hello from main"}
# Sub-application route should also work
response = client.get("/api/subapi/sub")
assert response.status_code == 200
assert response.json() == {"message": "Hello from sub"}
def test_router_mount_with_include_prefix():
"""Mount path should combine with both router prefix and include_router prefix."""
app = FastAPI()
api_router = APIRouter(prefix="/v1")
subapi = FastAPI()
@subapi.get("/endpoint")
def sub_endpoint():
return {"version": "1"}
api_router.mount("/mounted", subapi)
app.include_router(api_router, prefix="/api")
client = TestClient(app)
# Full path: /api + /v1 + /mounted + /endpoint
response = client.get("/api/v1/mounted/endpoint")
assert response.status_code == 200
assert response.json() == {"version": "1"}
def test_router_mount_without_prefix():
"""Mount should work on router without prefix."""
app = FastAPI()
api_router = APIRouter() # No prefix
subapi = FastAPI()
@subapi.get("/hello")
def hello():
return {"hello": "world"}
api_router.mount("/sub", subapi)
app.include_router(api_router)
client = TestClient(app)
response = client.get("/sub/hello")
assert response.status_code == 200
assert response.json() == {"hello": "world"}
def test_router_mount_applies_router_prefix():
"""Mount path should include the router's prefix."""
router = APIRouter(prefix="/api")
subapi = FastAPI()
@subapi.get("/test")
def test_route():
return {"test": True}
router.mount("/mounted", subapi)
# Check that the mount path includes the router prefix
mount_route = None
for route in router.routes:
if hasattr(route, "path") and "mounted" in route.path:
mount_route = route
break
assert mount_route is not None
assert mount_route.path == "/api/mounted"
def test_router_mount_multiple_subapps():
"""Multiple sub-applications can be mounted on the same router."""
app = FastAPI()
api_router = APIRouter(prefix="/api")
subapi1 = FastAPI()
subapi2 = FastAPI()
@subapi1.get("/one")
def route_one():
return {"app": 1}
@subapi2.get("/two")
def route_two():
return {"app": 2}
api_router.mount("/first", subapi1)
api_router.mount("/second", subapi2)
app.include_router(api_router)
client = TestClient(app)
response1 = client.get("/api/first/one")
assert response1.status_code == 200
assert response1.json() == {"app": 1}
response2 = client.get("/api/second/two")
assert response2.status_code == 200
assert response2.json() == {"app": 2}