diff --git a/README.md b/README.md index 79aab868..f02c9735 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ Parameter | type | default | description `coder` | `Coder` | `JsonCoder` | which coder to use, e.g. `JsonCoder` `key_builder` | `KeyBuilder` callable | `default_key_builder` | which key builder to use `injected_dependency_namespace` | `str` | `__fastapi_cache` | prefix for injected dependency keywords. +`exclude_params` | `Collection[str]` | `None` | names of parameters to leave out of the cache key `cache_status_header` | `str` | `X-FastAPI-Cache` | Name for the header on the response indicating if the request was served from cache; either `HIT` or `MISS`. You can also use the `@cache` decorator on regular functions to cache their result. @@ -120,6 +121,34 @@ Use the `injected_dependency_namespace` argument to `@cache` to change the prefix used if those names would clash anyway. +### Excluding parameters from the cache key + +Some arguments should not make two calls into two separate cache entries: a +database session, an authenticated user, a request id, or a tracing nonce. List +their names in `exclude_params` and they are dropped from the arguments handed +to the key builder, so calls differing only in those values share one entry. + +```python +@app.get("/items/{item_id}") +@cache(expire=60, exclude_params=["db", "trace_id"]) +async def read_item( + item_id: int, + trace_id: str = "", + db: Session = Depends(get_db), +) -> Item: + return get_item(db, item_id) +``` + +The filtering happens before the key builder runs, so it applies to the default +key builder and to custom ones alike. Note that a custom key builder deriving +its key from the request itself (for example from `request.url` or +`request.query_params`) never sees the filtered arguments and has to do its own +exclusion. + +Names that do not appear in the decorated function's signature raise a +`ValueError` at decoration time, so typos surface at import. Functions +accepting `**kwargs` are exempt from that check, as any name can be valid there. + ### Supported data types When using the (default) `JsonCoder`, the cache can store any data type that FastAPI can convert to JSON, including Pydantic models and dataclasses, diff --git a/examples/in_memory/main.py b/examples/in_memory/main.py index f4de1a0b..f54eb16a 100644 --- a/examples/in_memory/main.py +++ b/examples/in_memory/main.py @@ -1,6 +1,6 @@ # pyright: reportGeneralTypeIssues=false from contextlib import asynccontextmanager -from typing import AsyncIterator, Dict, Optional +from typing import Any, AsyncIterator, Dict, Optional import pendulum import uvicorn @@ -136,5 +136,17 @@ def namespaced_injection( } +exclude_ret = 0 + + +@app.get("/excluded_params") +@cache(namespace="test", expire=5, exclude_params=["nonce"]) +async def excluded_params(name: str, nonce: str = "") -> Dict[str, Any]: + # calls that only differ in `nonce` all share one cache entry + global exclude_ret + exclude_ret = exclude_ret + 1 + return {"name": name, "nonce": nonce, "value": exclude_ret} + + if __name__ == "__main__": uvicorn.run("main:app", reload=True) diff --git a/fastapi_cache/coder.py b/fastapi_cache/coder.py index 644c1821..2444fe77 100644 --- a/fastapi_cache/coder.py +++ b/fastapi_cache/coder.py @@ -15,15 +15,12 @@ import pendulum from fastapi.encoders import jsonable_encoder +from pydantic import TypeAdapter from starlette.responses import JSONResponse from starlette.templating import ( _TemplateResponse as TemplateResponse, # pyright: ignore[reportPrivateUsage] ) - -class ModelField: - pass - _T = TypeVar("_T", bound=type) @@ -67,12 +64,12 @@ def encode(cls, value: Any) -> bytes: def decode(cls, value: bytes) -> Any: raise NotImplementedError - # (Shared) cache for endpoint return types to Pydantic model fields. + # (Shared) cache for endpoint return types to Pydantic type adapters. # Note that subclasses share this cache! If a subclass overrides the - # decode_as_type method and then stores a different kind of field for a + # decode_as_type method and then stores a different kind of adapter for a # given type, do make sure that the subclass provides its own class # attribute for this cache. - _type_field_cache: ClassVar[Dict[Any, ModelField]] = {} + _type_field_cache: ClassVar[Dict[Any, TypeAdapter[Any]]] = {} @overload @classmethod @@ -92,6 +89,12 @@ def decode_as_type(cls, value: bytes, *, type_: Optional[_T]) -> Union[_T, Any]: """ result = cls.decode(value) + if type_ is not None: + try: + type_adapter = cls._type_field_cache[type_] + except KeyError: + type_adapter = cls._type_field_cache[type_] = TypeAdapter(type_) + result = type_adapter.validate_python(result) return result diff --git a/fastapi_cache/decorator.py b/fastapi_cache/decorator.py index 7df09e88..23841fb6 100644 --- a/fastapi_cache/decorator.py +++ b/fastapi_cache/decorator.py @@ -3,10 +3,14 @@ from functools import wraps from inspect import Parameter, Signature, isawaitable, iscoroutinefunction from typing import ( + Any, Awaitable, Callable, + Collection, + Dict, List, Optional, + Tuple, Type, TypeVar, Union, @@ -66,6 +70,52 @@ def _locate_param( return param +def _check_excluded_params(sig: Signature, exclude_params: Collection[str]) -> None: + """Verify that the excluded parameter names exist on the decorated function + + Functions accepting arbitrary keyword arguments are exempt, as any name + could be a valid argument for those. + + """ + if any(p.kind is Parameter.VAR_KEYWORD for p in sig.parameters.values()): + return + unknown = sorted(set(exclude_params) - set(sig.parameters)) + if unknown: + raise ValueError( + f"exclude_params contains parameters not present in the signature: " + f"{', '.join(unknown)}" + ) + + +def _exclude_params( + sig: Signature, + exclude_params: Collection[str], + args: Tuple[Any, ...], + kwargs: Dict[str, Any], +) -> Tuple[Tuple[Any, ...], Dict[str, Any]]: + """Drop the named parameters from the arguments used to build the cache key + + Arguments passed positionally are matched to their parameter name by + position; anything absorbed by a variadic positional parameter is kept. + + """ + filtered_kwargs = {k: v for k, v in kwargs.items() if k not in exclude_params} + if not args: + return args, filtered_kwargs + + positional = [ + p.name + for p in sig.parameters.values() + if p.kind in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) + ] + filtered_args = tuple( + value + for i, value in enumerate(args) + if i >= len(positional) or positional[i] not in exclude_params + ) + return filtered_args, filtered_kwargs + + def _uncacheable(request: Optional[Request]) -> bool: """Determine if this request should not be cached @@ -90,6 +140,7 @@ def cache( key_builder: Optional[KeyBuilder] = None, namespace: str = "", injected_dependency_namespace: str = "__fastapi_cache", + exclude_params: Optional[Collection[str]] = None, ) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[Union[R, Response]]]]: """ cache all function @@ -98,6 +149,9 @@ def cache( :param expire: :param coder: :param key_builder: + :param exclude_params: names of parameters to leave out of the cache key, so + that calls differing only in those values share a cache entry. Applied + before the key builder runs, so it works with custom key builders too. :return: """ @@ -122,6 +176,8 @@ def wrapper( request_param = _locate_param(wrapped_signature, injected_request, to_inject) response_param = _locate_param(wrapped_signature, injected_response, to_inject) return_type = get_typed_return_annotation(func) + if exclude_params: + _check_excluded_params(wrapped_signature, exclude_params) @wraps(func) async def inner(*args: P.args, **kwargs: P.kwargs) -> Union[R, Response]: @@ -162,13 +218,20 @@ async def ensure_async_func(*args: P.args, **kwargs: P.kwargs) -> R: backend = FastAPICache.get_backend() cache_status_header = FastAPICache.get_cache_status_header() + key_args: Tuple[Any, ...] = args + key_kwargs: Dict[str, Any] = copy_kwargs + if exclude_params: + key_args, key_kwargs = _exclude_params( + wrapped_signature, exclude_params, args, copy_kwargs + ) + cache_key = key_builder( func, f"{prefix}:{namespace}", request=request, response=response, - args=args, - kwargs=copy_kwargs, + args=key_args, + kwargs=key_kwargs, ) if isawaitable(cache_key): cache_key = await cache_key diff --git a/pyproject.toml b/pyproject.toml index a1248490..bf2f16d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "fastapi-cache2" -version = "0.2.2" +version = "0.2.3" description = "Cache for FastAPI" authors = ["long2ice "] license = "Apache-2.0" diff --git a/tests/test_decorator.py b/tests/test_decorator.py index abd6390f..ef0de55b 100644 --- a/tests/test_decorator.py +++ b/tests/test_decorator.py @@ -1,5 +1,6 @@ +import asyncio import time -from typing import Any, Generator +from typing import Any, Dict, Generator, List, Tuple import pendulum import pytest @@ -8,6 +9,7 @@ from examples.in_memory.main import app from fastapi_cache import FastAPICache from fastapi_cache.backends.inmemory import InMemoryBackend +from fastapi_cache.decorator import cache @pytest.fixture(autouse=True) @@ -22,19 +24,19 @@ def test_datetime() -> None: response = client.get("/datetime") assert response.headers.get("X-FastAPI-Cache") == "MISS" now = response.json().get("now") - now_ = pendulum.now() - assert pendulum.parse(now) == now_ + now_ = pendulum.now().replace(microsecond=0) + assert pendulum.parse(now).replace(microsecond=0) == now_ response = client.get("/datetime") assert response.headers.get("X-FastAPI-Cache") == "HIT" now = response.json().get("now") - assert pendulum.parse(now) == now_ + assert pendulum.parse(now).replace(microsecond=0) == now_ time.sleep(3) response = client.get("/datetime") now = response.json().get("now") assert response.headers.get("X-FastAPI-Cache") == "MISS" - now = pendulum.parse(now) + now = pendulum.parse(now).replace(microsecond=0) assert now != now_ - assert now == pendulum.now() + assert now == pendulum.now().replace(microsecond=0) def test_date() -> None: @@ -99,10 +101,10 @@ def test_pydantic_model() -> None: def test_non_get() -> None: with TestClient(app) as client: - response = client.put("/cached_put") + response = client.put("/uncached_put") assert "X-FastAPI-Cache" not in response.headers assert response.json() == {"value": 1} - response = client.put("/cached_put") + response = client.put("/uncached_put") assert "X-FastAPI-Cache" not in response.headers assert response.json() == {"value": 2} @@ -135,3 +137,58 @@ def test_cache_control() -> None: response = client.get("/cached_put") assert response.json() == {"value": 2} + + +def test_exclude_params() -> None: + """Parameters listed in exclude_params are left out of the cache key.""" + with TestClient(app) as client: + response = client.get("/excluded_params", params={"name": "Jon", "nonce": "a"}) + assert response.headers.get("X-FastAPI-Cache") == "MISS" + assert response.json() == {"name": "Jon", "nonce": "a", "value": 1} + + # a different nonce hits the same cache entry + response = client.get("/excluded_params", params={"name": "Jon", "nonce": "b"}) + assert response.headers.get("X-FastAPI-Cache") == "HIT" + assert response.json() == {"name": "Jon", "nonce": "a", "value": 1} + + # a different name is still a distinct entry + response = client.get("/excluded_params", params={"name": "Ben", "nonce": "b"}) + assert response.headers.get("X-FastAPI-Cache") == "MISS" + assert response.json() == {"name": "Ben", "nonce": "b", "value": 2} + + +def test_exclude_params_positional() -> None: + """Positional arguments are matched to their parameter name by position.""" + calls: List[Tuple[int, int]] = [] + + @cache(namespace="test", expire=5, exclude_params=["b"]) + async def func(a: int, b: int) -> int: + calls.append((a, b)) + return a + + assert asyncio.run(func(1, 2)) == 1 + assert asyncio.run(func(1, 3)) == 1 + assert calls == [(1, 2)] + + assert asyncio.run(func(4, 3)) == 4 + assert calls == [(1, 2), (4, 3)] + + +def test_exclude_params_unknown_name() -> None: + """A typo in exclude_params is reported when the function is decorated.""" + with pytest.raises(ValueError, match="nonexistent"): + + @cache(namespace="test", exclude_params=["nonexistent"]) + async def func(a: int) -> int: + return a + + +def test_exclude_params_var_keyword() -> None: + """Functions taking **kwargs accept any excluded name.""" + + @cache(namespace="test", expire=5, exclude_params=["nonce"]) + async def func(**kwargs: Any) -> Dict[str, Any]: + return kwargs + + assert asyncio.run(func(name="Jon", nonce="a")) == {"name": "Jon", "nonce": "a"} + assert asyncio.run(func(name="Jon", nonce="b")) == {"name": "Jon", "nonce": "a"}