Browse Source

🐛 Fix frontend fallback support for doted paths like `/users/john.doe` (#16011)

pull/14829/merge
Sebastián Ramírez 12 hours ago
committed by GitHub
parent
commit
eb75fd078e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      docs/en/docs/tutorial/frontend.md
  2. 17
      fastapi/routing.py
  3. 23
      tests/test_frontend.py

2
docs/en/docs/tutorial/frontend.md

@ -52,7 +52,7 @@ For that, use `fallback="index.html"`:
{* ../../docs_src/frontend/tutorial002_py310.py hl[5] *} {* ../../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. 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.

17
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: 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) request = Request(scope)
wildcard_accepted = False
html_rejected = False
for media_type, quality in _iter_accept_media_types( for media_type, quality in _iter_accept_media_types(
request.headers.get("accept", "") request.headers.get("accept", "")
): ):
if media_type in {"text/html", "application/xhtml+xml"}: if media_type in {"text/html", "application/xhtml+xml"} and quality != 0:
if quality == 0: return True
html_rejected = True return False
else:
return True
elif media_type == "*/*" and quality != 0:
wildcard_accepted = True
return wildcard_accepted and not html_rejected
class _FrontendRoute(BaseRoute): class _FrontendRoute(BaseRoute):

23
tests/test_frontend.py

@ -32,7 +32,7 @@ def test_frontend_exact_prefix_path_serves_index(tmp_path: Path):
app = FastAPI() app = FastAPI()
app.frontend("/", directory=dist) 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.status_code == 200
assert response.text == "app" 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"} 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" dist = tmp_path / "dist"
write_file(dist / "index.html", "app shell") write_file(dist / "index.html", "app shell")
app = FastAPI() app = FastAPI()
app.frontend("/", directory=dist, fallback="index.html") app.frontend("/", directory=dist, fallback="index.html")
response = TestClient(app).get( response = TestClient(app).get(path, headers={"accept": accept})
"/dashboard/settings", headers={"accept": "text/html"}
)
assert response.status_code == 200 assert response.status_code == 200
assert response.text == "app shell" 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") app.frontend("/", directory=dist, fallback="index.html")
response = TestClient(app).get( 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 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.css", "text/css"),
("/assets/missing.png", "image/png"), ("/assets/missing.png", "image/png"),
("/api/missing", "application/json"), ("/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 tmp_path: Path, path: str, accept: str
): ):
dist = tmp_path / "dist" dist = tmp_path / "dist"

Loading…
Cancel
Save