diff --git a/changelog.d/270.added.md b/changelog.d/270.added.md new file mode 100644 index 0000000..75c3183 --- /dev/null +++ b/changelog.d/270.added.md @@ -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. diff --git a/changelog.d/270.removed.md b/changelog.d/270.removed.md new file mode 100644 index 0000000..d17cf2d --- /dev/null +++ b/changelog.d/270.removed.md @@ -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. diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 129cf3e..4e083c6 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -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 diff --git a/docs/MIGRATING_0_4.md b/docs/MIGRATING_0_4.md index 31fd971..7c49f80 100644 --- a/docs/MIGRATING_0_4.md +++ b/docs/MIGRATING_0_4.md @@ -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 diff --git a/docs/api/http-caching.md b/docs/api/http-caching.md index da2b41d..9c3c441 100644 --- a/docs/api/http-caching.md +++ b/docs/api/http-caching.md @@ -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 diff --git a/fastapi_cachex/__init__.py b/fastapi_cachex/__init__.py index 29711c8..17ddee2 100644 --- a/fastapi_cachex/__init__.py +++ b/fastapi_cachex/__init__.py @@ -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 @@ -76,6 +77,7 @@ def _read_version() -> str: "BackendNotFoundError", "BackendProxy", "CacheBackend", + "CacheKey", "CacheKeyBuilder", "CacheLock", "CacheManager", diff --git a/fastapi_cachex/backends/memory.py b/fastapi_cachex/backends/memory.py index 3366244..b9ca1f4 100644 --- a/fastapi_cachex/backends/memory.py +++ b/fastapi_cachex/backends/memory.py @@ -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 @@ -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``.""" @@ -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( diff --git a/fastapi_cachex/backends/redis.py b/fastapi_cachex/backends/redis.py index 019ddba..6bb141a 100644 --- a/fastapi_cachex/backends/redis.py +++ b/fastapi_cachex/backends/redis.py @@ -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 @@ -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 = """ @@ -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). @@ -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) diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index 40c56b7..3a0def6 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -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 @@ -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 @@ -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 @@ -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: @@ -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 @@ -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 @@ -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. diff --git a/fastapi_cachex/cache_key.py b/fastapi_cachex/cache_key.py new file mode 100644 index 0000000..d74db35 --- /dev/null +++ b/fastapi_cachex/cache_key.py @@ -0,0 +1,164 @@ +"""The HTTP cache key: one type that builds, encodes and parses it. + +``@cache``, ``build_cache_key()``, ``invalidate()``, ``clear_path()`` and the +monitoring routes all go through ``CacheKey``, so the format is defined here +and nowhere else. +""" + +from dataclasses import dataclass +from operator import itemgetter +from urllib.parse import urlencode + +from fastapi import Request + +from .types import CACHE_KEY_SEPARATOR +from .types import escape_key_component +from .types import unescape_key_component + +# A key has at least method, host and path; the query string is optional only +# when parsing keys that were written without it. +_MIN_PARTS = 3 + +# 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) + + +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 _component_text(component: str | int) -> str: + """``component`` as key text: a ``str`` as is, an ``int`` in decimal. + + Raises: + TypeError: For anything else, ``bool`` included. + """ + 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) + return str(component) + + +@dataclass(frozen=True) +class CacheKey: + """An HTTP cache key, decoded into its components. + + The string form is ``method|||host|||path|||query``, followed by one + component per ``extra`` item. ``method``, ``host``, ``path`` and every + ``extra`` item hold the plain text and are percent-encoded by ``to_str()`` + (see ``escape_key_component``), so a client-controlled value cannot + contain the separator; a method token may contain ``|``. ``query`` is + kept URL-encoded, as it appears in the key. + + Build one from a request with ``from_request()`` and turn a stored key + back into one with ``parse()``:: + + key = CacheKey.from_request(request, request.state.tenant_id) + await backend.delete(key.to_str()) + + parsed = CacheKey.parse(stored_key) + if parsed is not None: + print(parsed.path, parsed.extra) + + Raises: + ValueError: If ``query`` contains the separator. It is not + percent-encoded, so the key could not be parsed back. A query + taken from a request never does: Starlette encodes ``|``. + """ + + method: str + host: str + path: str + query: str = "" + extra: tuple[str, ...] = () + + def __post_init__(self) -> None: + """Reject a ``query`` the string form could not keep apart.""" + if CACHE_KEY_SEPARATOR in self.query: + msg = f"CacheKey.query cannot contain {CACHE_KEY_SEPARATOR!r}" + raise ValueError(msg) + + @classmethod + def from_request( + cls, request: Request, *components: str | int, sort_query: bool = False + ) -> "CacheKey": + """The key ``@cache`` stores ``request`` under, plus extra components. + + ``build_cache_key(request, *components, sort_query=...)`` is + ``CacheKey.from_request(...).to_str()``; see it for the arguments. + + Raises: + TypeError: If a component is not a ``str`` or ``int``. + """ + return cls( + method=request.method, + host=request.headers.get("host", "unknown"), + path=request.url.path, + query=_query_component(request, sort_query), + extra=tuple(_component_text(component) for component in components), + ) + + def to_str(self) -> str: + """The key as stored in the backend.""" + return CACHE_KEY_SEPARATOR.join( + [ + escape_key_component(self.method), + escape_key_component(self.host), + escape_key_component(self.path), + self.query, + *(escape_key_component(part) for part in self.extra), + ] + ) + + @classmethod + def parse(cls, key: str) -> "CacheKey | None": + """Decode a stored HTTP key, or return ``None`` for any other key. + + ``key`` is the logical key, without a backend's ``key_prefix``. A key + with fewer than three components or an empty method (a ``CacheManager`` + or ``StateManager`` key, or one from a key builder that does not use + ``build_cache_key``) is not an HTTP key. A key that ends at the path + parses with an empty query. + """ + parts = key.split(CACHE_KEY_SEPARATOR) + if len(parts) < _MIN_PARTS or not parts[0]: + return None + return cls( + method=unescape_key_component(parts[0]), + host=unescape_key_component(parts[1]), + path=unescape_key_component(parts[2]), + query=parts[3] if len(parts) > _MIN_PARTS else "", + extra=tuple(unescape_key_component(part) for part in parts[4:]), + ) + + @staticmethod + def path_glob(path: str) -> str: + """A Redis glob that matches every HTTP key for ``path``. + + ``path`` is matched literally: glob characters in it are escaped. The + glob cannot pin the path to its component, so it also matches keys + that have the same text in another one; ``parse()`` each match and + compare ``path`` to keep only the right ones. Backends put their own + ``key_prefix`` in front. + """ + escaped = escape_glob(escape_key_component(path)) + return f"*{CACHE_KEY_SEPARATOR}{escaped}{CACHE_KEY_SEPARATOR}*" diff --git a/fastapi_cachex/routes.py b/fastapi_cachex/routes.py index 2ded024..7e36813 100644 --- a/fastapi_cachex/routes.py +++ b/fastapi_cachex/routes.py @@ -8,23 +8,14 @@ from typing import TYPE_CHECKING from typing import Any +from .cache_key import CacheKey from .exceptions import BackendNotFoundError from .proxy import BackendProxy -from .types import CACHE_KEY_SEPARATOR from .types import CacheEntry -from .types import unescape_key_component if TYPE_CHECKING: from fastapi import FastAPI -# Constants -CACHE_KEY_MIN_PARTS = 3 -# Index of the query string among a key's components. Components after it are -# the extra ones ``build_cache_key`` appends. Before 0.3.9 keys were split at -# most this many times, so extra components showed up inside the query string. -CACHE_KEY_MAX_SPLIT = 3 -# Former name, kept so existing imports keep working. -CACHE_KEY_MAX_PARTS = CACHE_KEY_MAX_SPLIT _PREVIEW_BYTES = 100 @@ -114,41 +105,6 @@ class CachedRecordsResponse: summary: CacheSummary -def _split_cache_key(cache_key: str) -> tuple[str, str, str, str, list[str]]: - """Split a cache key into its components, decoding the escaped ones. - - Args: - cache_key: Cache key in format - method|||host|||path|||query_params[|||extra...] - - Returns: - Tuple of (method, host, path, query_params, extra_components), all - empty for a key that is not a route key - """ - key_parts = cache_key.split(CACHE_KEY_SEPARATOR) - if len(key_parts) < CACHE_KEY_MIN_PARTS: - return "", "", "", "", [] - return ( - key_parts[0], - unescape_key_component(key_parts[1]), - unescape_key_component(key_parts[2]), - key_parts[3] if len(key_parts) > CACHE_KEY_MAX_SPLIT else "", - [unescape_key_component(part) for part in key_parts[4:]], - ) - - -def _parse_cache_key(cache_key: str) -> tuple[str, str, str, str]: - """Parse cache key into components. - - Args: - cache_key: Cache key in format method|||host|||path|||query_params - - Returns: - Tuple of (method, host, path, query_params) - """ - return _split_cache_key(cache_key)[:4] - - @dataclass class _Entry: """One parsed backend entry, shared by both monitoring views.""" @@ -171,17 +127,17 @@ def _parse_entries( now = time.time() entries: list[_Entry] = [] for cache_key, (entry, expiry) in cache_data.items(): - method, host, path, query_params, extra = _split_cache_key(cache_key) - if not method: + key = CacheKey.parse(cache_key) + if key is None: continue entries.append( _Entry( cache_key=cache_key, - method=method, - host=host, - path=path, - query_params=query_params, - extra_components=extra, + method=key.method, + host=key.host, + path=key.path, + query_params=key.query, + extra_components=list(key.extra), entry=entry, is_expired=expiry is not None and expiry <= now, ttl_remaining=( diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 3d18031..ef5b3e1 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -218,6 +218,17 @@ def per_tenant_key(request: Request) -> str: 由於路徑仍是第三段,`clear_path()` 依然找得到這些鍵:不帶 `include_params` 時,會清除該路徑下查詢字串為空的所有項目,不論其他段為何;帶上它則清除該路徑的所有項目。監控路由會把其他段解碼後列在 `extra_components` 中。`default_key_builder(request)` 就是 `build_cache_key(request)`。 +`CacheKey` 則是以值的形式表示同一個鍵。`CacheKey.from_request(request, *components)` 建立它,`to_str()` 得到與 `build_cache_key` 相同的字串,`CacheKey.parse(key)` 則把已儲存的鍵解碼為 `method`、`host`、`path`、`query` 與 `extra`;若不是 HTTP 鍵(例如 `CacheManager` 的鍵),則回傳 `None`: + +```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) +``` + Redis 與 Memcached 後端還會在每個鍵前面加上自己的前綴(預設為 `fastapi_cachex:`),讓其他應用程式可以共用同一台伺服器;`MemoryBackend` 沒有前綴。`CacheManager`(見[應用層快取](APP_CACHE.md))則使用另一個較簡單、以 `cache:` 為前綴的鍵命名空間,而不是這種以 `|||` 分隔的格式,因為它的鍵與 HTTP 請求無關。 ### 依請求標頭區分 {#varying-on-request-headers} diff --git a/i18n/zh-TW/docs/MIGRATING_0_4.md b/i18n/zh-TW/docs/MIGRATING_0_4.md index 5db0699..a0ffd74 100644 --- a/i18n/zh-TW/docs/MIGRATING_0_4.md +++ b/i18n/zh-TW/docs/MIGRATING_0_4.md @@ -254,7 +254,7 @@ CacheManagerProxy.set(CacheManager(lock=True)) - 主機名稱會正規化:轉為小寫,並去除該 scheme 的預設連接埠(`:80`、`:443`)。 - 過長的查詢字串(約超過 200 位元組)會以 `sha256:` 加上十六進位摘要儲存;路徑仍保持可讀。 - 查詢參數會依名稱排序:`sort_query`(0.3.9 起可選用)在 `@cache`、`build_cache_key()` 與 `invalidate()` 中預設為 `True`,因此 `?b=2&a=1` 與 `?a=1&b=2` 共用同一筆項目。 -- 由單一的 `CacheKey` 型別負責編碼與解析鍵;`routes.py` 中解析鍵的內部實作會改變。 +- 由單一、公開的 `CacheKey` 型別負責建立、編碼與解析鍵。`fastapi_cachex.routes` 中的 `CACHE_KEY_MIN_PARTS`、`CACHE_KEY_MAX_SPLIT` 與 `CACHE_KEY_MAX_PARTS` 已移除;請改用 `CacheKey.parse(key)` 讀取鍵的各段。 ```text 修改前:GET|||Example.com:80|||/users/1|||page=2 diff --git a/tests/test_build_cache_key.py b/tests/test_build_cache_key.py index c74a86c..4febb8b 100644 --- a/tests/test_build_cache_key.py +++ b/tests/test_build_cache_key.py @@ -13,9 +13,8 @@ from fastapi_cachex import cache from fastapi_cachex.backends import MemoryBackend from fastapi_cachex.cache import default_key_builder +from fastapi_cachex.cache_key import CacheKey from fastapi_cachex.proxy import BackendProxy -from fastapi_cachex.routes import _parse_cache_key -from fastapi_cachex.routes import _split_cache_key from fastapi_cachex.types import CACHE_KEY_SEPARATOR from fastapi_cachex.types import escape_key_component @@ -118,9 +117,12 @@ def test_build_cache_key_is_exported() -> None: def test_keys_with_components_split_into_query_and_extras() -> None: key = build_cache_key(_request(path="/p|q", query=b"x=1"), "a|b", 3) - assert _split_cache_key(key) == ("GET", "example.com", "/p|q", "x=1", ["a|b", "3"]) - assert _parse_cache_key(key) == ("GET", "example.com", "/p|q", "x=1") - assert _split_cache_key(build_cache_key(_request(), "u"))[3:] == ("", ["u"]) + assert CacheKey.parse(key) == CacheKey( + "GET", "example.com", "/p|q", "x=1", ("a|b", "3") + ) + parsed = CacheKey.parse(build_cache_key(_request(), "u")) + assert parsed is not None + assert (parsed.query, parsed.extra) == ("", ("u",)) @pytest.fixture diff --git a/tests/test_cache_key.py b/tests/test_cache_key.py index 620f301..c12a21a 100644 --- a/tests/test_cache_key.py +++ b/tests/test_cache_key.py @@ -7,13 +7,20 @@ from fastapi_cachex.backends import MemoryBackend from fastapi_cachex.cache import cache from fastapi_cachex.cache import default_key_builder +from fastapi_cachex.cache_key import CacheKey from fastapi_cachex.proxy import BackendProxy -from fastapi_cachex.routes import _parse_cache_key from fastapi_cachex.types import CACHE_KEY_SEPARATOR from fastapi_cachex.types import escape_key_component from fastapi_cachex.types import unescape_key_component +def _key_parts(cache_key: str) -> tuple[str, str, str, str]: + """``(method, host, path, query)`` of an HTTP key.""" + key = CacheKey.parse(cache_key) + assert key is not None + return key.method, key.host, key.path, key.query + + class TestCacheKeyGeneration: """Test cache key generation with various host formats.""" @@ -42,12 +49,9 @@ async def test_endpoint(): assert CACHE_KEY_SEPARATOR in cache_key # Parse the cache key to verify components - method, host, path, query_params = _parse_cache_key(cache_key) - - assert method == "GET" - assert host == "127.0.0.1:8000" # Port should be part of host - assert path == "/api/test" - assert query_params == "" + assert CacheKey.parse(cache_key) == CacheKey( + "GET", "127.0.0.1:8000", "/api/test", "" + ) # The port stays part of the host def test_cache_key_with_localhost(self): """Test cache key generation with localhost.""" @@ -68,7 +72,7 @@ async def users_endpoint(): cache_keys = list(backend.cache.keys()) assert len(cache_keys) == 1 - method, host, path, query_params = _parse_cache_key(cache_keys[0]) + method, host, path, query_params = _key_parts(cache_keys[0]) assert method == "GET" assert host == "localhost:8080" @@ -94,7 +98,7 @@ async def search_endpoint(q: str = ""): cache_keys = list(backend.cache.keys()) assert len(cache_keys) == 1 - method, host, path, query_params = _parse_cache_key(cache_keys[0]) + method, host, path, query_params = _key_parts(cache_keys[0]) assert method == "GET" assert host == "127.0.0.1:8000" @@ -105,7 +109,7 @@ def test_cache_key_with_ipv6_address(self): """Test cache key parsing with IPv6 address containing colons.""" # IPv6 addresses contain multiple colons, test that our separator doesn't break this cache_key = f"GET{CACHE_KEY_SEPARATOR}[::1]:8000{CACHE_KEY_SEPARATOR}/api/data{CACHE_KEY_SEPARATOR}" - method, host, path, query_params = _parse_cache_key(cache_key) + method, host, path, query_params = _key_parts(cache_key) assert method == "GET" assert host == "[::1]:8000" @@ -119,7 +123,7 @@ class TestCacheKeyParsing: def test_parse_valid_cache_key(self): """Test parsing a valid cache key.""" cache_key = f"GET{CACHE_KEY_SEPARATOR}localhost:8000{CACHE_KEY_SEPARATOR}/api/test{CACHE_KEY_SEPARATOR}id=123" - method, host, path, query_params = _parse_cache_key(cache_key) + method, host, path, query_params = _key_parts(cache_key) assert method == "GET" assert host == "localhost:8000" @@ -129,7 +133,7 @@ def test_parse_valid_cache_key(self): def test_parse_cache_key_without_query_params(self): """Test parsing cache key without query parameters.""" cache_key = f"POST{CACHE_KEY_SEPARATOR}127.0.0.1:3000{CACHE_KEY_SEPARATOR}/api/create{CACHE_KEY_SEPARATOR}" - method, host, path, query_params = _parse_cache_key(cache_key) + method, host, path, query_params = _key_parts(cache_key) assert method == "POST" assert host == "127.0.0.1:3000" @@ -139,7 +143,7 @@ def test_parse_cache_key_without_query_params(self): def test_parse_cache_key_with_complex_host(self): """Test parsing cache key with complex host (subdomain + port).""" cache_key = f"GET{CACHE_KEY_SEPARATOR}api.example.com:443{CACHE_KEY_SEPARATOR}/v1/users{CACHE_KEY_SEPARATOR}limit=10" - method, host, path, query_params = _parse_cache_key(cache_key) + method, host, path, query_params = _key_parts(cache_key) assert method == "GET" assert host == "api.example.com:443" @@ -147,14 +151,12 @@ def test_parse_cache_key_with_complex_host(self): assert query_params == "limit=10" def test_parse_invalid_cache_key(self): - """Test parsing invalid cache key returns empty strings.""" - cache_key = "invalid_key" - method, host, path, query_params = _parse_cache_key(cache_key) - - assert method == "" - assert host == "" - assert path == "" - assert query_params == "" + """A key that is not an HTTP key parses to None.""" + assert CacheKey.parse("invalid_key") is None + assert CacheKey.parse(f"GET{CACHE_KEY_SEPARATOR}host") is None + assert ( + CacheKey.parse(f"{CACHE_KEY_SEPARATOR}host{CACHE_KEY_SEPARATOR}/p") is None + ) def test_cache_key_separator_constant(self): """Test that CACHE_KEY_SEPARATOR constant is correctly defined.""" @@ -193,8 +195,8 @@ async def test_endpoint(): assert len(cache_keys) == 2 # Parse both keys and verify they differ in host - key1_method, key1_host, key1_path, _ = _parse_cache_key(cache_keys[0]) - key2_method, key2_host, key2_path, _ = _parse_cache_key(cache_keys[1]) + key1_method, key1_host, key1_path, _ = _key_parts(cache_keys[0]) + key2_method, key2_host, key2_path, _ = _key_parts(cache_keys[1]) assert key1_host != key2_host assert key1_method == key2_method == "GET" @@ -224,8 +226,8 @@ async def data_endpoint(): assert len(cache_keys) == 2 # Verify hosts are different - key1_method, key1_host, key1_path, _ = _parse_cache_key(cache_keys[0]) - key2_method, key2_host, key2_path, _ = _parse_cache_key(cache_keys[1]) + key1_method, key1_host, key1_path, _ = _key_parts(cache_keys[0]) + key2_method, key2_host, key2_path, _ = _key_parts(cache_keys[1]) assert key1_host == "localhost:8000" assert key2_host == "localhost:9000" @@ -312,4 +314,4 @@ def test_monitoring_parser_decodes_host_and_path(self) -> None: "q=1", ] ) - assert _parse_cache_key(key) == ("GET", "evil|||host", "/p|||/100%", "q=1") + assert _key_parts(key) == ("GET", "evil|||host", "/p|||/100%", "q=1") diff --git a/tests/test_cache_key_type.py b/tests/test_cache_key_type.py new file mode 100644 index 0000000..e93664d --- /dev/null +++ b/tests/test_cache_key_type.py @@ -0,0 +1,149 @@ +"""``CacheKey`` is the one encoder and decoder of HTTP cache keys (#270).""" + +import dataclasses + +import pytest +from fastapi import Request + +import fastapi_cachex +from fastapi_cachex import CacheKey +from fastapi_cachex import build_cache_key +from fastapi_cachex.types import CACHE_KEY_SEPARATOR + +SEP = CACHE_KEY_SEPARATOR + + +def _request( + path: str = "/items", query: bytes = b"", host: str = "example.com" +) -> Request: + return Request( + { + "type": "http", + "method": "GET", + "path": path, + "raw_path": path.encode(), + "query_string": query, + "headers": [(b"host", host.encode())], + } + ) + + +def test_cache_key_is_exported() -> None: + assert "CacheKey" in fastapi_cachex.__all__ + assert fastapi_cachex.CacheKey is CacheKey + + +@pytest.mark.parametrize( + ("args", "sort_query"), + [ + ((), False), + (("tenant", 7), False), + (("a|b", "100%"), True), + ], +) +def test_from_request_is_what_build_cache_key_returns( + args: tuple[str | int, ...], sort_query: bool +) -> None: + request = _request("/p|q", b"b=2&a=1", "Host|||x") + + key = CacheKey.from_request(request, *args, sort_query=sort_query) + + assert key.to_str() == build_cache_key(request, *args, sort_query=sort_query) + assert key.extra == tuple(str(arg) for arg in args) + + +def test_from_request_sorts_the_query_only_when_asked() -> None: + request = _request(query=b"b=2&a=1") + + assert CacheKey.from_request(request).query == "b=2&a=1" + assert CacheKey.from_request(request, sort_query=True).query == "a=1&b=2" + + +def test_build_cache_key_format_is_pinned() -> None: + request = _request("/p|q", b"b=2&a=1", "Host|||x") + + assert build_cache_key(request, "a|b", 100, sort_query=True) == ( + "GET|||Host%7C%7C%7Cx|||/p%7Cq|||a=1&b=2|||a%7Cb|||100" + ) + + +def test_to_str_escapes_every_component_but_the_query() -> None: + key = CacheKey("G|T%", "evil|||host", "/p|||/100%", "q=1", ("a|||b", "%7C")) + + assert key.to_str() == SEP.join( + [ + "G%7CT%25", + "evil%7C%7C%7Chost", + "/p%7C%7C%7C/100%25", + "q=1", + "a%7C%7C%7Cb", + "%257C", + ] + ) + + +@pytest.mark.parametrize( + "key", + [ + CacheKey("GET", "example.com", "/items"), + CacheKey("GET", "evil|||host", "/p|||/100%", "q=1&r=%7C", ("a|||b", "", "3")), + CacheKey("GET", "[::1]:8000", "/", "", ("only-extra",)), + ], +) +def test_parse_reverses_to_str(key: CacheKey) -> None: + assert CacheKey.parse(key.to_str()) == key + + +def test_parse_reads_a_key_that_ends_at_the_path() -> None: + assert CacheKey.parse(f"GET{SEP}example.com{SEP}/items") == CacheKey( + "GET", "example.com", "/items", "" + ) + + +@pytest.mark.parametrize( + "key", + [ + "cache:user:1", + "oauth_state:abc", + f"GET{SEP}example.com", + f"{SEP}example.com{SEP}/items{SEP}", + ], +) +def test_parse_returns_none_for_keys_that_are_not_http_keys(key: str) -> None: + assert CacheKey.parse(key) is None + + +def test_query_cannot_contain_the_separator() -> None: + with pytest.raises(ValueError, match=r"CacheKey\.query cannot contain"): + CacheKey("GET", "h", "/", f"a{SEP}b") + + +def test_a_method_with_the_separator_is_escaped_not_rejected() -> None: + request = Request( + { + "type": "http", + "method": "A|||B", + "path": "/p", + "raw_path": b"/p", + "query_string": b"", + "headers": [(b"host", b"h")], + } + ) + + key = build_cache_key(request) + + assert key == SEP.join(["A%7C%7C%7CB", "h", "/p", ""]) + assert CacheKey.parse(key) == CacheKey("A|||B", "h", "/p") + + +def test_cache_key_is_frozen() -> None: + key = CacheKey("GET", "h", "/") + + with pytest.raises(dataclasses.FrozenInstanceError): + key.path = "/other" # type: ignore[misc] + + +def test_path_glob_matches_the_escaped_path_literally() -> None: + assert CacheKey.path_glob("/files/[draft]*?\\|x%") == ( + f"*{SEP}/files/\\[draft\\]\\*\\?\\\\%7Cx%25{SEP}*" + ) diff --git a/tests/test_cache_sort_query.py b/tests/test_cache_sort_query.py index 9db2496..610b077 100644 --- a/tests/test_cache_sort_query.py +++ b/tests/test_cache_sort_query.py @@ -13,9 +13,9 @@ from fastapi_cachex.cache import cache from fastapi_cachex.cache import default_key_builder from fastapi_cachex.cache import invalidate +from fastapi_cachex.cache_key import CacheKey from fastapi_cachex.exceptions import CacheXError from fastapi_cachex.proxy import BackendProxy -from fastapi_cachex.routes import _parse_cache_key def _request(query: bytes) -> StarletteRequest: @@ -30,8 +30,14 @@ def _request(query: bytes) -> StarletteRequest: ) +def _query(key: str) -> str: + parsed = CacheKey.parse(key) + assert parsed is not None + return parsed.query + + def _sorted_query(query: bytes) -> str: - return _parse_cache_key(build_cache_key(_request(query), sort_query=True))[3] + return _query(build_cache_key(_request(query), sort_query=True)) def _app(*, sort_query: bool, sync: bool = False) -> tuple[TestClient, list[str]]: @@ -105,9 +111,7 @@ def test_default_key_is_unchanged() -> None: for query in (b"b=2&a=1", b"tag=b&tag=a", b"q=a%20b&n%26=x%3D", b""): request = _request(query) assert default_key_builder(request) == build_cache_key(request) - assert _parse_cache_key(build_cache_key(request))[3] == str( - request.query_params - ) + assert _query(build_cache_key(request)) == str(request.query_params) def test_sorted_key_matches_the_default_for_a_sorted_query() -> None: diff --git a/tests/test_routes.py b/tests/test_routes.py index 935dd26..a9a98c2 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -679,11 +679,3 @@ def test_routes_answer_empty_when_no_backend_is_configured(self, app, client): assert hits["summary"]["cached_paths"] == [] assert records["total_records"] == 0 assert records["summary"]["estimated_cache_size_kb"] == 0.0 - - -def test_cache_key_max_parts_is_an_alias_of_max_split(): - """The renamed maxsplit constant keeps its former name importable.""" - from fastapi_cachex.routes import CACHE_KEY_MAX_PARTS - from fastapi_cachex.routes import CACHE_KEY_MAX_SPLIT - - assert CACHE_KEY_MAX_PARTS == CACHE_KEY_MAX_SPLIT == 3