Browse Source
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 #10180pull/15246/head
2 changed files with 153 additions and 0 deletions
@ -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…
Reference in new issue