From 915f3dd8f2dfe6699a7d6202d8b9c687dd9b15df Mon Sep 17 00:00:00 2001 From: Martijn Pieters Date: Fri, 12 May 2023 14:12:00 +0100 Subject: [PATCH] Add a cache status header to the response The header name is configurable, and defaults to `X-FastAPI-Cache`, the value is either `HIT` or `MISS`. Note that the header is not set at all when the cache is disabled. --- README.md | 1 + fastapi_cache/__init__.py | 9 +++++++++ fastapi_cache/decorator.py | 3 +++ tests/test_decorator.py | 18 +++++++++++++++--- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b445a5d..3187b3d 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ namespace | str, namespace to use to store certain cache items coder | which coder to use, e.g. JsonCoder key_builder | which key builder to use, default to builtin injected_dependency_namespace | prefix for injected dependency keywords, defaults to `__fastapi_cache`. +cache_status_header | Name for the header on the response indicating if the request was served from cache; either `HIT` or `MISS`. Defaults to `X-FastAPI-Cache`. You can also use `cache` as decorator like other cache tools to cache common function result. diff --git a/fastapi_cache/__init__.py b/fastapi_cache/__init__.py index e9ef55e..77abb35 100644 --- a/fastapi_cache/__init__.py +++ b/fastapi_cache/__init__.py @@ -22,6 +22,7 @@ class FastAPICache: _init: ClassVar[bool] = False _coder: ClassVar[Optional[Type[Coder]]] = None _key_builder: ClassVar[Optional[KeyBuilder]] = None + _cache_status_header: ClassVar[Optional[str]] = None _enable: ClassVar[bool] = True @classmethod @@ -32,6 +33,7 @@ class FastAPICache: expire: Optional[int] = None, coder: Type[Coder] = JsonCoder, key_builder: KeyBuilder = default_key_builder, + cache_status_header: str = "X-FastAPI-Cache", enable: bool = True, ) -> None: if cls._init: @@ -42,6 +44,7 @@ class FastAPICache: cls._expire = expire cls._coder = coder cls._key_builder = key_builder + cls._cache_status_header = cache_status_header cls._enable = enable @classmethod @@ -52,6 +55,7 @@ class FastAPICache: cls._expire = None cls._coder = None cls._key_builder = None + cls._cache_status_header = None cls._enable = True @classmethod @@ -78,6 +82,11 @@ class FastAPICache: assert cls._key_builder, "You must call init first!" # nosec: B101 return cls._key_builder + @classmethod + def get_cache_status_header(cls) -> str: + assert cls._cache_status_header, "You must call init first!" # nosec: B101 + return cls._cache_status_header + @classmethod def get_enable(cls) -> bool: return cls._enable diff --git a/fastapi_cache/decorator.py b/fastapi_cache/decorator.py index 209064f..73221f9 100644 --- a/fastapi_cache/decorator.py +++ b/fastapi_cache/decorator.py @@ -144,6 +144,7 @@ def cache( expire = expire or FastAPICache.get_expire() key_builder = key_builder or FastAPICache.get_key_builder() backend = FastAPICache.get_backend() + cache_status_header = FastAPICache.get_cache_status_header() cache_key = key_builder( func, @@ -181,6 +182,7 @@ def cache( { "Cache-Control": f"max-age={expire}", "ETag": f"W/{hash(to_cache)}", + cache_status_header: "MISS", } ) @@ -191,6 +193,7 @@ def cache( { "Cache-Control": f"max-age={ttl}", "ETag": etag, + cache_status_header: "HIT", } ) diff --git a/tests/test_decorator.py b/tests/test_decorator.py index 5d3c472..5e2f62e 100644 --- a/tests/test_decorator.py +++ b/tests/test_decorator.py @@ -20,15 +20,18 @@ def init_cache() -> Generator[Any, Any, None]: def test_datetime() -> None: with TestClient(app) as client: response = client.get("/datetime") + assert response.headers.get("X-FastAPI-Cache") == "MISS" now = response.json().get("now") now_ = pendulum.now().replace(microsecond=0) # type: ignore[no-untyped-call] assert pendulum.parse(now).replace(microsecond=0) == now_ # type: ignore[attr-defined] response = client.get("/datetime") + assert response.headers.get("X-FastAPI-Cache") == "HIT" now = response.json().get("now") assert pendulum.parse(now).replace(microsecond=0) == now_ # type: ignore[attr-defined] time.sleep(3) response = client.get("/datetime") now = response.json().get("now") + assert response.headers.get("X-FastAPI-Cache") == "MISS" now = pendulum.parse(now).replace(microsecond=0) # type: ignore[attr-defined] assert now != now_ assert now == pendulum.now().replace(microsecond=0) # type: ignore[no-untyped-call] @@ -38,15 +41,18 @@ def test_date() -> None: """Test path function without request or response arguments.""" with TestClient(app) as client: response = client.get("/date") + assert response.headers.get("X-FastAPI-Cache") == "MISS" assert pendulum.parse(response.json()) == pendulum.today() # type: ignore[attr-defined] # do it again to test cache response = client.get("/date") + assert response.headers.get("X-FastAPI-Cache") == "HIT" assert pendulum.parse(response.json()) == pendulum.today() # type: ignore[attr-defined] # now test with cache disabled, as that's a separate code path FastAPICache._enable = False # pyright: ignore[reportPrivateUsage] response = client.get("/date") + assert "X-FastAPI-Cache" not in response.headers assert pendulum.parse(response.json()) == pendulum.today() # type: ignore[attr-defined] FastAPICache._enable = True # pyright: ignore[reportPrivateUsage] @@ -72,6 +78,7 @@ def test_kwargs() -> None: with TestClient(app) as client: name = "Jon" response = client.get("/kwargs", params={"name": name}) + assert "X-FastAPI-Cache" not in response.headers assert response.json() == {"name": name} @@ -83,20 +90,25 @@ def test_method() -> None: def test_pydantic_model() -> None: with TestClient(app) as client: - r1 = client.get("/pydantic_instance").json() - r2 = client.get("/pydantic_instance").json() - assert r1 == r2 + r1 = client.get("/pydantic_instance") + assert r1.headers.get("X-FastAPI-Cache") == "MISS" + r2 = client.get("/pydantic_instance") + assert r2.headers.get("X-FastAPI-Cache") == "HIT" + assert r1.json() == r2.json() def test_non_get() -> None: with TestClient(app) as client: response = client.put("/uncached_put") + assert "X-FastAPI-Cache" not in response.headers assert response.json() == {"value": 1} response = client.put("/uncached_put") + assert "X-FastAPI-Cache" not in response.headers assert response.json() == {"value": 2} def test_alternate_injected_namespace() -> None: with TestClient(app) as client: response = client.get("/namespaced_injection") + assert response.headers.get("X-FastAPI-Cache") == "MISS" assert response.json() == {"__fastapi_cache_request": 42, "__fastapi_cache_response": 17}