From 7a6d25f894deb19275b8fcdd54f454459c0cd8b5 Mon Sep 17 00:00:00 2001 From: kimchikingdom Date: Sun, 15 Feb 2026 00:31:12 +0900 Subject: [PATCH] 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 --- fastapi/routing.py | 4 +- tests/test_router_mount.py | 102 ++++++++++++++++--------------------- 2 files changed, 48 insertions(+), 58 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index 5438040414..07a63e883c 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -74,6 +74,7 @@ from starlette.routing import ( get_name, ) 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.websockets import WebSocket from typing_extensions import deprecated @@ -1490,7 +1491,8 @@ class APIRouter(routing.Router): prefix + route.path, route.endpoint, name=route.name ) 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: 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 index 43ebca2107..c8fdcfdb95 100644 --- a/tests/test_router_mount.py +++ b/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 starlette.staticfiles import StaticFiles -from starlette.testclient import TestClient +from fastapi.staticfiles import StaticFiles +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() api_router = APIRouter(prefix="/api") @@ -13,13 +19,7 @@ def test_mount_subapp_under_router_with_prefix() -> None: def read_main() -> dict: return {"message": "Hello World from main app"} - subapi = FastAPI() - - @subapi.get("/sub") - def read_sub() -> dict: - return {"message": "Hello World from sub API"} - - api_router.mount("/subapi", subapi) + api_router.mount("/static", StaticFiles(directory=tmp_path), name="static") app.include_router(api_router) client = TestClient(app) @@ -27,80 +27,68 @@ def test_mount_subapp_under_router_with_prefix() -> None: # Regular route should still work r = client.get("/api/app") assert r.status_code == 200 - assert r.json() == {"message": "Hello World from main app"} - # Mounted sub-app should be accessible under router prefix - r = client.get("/api/subapi/sub") + # StaticFiles should be accessible under router prefix + r = client.get("/api/static/hello.txt") 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() router = APIRouter() - - subapi = FastAPI() - - @subapi.get("/hello") - def hello() -> dict: - return {"msg": "hello"} - - router.mount("/sub", subapi) + router.mount("/static", StaticFiles(directory=tmp_path), name="static") app.include_router(router) client = TestClient(app) - r = client.get("/sub/hello") + r = client.get("/static/test.txt") 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() router = APIRouter(prefix="/api") - - subapi = FastAPI() - - @subapi.get("/test") - def test_endpoint() -> dict: - return {"msg": "test"} - - router.mount("/sub", subapi) + router.mount("/static", StaticFiles(directory=tmp_path), name="static") app.include_router(router, prefix="/v1") 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.json() == {"msg": "test"} + assert r.text == "combined prefix" -def test_mount_preserves_regular_routes() -> None: - """Adding Mount support should not break existing APIRoute handling.""" +def test_mount_non_staticfiles_app_is_ignored() -> None: + """Mounting a non-StaticFiles ASGI app on APIRouter should be silently ignored + (not propagated to the main app) to preserve existing behavior.""" app = FastAPI() 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.get("/status") - def status() -> dict: - return {"status": "ok"} + @subapi.get("/sub") + def read_sub() -> dict: + return {"message": "sub"} - api_router.mount("/health", subapi) + api_router.mount("/subapi", subapi) app.include_router(api_router) client = TestClient(app) - assert client.get("/api/items").status_code == 200 - assert client.post("/api/items").status_code == 200 - assert client.get("/api/health/status").status_code == 200 + # Non-StaticFiles sub-app mount is not propagated — returns 404 + r = client.get("/api/subapi/sub") + 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