diff --git a/fastapi/routing.py b/fastapi/routing.py index fe8d886093..3602d1c289 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -1009,6 +1009,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, @@ -1504,6 +1518,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: diff --git a/tests/test_router_mount.py b/tests/test_router_mount.py new file mode 100644 index 0000000000..2c4b80f33c --- /dev/null +++ b/tests/test_router_mount.py @@ -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}