From bb5624b24f97841e68842c1d722165f3a095ecc2 Mon Sep 17 00:00:00 2001 From: Slava Date: Wed, 24 Mar 2021 14:17:53 +0200 Subject: [PATCH] Implement ETag support (#4594) (#5562) 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 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 | 66 ++++- aiohttp/web_request.py | 57 ++++- aiohttp/web_response.py | 50 +++- 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 | 345 ++++++++++++++------------ 13 files changed, 562 insertions(+), 177 deletions(-) create mode 100644 CHANGES/4594.feature diff --git a/CHANGES/4594.feature b/CHANGES/4594.feature new file mode 100644 index 0000000000..f00e14a5e9 --- /dev/null +++ b/CHANGES/4594.feature @@ -0,0 +1 @@ +FileResponse now supports ETag. diff --git a/aiohttp/__init__.py b/aiohttp/__init__.py index f7f8910c0f..64ce87c73a 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, @@ -146,6 +146,7 @@ # helpers "BasicAuth", "ChainMapProxy", + "ETag", # http "HttpVersion", "HttpVersion10", diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index 393b55abdf..1a01e4c9b3 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -52,7 +52,7 @@ from .log import client_logger, internal_logger from .typedefs import PathLike, Protocol # noqa -__all__ = ("BasicAuth", "ChainMapProxy") +__all__ = ("BasicAuth", "ChainMapProxy", "ETag") PY_36 = sys.version_info >= (3, 6) PY_37 = sys.version_info >= (3, 7) @@ -776,3 +776,26 @@ def __bool__(self) -> bool: def __repr__(self) -> str: content = ", ".join(map(repr, self._maps)) return f"ChainMapProxy({content})" + + +# 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 = "*" + + +@attr.s(auto_attribs=True, frozen=True, slots=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 64348e6c79..df61bc74e4 100644 --- a/aiohttp/web_fileresponse.py +++ b/aiohttp/web_fileresponse.py @@ -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 Final, LooseHeaders from .web_exceptions import ( HTTPNotModified, @@ -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 + 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[assignment] + self.last_modified = last_modified # type: ignore[assignment] + # 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 @@ -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)) @@ -216,6 +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.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 3e4f7c50d9..d48a37461c 100644 --- a/aiohttp/web_request.py +++ b/aiohttp/web_request.py @@ -31,7 +31,16 @@ from . import hdrs from .abc import AbstractStreamWriter -from .helpers import DEBUG, ChainMapProxy, HeadersMixin, reify, sentinel +from .helpers import ( + DEBUG, + ETAG_ANY, + LIST_QUOTED_ETAG_RE, + ChainMapProxy, + ETag, + HeadersMixin, + reify, + sentinel, +) from .http_parser import RawRequestMessage from .http_writer import HttpVersion from .multipart import BodyPartReader, MultipartReader @@ -495,6 +504,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 fea8dc7062..291b61569a 100644 --- a/aiohttp/web_response.py +++ b/aiohttp/web_response.py @@ -27,7 +27,16 @@ from . import hdrs, payload from .abc import AbstractStreamWriter -from .helpers import PY_38, HeadersMixin, rfc822_formatted_time, sentinel +from .helpers import ( + ETAG_ANY, + PY_38, + QUOTED_ETAG_RE, + ETag, + HeadersMixin, + rfc822_formatted_time, + sentinel, + validate_etag_value, +) from .http import RESPONSES, SERVER_SOFTWARE, HttpVersion10, HttpVersion11 from .payload import Payload from .typedefs import JSONEncoder, LooseHeaders @@ -341,6 +350,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: @@ -435,7 +481,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 d7bf05f87e..90dddf494e 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 ebf58fdfd6..da917fb8e7 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -28,6 +28,7 @@ Dict Discord Django Dup +ETag Facebook HTTPException HttpProcessingError @@ -153,6 +154,7 @@ env environ eof epoll +etag facto fallback fallbacks diff --git a/docs/web_reference.rst b/docs/web_reference.rst index 6622a84b76..bd50382eca 100644 --- a/docs/web_reference.rst +++ b/docs/web_reference.rst @@ -336,6 +336,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 @@ -782,6 +802,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 f251e04f4b..584e7c4453 100644 --- a/tests/test_web_request.py +++ b/tests/test_web_request.py @@ -14,6 +14,7 @@ from aiohttp.streams import StreamReader from aiohttp.test_utils import make_mocked_request from aiohttp.web import BaseRequest, HTTPRequestEntityTooLarge +from aiohttp.web_request import ETag @pytest.fixture @@ -742,3 +743,47 @@ async def test_loop_prop() -> None: req = make_mocked_request("GET", "/path", loop=loop) with pytest.warns(DeprecationWarning): assert req.loop is loop + + +@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 a26d548bc1..4d4b63ca4d 100644 --- a/tests/test_web_response.py +++ b/tests/test_web_response.py @@ -11,6 +11,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 @@ -22,7 +23,7 @@ def make_request( headers=CIMultiDict(), version=HttpVersion11, on_response_prepare=None, - **kwargs + **kwargs, ): app = kwargs.pop("app", None) or mock.Mock() app._debug = False @@ -254,6 +255,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, match="is not a valid etag"): + 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, match="Unsupported etag type"): + 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 624cd26022..d7e078a7b3 100644 --- a/tests/test_web_sendfile.py +++ b/tests/test_web_sendfile.py @@ -14,7 +14,8 @@ def test_using_gzip_if_header_present_and_file_available(loop) -> 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" @@ -42,7 +43,8 @@ def test_gzip_if_header_not_present_and_file_available(loop) -> 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] @@ -65,7 +67,8 @@ def test_gzip_if_header_not_present_and_file_not_available(loop) -> 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] @@ -90,7 +93,8 @@ def test_gzip_if_header_present_and_file_not_available(loop) -> 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] @@ -108,7 +112,8 @@ def test_status_controlled_by_user(loop) -> 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 6eb28591b6..41d7ac8550 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,24 @@ def maker(*args, **kwargs): return maker -async def test_static_file_ok(aiohttp_client, sender) -> None: - filepath = pathlib.Path(__file__).parent / "data.unknown_mime_type" +@pytest.fixture +def app_with_static_route(sender): + 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 +83,11 @@ async def handler(request): await resp.release() -async def test_static_file_ok_string_path(aiohttp_client, sender) -> 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 @@ -209,16 +214,11 @@ async def handler(request): resp.close() -async def test_static_file_if_modified_since(aiohttp_client, sender) -> 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 @@ -230,20 +230,16 @@ 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, sender) -> 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_past_date( + aiohttp_client: Any, + app_with_static_route: web.Application, +) -> None: + client = await aiohttp_client(app_with_static_route) lastmod = "Mon, 1 Jan 1990 01:01:01 GMT" @@ -252,41 +248,136 @@ async def handler(request): resp.close() -async def test_static_file_if_modified_since_invalid_date(aiohttp_client, sender): - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename +async def test_static_file_if_modified_since_invalid_date( + aiohttp_client: Any, + app_with_static_route: web.Application, +) -> None: + client = await aiohttp_client(app_with_static_route) - async def handler(request): - return sender(filepath) + lastmod = "not a valid HTTP-date" - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) + resp = await client.get("/", headers={"If-Modified-Since": lastmod}) + assert 200 == resp.status + resp.close() - lastmod = "not a valid HTTP-date" + +async def test_static_file_if_modified_since_future_date( + aiohttp_client: Any, + app_with_static_route: web.Application, +) -> None: + client = await aiohttp_client(app_with_static_route) + + lastmod = "Fri, 31 Dec 9999 23:59:59 GMT" resp = await client.get("/", headers={"If-Modified-Since": lastmod}) + 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() -async def test_static_file_if_modified_since_future_date(aiohttp_client, sender): - filename = "data.unknown_mime_type" - filepath = pathlib.Path(__file__).parent / filename - async def handler(request): - return sender(filepath) +@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) - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) + 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() - lastmod = "Fri, 31 Dec 9999 23:59:59 GMT" - resp = await client.get("/", headers={"If-Modified-Since": lastmod}) +@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() @@ -553,16 +644,11 @@ async def handler(request): resp.close() -async def test_static_file_if_unmodified_since_past_with_range(aiohttp_client, sender): - 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_unmodified_since_past_with_range( + aiohttp_client: Any, + app_with_static_route: web.Application, +) -> None: + client = await aiohttp_client(app_with_static_route) lastmod = "Mon, 1 Jan 1990 01:01:01 GMT" @@ -574,17 +660,10 @@ async def handler(request): async def test_static_file_if_unmodified_since_future_with_range( - aiohttp_client, sender -): - 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) + aiohttp_client: Any, + app_with_static_route: web.Application, +) -> None: + client = await aiohttp_client(app_with_static_route) lastmod = "Fri, 31 Dec 9999 23:59:59 GMT" @@ -597,16 +676,11 @@ async def handler(request): resp.close() -async def test_static_file_if_range_past_with_range(aiohttp_client, sender): - 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, +) -> None: + client = await aiohttp_client(app_with_static_route) lastmod = "Mon, 1 Jan 1990 01:01:01 GMT" @@ -616,16 +690,11 @@ async def handler(request): resp.close() -async def test_static_file_if_range_future_with_range(aiohttp_client, sender): - 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, +) -> None: + client = await aiohttp_client(app_with_static_route) lastmod = "Fri, 31 Dec 9999 23:59:59 GMT" @@ -637,17 +706,10 @@ async def handler(request): async def test_static_file_if_unmodified_since_past_without_range( - aiohttp_client, sender -): - 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) + aiohttp_client: Any, + app_with_static_route: web.Application, +) -> None: + client = await aiohttp_client(app_with_static_route) lastmod = "Mon, 1 Jan 1990 01:01:01 GMT" @@ -657,17 +719,10 @@ async def handler(request): async def test_static_file_if_unmodified_since_future_without_range( - aiohttp_client, sender -): - 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) + aiohttp_client: Any, + app_with_static_route: web.Application, +) -> None: + client = await aiohttp_client(app_with_static_route) lastmod = "Fri, 31 Dec 9999 23:59:59 GMT" @@ -677,16 +732,11 @@ async def handler(request): resp.close() -async def test_static_file_if_range_past_without_range(aiohttp_client, sender): - 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_without_range( + aiohttp_client: Any, + app_with_static_route: web.Application, +) -> None: + client = await aiohttp_client(app_with_static_route) lastmod = "Mon, 1 Jan 1990 01:01:01 GMT" @@ -696,16 +746,11 @@ async def handler(request): resp.close() -async def test_static_file_if_range_future_without_range(aiohttp_client, sender): - 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_without_range( + aiohttp_client: Any, + app_with_static_route: web.Application, +) -> None: + client = await aiohttp_client(app_with_static_route) lastmod = "Fri, 31 Dec 9999 23:59:59 GMT" @@ -715,16 +760,11 @@ async def handler(request): resp.close() -async def test_static_file_if_unmodified_since_invalid_date(aiohttp_client, sender): - 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_unmodified_since_invalid_date( + aiohttp_client: Any, + app_with_static_route: web.Application, +) -> None: + client = await aiohttp_client(app_with_static_route) lastmod = "not a valid HTTP-date" @@ -733,16 +773,11 @@ async def handler(request): resp.close() -async def test_static_file_if_range_invalid_date(aiohttp_client, sender): - 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, +) -> None: + client = await aiohttp_client(app_with_static_route) lastmod = "not a valid HTTP-date"