Skip to content

Commit

Permalink
FileResponse now supports ETag (aio-libs#4594)
Browse files Browse the repository at this point in the history
  • Loading branch information
greshilov committed Nov 28, 2020
1 parent 7776e16 commit 6824187
Show file tree
Hide file tree
Showing 4 changed files with 228 additions and 171 deletions.
1 change: 1 addition & 0 deletions CHANGES/4594.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FileResponse now supports ETag
67 changes: 55 additions & 12 deletions aiohttp/web_fileresponse.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@
Any,
Awaitable,
Callable,
Iterator,
List,
Optional,
Tuple,
Union,
cast,
)

from . import hdrs
from .abc import AbstractStreamWriter
from .helpers import ETAG_ANY, ETag
from .typedefs import LooseHeaders
from .web_exceptions import (
HTTPNotModified,
Expand Down Expand Up @@ -100,6 +103,30 @@ async def _sendfile(
await super().write_eof()
return writer

@staticmethod
def _strong_etag_match(etag_value: str, etags: Tuple[ETag, ...]) -> bool:
if len(etags) == 1 and etags[0].value == ETAG_ANY:
return True
else:
return any(etag.value == etag_value for etag in etags if not etag.is_weak)

async def _not_modified(
self, request: "BaseRequest", etag_value: str, last_modified: float
) -> Optional[AbstractStreamWriter]:
self.set_status(HTTPNotModified.status_code)
self._length_check = False
self.etag = etag_value # type: ignore
self.last_modified = last_modified # type: ignore
# Delete any Content-Length headers provided by user. HTTP 304
# should always have empty response body
return await super().prepare(request)

async def _precondition_failed(
self, request: "BaseRequest"
) -> Optional[AbstractStreamWriter]:
self.set_status(HTTPPreconditionFailed.status_code)
return await super().prepare(request)

async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]:
filepath = self._path

Expand All @@ -112,20 +139,35 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter
gzip = True

loop = asyncio.get_event_loop()
st = await loop.run_in_executor(None, filepath.stat)
st: os.stat_result = await loop.run_in_executor(None, filepath.stat)

modsince = request.if_modified_since
if modsince is not None and st.st_mtime <= modsince.timestamp():
self.set_status(HTTPNotModified.status_code)
self._length_check = False
# Delete any Content-Length headers provided by user. HTTP 304
# should always have empty response body
return await super().prepare(request)
etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}"
last_modified = st.st_mtime

# https://tools.ietf.org/html/rfc7232#section-6
ifmatch = request.if_match
if ifmatch is not None and not self._strong_etag_match(etag_value, ifmatch):
return await self._precondition_failed(request)

unmodsince = request.if_unmodified_since
if unmodsince is not None and st.st_mtime > unmodsince.timestamp():
self.set_status(HTTPPreconditionFailed.status_code)
return await super().prepare(request)
if (
unmodsince is not None
and ifmatch is None
and st.st_mtime > unmodsince.timestamp()
):
return await self._precondition_failed(request)

ifnonematch = request.if_none_match
if ifnonematch is not None and self._strong_etag_match(etag_value, ifnonematch):
return await self._not_modified(request, etag_value, last_modified)

modsince = request.if_modified_since
if (
modsince is not None
and ifnonematch is None
and st.st_mtime <= modsince.timestamp()
):
return await self._not_modified(request, etag_value, last_modified)

if hdrs.CONTENT_TYPE not in self.headers:
ct, encoding = mimetypes.guess_type(str(filepath))
Expand Down Expand Up @@ -216,7 +258,8 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter
self.headers[hdrs.CONTENT_ENCODING] = encoding
if gzip:
self.headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING
self.last_modified = st.st_mtime # type: ignore
self.etag = etag_value # type: ignore
self.last_modified = last_modified # type: ignore
self.content_length = count

self.headers[hdrs.ACCEPT_RANGES] = "bytes"
Expand Down
15 changes: 10 additions & 5 deletions tests/test_web_sendfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ def test_using_gzip_if_header_present_and_file_available(loop: Any) -> None:
gz_filepath.open = mock.mock_open()
gz_filepath.is_file.return_value = True
gz_filepath.stat.return_value = mock.MagicMock()
gz_filepath.stat.st_size = 1024
gz_filepath.stat.return_value.st_size = 1024
gz_filepath.stat.return_value.st_mtime_ns = 1603733507222449291

filepath = mock.Mock()
filepath.name = "logo.png"
Expand Down Expand Up @@ -43,7 +44,8 @@ def test_gzip_if_header_not_present_and_file_available(loop: Any) -> None:
filepath.open = mock.mock_open()
filepath.with_name.return_value = gz_filepath
filepath.stat.return_value = mock.MagicMock()
filepath.stat.st_size = 1024
filepath.stat.return_value.st_size = 1024
filepath.stat.return_value.st_mtime_ns = 1603733507222449291

file_sender = FileResponse(filepath)
file_sender._sendfile = make_mocked_coro(None) # type: ignore
Expand All @@ -66,7 +68,8 @@ def test_gzip_if_header_not_present_and_file_not_available(loop: Any) -> None:
filepath.open = mock.mock_open()
filepath.with_name.return_value = gz_filepath
filepath.stat.return_value = mock.MagicMock()
filepath.stat.st_size = 1024
filepath.stat.return_value.st_size = 1024
filepath.stat.return_value.st_mtime_ns = 1603733507222449291

file_sender = FileResponse(filepath)
file_sender._sendfile = make_mocked_coro(None) # type: ignore
Expand All @@ -91,7 +94,8 @@ def test_gzip_if_header_present_and_file_not_available(loop: Any) -> None:
filepath.open = mock.mock_open()
filepath.with_name.return_value = gz_filepath
filepath.stat.return_value = mock.MagicMock()
filepath.stat.st_size = 1024
filepath.stat.return_value.st_size = 1024
filepath.stat.return_value.st_mtime_ns = 1603733507222449291

file_sender = FileResponse(filepath)
file_sender._sendfile = make_mocked_coro(None) # type: ignore
Expand All @@ -109,7 +113,8 @@ def test_status_controlled_by_user(loop: Any) -> None:
filepath.name = "logo.png"
filepath.open = mock.mock_open()
filepath.stat.return_value = mock.MagicMock()
filepath.stat.st_size = 1024
filepath.stat.return_value.st_size = 1024
filepath.stat.return_value.st_mtime_ns = 1603733507222449291

file_sender = FileResponse(filepath, status=203)
file_sender._sendfile = make_mocked_coro(None) # type: ignore
Expand Down
Loading

0 comments on commit 6824187

Please sign in to comment.