Skip to content
Merged
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
7 changes: 7 additions & 0 deletions changelog.d/270.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
**`CacheKey` builds, encodes and parses HTTP cache keys.**
`CacheKey.from_request(request, *components, sort_query=...)` gives the key
`@cache` stores a request under, `to_str()` the string the backend holds, and
`CacheKey.parse(key)` decodes a stored key back into `method`, `host`, `path`,
`query` and `extra`, or returns `None` for a key that is not an HTTP key.
`build_cache_key()`, `clear_path()` and the monitoring routes all go through
it, so the key format is defined in one place.
4 changes: 4 additions & 0 deletions changelog.d/270.removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**`CACHE_KEY_MIN_PARTS`, `CACHE_KEY_MAX_SPLIT` and `CACHE_KEY_MAX_PARTS` are
removed from `fastapi_cachex.routes`.** They described how the monitoring
routes split a key, which `CacheKey.parse()` now does. Use
`CacheKey.parse(key)` to read a key's components.
15 changes: 15 additions & 0 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,21 @@ query string whatever its extra components, and with it every entry for the
path. The monitoring routes show the extra components, decoded, in
`extra_components`. `default_key_builder(request)` is `build_cache_key(request)`.

`CacheKey` is the same key as a value. `CacheKey.from_request(request,
*components)` builds it, `to_str()` gives the string `build_cache_key` returns,
and `CacheKey.parse(key)` decodes a stored key into `method`, `host`, `path`,
`query` and `extra`, or returns `None` for a key that is not an HTTP key
(a `CacheManager` key, say):

```python
from fastapi_cachex import CacheKey

for key in await backend.get_all_keys():
parsed = CacheKey.parse(key)
if parsed is not None and parsed.path.startswith("/reports/"):
print(parsed.host, parsed.query, parsed.extra)
```

