From: Sebastián Ramírez Date: Thu, 16 Jul 2026 08:59:23 +0000 (+0200) Subject: 🐛 Fix frontend fallback support for doted paths like `/users/john.doe` (#16011) X-Git-Tag: 0.139.1~2 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=eb75fd078e83aed935016bcdf0705cd58bbf0d0e;p=thirdparty%2Ffastapi%2Ffastapi.git 🐛 Fix frontend fallback support for doted paths like `/users/john.doe` (#16011) --- diff --git a/docs/en/docs/tutorial/frontend.md b/docs/en/docs/tutorial/frontend.md index 433cea2751..0dacc73ba0 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 c442b122ba..28694b4e58 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 b8dd62fb8a..d647037e7c 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"