]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
🐛 Fix frontend fallback support for doted paths like `/users/john.doe` (#16011)
authorSebastián Ramírez <tiangolo@gmail.com>
Thu, 16 Jul 2026 08:59:23 +0000 (10:59 +0200)
committerGitHub <noreply@github.com>
Thu, 16 Jul 2026 08:59:23 +0000 (10:59 +0200)
docs/en/docs/tutorial/frontend.md
fastapi/routing.py
tests/test_frontend.py

index 433cea2751e3f75ed91b4a4870b9a0bdef7c1cef..0dacc73ba0b43f461d6297019f8d4b0daea9f675 100644 (file)
@@ -52,7 +52,7 @@ For that, use `fallback="index.html"`:
 
 {* ../../docs_src/frontend/tutorial002_py310.py hl[5] *}
 
-**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`.
+**FastAPI** uses this fallback only for `GET` and `HEAD` requests that explicitly accept HTML with `Accept: text/html` or `Accept: application/xhtml+xml`, as browser navigation requests normally do. 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.
 
index c442b122ba4e6368f1d119df755791ed0d576173..28694b4e586b7a1c956c6455a1e0aac2aefa5478 100644 (file)
@@ -1984,24 +1984,13 @@ def _iter_accept_media_types(accept: str) -> Iterator[tuple[str, float]]:
 
 
 def _is_frontend_navigation_request(scope: Scope) -> bool:
-    route_path = get_route_path(scope)
-    final_segment = route_path.rsplit("/", 1)[-1]
-    if os.path.splitext(final_segment)[1]:
-        return False
     request = Request(scope)
-    wildcard_accepted = False
-    html_rejected = False
     for media_type, quality in _iter_accept_media_types(
         request.headers.get("accept", "")
     ):
-        if media_type in {"text/html", "application/xhtml+xml"}:
-            if quality == 0:
-                html_rejected = True
-            else:
-                return True
-        elif media_type == "*/*" and quality != 0:
-            wildcard_accepted = True
-    return wildcard_accepted and not html_rejected
+        if media_type in {"text/html", "application/xhtml+xml"} and quality != 0:
+            return True
+    return False
 
 
 class _FrontendRoute(BaseRoute):
index b8dd62fb8af181e84313b4b3e26451c96165a4b4..d647037e7cdaf66cba0687f5f7a3efd4ade1803e 100644 (file)
@@ -32,7 +32,7 @@ def test_frontend_exact_prefix_path_serves_index(tmp_path: Path):
     app = FastAPI()
     app.frontend("/", directory=dist)
 
-    response = TestClient(app).get("/app")
+    response = TestClient(app).get("/app", headers={"accept": "text/html"})
 
     assert response.status_code == 200
     assert response.text == "app"
@@ -617,15 +617,21 @@ def test_api_route_404_is_not_replaced_by_frontend_fallback(tmp_path: Path):
     assert response.json() == {"detail": "api missing"}
 
 
-def test_index_fallback_for_navigation_request(tmp_path: Path):
+@pytest.mark.parametrize(
+    ("path", "accept"),
+    [
+        ("/dashboard/settings", "text/html"),
+        ("/users/jane.doe", "text/html"),
+        ("/v/1.2.3", "application/xhtml+xml"),
+    ],
+)
+def test_index_fallback_for_navigation_request(tmp_path: Path, path: str, accept: str):
     dist = tmp_path / "dist"
     write_file(dist / "index.html", "app shell")
     app = FastAPI()
     app.frontend("/", directory=dist, fallback="index.html")
 
-    response = TestClient(app).get(
-        "/dashboard/settings", headers={"accept": "text/html"}
-    )
+    response = TestClient(app).get(path, headers={"accept": accept})
 
     assert response.status_code == 200
     assert response.text == "app shell"
@@ -638,7 +644,8 @@ def test_index_fallback_parses_accept_parameters(tmp_path: Path):
     app.frontend("/", directory=dist, fallback="index.html")
 
     response = TestClient(app).get(
-        "/dashboard/settings", headers={"accept": "text/html; q=0.8"}
+        "/dashboard/settings",
+        headers={"accept": "text/html; charset=utf-8; q=0.8"},
     )
 
     assert response.status_code == 200
@@ -697,10 +704,10 @@ def test_index_fallback_respects_explicit_xhtml_rejection_with_wildcard(
         ("/assets/missing.css", "text/css"),
         ("/assets/missing.png", "image/png"),
         ("/api/missing", "application/json"),
-        ("/users/jane.doe", "text/html"),
+        ("/dashboard/settings", ""),
     ],
 )
-def test_index_fallback_does_not_handle_asset_like_or_non_html_requests(
+def test_index_fallback_requires_explicit_html_acceptance(
     tmp_path: Path, path: str, accept: str
 ):
     dist = tmp_path / "dist"