The Redis and Memcached backends also put their own prefix (`fastapi_cachex:` by
default) in front of every key, so other applications can share the server;
`MemoryBackend` has no prefix. `CacheManager` (see
Expand Down
2 changes: 1 addition & 1 deletion docs/MIGRATING_0_4.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ CacheManagerProxy.set(CacheManager(lock=True))
- The host is normalised: lower-cased, and the scheme's default port (`:80`, `:443`) dropped.
- A long query string (over about 200 bytes) is stored as `sha256:` and its hex digest; the path stays readable.
- Query parameters are sorted by name: `sort_query` (opt-in since 0.3.9) defaults to `True` in `@cache`, `build_cache_key()` and `invalidate()`, so `?b=2&a=1` and `?a=1&b=2` share one entry.
- One `CacheKey` type encodes and parses keys; the key-parsing internals of `routes.py` change.
- One public `CacheKey` type builds, encodes and parses keys. `CACHE_KEY_MIN_PARTS`, `CACHE_KEY_MAX_SPLIT` and `CACHE_KEY_MAX_PARTS` are removed from `fastapi_cachex.routes`; read a key's components with `CacheKey.parse(key)` instead.

```text
Before: GET|||Example.com:80|||/users/1|||page=2
Expand Down
2 changes: 2 additions & 0 deletions docs/api/http-caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ configured backend.

::: fastapi_cachex.cache.default_key_builder

::: fastapi_cachex.cache_key.CacheKey

::: fastapi_cachex.proxy.BackendProxy
options:
inherited_members: true
Expand Down
2 changes: 2 additions & 0 deletions fastapi_cachex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .cache import cache as cache
from .cache import default_key_builder as default_key_builder
from .cache import invalidate as invalidate
from .cache_key import CacheKey as CacheKey
from .dependencies import AppCache as AppCache
from .dependencies import CacheBackend as CacheBackend
from .dependencies import get_app_cache as get_app_cache
Expand Down Expand Up @@ -76,6 +77,7 @@ def _read_version() -> str:
"BackendNotFoundError",
"BackendProxy",
"CacheBackend",
"CacheKey",
"CacheKeyBuilder",
"CacheLock",
"CacheManager",
Expand Down
28 changes: 3 additions & 25 deletions fastapi_cachex/backends/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@
from collections.abc import Callable
from collections.abc import Iterable

from fastapi_cachex.types import CACHE_KEY_SEPARATOR
from fastapi_cachex.cache_key import CacheKey
from fastapi_cachex.types import CacheEntry
from fastapi_cachex.types import CacheItem
from fastapi_cachex.types import counter_entry
from fastapi_cachex.types import counter_value
from fastapi_cachex.types import escape_key_component

from .base import BaseCacheBackend
from .base import validate_delta
Expand All @@ -21,25 +20,6 @@

logger = logging.getLogger(__name__)

# HTTP cache keys are formatted as: method|||host|||path|||query_params
_PATH_INDEX = 2
_QUERY_INDEX = 3


def _split_http_key(key: str) -> tuple[str, bool] | None:
"""Return ``(path, has_query_params)`` for an HTTP cache key, else ``None``.

Keys without separators (CacheManager/StateManager keys or custom key
builders) are not HTTP keys and are matched on their raw value instead.
Components after the query string (see ``build_cache_key``) do not count
as query params.
"""
parts = key.split(CACHE_KEY_SEPARATOR)
if len(parts) <= _PATH_INDEX:
return None
has_params = len(parts) > _QUERY_INDEX and bool(parts[_QUERY_INDEX])
return parts[_PATH_INDEX], has_params


def _is_live(item: CacheItem, now: float) -> bool:
"""Whether ``item`` has not expired at ``now``."""
Expand Down Expand Up @@ -314,15 +294,13 @@ async def clear_path(self, path: str, include_params: bool = False) -> int:
Returns:
Number of cache entries cleared
"""
key_path = escape_key_component(path)

def matches(key: str) -> bool:
parsed = _split_http_key(key)
parsed = CacheKey.parse(key)
if parsed is None:
# Direct key match (custom key format without separators)
return key == path
cache_path, has_params = parsed
return cache_path == key_path and (include_params or not has_params)
return parsed.path == path and (include_params or not parsed.query)

cleared_count = await self._evict(matches)
logger.debug(
Expand Down
49 changes: 12 additions & 37 deletions fastapi_cachex/backends/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
from fastapi_cachex.backends.codec import encode_entry
from fastapi_cachex.backends.config import DEFAULT_REDIS_PREFIX as DEFAULT_REDIS_PREFIX # noqa: PLC0414
from fastapi_cachex.backends.config import RedisConfig
from fastapi_cachex.cache_key import CacheKey
from fastapi_cachex.cache_key import escape_glob
from fastapi_cachex.exceptions import CacheXError
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
from fastapi_cachex.types import CacheEntry
from fastapi_cachex.types import escape_key_component

from .base import BaseCacheBackend
from .base import validate_delta
Expand All @@ -30,22 +30,9 @@
_PTTL_NO_EXPIRY = -1
_PTTL_MISSING = -2

# Positions of the path and the query string among an HTTP key's components.
_PATH_INDEX = 2
_QUERY_INDEX = 3

# SCAN page size and DEL batch size; keeps individual commands small.
_BATCH_SIZE = 100

# Characters that are live in a Redis glob pattern.
_GLOB_SPECIAL = frozenset("*?[]\\")


def _escape_glob(text: str) -> str:
"""Backslash-escape ``text`` so a Redis glob pattern matches it literally."""
return "".join(f"\\{ch}" if ch in _GLOB_SPECIAL else ch for ch in text)


# INCRBY that attaches a TTL only when it creates the key, so a counter lives in
# a fixed window. KEYS[1] = key, ARGV[1] = delta, ARGV[2] = ttl (0 = none).
_INCREMENT_SCRIPT = """
Expand Down Expand Up @@ -231,7 +218,7 @@ def _make_key(self, key: str) -> str:
@property
def _prefix_pattern(self) -> str:
"""The key prefix as a literal glob, so ``*``/``?``/``[`` in it stay inert."""
return _escape_glob(self.key_prefix)
return escape_glob(self.key_prefix)

async def _scan_keys(self, pattern: str) -> list[str]:
"""Collect every key matching ``pattern`` (a full, prefixed glob).
Expand Down Expand Up @@ -427,30 +414,18 @@ async def clear_path(self, path: str, include_params: bool = False) -> int:
Returns:
Number of cache entries cleared
"""
# Keys are method|||host|||path|||query, optionally followed by extra
# components (build_cache_key). The glob finds every key with the path
# between two separators; a glob cannot pin it to the third component
# or tell an empty query from extra components after one, so each key
# SCAN returns is checked here. Without include_params only keys with
# an empty query match. The path is a literal, not a glob:
# "/files/[draft]" means those brackets. It is stored with "|" and "%"
# percent-encoded, so match it that way.
key_path = escape_key_component(path)
pattern = (
f"{self._prefix_pattern}*{CACHE_KEY_SEPARATOR}"
f"{_escape_glob(key_path)}{CACHE_KEY_SEPARATOR}*"
)
# The glob finds every key with the path between two separators, but
# cannot pin it to the path component or tell an empty query from
# extra components after one, so each key SCAN returns is parsed
# here. Without include_params only keys with an empty query match.
pattern = self._prefix_pattern + CacheKey.path_glob(path)

def matches(key: str) -> bool:
parts = key.removeprefix(self.key_prefix).split(CACHE_KEY_SEPARATOR)
parsed = CacheKey.parse(key.removeprefix(self.key_prefix))
return (
len(parts) > _PATH_INDEX
and parts[_PATH_INDEX] == key_path
and (
include_params
or len(parts) <= _QUERY_INDEX
or not parts[_QUERY_INDEX]
)
parsed is not None
and parsed.path == path
and (include_params or not parsed.query)
)

cleared_count = await self._delete_matching(pattern, matches)
Expand Down
49 changes: 9 additions & 40 deletions fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from functools import wraps
from inspect import Parameter
from inspect import Signature
from operator import itemgetter
from typing import TYPE_CHECKING
from typing import Annotated
from typing import Any
Expand All @@ -24,7 +23,6 @@
from typing import get_args
from typing import get_origin
from typing import get_type_hints
from urllib.parse import urlencode

from fastapi import Request
from fastapi import Response
Expand All @@ -38,6 +36,7 @@
from starlette.status import HTTP_304_NOT_MODIFIED

from .backends.base import MAX_TTL
from .cache_key import CacheKey
from .directives import DirectiveType
from .exceptions import BackendNotFoundError
from .exceptions import CacheXError
Expand Down Expand Up @@ -71,21 +70,6 @@
_now = time.time


def _query_component(request: Request, sort_query: bool) -> str:
"""The query string as it appears in the key.

Starlette parses the query (blank values kept, empty ``&&`` segments
dropped, names and values percent-decoded) and ``str()`` re-encodes the
pairs in the order sent. ``sort_query`` stable-sorts the same decoded
pairs by name before encoding them the same way, so only the order of
differently named parameters changes: repeated values of one name keep
their relative order, and an already sorted query gives the unsorted key.
"""
if not sort_query:
return str(request.query_params)
return urlencode(sorted(request.query_params.multi_items(), key=itemgetter(0)))


def build_cache_key(
request: Request, *components: str | int, sort_query: bool = False
) -> str:
Expand All @@ -106,7 +90,9 @@ def per_user_key(request: Request) -> str:

Keys built this way keep the path in the third component, so
``clear_path()`` still finds them and the monitoring routes still show
their method, host, path and query.
their method, host, path and query. This is
``CacheKey.from_request(...).to_str()``; ``CacheKey.parse()`` decodes the
key again.

Args:
request: The FastAPI Request object
Expand All @@ -129,17 +115,7 @@ def per_user_key(request: Request) -> str:
rejected too), e.g. ``None`` from a missing user ID, which would
otherwise put every such caller under one ``"None"`` key.
"""
key = _append_key_components(
CACHE_KEY_SEPARATOR.join(
[
request.method,
escape_key_component(request.headers.get("host", "unknown")),
escape_key_component(request.url.path),
_query_component(request, sort_query),
]
),
components,
)
key = CacheKey.from_request(request, *components, sort_query=sort_query).to_str()
logger.debug("Built cache key: %s", key)
return key

Expand Down Expand Up @@ -167,18 +143,11 @@ def _log_backend_failure(
logger.debug("Cache backend %s; key_ref=%s key=%s", what, key_ref, cache_key)


def _append_key_components(key: str, components: Sequence[str | int]) -> str:
def _append_key_components(key: str, components: Sequence[str]) -> str:
"""Append each component to ``key``, escaped, after another separator."""
parts = [key]
for component in components:
if isinstance(component, bool) or not isinstance(component, (str, int)):
msg = (
"build_cache_key components must be str or int, "
f"got {type(component).__name__}"
)
raise TypeError(msg)
parts.append(escape_key_component(str(component)))
return CACHE_KEY_SEPARATOR.join(parts)
return CACHE_KEY_SEPARATOR.join(
[key, *(escape_key_component(component) for component in components)]
)


# RFC 9110 §5.1: a field name is a token.
Expand Down
Loading
Loading