From 0d5ecfae6747bcf2b13ffe1a440eaf860b7930b6 Mon Sep 17 00:00:00 2001 From: Sai Tarrun Pitta Date: Fri, 27 Mar 2026 23:51:01 -0700 Subject: [PATCH] Fix mounting sub-applications under APIRouter (#10180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- fastapi/routing.py | 14 ++++ tests/test_router_mount.py | 139 +++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 tests/test_router_mount.py diff --git a/fastapi/routing.py b/fastapi/routing.py index 36acb6b89d..0f35ced88f 100644 --- a/fastapi/routing.py +++ b/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( diff --git a/tests/test_router_mount.py b/tests/test_router_mount.py new file mode 100644 index 0000000000..19e651097b --- /dev/null +++ b/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