From 7ce91c1dde5c8a33881be425de58299d95a0e26c Mon Sep 17 00:00:00 2001 From: Slava Date: Mon, 15 Feb 2021 00:20:52 +0200 Subject: [PATCH] Implement ETag support This change adds an `etag` property to the response object and `if_match`, `if_none_match` properties to the request object. Also, it implements ETag support in static routes and fixes a few bugs found along the way. Refs: * https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26 * https://tools.ietf.org/html/rfc7232#section-2.3 * https://tools.ietf.org/html/rfc7232#section-6 PR #5298 by @greshilov Resolves https://github.com/aio-libs/aiohttp/issues/4594 Co-authored-by: Serhiy Storchaka Co-authored-by: Andrew Svetlov --- CHANGES/4594.feature | 1 + aiohttp/__init__.py | 3 +- aiohttp/helpers.py | 25 +- aiohttp/web_fileresponse.py | 67 +++++- aiohttp/web_request.py | 49 ++++ aiohttp/web_response.py | 43 +++- docs/client_reference.rst | 17 ++ docs/spelling_wordlist.txt | 2 + docs/web_reference.rst | 34 +++ tests/test_web_request.py | 45 ++++ tests/test_web_response.py | 79 ++++++- tests/test_web_sendfile.py | 15 +- tests/test_web_sendfile_functional.py | 316 +++++++++++++------------- 13 files changed, 522 insertions(+), 174 deletions(-) create mode 100644 CHANGES/4594.feature diff --git a/CHANGES/4594.feature b/CHANGES/4594.feature new file mode 100644 index 00000000000..f00e14a5e93 --- /dev/null +++ b/CHANGES/4594.feature @@ -0,0 +1 @@ +FileResponse now supports ETag. diff --git a/aiohttp/__init__.py b/aiohttp/__init__.py index 113089ae0b9..4dd54a4e7e4 100644 --- a/aiohttp/__init__.py +++ b/aiohttp/__init__.py @@ -38,7 +38,7 @@ ) from .cookiejar import CookieJar as CookieJar, DummyCookieJar as DummyCookieJar from .formdata import FormData as FormData -from .helpers import BasicAuth as BasicAuth, ChainMapProxy as ChainMapProxy +from .helpers import BasicAuth, ChainMapProxy, ETag from .http import ( HttpVersion as HttpVersion, HttpVersion10 as HttpVersion10, @@ -145,6 +145,7 @@ # helpers "BasicAuth", "ChainMapProxy", + "ETag", # http "HttpVersion", "HttpVersion10", diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index ed5387aaf65..4f7c67bb9db 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -51,7 +51,7 @@ from .log import client_logger from .typedefs import PathLike # noqa -__all__ = ("BasicAuth", "ChainMapProxy") +__all__ = ("BasicAuth", "ChainMapProxy", "ETag") PY_38 = sys.version_info >= (3, 8) @@ -887,3 +887,26 @@ def populate_with_cookies( for cookie in cookies.values(): value = cookie.output(header="")[1:] headers.add(hdrs.SET_COOKIE, value) + + +# https://tools.ietf.org/html/rfc7232#section-2.3 +_ETAGC = r"[!#-}\x80-\xff]+" +_ETAGC_RE = re.compile(_ETAGC) +_QUOTED_ETAG = fr'(W/)?"({_ETAGC})"' +QUOTED_ETAG_RE = re.compile(_QUOTED_ETAG) +LIST_QUOTED_ETAG_RE = re.compile(fr"({_QUOTED_ETAG})(?:\s*,\s*|$)|(.)") + +ETAG_ANY = "*" + + +@dataclasses.dataclass(frozen=True) +class ETag: + value: str + is_weak: bool = False + + +def validate_etag_value(value: str) -> None: + if value != ETAG_ANY and not _ETAGC_RE.fullmatch(value): + raise ValueError( + f"Value {value!r} is not a valid etag. Maybe it contains '\"'?" + ) diff --git a/aiohttp/web_fileresponse.py b/aiohttp/web_fileresponse.py index ff904dd57b0..d11a72e838a 100644 --- a/aiohttp/web_fileresponse.py +++ b/aiohttp/web_fileresponse.py @@ -9,8 +9,10 @@ Any, Awaitable, Callable, + Iterator, List, Optional, + Tuple, Union, cast, ) @@ -19,6 +21,7 @@ from . import hdrs from .abc import AbstractStreamWriter +from .helpers import ETAG_ANY, ETag from .typedefs import LooseHeaders from .web_exceptions import ( HTTPNotModified, @@ -102,6 +105,31 @@ 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) + self.content_length = 0 + return await super().prepare(request) + async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]: filepath = self._path @@ -114,20 +142,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)) @@ -218,6 +261,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.etag = etag_value # type: ignore[assignment] self.last_modified = st.st_mtime # type: ignore[assignment] self.content_length = count diff --git a/aiohttp/web_request.py b/aiohttp/web_request.py index 4ecbdc98884..0d9b9e2c06d 100644 --- a/aiohttp/web_request.py +++ b/aiohttp/web_request.py @@ -34,7 +34,10 @@ from .abc import AbstractStreamWriter from .helpers import ( _SENTINEL, + ETAG_ANY, + LIST_QUOTED_ETAG_RE, ChainMapProxy, + ETag, HeadersMixin, is_expected_content_type, reify, @@ -500,6 +503,52 @@ def if_unmodified_since(self) -> Optional[datetime.datetime]: """ return self._http_date(self.headers.get(hdrs.IF_UNMODIFIED_SINCE)) + @staticmethod + def _etag_values(etag_header: str) -> Iterator[ETag]: + """Extract `ETag` objects from raw header.""" + if etag_header == ETAG_ANY: + yield ETag( + is_weak=False, + value=ETAG_ANY, + ) + else: + for match in LIST_QUOTED_ETAG_RE.finditer(etag_header): + is_weak, value, garbage = match.group(2, 3, 4) + # Any symbol captured by 4th group means + # that the following sequence is invalid. + if garbage: + break + + yield ETag( + is_weak=bool(is_weak), + value=value, + ) + + @classmethod + def _if_match_or_none_impl( + cls, header_value: Optional[str] + ) -> Optional[Tuple[ETag, ...]]: + if not header_value: + return None + + return tuple(cls._etag_values(header_value)) + + @reify + def if_match(self) -> Optional[Tuple[ETag, ...]]: + """The value of If-Match HTTP header, or None. + + This header is represented as a `tuple` of `ETag` objects. + """ + return self._if_match_or_none_impl(self.headers.get(hdrs.IF_MATCH)) + + @reify + def if_none_match(self) -> Optional[Tuple[ETag, ...]]: + """The value of If-None-Match HTTP header, or None. + + This header is represented as a `tuple` of `ETag` objects. + """ + return self._if_match_or_none_impl(self.headers.get(hdrs.IF_NONE_MATCH)) + @reify def if_range(self) -> Optional[datetime.datetime]: """The value of If-Range HTTP header, or None. diff --git a/aiohttp/web_response.py b/aiohttp/web_response.py index ce3d4fda21d..634e38b0725 100644 --- a/aiohttp/web_response.py +++ b/aiohttp/web_response.py @@ -28,12 +28,16 @@ from . import hdrs, payload from .abc import AbstractStreamWriter from .helpers import ( + ETAG_ANY, PY_38, + QUOTED_ETAG_RE, CookieMixin, + ETag, HeadersMixin, populate_with_cookies, rfc822_formatted_time, sentinel, + validate_etag_value, ) from .http import RESPONSES, SERVER_SOFTWARE, HttpVersion10, HttpVersion11 from .payload import Payload @@ -271,6 +275,43 @@ def last_modified( elif isinstance(value, str): self._headers[hdrs.LAST_MODIFIED] = value + @property + def etag(self) -> Optional[ETag]: + quoted_value = self._headers.get(hdrs.ETAG) + if not quoted_value: + return None + elif quoted_value == ETAG_ANY: + return ETag(value=ETAG_ANY) + match = QUOTED_ETAG_RE.fullmatch(quoted_value) + if not match: + return None + is_weak, value = match.group(1, 2) + return ETag( + is_weak=bool(is_weak), + value=value, + ) + + @etag.setter + def etag(self, value: Optional[Union[ETag, str]]) -> None: + if value is None: + self._headers.pop(hdrs.ETAG, None) + elif (isinstance(value, str) and value == ETAG_ANY) or ( + isinstance(value, ETag) and value.value == ETAG_ANY + ): + self._headers[hdrs.ETAG] = ETAG_ANY + elif isinstance(value, str): + validate_etag_value(value) + self._headers[hdrs.ETAG] = f'"{value}"' + elif isinstance(value, ETag) and isinstance(value.value, str): + validate_etag_value(value.value) + hdr_value = f'W/"{value.value}"' if value.is_weak else f'"{value.value}"' + self._headers[hdrs.ETAG] = hdr_value + else: + raise ValueError( + f"Unsupported etag type: {type(value)}. " + f"etag must be str, ETag or None" + ) + def _generate_content_type_header( self, CONTENT_TYPE: istr = hdrs.CONTENT_TYPE ) -> None: @@ -363,7 +404,7 @@ async def _prepare_headers(self) -> None: elif version >= HttpVersion11 and self.status in (100, 101, 102, 103, 204): del headers[hdrs.CONTENT_LENGTH] - if self.status != 204: + if self.status not in (204, 304): headers.setdefault(hdrs.CONTENT_TYPE, "application/octet-stream") headers.setdefault(hdrs.DATE, rfc822_formatted_time()) headers.setdefault(hdrs.SERVER, SERVER_SOFTWARE) diff --git a/docs/client_reference.rst b/docs/client_reference.rst index d7bf05f87e4..90dddf494e2 100644 --- a/docs/client_reference.rst +++ b/docs/client_reference.rst @@ -1688,6 +1688,23 @@ ClientTimeout .. versionadded:: 3.3 +ETag +^^^^ + +.. class:: ETag(name, is_weak=False) + + Represents `ETag` identifier. + + .. attribute:: value + + Value of corresponding etag without quotes. + + .. attribute:: is_weak + + Flag indicates that etag is weak (has `W/` prefix). + + .. versionadded:: 3.8 + RequestInfo ^^^^^^^^^^^ diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 859ca7b10b4..4a18a93b0b5 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -29,6 +29,7 @@ Dict Discord Django Dup +ETag Facebook HTTPException HttpProcessingError @@ -155,6 +156,7 @@ env environ eof epoll +etag facto fallback fallbacks diff --git a/docs/web_reference.rst b/docs/web_reference.rst index 78d352f5adc..f4da15af2c1 100644 --- a/docs/web_reference.rst +++ b/docs/web_reference.rst @@ -318,6 +318,26 @@ and :ref:`aiohttp-web-signals` handlers. .. versionadded:: 3.1 + .. attribute:: if_match + + Read-only property that returns :class:`ETag` objects specified + in the *If-Match* header. + + Returns :class:`tuple` of :class:`ETag` or ``None`` if + *If-Match* header is absent. + + .. versionadded:: 3.8 + + .. attribute:: if_none_match + + Read-only property that returns :class:`ETag` objects specified + *If-None-Match* header. + + Returns :class:`tuple` of :class:`ETag` or ``None`` if + *If-None-Match* header is absent. + + .. versionadded:: 3.8 + .. attribute:: if_range Read-only property that returns the date specified in the @@ -774,6 +794,20 @@ StreamResponse as an :class:`int` or a :class:`float` object, and the value ``None`` to unset the header. + .. attribute:: etag + + *ETag* header for outgoing response. + + This property accepts raw :class:`str` values, :class:`ETag` + objects and the value ``None`` to unset the header. + + In case of :class:`str` input, etag is considered as strong by default. + + **Do not** use double quotes ``"`` in the etag value, + they will be added automatically. + + .. versionadded:: 3.8 + .. comethod:: prepare(request) :param aiohttp.web.Request request: HTTP request object, that the diff --git a/tests/test_web_request.py b/tests/test_web_request.py index 8a9de26907c..ee4e3ab6f68 100644 --- a/tests/test_web_request.py +++ b/tests/test_web_request.py @@ -17,6 +17,7 @@ from aiohttp.streams import StreamReader from aiohttp.test_utils import make_mocked_request from aiohttp.web import HTTPRequestEntityTooLarge, HTTPUnsupportedMediaType +from aiohttp.web_request import ETag @pytest.fixture @@ -820,3 +821,47 @@ async def invalid_handler_1(request): async with client.get("/1") as resp: assert 500 == resp.status + + +@pytest.mark.parametrize( + "header,header_attr", + [ + pytest.param("If-Match", "if_match"), + pytest.param("If-None-Match", "if_none_match"), + ], +) +@pytest.mark.parametrize( + "header_val,expected", + [ + pytest.param( + '"67ab43", W/"54ed21", "7892,dd"', + ( + ETag(is_weak=False, value="67ab43"), + ETag(is_weak=True, value="54ed21"), + ETag(is_weak=False, value="7892,dd"), + ), + ), + pytest.param( + '"bfc1ef-5b2c2730249c88ca92d82d"', + (ETag(is_weak=False, value="bfc1ef-5b2c2730249c88ca92d82d"),), + ), + pytest.param( + '"valid-tag", "also-valid-tag",somegarbage"last-tag"', + ( + ETag(is_weak=False, value="valid-tag"), + ETag(is_weak=False, value="also-valid-tag"), + ), + ), + pytest.param( + '"ascii", "это точно не ascii", "ascii again"', + (ETag(is_weak=False, value="ascii"),), + ), + pytest.param( + "*", + (ETag(is_weak=False, value="*"),), + ), + ], +) +def test_etag_headers(header, header_attr, header_val, expected) -> None: + req = make_mocked_request("GET", "/", headers={header: header_val}) + assert getattr(req, header_attr) == expected diff --git a/tests/test_web_response.py b/tests/test_web_response.py index a80184029a5..655bc2be59a 100644 --- a/tests/test_web_response.py +++ b/tests/test_web_response.py @@ -14,6 +14,7 @@ from re_assert import Matches from aiohttp import HttpVersion, HttpVersion10, HttpVersion11, hdrs +from aiohttp.helpers import ETag from aiohttp.payload import BytesPayload from aiohttp.test_utils import make_mocked_coro, make_mocked_request from aiohttp.web import ContentCoding, Response, StreamResponse, json_response @@ -25,7 +26,7 @@ def make_request( headers: Any = CIMultiDict(), version: Any = HttpVersion11, on_response_prepare: Optional[Any] = None, - **kwargs: Any + **kwargs: Any, ): app = kwargs.pop("app", None) or mock.Mock() app._debug = False @@ -257,6 +258,82 @@ def test_last_modified_reset() -> None: assert resp.last_modified is None +def test_etag_initial() -> None: + resp = StreamResponse() + assert resp.etag is None + + +def test_etag_string() -> None: + resp = StreamResponse() + value = "0123-kotik" + resp.etag = value + assert resp.etag == ETag(value=value) + assert resp.headers[hdrs.ETAG] == f'"{value}"' + + +@pytest.mark.parametrize( + "etag,expected_header", + ( + (ETag(value="0123-weak-kotik", is_weak=True), 'W/"0123-weak-kotik"'), + (ETag(value="0123-strong-kotik", is_weak=False), '"0123-strong-kotik"'), + ), +) +def test_etag_class(etag, expected_header) -> None: + resp = StreamResponse() + resp.etag = etag + assert resp.etag == etag + assert resp.headers[hdrs.ETAG] == expected_header + + +def test_etag_any() -> None: + resp = StreamResponse() + resp.etag = "*" + assert resp.etag == ETag(value="*") + assert resp.headers[hdrs.ETAG] == "*" + + +@pytest.mark.parametrize( + "invalid_value", + ( + '"invalid"', + "повинен бути ascii", + ETag(value='"invalid"', is_weak=True), + ETag(value="bad ©®"), + ), +) +def test_etag_invalid_value_set(invalid_value) -> None: + resp = StreamResponse() + with pytest.raises(ValueError): + resp.etag = invalid_value + + +@pytest.mark.parametrize( + "header", + ( + "forgotten quotes", + '"∀ x ∉ ascii"', + ), +) +def test_etag_invalid_value_get(header) -> None: + resp = StreamResponse() + resp.headers["ETag"] = header + assert resp.etag is None + + +@pytest.mark.parametrize("invalid", (123, ETag(value=123, is_weak=True))) +def test_etag_invalid_value_class(invalid) -> None: + resp = StreamResponse() + with pytest.raises(ValueError): + resp.etag = invalid + + +def test_etag_reset() -> None: + resp = StreamResponse() + resp.etag = "*" + resp.etag = None + assert resp.etag is None + + async def test_start() -> None: req = make_request("GET", "/") resp = StreamResponse() diff --git a/tests/test_web_sendfile.py b/tests/test_web_sendfile.py index aa366c22891..5462fadea87 100644 --- a/tests/test_web_sendfile.py +++ b/tests/test_web_sendfile.py @@ -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" @@ -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[assignment] @@ -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[assignment] @@ -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[assignment] @@ -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[assignment] diff --git a/tests/test_web_sendfile_functional.py b/tests/test_web_sendfile_functional.py index 31e364b73c1..3351ead3754 100644 --- a/tests/test_web_sendfile_functional.py +++ b/tests/test_web_sendfile_functional.py @@ -3,7 +3,7 @@ import pathlib import socket import zlib -from typing import Any +from typing import Any, Iterable import pytest @@ -36,15 +36,23 @@ def maker(*args, **kwargs): return maker -async def test_static_file_ok(aiohttp_client: Any, sender: Any) -> None: - filepath = pathlib.Path(__file__).parent / "data.unknown_mime_type" +@pytest.fixture +def app_with_static_route(sender: Any) -> web.Application: + filename = "data.unknown_mime_type" + filepath = pathlib.Path(__file__).parent / filename async def handler(request): return sender(filepath) app = web.Application() app.router.add_get("/", handler) - client = await aiohttp_client(app) + return app + + +async def test_static_file_ok( + aiohttp_client: Any, app_with_static_route: web.Application +) -> None: + client = await aiohttp_client(app_with_static_route) resp = await client.get("/") assert resp.status == 200 @@ -74,15 +82,10 @@ async def handler(request): await resp.release() -async def test_static_file_ok_string_path(aiohttp_client: Any, sender: Any) -> None: - filepath = pathlib.Path(__file__).parent / "data.unknown_mime_type" - - async def handler(request): - return sender(str(filepath)) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) +async def test_static_file_ok_string_path( + aiohttp_client: Any, app_with_static_route: web.Application +) -> None: + client = await aiohttp_client(app_with_static_route) resp = await client.get("/") assert resp.status == 200 @@ -215,16 +218,10 @@ async def handler(request): resp.close() -async def test_static_file_if_modified_since(aiohttp_client: Any, sender: Any) -> None: - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename - - async def handler(request): - return sender(filepath) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) +async def test_static_file_if_modified_since( + aiohttp_client: Any, app_with_static_route: web.Application +) -> None: + client = await aiohttp_client(app_with_static_route) resp = await client.get("/") assert 200 == resp.status @@ -236,22 +233,15 @@ async def handler(request): body = await resp.read() assert 304 == resp.status assert resp.headers.get("Content-Length") is None + assert resp.headers.get("Last-Modified") == lastmod assert b"" == body resp.close() async def test_static_file_if_modified_since_past_date( - aiohttp_client: Any, sender: Any + aiohttp_client: Any, app_with_static_route: web.Application ) -> None: - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename - - async def handler(request): - return sender(filepath) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) + client = await aiohttp_client(app_with_static_route) lastmod = "Mon, 1 Jan 1990 01:01:01 GMT" @@ -261,17 +251,9 @@ async def handler(request): async def test_static_file_if_modified_since_invalid_date( - aiohttp_client: Any, sender: Any + aiohttp_client: Any, app_with_static_route: web.Application ): - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename - - async def handler(request): - return sender(filepath) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) + client = await aiohttp_client(app_with_static_route) lastmod = "not a valid HTTP-date" @@ -281,17 +263,9 @@ async def handler(request): async def test_static_file_if_modified_since_future_date( - aiohttp_client: Any, sender: Any + aiohttp_client: Any, app_with_static_route: web.Application ): - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename - - async def handler(request): - return sender(filepath) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) + client = await aiohttp_client(app_with_static_route) lastmod = "Fri, 31 Dec 9999 23:59:59 GMT" @@ -299,13 +273,121 @@ async def handler(request): body = await resp.read() assert 304 == resp.status assert resp.headers.get("Content-Length") is None + assert resp.headers.get("Last-Modified") + assert b"" == body + resp.close() + + +@pytest.mark.parametrize("if_unmodified_since", ("", "Fri, 31 Dec 0000 23:59:59 GMT")) +async def test_static_file_if_match( + aiohttp_client: Any, + app_with_static_route: web.Application, + if_unmodified_since: str, +) -> None: + client = await aiohttp_client(app_with_static_route) + + resp = await client.get("/") + assert 200 == resp.status + original_etag = resp.headers.get("ETag") + + assert original_etag is not None + resp.close() + + headers = {"If-Match": original_etag, "If-Unmodified-Since": if_unmodified_since} + resp = await client.head("/", headers=headers) + body = await resp.read() + assert 200 == resp.status + assert resp.headers.get("ETag") + assert resp.headers.get("Last-Modified") + assert b"" == body + resp.close() + + +@pytest.mark.parametrize("if_unmodified_since", ("", "Fri, 31 Dec 0000 23:59:59 GMT")) +@pytest.mark.parametrize( + "etags,expected_status", + [ + (("*",), 200), + (('"example-tag"', 'W/"weak-tag"'), 412), + ], +) +async def test_static_file_if_match_custom_tags( + aiohttp_client: Any, + app_with_static_route: web.Application, + if_unmodified_since: str, + etags: Iterable[str], + expected_status: Iterable[int], +) -> None: + client = await aiohttp_client(app_with_static_route) + + if_match = ", ".join(etags) + headers = {"If-Match": if_match, "If-Unmodified-Since": if_unmodified_since} + resp = await client.head("/", headers=headers) + body = await resp.read() + assert expected_status == resp.status + assert b"" == body + resp.close() + + +@pytest.mark.parametrize("if_modified_since", ("", "Fri, 31 Dec 9999 23:59:59 GMT")) +@pytest.mark.parametrize( + "additional_etags", + ( + (), + ('"some-other-strong-etag"', 'W/"weak-tag"', "invalid-tag"), + ), +) +async def test_static_file_if_none_match( + aiohttp_client: Any, + app_with_static_route: web.Application, + if_modified_since: str, + additional_etags: Iterable[str], +) -> None: + client = await aiohttp_client(app_with_static_route) + + resp = await client.get("/") + assert 200 == resp.status + original_etag = resp.headers.get("ETag") + + assert resp.headers.get("Last-Modified") is not None + assert original_etag is not None + resp.close() + + etag = ",".join((original_etag, *additional_etags)) + + resp = await client.get( + "/", headers={"If-None-Match": etag, "If-Modified-Since": if_modified_since} + ) + body = await resp.read() + assert 304 == resp.status + assert resp.headers.get("Content-Length") is None + assert resp.headers.get("ETag") == original_etag + assert b"" == body + resp.close() + + +async def test_static_file_if_none_match_star( + aiohttp_client: Any, + app_with_static_route: web.Application, +) -> None: + client = await aiohttp_client(app_with_static_route) + + resp = await client.head("/", headers={"If-None-Match": "*"}) + body = await resp.read() + assert 304 == resp.status + assert resp.headers.get("Content-Length") is None + assert resp.headers.get("ETag") + assert resp.headers.get("Last-Modified") assert b"" == body resp.close() @pytest.mark.skipif(not ssl, reason="ssl not supported") async def test_static_file_ssl( - aiohttp_server: Any, ssl_ctx: Any, aiohttp_client: Any, client_ssl_ctx: Any + aiohttp_server: Any, + ssl_ctx: Any, + aiohttp_client: Any, + client_ssl_ctx: Any, ) -> None: dirname = pathlib.Path(__file__).parent filename = "data.unknown_mime_type" @@ -564,17 +646,9 @@ async def handler(request): async def test_static_file_if_unmodified_since_past_with_range( - aiohttp_client: Any, sender: Any + aiohttp_client: Any, app_with_static_route: web.Application ): - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename - - async def handler(request): - return sender(filepath) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) + client = await aiohttp_client(app_with_static_route) lastmod = "Mon, 1 Jan 1990 01:01:01 GMT" @@ -586,17 +660,9 @@ async def handler(request): async def test_static_file_if_unmodified_since_future_with_range( - aiohttp_client: Any, sender: Any + aiohttp_client: Any, app_with_static_route: web.Application ): - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename - - async def handler(request): - return sender(filepath) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) + client = await aiohttp_client(app_with_static_route) lastmod = "Fri, 31 Dec 9999 23:59:59 GMT" @@ -609,16 +675,10 @@ async def handler(request): resp.close() -async def test_static_file_if_range_past_with_range(aiohttp_client: Any, sender: Any): - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename - - async def handler(request): - return sender(filepath) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) +async def test_static_file_if_range_past_with_range( + aiohttp_client: Any, app_with_static_route: web.Application +): + client = await aiohttp_client(app_with_static_route) lastmod = "Mon, 1 Jan 1990 01:01:01 GMT" @@ -628,16 +688,10 @@ async def handler(request): resp.close() -async def test_static_file_if_range_future_with_range(aiohttp_client: Any, sender: Any): - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename - - async def handler(request): - return sender(filepath) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) +async def test_static_file_if_range_future_with_range( + aiohttp_client: Any, app_with_static_route: web.Application +): + client = await aiohttp_client(app_with_static_route) lastmod = "Fri, 31 Dec 9999 23:59:59 GMT" @@ -649,17 +703,9 @@ async def handler(request): async def test_static_file_if_unmodified_since_past_without_range( - aiohttp_client: Any, sender: Any + aiohttp_client: Any, app_with_static_route: web.Application ): - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename - - async def handler(request): - return sender(filepath) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) + client = await aiohttp_client(app_with_static_route) lastmod = "Mon, 1 Jan 1990 01:01:01 GMT" @@ -669,17 +715,9 @@ async def handler(request): async def test_static_file_if_unmodified_since_future_without_range( - aiohttp_client: Any, sender: Any + aiohttp_client: Any, app_with_static_route: web.Application ): - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename - - async def handler(request): - return sender(filepath) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) + client = await aiohttp_client(app_with_static_route) lastmod = "Fri, 31 Dec 9999 23:59:59 GMT" @@ -690,17 +728,9 @@ async def handler(request): async def test_static_file_if_range_past_without_range( - aiohttp_client: Any, sender: Any + aiohttp_client: Any, app_with_static_route: web.Application ): - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename - - async def handler(request): - return sender(filepath) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) + client = await aiohttp_client(app_with_static_route) lastmod = "Mon, 1 Jan 1990 01:01:01 GMT" @@ -711,17 +741,9 @@ async def handler(request): async def test_static_file_if_range_future_without_range( - aiohttp_client: Any, sender: Any + aiohttp_client: Any, app_with_static_route: web.Application ): - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename - - async def handler(request): - return sender(filepath) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) + client = await aiohttp_client(app_with_static_route) lastmod = "Fri, 31 Dec 9999 23:59:59 GMT" @@ -732,17 +754,9 @@ async def handler(request): async def test_static_file_if_unmodified_since_invalid_date( - aiohttp_client: Any, sender: Any + aiohttp_client: Any, app_with_static_route: web.Application ): - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename - - async def handler(request): - return sender(filepath) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) + client = await aiohttp_client(app_with_static_route) lastmod = "not a valid HTTP-date" @@ -751,16 +765,10 @@ async def handler(request): resp.close() -async def test_static_file_if_range_invalid_date(aiohttp_client: Any, sender: Any): - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename - - async def handler(request): - return sender(filepath) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) +async def test_static_file_if_range_invalid_date( + aiohttp_client: Any, app_with_static_route: web.Application +): + client = await aiohttp_client(app_with_static_route) lastmod = "not a valid HTTP-date"