From eb75fd078e83aed935016bcdf0705cd58bbf0d0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 16 Jul 2026 10:59:23 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20frontend=20fallback=20supp?= =?UTF-8?q?ort=20for=20doted=20paths=20like=20`/users/john.doe`=20(#16011)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/tutorial/frontend.md | 2 +- fastapi/routing.py | 17 +++-------------- tests/test_frontend.py | 23 +++++++++++++++-------- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/docs/en/docs/tutorial/frontend.md b/docs/en/docs/tutorial/frontend.md index 433cea275..0dacc73ba 100644 --- a/docs/en/docs/tutorial/frontend.md +++ b/docs/en/docs/tutorial/frontend.md @@ -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. diff --git a/fastapi/routing.py b/fastapi/routing.py index c442b122b..28694b4e5 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -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): diff --git a/tests/test_frontend.py b/tests/test_frontend.py index b8dd62fb8..d647037e7 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -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"