From 4843aa0e13d5dd40eb5d886b93d65074a7a81932 Mon Sep 17 00:00:00 2001 From: kimchikingdom Date: Sun, 15 Feb 2026 01:19:57 +0900 Subject: [PATCH] fix: support mounting StaticFiles on APIRouter and propagate via include_router - Override APIRouter.mount() to only allow StaticFiles; raise FastAPIError for any other ASGI app with a clear error message - Add APIRouter._mount() as internal method used by include_router() and FastAPI.mount() to bypass the StaticFiles-only restriction - Override FastAPI.mount() to call self.router._mount() directly - Propagate StaticFiles mounts in include_router() combining prefix + router.prefix + route.path correctly Fixes #10180 --- fastapi/applications.py | 3 ++ fastapi/routing.py | 18 ++++++++ tests/test_apirouter_mount.py | 81 +++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 tests/test_apirouter_mount.py diff --git a/fastapi/applications.py b/fastapi/applications.py index 41d86143ec..ab9176c14d 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -1133,6 +1133,9 @@ class FastAPI(Starlette): scope["root_path"] = self.root_path await super().__call__(scope, receive, send) + def mount(self, path: str, app: ASGIApp, name: str | None = None) -> None: + self.router._mount(path, app=app, name=name) + def add_api_route( self, path: str, diff --git a/fastapi/routing.py b/fastapi/routing.py index ea82ab14a3..eaaeee7481 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 @@ -993,6 +994,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: str | None = None) -> None: + """ + Mount a StaticFiles instance. + Will raise a FastAPIError exception if app is not an instance of StaticFiles. + """ + if not isinstance(app, StaticFiles): + raise FastAPIError( + "APIRouter does not support mounting ASGI applications other than StaticFiles." + ) + self._mount(path=path, app=app, name=name) + + def _mount(self, path: str, app: ASGIApp, name: str | None = None) -> None: + super().mount(path=path, app=app, name=name) + def route( self, path: str, @@ -1489,6 +1504,9 @@ class APIRouter(routing.Router): self.add_websocket_route( prefix + route.path, route.endpoint, name=route.name ) + elif isinstance(route, routing.Mount): + 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_apirouter_mount.py b/tests/test_apirouter_mount.py new file mode 100644 index 0000000000..1a2de54822 --- /dev/null +++ b/tests/test_apirouter_mount.py @@ -0,0 +1,81 @@ +"""Tests for mounting StaticFiles under APIRouter (issue #10180).""" +from pathlib import Path + +import pytest +from fastapi import APIRouter, FastAPI +from fastapi.exceptions import FastAPIError +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") + + app = FastAPI() + api_router = APIRouter(prefix="/api") + + @api_router.get("/app") + def read_main() -> dict: + return {"message": "Hello World from main app"} + + api_router.mount("/static", StaticFiles(directory=tmp_path), name="static") + app.include_router(api_router) + + client = TestClient(app) + + # Regular route should still work + r = client.get("/api/app") + assert r.status_code == 200 + + # StaticFiles should be accessible under router prefix + r = client.get("/api/static/hello.txt") + assert r.status_code == 200 + 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") + + app = FastAPI() + router = APIRouter() + router.mount("/static", StaticFiles(directory=tmp_path), name="static") + app.include_router(router) + + client = TestClient(app) + + r = client.get("/static/test.txt") + assert r.status_code == 200 + 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") + + app = FastAPI() + router = APIRouter(prefix="/api") + router.mount("/static", StaticFiles(directory=tmp_path), name="static") + app.include_router(router, prefix="/v1") + + client = TestClient(app) + + r = client.get("/v1/api/static/file.txt") + assert r.status_code == 200 + assert r.text == "combined prefix" + + +def test_mount_non_staticfiles_app_raises_error() -> None: + """Mounting a non-StaticFiles ASGI app on APIRouter should raise FastAPIError.""" + router = APIRouter() + sub_app = FastAPI() + + with pytest.raises( + FastAPIError, + match="APIRouter does not support mounting ASGI applications other than StaticFiles.", + ): + router.mount("/sub", sub_app)