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")
):
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)
import os
import runpy
from pathlib import Path
+from typing import Literal
import anyio
import pytest
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")
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",
[