Skip to content

Commit 2bbda0a

Browse files
committed
Add support for ASGI pathsend extension
1 parent c78c9aa commit 2bbda0a

File tree

5 files changed

+91
-2
lines changed

5 files changed

+91
-2
lines changed

docs/middleware.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ around explicitly, rather than mutating the middleware instance.
264264
Currently, the `BaseHTTPMiddleware` has some known limitations:
265265

266266
- 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).
267+
- 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.
267268

268269
To overcome these limitations, use [pure ASGI middleware](#pure-asgi-middleware), as shown below.
269270

starlette/middleware/gzip.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ async def send_with_gzip(self, message: Message) -> None:
9393

9494
await self.send(self.initial_message)
9595
await self.send(message)
96-
9796
elif message_type == "http.response.body":
9897
# Remaining body in streaming GZip response.
9998
body = message.get("body", b"")
@@ -108,6 +107,10 @@ async def send_with_gzip(self, message: Message) -> None:
108107
self.gzip_buffer.truncate()
109108

110109
await self.send(message)
110+
elif message_type == "http.response.pathsend":
111+
# Don't apply GZip to pathsend responses
112+
await self.send(self.initial_message)
113+
await self.send(message)
111114

112115

113116
async def unattached_send(message: Message) -> typing.NoReturn:

starlette/responses.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,8 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
339339
)
340340
if scope["method"].upper() == "HEAD":
341341
await send({"type": "http.response.body", "body": b"", "more_body": False})
342+
elif "http.response.pathsend" in scope["extensions"]:
343+
await send({"type": "http.response.pathsend", "path": str(self.path)})
342344
else:
343345
async with await anyio.open_file(self.path, mode="rb") as file:
344346
more_body = True

tests/middleware/test_gzip.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
17
from starlette.applications import Starlette
28
from starlette.middleware import Middleware
39
from starlette.middleware.gzip import GZipMiddleware
410
from starlette.requests import Request
5-
from starlette.responses import ContentStream, PlainTextResponse, StreamingResponse
11+
from starlette.responses import (
12+
ContentStream,
13+
FileResponse,
14+
PlainTextResponse,
15+
StreamingResponse,
16+
)
617
from starlette.routing import Route
18+
from starlette.types import Message
719
from tests.types import TestClientFactory
820

921

@@ -106,3 +118,42 @@ async def generator(bytes: bytes, count: int) -> ContentStream:
106118
assert response.text == "x" * 4000
107119
assert response.headers["Content-Encoding"] == "text"
108120
assert "Content-Length" not in response.headers
121+
122+
123+
@pytest.mark.anyio
124+
async def test_gzip_ignored_for_pathsend_responses(tmpdir: Path) -> None:
125+
path = tmpdir / "example.txt"
126+
with path.open("w") as file:
127+
file.write("<file content>")
128+
129+
events: list[Message] = []
130+
131+
async def endpoint_with_pathsend(request: Request) -> FileResponse:
132+
_ = await request.body()
133+
return FileResponse(path)
134+
135+
app = Starlette(
136+
routes=[Route("/", endpoint=endpoint_with_pathsend)],
137+
middleware=[Middleware(GZipMiddleware)],
138+
)
139+
140+
scope = {
141+
"type": "http",
142+
"version": "3",
143+
"method": "GET",
144+
"path": "/",
145+
"headers": [(b"accept-encoding", b"gzip, text")],
146+
"extensions": {"http.response.pathsend": {}},
147+
}
148+
149+
async def receive() -> Message:
150+
return {"type": "http.request", "body": b"", "more_body": False}
151+
152+
async def send(message: Message) -> None:
153+
events.append(message)
154+
155+
await app(scope, receive, send)
156+
157+
assert len(events) == 2
158+
assert events[0]["type"] == "http.response.start"
159+
assert events[1]["type"] == "http.response.pathsend"

tests/test_responses.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,38 @@ def test_file_response_with_method_warns(tmp_path: Path) -> None:
356356
FileResponse(path=tmp_path, filename="example.png", method="GET")
357357

358358

359+
@pytest.mark.anyio
360+
async def test_file_response_with_pathsend(tmpdir: Path):
361+
path = tmpdir / "xyz"
362+
content = b"<file content>" * 1000
363+
with open(path, "wb") as file:
364+
file.write(content)
365+
366+
app = FileResponse(path=path, filename="example.png")
367+
368+
async def receive() -> Message: # type: ignore[empty-body]
369+
... # pragma: no cover
370+
371+
async def send(message: Message) -> None:
372+
if message["type"] == "http.response.start":
373+
assert message["status"] == status.HTTP_200_OK
374+
headers = Headers(raw=message["headers"])
375+
assert headers["content-type"] == "image/png"
376+
assert "content-length" in headers
377+
assert "content-disposition" in headers
378+
assert "last-modified" in headers
379+
assert "etag" in headers
380+
elif message["type"] == "http.response.pathsend":
381+
assert message["path"] == str(path)
382+
383+
# Since the TestClient doesn't support `pathsend`, we need to test this directly.
384+
await app(
385+
{"type": "http", "method": "get", "extensions": {"http.response.pathsend": {}}},
386+
receive,
387+
send,
388+
)
389+
390+
359391
def test_set_cookie(
360392
test_client_factory: TestClientFactory, monkeypatch: pytest.MonkeyPatch
361393
) -> None:

0 commit comments

Comments
 (0)