]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
♻️ Make `app.frontend()` return 404 for methods other than `GET` or `HEAD` with no...
authorSebastián Ramírez <tiangolo@gmail.com>
Mon, 29 Jun 2026 11:57:59 +0000 (13:57 +0200)
committerGitHub <noreply@github.com>
Mon, 29 Jun 2026 11:57:59 +0000 (13:57 +0200)
docs/en/docs/tutorial/frontend.md
fastapi/routing.py
tests/test_frontend.py

index 9f0bc0566b43608bbb659916dcf709faf24d0c36..4cbc21fa13826ef4161ce7b0d69f392b7395f774 100644 (file)
@@ -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
 
index 3a2d75422f15abb9bf997921324856e6e8da82ec..e41ef6a59991d41132ec9104745bcce3556a9354 100644 (file)
@@ -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)
-
-        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 await self._lookup_static_resource(path) is not None:
+                raise HTTPException(status_code=405)
+            raise HTTPException(status_code=404)
 
-        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)
index 12be8eaf2ba4a359cee9e49c1084c34ed75ea04a..81cffc228592a45fe7bd17fe7e3bd57cfd140ff9 100644 (file)
@@ -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",
     [