Allow to disable Transfer-Encoding: chunked (#1560)

Allow to disable Transfer-Encoding: chunked
This commit is contained in:
Eli Uriegas 2019-04-30 14:56:27 -07:00 committed by GitHub
commit ef6d78c580
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 148 additions and 12 deletions

View File

@ -60,6 +60,8 @@ async def index(request):
return response.stream(streaming_fn, content_type='text/plain') return response.stream(streaming_fn, content_type='text/plain')
``` ```
See [Streaming](streaming.md) for more information.
## File Streaming ## File Streaming
For large files, a combination of File and Streaming above For large files, a combination of File and Streaming above
```python ```python

View File

@ -117,3 +117,27 @@ async def index(request):
return stream(stream_from_db) return stream(stream_from_db)
``` ```
If a client supports HTTP/1.1, Sanic will use [chunked transfer encoding](https://en.wikipedia.org/wiki/Chunked_transfer_encoding); you can explicitly enable or disable it using `chunked` option of the `stream` function.
## File Streaming
Sanic provides `sanic.response.file_stream` function that is useful when you want to send a large file. It returns a `StreamingHTTPResponse` object and will use chunked transfer encoding by default; for this reason Sanic doesn't add `Content-Length` HTTP header in the response. If you want to use this header, you can disable chunked transfer encoding and add it manually:
```python
from aiofiles import os as async_os
from sanic.response import file_stream
@app.route("/")
async def index(request):
file_path = "/srv/www/whatever.png"
file_stat = await async_os.stat(file_path)
headers = {"Content-Length": str(file_stat.st_size)}
return await file_stream(
file_path,
headers=headers,
chunked=False,
)
```

View File

@ -59,16 +59,23 @@ class StreamingHTTPResponse(BaseHTTPResponse):
"status", "status",
"content_type", "content_type",
"headers", "headers",
"chunked",
"_cookies", "_cookies",
) )
def __init__( def __init__(
self, streaming_fn, status=200, headers=None, content_type="text/plain" self,
streaming_fn,
status=200,
headers=None,
content_type="text/plain",
chunked=True,
): ):
self.content_type = content_type self.content_type = content_type
self.streaming_fn = streaming_fn self.streaming_fn = streaming_fn
self.status = status self.status = status
self.headers = CIMultiDict(headers or {}) self.headers = CIMultiDict(headers or {})
self.chunked = chunked
self._cookies = None self._cookies = None
async def write(self, data): async def write(self, data):
@ -79,7 +86,10 @@ class StreamingHTTPResponse(BaseHTTPResponse):
if type(data) != bytes: if type(data) != bytes:
data = self._encode_body(data) data = self._encode_body(data)
if self.chunked:
self.protocol.push_data(b"%x\r\n%b\r\n" % (len(data), data)) self.protocol.push_data(b"%x\r\n%b\r\n" % (len(data), data))
else:
self.protocol.push_data(data)
await self.protocol.drain() await self.protocol.drain()
async def stream( async def stream(
@ -88,6 +98,8 @@ class StreamingHTTPResponse(BaseHTTPResponse):
"""Streams headers, runs the `streaming_fn` callback that writes """Streams headers, runs the `streaming_fn` callback that writes
content to the response body, then finalizes the response body. content to the response body, then finalizes the response body.
""" """
if version != "1.1":
self.chunked = False
headers = self.get_headers( headers = self.get_headers(
version, version,
keep_alive=keep_alive, keep_alive=keep_alive,
@ -96,6 +108,7 @@ class StreamingHTTPResponse(BaseHTTPResponse):
self.protocol.push_data(headers) self.protocol.push_data(headers)
await self.protocol.drain() await self.protocol.drain()
await self.streaming_fn(self) await self.streaming_fn(self)
if self.chunked:
self.protocol.push_data(b"0\r\n\r\n") self.protocol.push_data(b"0\r\n\r\n")
# no need to await drain here after this write, because it is the # no need to await drain here after this write, because it is the
# very last thing we write and nothing needs to wait for it. # very last thing we write and nothing needs to wait for it.
@ -109,6 +122,7 @@ class StreamingHTTPResponse(BaseHTTPResponse):
if keep_alive and keep_alive_timeout is not None: if keep_alive and keep_alive_timeout is not None:
timeout_header = b"Keep-Alive: %d\r\n" % keep_alive_timeout timeout_header = b"Keep-Alive: %d\r\n" % keep_alive_timeout
if self.chunked and version == "1.1":
self.headers["Transfer-Encoding"] = "chunked" self.headers["Transfer-Encoding"] = "chunked"
self.headers.pop("Content-Length", None) self.headers.pop("Content-Length", None)
self.headers["Content-Type"] = self.headers.get( self.headers["Content-Type"] = self.headers.get(
@ -327,6 +341,7 @@ async def file_stream(
mime_type=None, mime_type=None,
headers=None, headers=None,
filename=None, filename=None,
chunked=True,
_range=None, _range=None,
): ):
"""Return a streaming response object with file data. """Return a streaming response object with file data.
@ -336,6 +351,7 @@ async def file_stream(
:param mime_type: Specific mime_type. :param mime_type: Specific mime_type.
:param headers: Custom Headers. :param headers: Custom Headers.
:param filename: Override filename. :param filename: Override filename.
:param chunked: Enable or disable chunked transfer-encoding
:param _range: :param _range:
""" """
headers = headers or {} headers = headers or {}
@ -383,6 +399,7 @@ async def file_stream(
status=status, status=status,
headers=headers, headers=headers,
content_type=mime_type, content_type=mime_type,
chunked=chunked,
) )
@ -391,6 +408,7 @@ def stream(
status=200, status=200,
headers=None, headers=None,
content_type="text/plain; charset=utf-8", content_type="text/plain; charset=utf-8",
chunked=True,
): ):
"""Accepts an coroutine `streaming_fn` which can be used to """Accepts an coroutine `streaming_fn` which can be used to
write chunks to a streaming response. Returns a `StreamingHTTPResponse`. write chunks to a streaming response. Returns a `StreamingHTTPResponse`.
@ -409,9 +427,14 @@ def stream(
writes content to that response. writes content to that response.
:param mime_type: Specific mime_type. :param mime_type: Specific mime_type.
:param headers: Custom Headers. :param headers: Custom Headers.
:param chunked: Enable or disable chunked transfer-encoding
""" """
return StreamingHTTPResponse( return StreamingHTTPResponse(
streaming_fn, headers=headers, content_type=content_type, status=status streaming_fn,
headers=headers,
content_type=content_type,
status=status,
chunked=chunked,
) )

View File

@ -192,22 +192,59 @@ def test_no_content(json_app):
def streaming_app(app): def streaming_app(app):
@app.route("/") @app.route("/")
async def test(request): async def test(request):
return stream(sample_streaming_fn, content_type="text/csv") return stream(
sample_streaming_fn,
headers={"Content-Length": "7"},
content_type="text/csv",
)
return app return app
def test_streaming_adds_correct_headers(streaming_app): @pytest.fixture
def non_chunked_streaming_app(app):
@app.route("/")
async def test(request):
return stream(
sample_streaming_fn,
headers={"Content-Length": "7"},
content_type="text/csv",
chunked=False,
)
return app
def test_chunked_streaming_adds_correct_headers(streaming_app):
request, response = streaming_app.test_client.get("/") request, response = streaming_app.test_client.get("/")
assert response.headers["Transfer-Encoding"] == "chunked" assert response.headers["Transfer-Encoding"] == "chunked"
assert response.headers["Content-Type"] == "text/csv" assert response.headers["Content-Type"] == "text/csv"
# Content-Length is not allowed by HTTP/1.1 specification
# when "Transfer-Encoding: chunked" is used
assert "Content-Length" not in response.headers
def test_streaming_returns_correct_content(streaming_app): def test_chunked_streaming_returns_correct_content(streaming_app):
request, response = streaming_app.test_client.get("/") request, response = streaming_app.test_client.get("/")
assert response.text == "foo,bar" assert response.text == "foo,bar"
def test_non_chunked_streaming_adds_correct_headers(
non_chunked_streaming_app
):
request, response = non_chunked_streaming_app.test_client.get("/")
assert "Transfer-Encoding" not in response.headers
assert response.headers["Content-Type"] == "text/csv"
assert response.headers["Content-Length"] == "7"
def test_non_chunked_streaming_returns_correct_content(
non_chunked_streaming_app
):
request, response = non_chunked_streaming_app.test_client.get("/")
assert response.text == "foo,bar"
@pytest.mark.parametrize("status", [200, 201, 400, 401]) @pytest.mark.parametrize("status", [200, 201, 400, 401])
def test_stream_response_status_returns_correct_headers(status): def test_stream_response_status_returns_correct_headers(status):
response = StreamingHTTPResponse(sample_streaming_fn, status=status) response = StreamingHTTPResponse(sample_streaming_fn, status=status)
@ -227,13 +264,27 @@ def test_stream_response_keep_alive_returns_correct_headers(
assert b"Keep-Alive: %s\r\n" % str(keep_alive_timeout).encode() in headers assert b"Keep-Alive: %s\r\n" % str(keep_alive_timeout).encode() in headers
def test_stream_response_includes_chunked_header(): def test_stream_response_includes_chunked_header_http11():
response = StreamingHTTPResponse(sample_streaming_fn) response = StreamingHTTPResponse(sample_streaming_fn)
headers = response.get_headers() headers = response.get_headers(version="1.1")
assert b"Transfer-Encoding: chunked\r\n" in headers assert b"Transfer-Encoding: chunked\r\n" in headers
def test_stream_response_writes_correct_content_to_transport(streaming_app): def test_stream_response_does_not_include_chunked_header_http10():
response = StreamingHTTPResponse(sample_streaming_fn)
headers = response.get_headers(version="1.0")
assert b"Transfer-Encoding: chunked\r\n" not in headers
def test_stream_response_does_not_include_chunked_header_if_disabled():
response = StreamingHTTPResponse(sample_streaming_fn, chunked=False)
headers = response.get_headers(version="1.1")
assert b"Transfer-Encoding: chunked\r\n" not in headers
def test_stream_response_writes_correct_content_to_transport_when_chunked(
streaming_app
):
response = StreamingHTTPResponse(sample_streaming_fn) response = StreamingHTTPResponse(sample_streaming_fn)
response.protocol = MagicMock(HttpProtocol) response.protocol = MagicMock(HttpProtocol)
response.protocol.transport = MagicMock(asyncio.Transport) response.protocol.transport = MagicMock(asyncio.Transport)
@ -262,6 +313,42 @@ def test_stream_response_writes_correct_content_to_transport(streaming_app):
b"0\r\n\r\n" b"0\r\n\r\n"
) )
assert len(response.protocol.transport.write.call_args_list) == 4
app.stop()
streaming_app.run(host=HOST, port=PORT)
def test_stream_response_writes_correct_content_to_transport_when_not_chunked(
streaming_app,
):
response = StreamingHTTPResponse(sample_streaming_fn)
response.protocol = MagicMock(HttpProtocol)
response.protocol.transport = MagicMock(asyncio.Transport)
async def mock_drain():
pass
def mock_push_data(data):
response.protocol.transport.write(data)
response.protocol.push_data = mock_push_data
response.protocol.drain = mock_drain
@streaming_app.listener("after_server_start")
async def run_stream(app, loop):
await response.stream(version="1.0")
assert response.protocol.transport.write.call_args_list[1][0][0] == (
b"foo,"
)
assert response.protocol.transport.write.call_args_list[2][0][0] == (
b"bar"
)
assert len(response.protocol.transport.write.call_args_list) == 3
app.stop() app.stop()
streaming_app.run(host=HOST, port=PORT) streaming_app.run(host=HOST, port=PORT)