Browse Source

fix: propagate StaticFiles Mount objects when using include_router

Per maintainer feedback on #14924, limit Mount propagation in
include_router() to StaticFiles only, matching the approach in #13944.

Non-StaticFiles sub-app mounts are silently ignored (not propagated)
to preserve existing behavior, since sub-applications should be mounted
directly on the root FastAPI instance.

Fixes #10180
pull/14924/head
kimchikingdom 5 months ago
parent
commit
7a6d25f894
  1. 4
      fastapi/routing.py
  2. 102
      tests/test_router_mount.py

4
fastapi/routing.py

@ -74,6 +74,7 @@ from starlette.routing import (
get_name, get_name,
) )
from starlette.routing import Mount as Mount # noqa from starlette.routing import Mount as Mount # noqa
from starlette.staticfiles import StaticFiles
from starlette.types import AppType, ASGIApp, Lifespan, Receive, Scope, Send from starlette.types import AppType, ASGIApp, Lifespan, Receive, Scope, Send
from starlette.websockets import WebSocket from starlette.websockets import WebSocket
from typing_extensions import deprecated from typing_extensions import deprecated
@ -1490,7 +1491,8 @@ class APIRouter(routing.Router):
prefix + route.path, route.endpoint, name=route.name prefix + route.path, route.endpoint, name=route.name
) )
elif isinstance(route, routing.Mount): elif isinstance(route, routing.Mount):
self.mount(prefix + router.prefix + route.path, route.app, name=route.name) if isinstance(route.app, StaticFiles):
self.mount(prefix + router.prefix + route.path, route.app, name=route.name)
for handler in router.on_startup: for handler in router.on_startup:
self.add_event_handler("startup", handler) self.add_event_handler("startup", handler)
for handler in router.on_shutdown: for handler in router.on_shutdown:

102
tests/test_router_mount.py

@ -1,11 +1,17 @@
"""Tests for mounting sub-applications under APIRouter (issue #10180).""" """Tests for mounting StaticFiles under APIRouter (issue #10180)."""
from pathlib import Path
import pytest
from fastapi import APIRouter, FastAPI from fastapi import APIRouter, FastAPI
from starlette.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.testclient import TestClient from fastapi.testclient import TestClient
def test_mount_staticfiles_under_router_with_prefix(tmp_path: Path) -> None:
"""StaticFiles mounted on APIRouter with prefix should be accessible."""
static_file = tmp_path / "hello.txt"
static_file.write_text("hello world")
def test_mount_subapp_under_router_with_prefix() -> None:
"""APIRouter with prefix should propagate Mount to the main app."""
app = FastAPI() app = FastAPI()
api_router = APIRouter(prefix="/api") api_router = APIRouter(prefix="/api")
@ -13,13 +19,7 @@ def test_mount_subapp_under_router_with_prefix() -> None:
def read_main() -> dict: def read_main() -> dict:
return {"message": "Hello World from main app"} return {"message": "Hello World from main app"}
subapi = FastAPI() api_router.mount("/static", StaticFiles(directory=tmp_path), name="static")
@subapi.get("/sub")
def read_sub() -> dict:
return {"message": "Hello World from sub API"}
api_router.mount("/subapi", subapi)
app.include_router(api_router) app.include_router(api_router)
client = TestClient(app) client = TestClient(app)
@ -27,80 +27,68 @@ def test_mount_subapp_under_router_with_prefix() -> None:
# Regular route should still work # Regular route should still work
r = client.get("/api/app") r = client.get("/api/app")
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"message": "Hello World from main app"}
# Mounted sub-app should be accessible under router prefix # StaticFiles should be accessible under router prefix
r = client.get("/api/subapi/sub") r = client.get("/api/static/hello.txt")
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"message": "Hello World from sub API"} assert r.text == "hello world"
def test_mount_staticfiles_under_router_without_prefix(tmp_path: Path) -> None:
"""StaticFiles mounted on APIRouter without prefix should be accessible."""
static_file = tmp_path / "test.txt"
static_file.write_text("test content")
def test_mount_subapp_under_router_without_prefix() -> None:
"""APIRouter without prefix should propagate Mount correctly."""
app = FastAPI() app = FastAPI()
router = APIRouter() router = APIRouter()
router.mount("/static", StaticFiles(directory=tmp_path), name="static")
subapi = FastAPI()
@subapi.get("/hello")
def hello() -> dict:
return {"msg": "hello"}
router.mount("/sub", subapi)
app.include_router(router) app.include_router(router)
client = TestClient(app) client = TestClient(app)
r = client.get("/sub/hello") r = client.get("/static/test.txt")
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"msg": "hello"} assert r.text == "test content"
def test_mount_staticfiles_with_include_router_prefix(tmp_path: Path) -> None:
"""include_router prefix + router prefix + mount path should all combine."""
static_file = tmp_path / "file.txt"
static_file.write_text("combined prefix")
def test_mount_subapp_with_include_router_prefix() -> None:
"""include_router prefix should be combined with router prefix and mount path."""
app = FastAPI() app = FastAPI()
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
router.mount("/static", StaticFiles(directory=tmp_path), name="static")
subapi = FastAPI()
@subapi.get("/test")
def test_endpoint() -> dict:
return {"msg": "test"}
router.mount("/sub", subapi)
app.include_router(router, prefix="/v1") app.include_router(router, prefix="/v1")
client = TestClient(app) client = TestClient(app)
r = client.get("/v1/api/sub/test") r = client.get("/v1/api/static/file.txt")
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"msg": "test"} assert r.text == "combined prefix"
def test_mount_preserves_regular_routes() -> None: def test_mount_non_staticfiles_app_is_ignored() -> None:
"""Adding Mount support should not break existing APIRoute handling.""" """Mounting a non-StaticFiles ASGI app on APIRouter should be silently ignored
(not propagated to the main app) to preserve existing behavior."""
app = FastAPI() app = FastAPI()
api_router = APIRouter(prefix="/api") api_router = APIRouter(prefix="/api")
@api_router.get("/items")
def list_items() -> dict:
return {"items": [1, 2, 3]}
@api_router.post("/items")
def create_item() -> dict:
return {"created": True}
subapi = FastAPI() subapi = FastAPI()
@subapi.get("/status") @subapi.get("/sub")
def status() -> dict: def read_sub() -> dict:
return {"status": "ok"} return {"message": "sub"}
api_router.mount("/health", subapi) api_router.mount("/subapi", subapi)
app.include_router(api_router) app.include_router(api_router)
client = TestClient(app) client = TestClient(app)
assert client.get("/api/items").status_code == 200 # Non-StaticFiles sub-app mount is not propagated — returns 404
assert client.post("/api/items").status_code == 200 r = client.get("/api/subapi/sub")
assert client.get("/api/health/status").status_code == 200 assert r.status_code == 404
# Regular routes are unaffected
# (no routes defined here, but include_router itself should not crash)
assert app is not None

Loading…
Cancel
Save