diff --git a/docs/middleware.md b/docs/middleware.md index 9e5601819f..23f0eeb840 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -264,6 +264,7 @@ around explicitly, rather than mutating the middleware instance. Currently, the `BaseHTTPMiddleware` has some known limitations: - Using `BaseHTTPMiddleware` will prevent changes to [`contextlib.ContextVar`](https://docs.python.org/3/library/contextvars.html#contextvars.ContextVar)s from propagating upwards. That is, if you set a value for a `ContextVar` in your endpoint and try to read it from a middleware you will find that the value is not the same value you set in your endpoint (see [this test](https://github.com/encode/starlette/blob/621abc747a6604825190b93467918a0ec6456a24/tests/middleware/test_base.py#L192-L223) for an example of this behavior). +- Using `BaseHTTPMiddleware` will prevent [ASGI pathsend extension](https://asgi.readthedocs.io/en/latest/extensions.html#path-send) to work properly. Thus, if you run your Starlette application with a server implementing this extension, routes returning [FileResponse](responses.md#fileresponse) should avoid the usage of this middleware. To overcome these limitations, use [pure ASGI middleware](#pure-asgi-middleware), as shown below. diff --git a/starlette/middleware/gzip.py b/starlette/middleware/gzip.py index 0579e0410a..298c126f81 100644 --- a/starlette/middleware/gzip.py +++ b/starlette/middleware/gzip.py @@ -93,7 +93,6 @@ async def send_with_gzip(self, message: Message) -> None: await self.send(self.initial_message) await self.send(message) - elif message_type == "http.response.body": # Remaining body in streaming GZip response. body = message.get("body", b"") @@ -108,6 +107,10 @@ async def send_with_gzip(self, message: Message) -> None: self.gzip_buffer.truncate() await self.send(message) + elif message_type == "http.response.pathsend": + # Don't apply GZip to pathsend responses + await self.send(self.initial_message) + await self.send(message) async def unattached_send(message: Message) -> typing.NoReturn: diff --git a/starlette/responses.py b/starlette/responses.py index 4f15404ca1..1459bf4e96 100644 --- a/starlette/responses.py +++ b/starlette/responses.py @@ -339,6 +339,8 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: ) if scope["method"].upper() == "HEAD": await send({"type": "http.response.body", "body": b"", "more_body": False}) + elif "http.response.pathsend" in scope["extensions"]: + await send({"type": "http.response.pathsend", "path": str(self.path)}) else: async with await anyio.open_file(self.path, mode="rb") as file: more_body = True diff --git a/tests/middleware/test_gzip.py b/tests/middleware/test_gzip.py index b6f68296dc..61ef23ff15 100644 --- a/tests/middleware/test_gzip.py +++ b/tests/middleware/test_gzip.py @@ -1,9 +1,21 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.gzip import GZipMiddleware from starlette.requests import Request -from starlette.responses import ContentStream, PlainTextResponse, StreamingResponse +from starlette.responses import ( + ContentStream, + FileResponse, + PlainTextResponse, + StreamingResponse, +) from starlette.routing import Route +from starlette.types import Message from tests.types import TestClientFactory @@ -106,3 +118,42 @@ async def generator(bytes: bytes, count: int) -> ContentStream: assert response.text == "x" * 4000 assert response.headers["Content-Encoding"] == "text" assert "Content-Length" not in response.headers + + +@pytest.mark.anyio +async def test_gzip_ignored_for_pathsend_responses(tmpdir: Path) -> None: + path = tmpdir / "example.txt" + with path.open("w") as file: + file.write("") + + events: list[Message] = [] + + async def endpoint_with_pathsend(request: Request) -> FileResponse: + _ = await request.body() + return FileResponse(path) + + app = Starlette( + routes=[Route("/", endpoint=endpoint_with_pathsend)], + middleware=[Middleware(GZipMiddleware)], + ) + + scope = { + "type": "http", + "version": "3", + "method": "GET", + "path": "/", + "headers": [(b"accept-encoding", b"gzip, text")], + "extensions": {"http.response.pathsend": {}}, + } + + async def receive() -> Message: + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: Message) -> None: + events.append(message) + + await app(scope, receive, send) + + assert len(events) == 2 + assert events[0]["type"] == "http.response.start" + assert events[1]["type"] == "http.response.pathsend" diff --git a/tests/test_responses.py b/tests/test_responses.py index c63c92de58..1fb3bcb501 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -356,6 +356,38 @@ def test_file_response_with_method_warns(tmp_path: Path) -> None: FileResponse(path=tmp_path, filename="example.png", method="GET") +@pytest.mark.anyio +async def test_file_response_with_pathsend(tmpdir: Path) -> None: + path = tmpdir / "xyz" + content = b"" * 1000 + with open(path, "wb") as file: + file.write(content) + + app = FileResponse(path=path, filename="example.png") + + async def receive() -> Message: # type: ignore[empty-body] + ... # pragma: no cover + + async def send(message: Message) -> None: + if message["type"] == "http.response.start": + assert message["status"] == status.HTTP_200_OK + headers = Headers(raw=message["headers"]) + assert headers["content-type"] == "image/png" + assert "content-length" in headers + assert "content-disposition" in headers + assert "last-modified" in headers + assert "etag" in headers + elif message["type"] == "http.response.pathsend": + assert message["path"] == str(path) + + # Since the TestClient doesn't support `pathsend`, we need to test this directly. + await app( + {"type": "http", "method": "get", "extensions": {"http.response.pathsend": {}}}, + receive, + send, + ) + + def test_set_cookie( test_client_factory: TestClientFactory, monkeypatch: pytest.MonkeyPatch ) -> None: