From b6412448bc3b1ac70661ac198d2b5a3c0b230367 Mon Sep 17 00:00:00 2001 From: Jonathan Fulton Date: Sat, 31 Jan 2026 18:57:11 -0500 Subject: [PATCH] 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 --- fastapi/routing.py | 17 +++++ tests/test_router_mount.py | 133 +++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 tests/test_router_mount.py diff --git a/fastapi/routing.py b/fastapi/routing.py index 9ca2f46732..b4206444b9 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -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: 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}