mirror of
https://github.com/fastapi/fastapi.git
synced 2026-06-30 18:16:43 -04:00
♻️ Make app.frontend() return 404 for methods other than GET or HEAD with no static file matches (#15863)
This commit is contained in:
committed by
GitHub
parent
c2708d9817
commit
b790e14cb6
@@ -52,7 +52,9 @@ For that, use `fallback="index.html"`:
|
||||
|
||||
{* ../../docs_src/frontend/tutorial002_py310.py hl[5] *}
|
||||
|
||||
**FastAPI** uses this fallback only for requests that look like browser navigation. Missing files like JavaScript, CSS, and images still return `404`.
|
||||
**FastAPI** uses this fallback only for `GET` and `HEAD` requests that look like browser navigation. Missing files like JavaScript, CSS, and images still return `404`.
|
||||
|
||||
Requests with other methods, like `POST` or `PUT`, to paths that only match the frontend fallback also return `404`. Regular **FastAPI** *path operations* still have higher priority than frontend routes.
|
||||
|
||||
/// tip
|
||||
|
||||
|
||||
@@ -1841,34 +1841,19 @@ class _FrontendStaticFiles(StaticFiles):
|
||||
|
||||
async def get_response(self, path: str, scope: Scope) -> Response:
|
||||
if scope["method"] not in ("GET", "HEAD"):
|
||||
raise HTTPException(status_code=405)
|
||||
if await self._lookup_static_resource(path) is not None:
|
||||
raise HTTPException(status_code=405)
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
try:
|
||||
full_path, stat_result = await run_in_threadpool(self.lookup_path, path)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=401) from None
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.ENAMETOOLONG:
|
||||
raise HTTPException(status_code=404) from None
|
||||
raise exc
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404) from None
|
||||
|
||||
if stat_result and stat.S_ISREG(stat_result.st_mode):
|
||||
static_resource = await self._lookup_static_resource(path)
|
||||
if static_resource is not None:
|
||||
full_path, stat_result, is_directory_index = static_resource
|
||||
if is_directory_index and not scope["path"].endswith("/"):
|
||||
url = URL(scope=scope)
|
||||
url = url.replace(path=url.path + "/")
|
||||
return RedirectResponse(url=url)
|
||||
return self.file_response(full_path, stat_result, scope)
|
||||
|
||||
if stat_result and stat.S_ISDIR(stat_result.st_mode):
|
||||
index_path = os.path.join(path, "index.html")
|
||||
full_path, stat_result = await run_in_threadpool(
|
||||
self.lookup_path, index_path
|
||||
)
|
||||
if stat_result is not None and stat.S_ISREG(stat_result.st_mode):
|
||||
if not scope["path"].endswith("/"):
|
||||
url = URL(scope=scope)
|
||||
url = url.replace(path=url.path + "/")
|
||||
return RedirectResponse(url=url)
|
||||
return self.file_response(full_path, stat_result, scope)
|
||||
|
||||
if self.fallback == "404.html" or (
|
||||
self.fallback == "auto" and self._fallback_file_exists("404.html")
|
||||
):
|
||||
@@ -1882,6 +1867,33 @@ class _FrontendStaticFiles(StaticFiles):
|
||||
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
async def _lookup_path(self, path: str) -> tuple[str, os.stat_result | None]:
|
||||
try:
|
||||
return await run_in_threadpool(self.lookup_path, path)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=401) from None
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.ENAMETOOLONG:
|
||||
raise HTTPException(status_code=404) from None
|
||||
raise exc
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404) from None
|
||||
|
||||
async def _lookup_static_resource(
|
||||
self, path: str
|
||||
) -> tuple[str, os.stat_result, bool] | None:
|
||||
full_path, stat_result = await self._lookup_path(path)
|
||||
if stat_result is None:
|
||||
return None
|
||||
if stat.S_ISREG(stat_result.st_mode):
|
||||
return full_path, stat_result, False
|
||||
if stat.S_ISDIR(stat_result.st_mode):
|
||||
index_path = os.path.join(path, "index.html")
|
||||
full_path, stat_result = await self._lookup_path(index_path)
|
||||
if stat_result is not None and stat.S_ISREG(stat_result.st_mode):
|
||||
return full_path, stat_result, True
|
||||
return None
|
||||
|
||||
def _fallback_file_exists(self, fallback: str) -> bool:
|
||||
_, stat_result = self.lookup_path(fallback)
|
||||
return stat_result is not None and stat.S_ISREG(stat_result.st_mode)
|
||||
|
||||
@@ -2,6 +2,7 @@ import errno
|
||||
import os
|
||||
import runpy
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
@@ -639,6 +640,21 @@ def test_head_requests_work(tmp_path: Path):
|
||||
assert response.headers["content-length"] == "2"
|
||||
|
||||
|
||||
def test_head_fallback_request_works(tmp_path: Path):
|
||||
dist = tmp_path / "dist"
|
||||
write_file(dist / "index.html", "app shell")
|
||||
app = FastAPI()
|
||||
app.frontend("/", directory=dist, fallback="index.html")
|
||||
|
||||
response = TestClient(app).head(
|
||||
"/dashboard/settings", headers={"accept": "text/html"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == ""
|
||||
assert response.headers["content-length"] == "9"
|
||||
|
||||
|
||||
def test_unsupported_methods_return_405(tmp_path: Path):
|
||||
dist = tmp_path / "dist"
|
||||
write_file(dist / "asset.txt", "ok")
|
||||
@@ -650,6 +666,125 @@ def test_unsupported_methods_return_405(tmp_path: Path):
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
|
||||
def test_unsupported_methods_to_fallback_only_routes_return_404(
|
||||
tmp_path: Path, method: str
|
||||
):
|
||||
dist = tmp_path / "dist"
|
||||
write_file(dist / "index.html", "app shell")
|
||||
app = FastAPI()
|
||||
app.frontend("/", directory=dist, fallback="index.html")
|
||||
|
||||
response = TestClient(app).request(
|
||||
method, "/dashboard/settings", headers={"accept": "text/html"}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_unsupported_methods_to_frontend_root_and_directory_index_return_405(
|
||||
tmp_path: Path,
|
||||
):
|
||||
dist = tmp_path / "dist"
|
||||
write_file(dist / "index.html", "app")
|
||||
write_file(dist / "about" / "index.html", "about")
|
||||
app = FastAPI()
|
||||
app.frontend("/", directory=dist)
|
||||
client = TestClient(app)
|
||||
|
||||
root_response = client.post("/")
|
||||
directory_response = client.post("/about/")
|
||||
|
||||
assert root_response.status_code == 405
|
||||
assert directory_response.status_code == 405
|
||||
|
||||
|
||||
def test_unsupported_method_to_directory_without_index_returns_404(tmp_path: Path):
|
||||
dist = tmp_path / "dist"
|
||||
(dist / "empty").mkdir(parents=True)
|
||||
write_file(dist / "index.html", "app")
|
||||
app = FastAPI()
|
||||
app.frontend("/", directory=dist)
|
||||
|
||||
response = TestClient(app).post("/empty/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_unsupported_methods_to_fallback_only_routes_ignore_accept(
|
||||
tmp_path: Path,
|
||||
):
|
||||
dist = tmp_path / "dist"
|
||||
write_file(dist / "index.html", "app shell")
|
||||
app = FastAPI()
|
||||
app.frontend("/", directory=dist, fallback="index.html")
|
||||
|
||||
response = TestClient(app).post(
|
||||
"/dashboard/settings", headers={"accept": "application/json"}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fallback", "files"),
|
||||
[
|
||||
("404.html", {"404.html": "missing"}),
|
||||
("auto", {"index.html": "app shell"}),
|
||||
(None, {"index.html": "app shell"}),
|
||||
],
|
||||
)
|
||||
def test_unsupported_methods_to_fallback_only_routes_return_404_for_fallback_modes(
|
||||
tmp_path: Path,
|
||||
fallback: Literal["auto", "index.html", "404.html"] | None,
|
||||
files: dict[str, str],
|
||||
):
|
||||
dist = tmp_path / "dist"
|
||||
for file, content in files.items():
|
||||
write_file(dist / file, content)
|
||||
app = FastAPI()
|
||||
app.frontend("/", directory=dist, fallback=fallback)
|
||||
|
||||
response = TestClient(app).post(
|
||||
"/dashboard/settings", headers={"accept": "text/html"}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_apirouter_frontend_unsupported_method_to_fallback_only_route_returns_404(
|
||||
tmp_path: Path,
|
||||
):
|
||||
dist = tmp_path / "dist"
|
||||
write_file(dist / "index.html", "admin")
|
||||
router = APIRouter()
|
||||
router.frontend("/", directory=dist, fallback="index.html")
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/admin")
|
||||
|
||||
response = TestClient(app).post(
|
||||
"/admin/client-route", headers={"accept": "text/html"}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_unsupported_method_uses_longest_matching_frontend_prefix(tmp_path: Path):
|
||||
site = tmp_path / "site"
|
||||
admin = tmp_path / "admin"
|
||||
write_file(site / "admin" / "client-route", "site asset")
|
||||
write_file(admin / "index.html", "admin")
|
||||
app = FastAPI()
|
||||
app.frontend("/", directory=site)
|
||||
app.frontend("/admin", directory=admin, fallback="index.html")
|
||||
|
||||
response = TestClient(app).post(
|
||||
"/admin/client-route", headers={"accept": "text/html"}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user