Browse Source

🐛 Fix frontend dependencies not propagating response headers, cookies, and background tasks

pull/15919/head
serverestaa 3 weeks ago
parent
commit
112b011fd6
  1. 41
      fastapi/routing.py
  2. 150
      tests/test_frontend.py

41
fastapi/routing.py

@ -49,6 +49,7 @@ from fastapi._compat import (
from fastapi.datastructures import Default, DefaultPlaceholder
from fastapi.dependencies.models import Dependant
from fastapi.dependencies.utils import (
SolvedDependency,
_should_embed_body_fields,
get_body_field,
get_dependant,
@ -103,7 +104,7 @@ from starlette.routing import (
)
from starlette.routing import Mount as Mount # noqa
from starlette.staticfiles import StaticFiles
from starlette.types import AppType, ASGIApp, Lifespan, Receive, Scope, Send
from starlette.types import AppType, ASGIApp, Lifespan, Message, Receive, Scope, Send
from starlette.websockets import WebSocket
from typing_extensions import deprecated
@ -2156,8 +2157,35 @@ class _FrontendRouteGroup(BaseRoute):
dependant=dependant,
dependency_overrides_provider=dependency_overrides_provider,
embed_body_fields=embed_body_fields,
):
await route.handle(scope, receive, send)
) as solved_result:
dependency_response = solved_result.response
body_allowed = True
async def send_with_dependency_response(message: Message) -> None:
nonlocal body_allowed
if message["type"] == "http.response.start":
# Only override a plain 200, semantic statuses from the
# file response (e.g. 304 Not Modified, 206 Partial
# Content) are preserved.
if dependency_response.status_code and message["status"] == 200:
message["status"] = dependency_response.status_code
headers = list(message.get("headers", []))
headers.extend(dependency_response.headers.raw)
if not is_body_allowed_for_status_code(message["status"]):
body_allowed = False
headers = [
(name, value)
for name, value in headers
if name.lower() != b"content-length"
]
message["headers"] = headers
elif message["type"] == "http.response.body" and not body_allowed:
message["body"] = b""
await send(message)
await route.handle(scope, receive, send_with_dependency_response)
if solved_result.background_tasks:
await solved_result.background_tasks()
return
await route.handle(scope, receive, send)
@ -2177,7 +2205,7 @@ class _FrontendRouteGroup(BaseRoute):
dependant: Dependant,
dependency_overrides_provider: Any | None,
embed_body_fields: bool,
) -> AsyncIterator[None]:
) -> AsyncIterator[SolvedDependency]:
request = Request(scope, receive, send)
previous_inner_astack = scope.get("fastapi_inner_astack", _SCOPE_MISSING)
previous_function_astack = scope.get("fastapi_function_astack", _SCOPE_MISSING)
@ -2195,7 +2223,10 @@ class _FrontendRouteGroup(BaseRoute):
)
if solved_result.errors:
raise RequestValidationError(solved_result.errors)
yield
# Function-scoped dependencies exit before the response is
# sent and before background tasks run, as in request_response()
# above for regular routes.
yield solved_result
finally:
if previous_inner_astack is _SCOPE_MISSING:
scope.pop("fastapi_inner_astack", None)

150
tests/test_frontend.py

@ -1,13 +1,22 @@
import errno
import os
import runpy
from collections.abc import Iterator
from contextlib import AsyncExitStack
from pathlib import Path
from typing import Literal
import anyio
import pytest
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, WebSocket
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
@ -515,6 +524,145 @@ def test_frontend_dependency_validation_errors_return_422(tmp_path: Path):
}
def test_frontend_dependency_response_headers_and_cookies(tmp_path: Path):
def refresh_session(response: Response) -> None:
response.set_cookie("session", "refreshed")
response.headers["X-Dep"] = "ran"
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
write_file(dist / "assets" / "app.js", "console.log('ok')")
app = FastAPI(dependencies=[Depends(refresh_session)])
app.frontend("/", directory=dist, fallback="index.html")
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert response.text == "app"
assert response.cookies["session"] == "refreshed"
assert response.headers["x-dep"] == "ran"
response = client.get("/assets/app.js")
assert response.status_code == 200
assert response.cookies["session"] == "refreshed"
assert response.headers["x-dep"] == "ran"
response = client.get("/dashboard", headers={"accept": "text/html"})
assert response.status_code == 200
assert response.text == "app"
assert response.cookies["session"] == "refreshed"
assert response.headers["x-dep"] == "ran"
def test_frontend_dependency_response_status_code(tmp_path: Path):
def set_status(response: Response) -> None:
response.status_code = 203
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI(dependencies=[Depends(set_status)])
app.frontend("/", directory=dist)
response = TestClient(app).get("/")
assert response.status_code == 203
assert response.text == "app"
def test_frontend_dependency_does_not_override_304_not_modified(tmp_path: Path):
def set_status_and_header(response: Response) -> None:
response.status_code = 203
response.headers["X-Dep"] = "ran"
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI(dependencies=[Depends(set_status_and_header)])
app.frontend("/", directory=dist)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 203
etag = response.headers["etag"]
response = client.get("/", headers={"If-None-Match": etag})
assert response.status_code == 304
assert response.text == ""
assert response.headers["x-dep"] == "ran"
def test_frontend_dependency_does_not_override_206_partial_content(tmp_path: Path):
def set_status_and_header(response: Response) -> None:
response.status_code = 203
response.headers["X-Dep"] = "ran"
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI(dependencies=[Depends(set_status_and_header)])
app.frontend("/", directory=dist)
response = TestClient(app).get("/", headers={"Range": "bytes=0-1"})
assert response.status_code == 206
assert response.text == "ap"
assert response.headers["x-dep"] == "ran"
def test_frontend_dependency_bodyless_status_code_strips_body(tmp_path: Path):
def set_status(response: Response) -> None:
response.status_code = 304
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI(dependencies=[Depends(set_status)])
app.frontend("/", directory=dist)
response = TestClient(app).get("/")
assert response.status_code == 304
assert response.text == ""
assert "content-length" not in response.headers
def test_frontend_dependency_background_tasks_run(tmp_path: Path):
tasks_run: list[str] = []
def add_task(background_tasks: BackgroundTasks) -> None:
background_tasks.add_task(tasks_run.append, "ran")
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI(dependencies=[Depends(add_task)])
app.frontend("/", directory=dist)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert response.text == "app"
assert tasks_run == ["ran"]
def test_frontend_function_scoped_yield_dependency_exits_before_background_tasks(
tmp_path: Path,
):
events: list[str] = []
def dep(background_tasks: BackgroundTasks) -> Iterator[None]:
background_tasks.add_task(events.append, "background")
yield
events.append("teardown")
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI(dependencies=[Depends(dep, scope="function")])
app.frontend("/", directory=dist)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert response.text == "app"
assert events == ["teardown", "background"]
@pytest.mark.anyio
async def test_frontend_dependency_restores_existing_dependency_stacks(
tmp_path: Path,

Loading…
Cancel
Save