Skip to content

Commit 6bb54a6

Browse files
committed
fix: handle ASGI pathsend extension in WebFilterChainMiddleware (Granian)
Granian uses http.response.pathsend (zero-copy sendfile) instead of http.response.body for FileResponse. The middleware silently ignored this message type, causing static file bodies (CSS/JS) to be empty — resulting in a blank admin dashboard under Granian.
1 parent 5b34b77 commit 6bb54a6

3 files changed

Lines changed: 57 additions & 1 deletion

File tree

src/pyfly/web/adapters/starlette/filter_chain.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ async def _intercept(message: Any) -> None:
6262
body = message.get("body", b"")
6363
if body:
6464
body_parts.append(body)
65+
elif message["type"] == "http.response.pathsend":
66+
# ASGI pathsend extension (Granian zero-copy file serving).
67+
# Read the file into body_parts so filters can process it.
68+
from pathlib import Path
69+
70+
path = message.get("path", "")
71+
if path:
72+
body_parts.append(Path(path).read_bytes())
6573

6674
await self.app(scope, receive, _intercept)
6775

tests/web/test_filter_chain.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,54 @@ def test_no_filters_passes_through(self):
146146
assert resp.text == "OK"
147147

148148

149+
class TestFilterChainPathsend:
150+
"""Tests for ASGI pathsend extension (used by Granian for FileResponse)."""
151+
152+
@pytest.mark.asyncio
153+
async def test_pathsend_file_content_captured(self, tmp_path):
154+
"""Middleware should read file content when receiving http.response.pathsend."""
155+
file = tmp_path / "hello.txt"
156+
file.write_text("file content here")
157+
158+
async def pathsend_app(scope, receive, send):
159+
await send(
160+
{
161+
"type": "http.response.start",
162+
"status": 200,
163+
"headers": [
164+
(b"content-type", b"text/plain"),
165+
],
166+
}
167+
)
168+
await send({"type": "http.response.pathsend", "path": str(file)})
169+
170+
mw = WebFilterChainMiddleware(app=pathsend_app, filters=[HeaderFilter()])
171+
172+
sent_messages: list[dict] = []
173+
174+
async def capture_send(message):
175+
sent_messages.append(message)
176+
177+
scope = {"type": "http", "method": "GET", "path": "/file", "query_string": b"", "headers": []}
178+
179+
async def receive():
180+
return {"type": "http.request", "body": b""}
181+
182+
await mw(scope, receive, capture_send)
183+
184+
start_msg = sent_messages[0]
185+
assert start_msg["type"] == "http.response.start"
186+
assert start_msg["status"] == 200
187+
188+
body_msg = sent_messages[1]
189+
assert body_msg["type"] == "http.response.body"
190+
assert body_msg["body"] == b"file content here"
191+
192+
# Verify filter headers were applied
193+
headers = dict(start_msg["headers"])
194+
assert headers[b"x-filter-a"] == b"applied"
195+
196+
149197
class TestFilterChainCustomDiscovery:
150198
@pytest.mark.asyncio
151199
async def test_custom_filter_auto_discovered_in_create_app(self):

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)