Browse Source

🐛 Fix support for background tasks and headers from dependencies in `app.frontend()` (#16105)

pull/16106/head
Sebastián Ramírez 3 weeks ago
committed by GitHub
parent
commit
d62354434b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      docs/en/docs/tutorial/frontend.md
  2. 22
      fastapi/routing.py
  3. 34
      tests/test_frontend.py

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

@ -136,6 +136,8 @@ Frontend responses run inside the normal **FastAPI** application, so HTTP middle
Dependencies from the app, from an `APIRouter`, and from `include_router()` also apply to frontend responses. This can be useful for protecting a frontend with cookie authentication or similar. Dependencies from the app, from an `APIRouter`, and from `include_router()` also apply to frontend responses. This can be useful for protecting a frontend with cookie authentication or similar.
Dependencies can also modify response headers and add background tasks, as with normal *path operations*.
## Static Build Output Only { #static-build-output-only } ## Static Build Output Only { #static-build-output-only }
`app.frontend()` serves files already generated by your frontend build. `app.frontend()` serves files already generated by your frontend build.

22
fastapi/routing.py

@ -56,6 +56,7 @@ from fastapi.dependencies.models import (
_is_gen_callable, _is_gen_callable,
) )
from fastapi.dependencies.utils import ( from fastapi.dependencies.utils import (
SolvedDependency,
_get_body_field, _get_body_field,
_get_flat_body_params, _get_flat_body_params,
_should_embed_body_fields, _should_embed_body_fields,
@ -1936,6 +1937,12 @@ class _FrontendStaticFiles(StaticFiles):
assert isinstance(path, str) assert isinstance(path, str)
return os.path.normpath(os.path.join(*path.split("/"))) return os.path.normpath(os.path.join(*path.split("/")))
async def get_response_for_scope(self, scope: Scope) -> Response:
if not self.config_checked:
await self.check_config()
self.config_checked = True
return await self.get_response(self.get_path(scope), scope)
async def get_response(self, path: str, scope: Scope) -> Response: async def get_response(self, path: str, scope: Scope) -> Response:
if scope["method"] not in ("GET", "HEAD"): if scope["method"] not in ("GET", "HEAD"):
if await self._lookup_static_resource(path) is not None: if await self._lookup_static_resource(path) is not None:
@ -2086,7 +2093,8 @@ class _FrontendRoute(BaseRoute):
return None return None
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None: async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
await self.app(scope, receive, send) response = await self.app.get_response_for_scope(scope)
await response(scope, receive, send)
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath: def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
raise NoMatchFound(name, path_params) raise NoMatchFound(name, path_params)
@ -2189,8 +2197,12 @@ class _FrontendRouteGroup(BaseRoute):
dependant=dependant, dependant=dependant,
dependency_overrides_provider=dependency_overrides_provider, dependency_overrides_provider=dependency_overrides_provider,
embed_body_fields=embed_body_fields, embed_body_fields=embed_body_fields,
): ) as solved_result:
await route.handle(scope, receive, send) response = await route.app.get_response_for_scope(scope)
if response.background is None:
response.background = solved_result.background_tasks
response.headers.raw.extend(solved_result.response.headers.raw)
await response(scope, receive, send)
return return
await route.handle(scope, receive, send) await route.handle(scope, receive, send)
@ -2210,7 +2222,7 @@ class _FrontendRouteGroup(BaseRoute):
dependant: Dependant, dependant: Dependant,
dependency_overrides_provider: Any | None, dependency_overrides_provider: Any | None,
embed_body_fields: bool, embed_body_fields: bool,
) -> AsyncIterator[None]: ) -> AsyncIterator[SolvedDependency]:
request = Request(scope, receive, send) request = Request(scope, receive, send)
previous_inner_astack = scope.get("fastapi_inner_astack", _SCOPE_MISSING) previous_inner_astack = scope.get("fastapi_inner_astack", _SCOPE_MISSING)
previous_function_astack = scope.get("fastapi_function_astack", _SCOPE_MISSING) previous_function_astack = scope.get("fastapi_function_astack", _SCOPE_MISSING)
@ -2228,7 +2240,7 @@ class _FrontendRouteGroup(BaseRoute):
) )
if solved_result.errors: if solved_result.errors:
raise RequestValidationError(solved_result.errors) raise RequestValidationError(solved_result.errors)
yield yield solved_result
finally: finally:
if previous_inner_astack is _SCOPE_MISSING: if previous_inner_astack is _SCOPE_MISSING:
scope.pop("fastapi_inner_astack", None) scope.pop("fastapi_inner_astack", None)

34
tests/test_frontend.py

@ -7,7 +7,15 @@ from typing import Literal
import anyio import anyio
import pytest 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 fastapi.testclient import TestClient
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import PlainTextResponse, Response from starlette.responses import PlainTextResponse, Response
@ -491,6 +499,30 @@ def test_app_middleware_still_runs_for_frontend_dependencies(tmp_path: Path):
assert calls == ["middleware-before", "dependency", "middleware-after"] 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 test_frontend_dependency_validation_errors_return_422(tmp_path: Path):
def require_token(token: str) -> None: def require_token(token: str) -> None:
pass # pragma: no cover pass # pragma: no cover

Loading…
Cancel
Save