Files
fastapi/tests/test_frontend.py

1407 lines
42 KiB
Python

import errno
import os
import runpy
from contextlib import AsyncExitStack
from pathlib import Path
from typing import Literal
import anyio
import pytest
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
FastAPI,
HTTPException,
Request,
WebSocket,
)
from fastapi.testclient import TestClient
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import PlainTextResponse, Response
from starlette.routing import BaseRoute, Match, NoMatchFound, Route
def write_file(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
def record_dependency(calls: list[str], name: str):
def dependency() -> None:
calls.append(name)
return dependency
def test_frontend_exact_prefix_path_serves_index(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).get("/app", headers={"accept": "text/html"})
assert response.status_code == 200
assert response.text == "app"
def test_apirouter_frontend_with_router_prefix_and_frontend_subpath(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "asset.txt", "asset")
router = APIRouter(prefix="/internal")
router.frontend("/ui", directory=dist)
app = FastAPI()
app.include_router(router, prefix="/prefix")
response = TestClient(app).get("/prefix/internal/ui/asset.txt")
assert response.status_code == 200
assert response.text == "asset"
def test_frontend_fallback_rejects_invalid_fallback(tmp_path: Path):
dist = tmp_path / "dist"
dist.mkdir()
app = FastAPI()
with pytest.raises(AssertionError, match="fallback"):
app.frontend("/", directory=dist, fallback="invalid") # type: ignore[arg-type] # ty: ignore[invalid-argument-type]
def test_index_fallback_ignores_invalid_q_value(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).get(
"/dashboard/settings", headers={"accept": "text/html; q=wat"}
)
assert response.status_code == 200
assert response.text == "app shell"
def test_frontend_static_files_lookup_errors(monkeypatch, tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI()
app.frontend("/", directory=dist)
frontend_routes = app.router._frontend_routes
assert frontend_routes is not None
static_files = frontend_routes.routes[0].app
def raise_permission_error(path: str):
raise PermissionError
monkeypatch.setattr(static_files, "lookup_path", raise_permission_error)
response = TestClient(app).get("/asset.txt")
assert response.status_code == 401
def raise_value_error(path: str):
raise ValueError
monkeypatch.setattr(static_files, "lookup_path", raise_value_error)
response = TestClient(app).get("/asset.txt")
assert response.status_code == 404
def raise_name_too_long(path: str):
raise OSError(errno.ENAMETOOLONG, "name too long")
monkeypatch.setattr(static_files, "lookup_path", raise_name_too_long)
response = TestClient(app).get("/asset.txt")
assert response.status_code == 404
def raise_os_error(path: str):
raise OSError(5, "other")
monkeypatch.setattr(static_files, "lookup_path", raise_os_error)
with pytest.raises(OSError):
TestClient(app).get("/asset.txt")
def test_frontend_route_group_helpers(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI()
app.frontend("/app", directory=dist)
route_group = app.router._frontend_routes
assert route_group is not None
match, child_scope = route_group.matches({"type": "websocket", "path": "/"})
assert match == Match.NONE
assert child_scope == {}
match, child_scope = route_group.matches_with_prefix(
{"type": "http", "path": "/prefix/app", "method": "GET"},
"/prefix",
)
assert match == Match.FULL
assert child_scope["fastapi"]["frontend_path"] == ""
match, child_scope = route_group.routes[0].matches(
{"type": "http", "path": "/app", "method": "GET"}
)
assert match == Match.FULL
assert child_scope["fastapi"]["frontend_path"] == ""
with pytest.raises(StarletteHTTPException) as exc_info:
anyio.run(
route_group.handle,
{"type": "http", "path": "/missing", "method": "GET"},
None,
None,
)
assert exc_info.value.status_code == 404
with pytest.raises(NoMatchFound):
route_group.url_path_for("frontend")
with pytest.raises(NoMatchFound):
route_group.routes[0].url_path_for("frontend")
def test_included_low_priority_routes_cache_is_reused():
async def low_priority_endpoint(request: Request):
return PlainTextResponse("low")
router = APIRouter()
router._low_priority_routes.append(Route("/low", low_priority_endpoint))
router._mark_routes_changed()
app = FastAPI()
app.include_router(router, prefix="/prefix")
included_router = next(
route
for route in app.router.routes
if hasattr(route, "effective_low_priority_routes")
)
first = included_router.effective_low_priority_routes() # ty: ignore[call-non-callable]
second = included_router.effective_low_priority_routes() # ty: ignore[call-non-callable]
response = TestClient(app).get("/prefix/low")
assert first is second
assert response.status_code == 200
assert response.text == "low"
def test_low_priority_api_route_handles_with_context():
app = FastAPI()
async def endpoint(request: Request) -> Response:
return PlainTextResponse(request.scope["path_params"]["item_id"])
route = app.router.route_class("/low/{item_id}", endpoint=endpoint, methods=["GET"])
app.router._low_priority_routes.append(route)
app.router._mark_routes_changed()
response = TestClient(app).get("/low/abc")
assert response.status_code == 200
assert response.text == "abc"
def test_included_low_priority_api_route_handles_with_context():
router = APIRouter()
async def endpoint(request: Request) -> Response:
return PlainTextResponse(request.scope["path_params"]["item_id"])
route = router.route_class("/low/{item_id}", endpoint=endpoint, methods=["GET"])
router._low_priority_routes.append(route)
router._mark_routes_changed()
app = FastAPI()
app.include_router(router, prefix="/prefix")
response = TestClient(app).get("/prefix/low/abc")
assert response.status_code == 200
assert response.text == "abc"
def test_normal_route_partial_match_returns_before_frontend(tmp_path: Path):
class PartialRoute(BaseRoute):
def matches(self, scope):
return Match.PARTIAL, {}
async def handle(self, scope, receive, send):
response = PlainTextResponse("partial", status_code=405)
await response(scope, receive, send)
dist = tmp_path / "dist"
write_file(dist / "index.html", "frontend")
app = FastAPI()
app.router.routes.append(PartialRoute())
app.frontend("/", directory=dist)
response = TestClient(app).get("/anything")
assert response.status_code == 405
assert response.text == "partial"
def test_normal_route_partial_match_wins_before_frontend(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "api", "frontend")
app = FastAPI()
@app.get("/api")
def read_api():
return {"source": "api"}
app.frontend("/", directory=dist)
client = TestClient(app)
response = client.get("/api")
assert response.status_code == 200
assert response.json() == {"source": "api"}
response = client.post("/api")
assert response.status_code == 405
def test_basic_file_serving(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "assets" / "app.js", "console.log('ok')")
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).get("/assets/app.js")
assert response.status_code == 200
assert response.text == "console.log('ok')"
assert "etag" in response.headers
assert "last-modified" in response.headers
def test_app_frontend_dependencies_protect_root_asset_and_fallback(tmp_path: Path):
calls: list[str] = []
def require_cookie(request: Request) -> None:
calls.append(request.url.path)
if request.cookies.get("session") != "ok":
raise HTTPException(status_code=401)
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
write_file(dist / "assets" / "app.js", "console.log('ok')")
app = FastAPI(dependencies=[Depends(require_cookie)])
app.frontend("/", directory=dist, fallback="index.html")
client = TestClient(app)
response = client.get("/")
assert response.status_code == 401
response = client.get("/", headers={"cookie": "session=ok"})
assert response.status_code == 200
assert response.text == "app"
response = client.get("/assets/app.js", headers={"cookie": "session=ok"})
assert response.status_code == 200
assert response.text == "console.log('ok')"
response = client.get(
"/dashboard",
headers={"accept": "text/html", "cookie": "session=ok"},
)
assert response.status_code == 200
assert response.text == "app"
assert calls == ["/", "/", "/assets/app.js", "/dashboard"]
def test_apirouter_frontend_dependencies_protect_prefixed_frontend(tmp_path: Path):
def require_cookie(request: Request) -> None:
if request.cookies.get("session") != "ok":
raise HTTPException(status_code=401)
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
write_file(dist / "assets" / "app.js", "console.log('ok')")
router = APIRouter(dependencies=[Depends(require_cookie)])
router.frontend("/", directory=dist, fallback="index.html")
app = FastAPI()
app.include_router(router, prefix="/app")
client = TestClient(app)
response = client.get("/app/")
assert response.status_code == 401
response = client.get("/app/", headers={"cookie": "session=ok"})
assert response.status_code == 200
assert response.text == "app"
response = client.get("/app/assets/app.js", headers={"cookie": "session=ok"})
assert response.status_code == 200
assert response.text == "console.log('ok')"
response = client.get(
"/app/dashboard",
headers={"accept": "text/html", "cookie": "session=ok"},
)
assert response.status_code == 200
assert response.text == "app"
def test_included_frontend_does_not_block_url_path_for(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
frontend_router = APIRouter()
frontend_router.frontend("/", directory=dist)
api_router = APIRouter()
@api_router.get("/api", name="read_api")
def read_api():
return {"ok": True}
app = FastAPI()
app.include_router(frontend_router, prefix="/app")
app.include_router(api_router)
included_frontend = next(
route
for route in app.router.routes
if hasattr(route, "effective_low_priority_routes")
)
with pytest.raises(NoMatchFound):
included_frontend.url_path_for("missing")
assert app.url_path_for("read_api") == "/api"
response = TestClient(app).get("/api")
assert response.status_code == 200
assert response.json() == {"ok": True}
def test_include_router_frontend_dependencies_apply_in_nested_order(tmp_path: Path):
calls: list[str] = []
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
child = APIRouter(dependencies=[Depends(record_dependency(calls, "child"))])
child.frontend("/ui", directory=dist)
parent = APIRouter(dependencies=[Depends(record_dependency(calls, "parent"))])
parent.include_router(
child,
prefix="/child",
dependencies=[Depends(record_dependency(calls, "parent-include"))],
)
app = FastAPI(dependencies=[Depends(record_dependency(calls, "app"))])
app.include_router(
parent,
prefix="/parent",
dependencies=[Depends(record_dependency(calls, "app-include"))],
)
response = TestClient(app).get("/parent/child/ui/")
assert response.status_code == 200
assert response.text == "app"
assert calls == ["app", "app-include", "parent", "parent-include", "child"]
def test_frontend_dependency_overrides_apply(tmp_path: Path):
calls: list[str] = []
def require_cookie() -> None:
raise HTTPException(status_code=401) # pragma: no cover
def allow_cookie() -> None:
calls.append("override")
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI(dependencies=[Depends(require_cookie)])
app.dependency_overrides[require_cookie] = allow_cookie
app.frontend("/", directory=dist)
response = TestClient(app).get("/")
assert response.status_code == 200
assert response.text == "app"
assert calls == ["override"]
def test_frontend_dependencies_do_not_run_when_api_route_wins(tmp_path: Path):
calls: list[str] = []
def frontend_dependency() -> None:
calls.append("frontend") # pragma: no cover
dist = tmp_path / "dist"
write_file(dist / "api", "frontend")
router = APIRouter(dependencies=[Depends(frontend_dependency)])
router.frontend("/", directory=dist)
app = FastAPI()
@app.get("/api")
def read_api():
return {"source": "api"}
app.include_router(router)
response = TestClient(app).get("/api")
assert response.status_code == 200
assert response.json() == {"source": "api"}
assert calls == []
def test_only_selected_frontend_mount_dependencies_run(tmp_path: Path):
calls: list[str] = []
site = tmp_path / "site"
admin = tmp_path / "admin"
write_file(site / "index.html", "site")
write_file(admin / "index.html", "admin")
site_router = APIRouter()
site_router.frontend("/", directory=site)
admin_router = APIRouter()
admin_router.frontend("/", directory=admin)
app = FastAPI()
app.include_router(
site_router, dependencies=[Depends(record_dependency(calls, "site"))]
)
app.include_router(
admin_router,
prefix="/admin",
dependencies=[Depends(record_dependency(calls, "admin"))],
)
response = TestClient(app).get("/admin/")
assert response.status_code == 200
assert response.text == "admin"
assert calls == ["admin"]
def test_app_middleware_still_runs_for_frontend_dependencies(tmp_path: Path):
calls: list[str] = []
def frontend_dependency() -> None:
calls.append("dependency")
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI(dependencies=[Depends(frontend_dependency)])
@app.middleware("http")
async def record_middleware(request: Request, call_next):
calls.append("middleware-before")
response = await call_next(request)
calls.append("middleware-after")
return response
app.frontend("/", directory=dist)
response = TestClient(app).get("/")
assert response.status_code == 200
assert response.text == "app"
assert calls == ["middleware-before", "dependency", "middleware-after"]
def test_frontend_dependency_response_headers_and_background_tasks(tmp_path: Path):
calls: list[str] = []
def frontend_dependency(
response: Response, background_tasks: BackgroundTasks
) -> None:
response.headers["X-Frontend-Dependency"] = "applied"
response.set_cookie("frontend", "dependency")
background_tasks.add_task(calls.append, "background")
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI(dependencies=[Depends(frontend_dependency)])
app.frontend("/", directory=dist)
response = TestClient(app).get("/")
assert response.status_code == 200
assert response.text == "app"
assert response.headers["X-Frontend-Dependency"] == "applied"
assert response.cookies["frontend"] == "dependency"
assert calls == ["background"]
def test_frontend_dependency_validation_errors_return_422(tmp_path: Path):
def require_token(token: str) -> None:
pass # pragma: no cover
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI(dependencies=[Depends(require_token)])
app.frontend("/", directory=dist)
response = TestClient(app).get("/")
assert response.status_code == 422
assert response.json() == {
"detail": [
{
"type": "missing",
"loc": ["query", "token"],
"msg": "Field required",
"input": None,
}
]
}
@pytest.mark.anyio
async def test_frontend_dependency_restores_existing_dependency_stacks(
tmp_path: Path,
):
def frontend_dependency() -> None:
pass
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI(dependencies=[Depends(frontend_dependency)])
app.frontend("/", directory=dist)
assert app.router._frontend_routes is not None
inner_stack = AsyncExitStack()
function_stack = AsyncExitStack()
scope = {
"type": "http",
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": "/",
"root_path": "",
"query_string": b"",
"headers": [],
"client": ("testclient", 50000),
"server": ("testserver", 80),
"fastapi_inner_astack": inner_stack,
"fastapi_function_astack": function_stack,
}
messages = []
async def receive():
return { # pragma: no cover
"type": "http.request",
"body": b"",
"more_body": False,
}
async def send(message):
messages.append(message)
async with inner_stack, function_stack:
await app.router._frontend_routes.handle(scope, receive, send)
assert scope["fastapi_inner_astack"] is inner_stack
assert scope["fastapi_function_astack"] is function_stack
assert messages[0]["type"] == "http.response.start"
assert messages[0]["status"] == 200
def test_non_frontend_low_priority_route_keeps_order_before_frontend(
tmp_path: Path,
):
async def low_priority_endpoint(request: Request):
return PlainTextResponse("low")
dist = tmp_path / "dist"
write_file(dist / "index.html", "frontend")
app = FastAPI()
app.router._low_priority_routes.append(Route("/admin", low_priority_endpoint))
app.router._mark_routes_changed()
app.frontend("/", directory=dist)
response = TestClient(app).get("/admin")
assert response.status_code == 200
assert response.text == "low"
def test_existing_api_route_wins_over_frontend(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "api" / "users", "frontend")
app = FastAPI()
@app.get("/api/users")
def read_users():
return {"source": "api"}
app.frontend("/", directory=dist)
response = TestClient(app).get("/api/users")
assert response.status_code == 200
assert response.json() == {"source": "api"}
def test_api_route_404_is_not_replaced_by_frontend_fallback(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "frontend")
app = FastAPI()
@app.get("/api/users")
def read_users():
raise HTTPException(status_code=404, detail="api missing")
app.frontend("/", directory=dist, fallback="index.html")
response = TestClient(app).get("/api/users", headers={"accept": "text/html"})
assert response.status_code == 404
assert response.json() == {"detail": "api missing"}
@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(path, headers={"accept": accept})
assert response.status_code == 200
assert response.text == "app shell"
def test_index_fallback_parses_accept_parameters(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).get(
"/dashboard/settings",
headers={"accept": "text/html; charset=utf-8; q=0.8"},
)
assert response.status_code == 200
assert response.text == "app shell"
def test_index_fallback_ignores_q_zero_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).get(
"/dashboard/settings", headers={"accept": "text/html; q=0.0"}
)
assert response.status_code == 404
def test_index_fallback_respects_explicit_html_rejection_with_wildcard(
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).get(
"/dashboard/settings",
headers={"accept": "text/html; q=0, */*; q=1"},
)
assert response.status_code == 404
def test_index_fallback_respects_explicit_xhtml_rejection_with_wildcard(
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).get(
"/dashboard/settings",
headers={"accept": "application/xhtml+xml; q=0, */*; q=1"},
)
assert response.status_code == 404
@pytest.mark.parametrize(
("path", "accept"),
[
("/assets/missing.js", "*/*"),
("/assets/missing.css", "text/css"),
("/assets/missing.png", "image/png"),
("/api/missing", "application/json"),
("/dashboard/settings", ""),
],
)
def test_index_fallback_requires_explicit_html_acceptance(
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(path, headers={"accept": accept})
assert response.status_code == 404
assert response.text != "app shell"
def test_404_fallback_handles_missing_assets(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "404.html", "missing")
app = FastAPI()
app.frontend("/", directory=dist, fallback="404.html")
response = TestClient(app).get("/assets/missing.js")
assert response.status_code == 404
assert response.text == "missing"
def test_auto_fallback_prefers_404_over_index(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app shell")
write_file(dist / "404.html", "missing")
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).get("/dashboard", headers={"accept": "text/html"})
assert response.status_code == 404
assert response.text == "missing"
def test_auto_fallback_uses_index_when_404_is_missing(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app shell")
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).get("/dashboard", headers={"accept": "text/html"})
assert response.status_code == 200
assert response.text == "app shell"
def test_auto_fallback_returns_normal_404_without_fallback_files(tmp_path: Path):
dist = tmp_path / "dist"
dist.mkdir()
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).get("/dashboard", headers={"accept": "text/html"})
assert response.status_code == 404
assert response.json() == {"detail": "Not Found"}
def test_no_fallback_returns_normal_404(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app shell")
app = FastAPI()
app.frontend("/", directory=dist, fallback=None)
response = TestClient(app).get("/dashboard", headers={"accept": "text/html"})
assert response.status_code == 404
assert response.json() == {"detail": "Not Found"}
def test_directory_index_and_redirect(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "about" / "index.html", "about")
app = FastAPI()
app.frontend("/", directory=dist)
client = TestClient(app)
redirect = client.get("/about", follow_redirects=False)
response = client.get("/about/")
assert redirect.status_code == 307
assert redirect.headers["location"] == "http://testserver/about/"
assert response.status_code == 200
assert response.text == "about"
def test_path_validation_and_trailing_slash_normalization(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "asset.txt", "ok")
app = FastAPI()
with pytest.raises(AssertionError):
app.frontend("", directory=dist)
with pytest.raises(AssertionError):
app.frontend("app", directory=dist)
app.frontend("/app/", directory=dist)
response = TestClient(app).get("/app/asset.txt")
assert response.status_code == 200
assert response.text == "ok"
def test_frontend_path_matching_uses_segment_boundaries(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI()
app.frontend("/app", directory=dist, fallback="index.html")
response = TestClient(app).get("/application", headers={"accept": "text/html"})
assert response.status_code == 404
def test_multiple_frontends_use_longest_matching_prefix(tmp_path: Path):
site = tmp_path / "site"
admin = tmp_path / "admin"
write_file(site / "index.html", "site")
write_file(admin / "index.html", "admin")
app = FastAPI()
app.frontend("/", directory=site, fallback="index.html")
app.frontend("/admin", directory=admin, fallback="index.html")
response = TestClient(app).get("/admin/settings", headers={"accept": "text/html"})
assert response.status_code == 200
assert response.text == "admin"
def test_apirouter_frontend_uses_include_prefix(tmp_path: Path):
dist = tmp_path / "admin"
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).get("/admin/settings", headers={"accept": "text/html"})
assert response.status_code == 200
assert response.text == "admin"
def test_global_priority_across_included_routers(tmp_path: Path):
dist = tmp_path / "site"
write_file(dist / "index.html", "site")
site_router = APIRouter()
site_router.frontend("/", directory=dist, fallback="index.html")
api_router = APIRouter()
@api_router.get("/api/users")
def read_users():
return {"source": "api"}
app = FastAPI()
app.include_router(site_router)
app.include_router(api_router)
response = TestClient(app).get("/api/users", headers={"accept": "text/html"})
assert response.status_code == 200
assert response.json() == {"source": "api"}
def test_nested_apirouter_frontend_uses_all_include_prefixes(tmp_path: Path):
dist = tmp_path / "admin"
write_file(dist / "index.html", "admin")
child_router = APIRouter()
child_router.frontend("/", directory=dist, fallback="index.html")
parent_router = APIRouter()
parent_router.include_router(child_router, prefix="/child")
app = FastAPI()
app.include_router(parent_router, prefix="/parent")
response = TestClient(app).get(
"/parent/child/settings", headers={"accept": "text/html"}
)
assert response.status_code == 200
assert response.text == "admin"
def test_low_priority_cache_updates_after_route_added_to_included_router(
tmp_path: Path,
):
dist = tmp_path / "site"
write_file(dist / "index.html", "site")
router = APIRouter()
router.frontend("/", directory=dist, fallback="index.html")
app = FastAPI()
app.include_router(router, prefix="/app")
client = TestClient(app)
frontend_response = client.get("/app/dashboard", headers={"accept": "text/html"})
@router.get("/dashboard")
def read_dashboard():
return {"source": "api"}
api_response = client.get("/app/dashboard", headers={"accept": "text/html"})
assert frontend_response.status_code == 200
assert frontend_response.text == "site"
assert api_response.status_code == 200
assert api_response.json() == {"source": "api"}
def test_normal_route_slash_redirect_wins_before_frontend_redirect(tmp_path: Path):
dist = tmp_path / "site"
write_file(dist / "api" / "index.html", "frontend")
app = FastAPI()
@app.get("/api/")
def read_api():
return {"source": "api"}
app.frontend("/", directory=dist)
response = TestClient(app).get("/api", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"] == "http://testserver/api/"
followed = TestClient(app).get("/api/")
assert followed.status_code == 200
assert followed.json() == {"source": "api"}
def test_frontend_respects_root_path(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "assets" / "app.js", "console.log('ok')")
app = FastAPI()
app.frontend("/app", directory=dist)
response = TestClient(app, root_path="/proxy").get("/app/assets/app.js")
assert response.status_code == 200
assert response.text == "console.log('ok')"
def test_websocket_route_wins_over_frontend(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "ws", "frontend")
app = FastAPI()
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
await websocket.send_text("websocket")
await websocket.close()
app.frontend("/", directory=dist)
with TestClient(app).websocket_connect("/ws") as websocket:
data = websocket.receive_text()
assert data == "websocket"
def test_head_requests_work(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "asset.txt", "ok")
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).head("/asset.txt")
assert response.status_code == 200
assert response.text == ""
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")
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).post("/asset.txt")
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",
[
"/../secret.txt",
"/%2e%2e/secret.txt",
"/..%2fsecret.txt",
"/%5c..%5csecret.txt",
"/..%5csecret.txt",
],
)
def test_path_traversal_cannot_escape_directory(tmp_path: Path, path: str):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
write_file(tmp_path / "secret.txt", "secret")
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).get(path)
assert response.status_code == 404
assert response.text != "secret"
def test_symlink_outside_directory_is_not_served(tmp_path: Path):
dist = tmp_path / "dist"
dist.mkdir()
outside = tmp_path / "secret.txt"
outside.write_text("secret")
link = dist / "secret.txt"
try:
os.symlink(outside, link)
except (OSError, NotImplementedError): # pragma: no cover
pytest.skip("symlinks are not supported")
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).get("/secret.txt")
assert response.status_code == 404
assert response.text != "secret"
def test_check_dir_true_fails_early_for_missing_directory(monkeypatch, tmp_path: Path):
app = FastAPI()
monkeypatch.chdir(tmp_path)
with pytest.raises(RuntimeError, match="does not exist") as exc_info:
app.frontend("/", directory="missing", check_dir=True)
message = str(exc_info.value)
assert "'missing'" in message
assert str(tmp_path / "missing") in message
def test_check_dir_auto_warns_in_development(monkeypatch, tmp_path: Path):
monkeypatch.setenv("FASTAPI_ENV", "development")
app = FastAPI()
with pytest.warns(UserWarning, match="does not exist") as warnings:
app.frontend("/", directory=tmp_path / "missing")
assert str(tmp_path / "missing") in str(warnings[0].message)
assert warnings[0].filename == __file__
def test_check_dir_auto_router_warning_points_to_user_code(monkeypatch, tmp_path: Path):
monkeypatch.setenv("FASTAPI_ENV", "development")
router = APIRouter()
with pytest.warns(UserWarning, match="does not exist") as warnings:
router.frontend("/", directory=tmp_path / "missing")
assert warnings[0].filename == __file__
def test_check_dir_true_fails_in_development(monkeypatch, tmp_path: Path):
monkeypatch.setenv("FASTAPI_ENV", "development")
app = FastAPI()
with pytest.raises(RuntimeError, match="does not exist"):
app.frontend("/", directory=tmp_path / "missing", check_dir=True)
def test_check_dir_auto_fails_outside_development(monkeypatch, tmp_path: Path):
monkeypatch.setenv("FASTAPI_ENV", "production")
router = APIRouter()
with pytest.raises(RuntimeError, match="does not exist"):
router.frontend("/", directory=tmp_path / "missing")
def test_check_dir_false_allows_missing_directory_and_fails_on_request(tmp_path: Path):
app = FastAPI()
app.frontend("/", directory=tmp_path / "missing", check_dir=False)
with pytest.raises(RuntimeError, match="does not exist"):
TestClient(app).get("/asset.txt")
def test_explicit_fallback_files_fail_clearly_when_missing(monkeypatch, tmp_path: Path):
dist = tmp_path / "dist"
dist.mkdir()
monkeypatch.chdir(tmp_path)
app = FastAPI()
with pytest.raises(RuntimeError, match="index.html") as exc_info:
app.frontend("/", directory="dist", fallback="index.html")
message = str(exc_info.value)
assert "directory 'dist'" in message
assert str(dist) in message
app = FastAPI()
app.frontend("/", directory="dist", fallback="404.html", check_dir=False)
with pytest.raises(RuntimeError, match="404.html") as exc_info:
TestClient(app).get("/missing.js")
message = str(exc_info.value)
assert "directory 'dist'" in message
assert str(dist) in message
def test_frontend_routes_are_not_in_openapi(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI()
@app.get("/api")
def read_api():
return {"ok": True}
app.frontend("/", directory=dist, fallback="index.html")
schema = TestClient(app).get("/openapi.json").json()
assert set(schema["paths"]) == {"/api"}
response = TestClient(app).get("/api")
assert response.status_code == 200
assert response.json() == {"ok": True}
@pytest.mark.parametrize(
("example", "files", "path", "status_code", "body"),
[
(
"tutorial001_py310.py",
{"asset.txt": "asset"},
"/asset.txt",
200,
"asset",
),
(
"tutorial002_py310.py",
{"index.html": "index"},
"/dashboard",
200,
"index",
),
(
"tutorial003_py310.py",
{"404.html": "missing"},
"/missing",
404,
"missing",
),
(
"tutorial004_py310.py",
{"index.html": "index"},
"/app/dashboard",
200,
"index",
),
(
"tutorial005_py310.py",
{"index.html": "index"},
"/dashboard",
404,
'{"detail":"Not Found"}',
),
(
"tutorial006_py310.py",
{"asset.txt": "asset"},
"/asset.txt",
200,
"asset",
),
],
)
def test_docs_frontend_examples(
tmp_path: Path,
monkeypatch,
example: str,
files: dict[str, str],
path: str,
status_code: int,
body: str,
):
dist = tmp_path / "dist"
for file, content in files.items():
write_file(dist / file, content)
monkeypatch.chdir(tmp_path)
namespace = runpy.run_path(
str(Path(__file__).parents[1] / "docs_src" / "frontend" / example)
)
app = namespace["app"]
assert isinstance(app, FastAPI)
response = TestClient(app).get(path, headers={"accept": "text/html"})
assert response.status_code == status_code
assert response.text == body
def test_low_priority_routes_can_store_non_frontend_routes():
async def low_priority_endpoint(request):
return PlainTextResponse("low")
app = FastAPI()
app.router._low_priority_routes.append(Route("/low", low_priority_endpoint))
app.router._mark_routes_changed()
response = TestClient(app).get("/low")
assert response.status_code == 200
assert response.text == "low"
def test_included_low_priority_routes_can_store_non_frontend_routes():
async def low_priority_endpoint(request):
return PlainTextResponse("low")
router = APIRouter()
router._low_priority_routes.append(Route("/low", low_priority_endpoint))
router._mark_routes_changed()
app = FastAPI()
app.include_router(router, prefix="/prefix")
response = TestClient(app).get("/prefix/low")
assert response.status_code == 200
assert response.text == "low"