diff --git a/CHANGELOG.md b/CHANGELOG.md index 283e505c..16fb208c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Add `force_cache` extension to enforce the request to be cached, ignoring the HTTP headers. (#117) + ## 0.0.18 (23/11/2023) - Fix issue where freshness cannot be calculated to re-send request. (#104) diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index 1e0faef1..9aba4cd1 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -13,6 +13,19 @@ using a `hishel` transport. ## Request extensions +### force_cache + +If this extension is set to true, `Hishel` will cache the response even if response headers +would otherwise prevent caching the response. + +For example, if the response has a `Cache-Control` header that contains a `no-store` directive, it will not cache the response unless the `force_cache` extension is set to true. + +```python +>>> import hishel +>>> client = hishel.CacheClient() +>>> response = client.get("https://www.example.com/uncachable-endpoint", extensions={"force_cache": True}) +``` + ### cache_disabled This extension temporarily disables the cache by passing appropriate RFC9111 headers to diff --git a/hishel/_controller.py b/hishel/_controller.py index dfd25b25..eb5347eb 100644 --- a/hishel/_controller.py +++ b/hishel/_controller.py @@ -139,6 +139,9 @@ def is_cachable(self, request: Request, response: Response) -> bool: """ method = request.method.decode("ascii") + if request.extensions.get("force_cache", False): + return True + if response.status not in self._cacheable_status_codes: return False @@ -270,6 +273,10 @@ def construct_response_from_cache( # If the vary headers does not match, then do not use the response return None # pragma: no cover + # !!! this should be after the "vary" header validation. + if request.extensions.get("force_cache", False): + return response + # the stored response does not contain the # no-cache directive (Section 5.2.2.4), unless # it is successfully validated (Section 4.3) diff --git a/tests/test_controller.py b/tests/test_controller.py index b9340bef..b1a67900 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -762,3 +762,48 @@ def test_freshness_lifetime_invalid_information(): request=request, response=response, original_request=original_request ) assert isinstance(conditional_request, Request) + + +def test_force_cache_extension_for_is_cachable(): + controller = Controller() + request = Request("GET", "https://example.com") + uncachable_response = Response(status=400) + + assert controller.is_cachable(request=request, response=uncachable_response) is False + + request = Request("GET", "https://example.com", extensions={"force_cache": True}) + + assert controller.is_cachable(request=request, response=uncachable_response) is True + + +def test_force_cache_extension_for_construct_response_from_cache(): + class MockedClock(BaseClock): + def now(self) -> int: + return 1440504001 # Mon, 25 Aug 2015 12:00:01 GMT + + controller = Controller(clock=MockedClock()) + original_request = Request("GET", "https://example.com") + request = Request("GET", "https://example.com") + cachable_response = Response( + 200, + headers=[ + (b"Cache-Control", b"max-age=0"), + (b"Date", b"Mon, 25 Aug 2015 12:00:00 GMT"), # 1 second before the clock + ], + ) + + assert isinstance( + controller.construct_response_from_cache( + request=request, response=cachable_response, original_request=original_request + ), + Request, + ) + + request = Request("Get", "https://example.com", extensions={"force_cache": True}) + + assert isinstance( + controller.construct_response_from_cache( + request=request, response=cachable_response, original_request=original_request + ), + Response, + )