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 scope["root_path"] = self.root_path
await super().__call__(scope, receive, send) 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( def add_api_route(
self, self,
path: str, path: str,

18
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
@ -993,6 +994,20 @@ class APIRouter(routing.Router):
self.default_response_class = default_response_class self.default_response_class = default_response_class
self.generate_unique_id_function = generate_unique_id_function 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( def route(
self, self,
path: str, path: str,
@ -1489,6 +1504,9 @@ class APIRouter(routing.Router):
self.add_websocket_route( self.add_websocket_route(
prefix + route.path, route.endpoint, name=route.name 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: 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:

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