Browse Source

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
pull/14926/head
kimchikingdom 5 months ago
parent
commit
4843aa0e13
  1. 3
      fastapi/applications.py
  2. 18
      fastapi/routing.py
  3. 81
      tests/test_apirouter_mount.py

3
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,

18
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:

81
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)
Loading…
Cancel
Save