Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
14 changes: 13 additions & 1 deletion examples/in_memory/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
17 changes: 10 additions & 7 deletions fastapi_cache/coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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
Expand All @@ -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


Expand Down
67 changes: 65 additions & 2 deletions fastapi_cache/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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:
"""
Expand All @@ -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]:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "fastapi-cache2"
version = "0.2.2"
version = "0.2.3"
description = "Cache for FastAPI"
authors = ["long2ice <long2ice@gmail.com>"]
license = "Apache-2.0"
Expand Down
73 changes: 65 additions & 8 deletions tests/test_decorator.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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}

Expand Down Expand Up @@ -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"}