From ef383bb53862dbf21063ba7cae68e377dee3d32d Mon Sep 17 00:00:00 2001 From: allen0099 Date: Sun, 27 Sep 2026 13:34:45 +0000 Subject: [PATCH] feat(cache): add build_cache_key() for custom key builders build_cache_key(request, *components) returns the default key unchanged and appends each str/int component, escaped with escape_key_component, after the query string. clear_path() on the memory and Redis backends matches such keys by their path and no longer counts the extra components as a query string; the monitoring routes report them in extra_components. The per-user example in HTTP_CACHING.md now uses it. --- changelog.d/264.added.md | 12 ++ docs/CACHE_FLOW.md | 7 +- docs/HTTP_CACHING.md | 46 ++++- docs/api/http-caching.md | 2 + fastapi_cachex/__init__.py | 2 + fastapi_cachex/backends/memory.py | 4 +- fastapi_cachex/backends/redis.py | 46 ++++- fastapi_cachex/cache.py | 77 ++++++-- fastapi_cachex/routes.py | 54 ++++-- i18n/zh-TW/docs/CACHE_FLOW.md | 4 +- i18n/zh-TW/docs/HTTP_CACHING.md | 34 +++- tests/backends/test_clear_pattern_contract.py | 22 +++ tests/test_build_cache_key.py | 175 ++++++++++++++++++ 13 files changed, 426 insertions(+), 59 deletions(-) create mode 100644 changelog.d/264.added.md create mode 100644 tests/test_build_cache_key.py diff --git a/changelog.d/264.added.md b/changelog.d/264.added.md new file mode 100644 index 0000000..3a88e1d --- /dev/null +++ b/changelog.d/264.added.md @@ -0,0 +1,12 @@ +**`build_cache_key(request, *components)` builds the default cache key plus +extra components.** A custom `key_builder` that adds a user ID, tenant or +locale no longer rebuilds `method|||host|||path|||query` by hand: with no +components the helper returns exactly the default key (existing entries keep +their keys), and each `str` or `int` component is appended after the query +string, percent-encoded like the host and path so it cannot inject the +separator. `clear_path()` on the memory and Redis backends still clears such +keys by path, and without `include_params` no longer mistakes the extra +components for a query string; the monitoring routes report them, decoded, in +a new `extra_components` field. The per-user example in HTTP_CACHING.md +("Authenticated endpoints") now uses it. `default_key_builder` stays and +returns `build_cache_key(request)`. diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index bb4b542..09e6b1e 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -58,7 +58,7 @@ When a request arrives, the `@cache` decorator does the following: from fastapi_cachex.types import CACHE_KEY_SEPARATOR # "|||" from fastapi_cachex.types import escape_key_component -# Cache key format (default_key_builder in fastapi_cachex/cache.py) +# Cache key format (build_cache_key in fastapi_cachex/cache.py) cache_key = CACHE_KEY_SEPARATOR.join( [ request.method, @@ -83,6 +83,11 @@ client, and a raw `|||` in either would shift the components so that one request's key could equal another's. The query string is URL-encoded already. The monitoring routes decode them again for display. +A custom `key_builder` can add components after the query string with +`build_cache_key(request, *components)`; they are encoded the same way, and +`clear_path()` still matches the path (see "Adding components to the key" in +[HTTP caching](HTTP_CACHING.md#adding-components-to-the-key)). + Query parameters are joined in the order the request sent them (`str(request.query_params)`) and are **not sorted**, so `?page=1&limit=10` and `?limit=10&page=1` are two separate cache entries. If you want them treated as diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index ef39ef3..19256c3 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -187,6 +187,40 @@ app.add_middleware( ) ``` +### Adding components to the key + +A custom `key_builder` that needs one more dimension (a user ID, a tenant, a +locale) should call `build_cache_key(request, *components)` rather than +rebuilding the format by hand. With no components it returns exactly the +default key; each component is appended after the query string: + +``` +{method}|||{host}|||{path}|||{query_params}|||{component}|||... +``` + +```python +from fastapi import Request + +from fastapi_cachex import build_cache_key + + +def per_tenant_key(request: Request) -> str: + return build_cache_key(request, request.state.tenant_id) +``` + +Components are `str` or `int` (an `int` is written in decimal, so `1` and `"1"` +are the same component); anything else, `None` included, raises `TypeError`, so +a missing ID cannot quietly put every such caller under one `"None"` key. Each +component is percent-encoded like the host and path, so a value containing +`|||` cannot shift the components. An empty string is still a component: +`build_cache_key(request, "")` is not the default key. + +Because the path stays the third component, `clear_path()` still finds these +keys: without `include_params` it clears every entry for the path with an empty +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)`. + 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 @@ -217,9 +251,8 @@ HTTP requests. ```python from fastapi import Request, Response +from fastapi_cachex import build_cache_key from fastapi_cachex import cache -from fastapi_cachex.types import CACHE_KEY_SEPARATOR -from fastapi_cachex.types import escape_key_component # 1. Keep it out of the shared cache entirely. @@ -235,13 +268,8 @@ def per_user_key(request: Request) -> str: # it has verified the caller — never read the identity straight off an # unverified request header (see the note below). user_id = getattr(request.state, "user_id", "anonymous") - return ( - f"{request.method}{CACHE_KEY_SEPARATOR}" - f"{escape_key_component(request.headers.get('host', 'unknown'))}" - f"{CACHE_KEY_SEPARATOR}" - f"{escape_key_component(request.url.path)}{CACHE_KEY_SEPARATOR}" - f"{request.query_params}{CACHE_KEY_SEPARATOR}{user_id}" - ) + # The default key plus the user ID, escaped like the host and path. + return build_cache_key(request, user_id) @app.get("/me/dashboard") diff --git a/docs/api/http-caching.md b/docs/api/http-caching.md index 33a6244..da2b41d 100644 --- a/docs/api/http-caching.md +++ b/docs/api/http-caching.md @@ -7,6 +7,8 @@ configured backend. ::: fastapi_cachex.cache.invalidate +::: fastapi_cachex.cache.build_cache_key + ::: fastapi_cachex.cache.default_key_builder ::: fastapi_cachex.proxy.BackendProxy diff --git a/fastapi_cachex/__init__.py b/fastapi_cachex/__init__.py index 7c20d7c..216f088 100644 --- a/fastapi_cachex/__init__.py +++ b/fastapi_cachex/__init__.py @@ -4,6 +4,7 @@ from importlib.metadata import PackageNotFoundError from importlib.metadata import version +from .cache import build_cache_key as build_cache_key from .cache import cache as cache from .cache import default_key_builder as default_key_builder from .cache import invalidate as invalidate @@ -107,6 +108,7 @@ def _read_version() -> str: "StateManagerProxy", "__version__", "add_routes", + "build_cache_key", "cache", "default_key_builder", "get_app_cache", diff --git a/fastapi_cachex/backends/memory.py b/fastapi_cachex/backends/memory.py index 3c2972a..3366244 100644 --- a/fastapi_cachex/backends/memory.py +++ b/fastapi_cachex/backends/memory.py @@ -31,8 +31,10 @@ def _split_http_key(key: str) -> tuple[str, bool] | 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, _QUERY_INDEX) + parts = key.split(CACHE_KEY_SEPARATOR) if len(parts) <= _PATH_INDEX: return None has_params = len(parts) > _QUERY_INDEX and bool(parts[_QUERY_INDEX]) diff --git a/fastapi_cachex/backends/redis.py b/fastapi_cachex/backends/redis.py index c15da04..76383e1 100644 --- a/fastapi_cachex/backends/redis.py +++ b/fastapi_cachex/backends/redis.py @@ -4,6 +4,7 @@ import logging import time import warnings +from collections.abc import Callable from collections.abc import Iterable from typing import TYPE_CHECKING from typing import Any @@ -32,6 +33,10 @@ _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 @@ -229,9 +234,14 @@ async def _scan_keys(self, pattern: str) -> list[str]: if cursor == 0: return list(keys) - async def _delete_matching(self, pattern: str) -> int: + async def _delete_matching( + self, pattern: str, keep: Callable[[str], bool] | None = None + ) -> int: """Delete every key matching ``pattern``, one SCAN page at a time. + With ``keep``, only the matching keys it returns ``True`` for (given + the full, prefixed key) are deleted. + Each page is deleted as it arrives, so the keyspace is never held in memory. Deleting keys SCAN already returned is safe: SCAN still returns every key present for the whole iteration. A key SCAN repeats is @@ -247,6 +257,8 @@ async def _delete_matching(self, pattern: str) -> int: cursor, page = await self.client.scan( cursor, match=pattern, count=_BATCH_SIZE ) + if keep is not None: + page = [key for key in page if keep(key)] if page: deleted += await self.client.delete(*page) if cursor == 0: @@ -392,17 +404,33 @@ async def clear_path(self, path: str, include_params: bool = False) -> int: Returns: Number of cache entries cleared """ - # Keys are method|||host|||path|||query. Without include_params only the - # exact path is matched: default_key_builder always appends a separator - # after the path, so keys with no query params end with "|||". 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. - suffix = "*" if include_params else "" + # 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(escape_key_component(path))}{CACHE_KEY_SEPARATOR}{suffix}" + f"{_escape_glob(key_path)}{CACHE_KEY_SEPARATOR}*" ) - cleared_count = await self._delete_matching(pattern) + + def matches(key: str) -> bool: + parts = key.removeprefix(self.key_prefix).split(CACHE_KEY_SEPARATOR) + return ( + len(parts) > _PATH_INDEX + and parts[_PATH_INDEX] == key_path + and ( + include_params + or len(parts) <= _QUERY_INDEX + or not parts[_QUERY_INDEX] + ) + ) + + cleared_count = await self._delete_matching(pattern, matches) # Also match direct keys (custom key formats without separators) # e.g. key_prefix + "gitlab:template" stored directly via backend.set(). diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index 0ad2aa4..167ff0a 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -56,33 +56,78 @@ _NO_STORE = DirectiveType.NO_STORE.value -def default_key_builder(request: Request) -> str: - """Default cache key builder function. +def build_cache_key(request: Request, *components: str | int) -> str: + """Build the default cache key for ``request``, plus extra components. - Generates cache key in format: method|||host|||path|||query_params + With no ``components`` the key is ``method|||host|||path|||query_params``, + exactly what ``@cache`` uses by default. Each extra component is appended + after another separator, so a custom ``key_builder`` can add a dimension + (user ID, tenant, locale) without rebuilding the default key by hand:: + + def per_user_key(request: Request) -> str: + return build_cache_key(request, request.state.user_id) - ``|`` and ``%`` in the host and path are percent-encoded (see - ``escape_key_component``), so a ``Host`` header or path containing - ``|||`` cannot make one request's key equal another's. The query string - is already URL-encoded and never contains ``|``. + ``|`` and ``%`` in the host, the path and every extra component are + percent-encoded (see ``escape_key_component``), so none of them can + contain the separator and make one request's key equal another's. The + query string is already URL-encoded and never contains ``|``. + + 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. Args: request: The FastAPI Request object + *components: Extra key components, appended in order. A ``str`` is + used as is and an ``int`` is written in decimal, so ``1`` and + ``"1"`` give the same key. An empty string is a component of its + own: ``build_cache_key(request, "")`` differs from + ``build_cache_key(request)``. Returns: Generated cache key string + + Raises: + TypeError: If a component is not a ``str`` or ``int`` (``bool`` is + rejected too), e.g. ``None`` from a missing user ID, which would + otherwise put every such caller under one ``"None"`` key. """ - key = ( - f"{request.method}{CACHE_KEY_SEPARATOR}" - f"{escape_key_component(request.headers.get('host', 'unknown'))}" - f"{CACHE_KEY_SEPARATOR}" - f"{escape_key_component(request.url.path)}{CACHE_KEY_SEPARATOR}" - f"{request.query_params}" - ) + parts = [ + request.method, + escape_key_component(request.headers.get("host", "unknown")), + escape_key_component(request.url.path), + str(request.query_params), + ] + 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))) + key = CACHE_KEY_SEPARATOR.join(parts) logger.debug("Built cache key: %s", key) return key +def default_key_builder(request: Request) -> str: + """Default cache key builder function: ``build_cache_key(request)``. + + Generates cache key in format: method|||host|||path|||query_params + + Kept as the name ``@cache`` and ``invalidate()`` fall back to. To add + components to the default key, call ``build_cache_key`` instead. + + Args: + request: The FastAPI Request object + + Returns: + Generated cache key string + """ + return build_cache_key(request) + + async def invalidate( request: Request, key_builder: CacheKeyBuilder | None = None, @@ -588,7 +633,9 @@ def cache( immutable: Send ``immutable``. must_revalidate: Send ``must-revalidate``. key_builder: Custom function to build cache keys. If None, uses - ``default_key_builder``. + ``default_key_builder``. To add a component (user ID, tenant, + locale) to the default key, return + ``build_cache_key(request, component)``. fail_open: When the backend raises, log a warning and answer without the cache: a failed read counts as a miss and a failed write leaves the response unstored. ``False`` lets the error propagate, diff --git a/fastapi_cachex/routes.py b/fastapi_cachex/routes.py index 3bddacd..e2be483 100644 --- a/fastapi_cachex/routes.py +++ b/fastapi_cachex/routes.py @@ -4,6 +4,7 @@ import warnings from collections.abc import Sequence from dataclasses import dataclass +from dataclasses import field from typing import TYPE_CHECKING from typing import Any @@ -18,8 +19,9 @@ # Constants CACHE_KEY_MIN_PARTS = 3 -# A ``maxsplit`` count for ``str.split``, not a number of parts: splitting at -# most three times keeps a query string containing the separator in one piece. +# 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 @@ -38,6 +40,7 @@ class CacheHitRecord: etag: str is_expired: bool ttl_remaining: float | None + extra_components: list[str] = field(default_factory=list) @dataclass @@ -70,7 +73,9 @@ class CachedRecord: ``media_type`` is the stored response's media type (``None`` when it had none). ``content_type`` is always ``"bytes"``, the type of the stored body, - and is kept for compatibility. + and is kept for compatibility. ``extra_components`` holds the components a + key builder appended after the query string (see ``build_cache_key``), + decoded. """ cache_key: str @@ -85,6 +90,7 @@ class CachedRecord: ttl_remaining: float | None content_preview: str | None media_type: str | None + extra_components: list[str] = field(default_factory=list) @dataclass @@ -108,6 +114,29 @@ 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. @@ -117,15 +146,7 @@ def _parse_cache_key(cache_key: str) -> tuple[str, str, str, str]: Returns: Tuple of (method, host, path, query_params) """ - key_parts = cache_key.split(CACHE_KEY_SEPARATOR, CACHE_KEY_MAX_SPLIT) - if len(key_parts) >= CACHE_KEY_MIN_PARTS: - method = key_parts[0] - host = unescape_key_component(key_parts[1]) - path = unescape_key_component(key_parts[2]) - query_params = key_parts[3] if len(key_parts) > CACHE_KEY_MIN_PARTS else "" - return method, host, path, query_params - - return "", "", "", "" + return _split_cache_key(cache_key)[:4] @dataclass @@ -137,6 +158,7 @@ class _Entry: host: str path: str query_params: str + extra_components: list[str] entry: CacheEntry is_expired: bool ttl_remaining: float | None @@ -149,7 +171,7 @@ def _parse_entries( now = time.time() entries: list[_Entry] = [] for cache_key, (entry, expiry) in cache_data.items(): - method, host, path, query_params = _parse_cache_key(cache_key) + method, host, path, query_params, extra = _split_cache_key(cache_key) if not method: continue entries.append( @@ -159,6 +181,7 @@ def _parse_entries( host=host, path=path, query_params=query_params, + extra_components=extra, entry=entry, is_expired=expiry is not None and expiry <= now, ttl_remaining=( @@ -189,6 +212,7 @@ def _cached_hits(entries: list[_Entry]) -> CacheHitsResponse: etag=e.entry.fingerprint, is_expired=e.is_expired, ttl_remaining=e.ttl_remaining, + extra_components=e.extra_components, ) for e in entries ] @@ -230,6 +254,7 @@ def _cached_records( else None ), media_type=e.entry.media_type, + extra_components=e.extra_components, ) for e in entries ] @@ -316,7 +341,8 @@ def add_routes( async def get_cached_hits() -> CacheHitsResponse: """List the cached route entries. - Splits every cached key into method, host, path and query, with its + Splits every cached key into method, host, path, query and any + extra components a key builder appended, with its ETag and expiry, plus counts of valid and expired entries and the distinct cached paths. Cache hits are not counted. diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index 97e843c..6c3ee23 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -55,7 +55,7 @@ handler 自己送出的 private/no-store Cache-Control 永遠不會被取代 from fastapi_cachex.types import CACHE_KEY_SEPARATOR # "|||" from fastapi_cachex.types import escape_key_component -# 快取鍵格式(fastapi_cachex/cache.py 中的 default_key_builder) +# 快取鍵格式(fastapi_cachex/cache.py 中的 build_cache_key) cache_key = CACHE_KEY_SEPARATOR.join( [ request.method, @@ -74,6 +74,8 @@ cache_key = CACHE_KEY_SEPARATOR.join( host 與路徑會先經過百分比編碼:`|` 變成 `%7C`,`%` 變成 `%25`(`fastapi_cachex/types.py` 中的 `escape_key_component`)。兩者都來自用戶端,其中若出現未編碼的 `|||`,各段就會錯位,使某個請求的快取鍵可能與另一個請求相同。查詢字串本來就經過 URL 編碼。監控路由顯示時會再解碼。 +自訂的 `key_builder` 可以用 `build_cache_key(request, *components)` 在查詢字串之後加入其他段;這些段以同樣方式編碼,`clear_path()` 也仍會比對路徑(見 [HTTP 快取](HTTP_CACHING.md#adding-components-to-the-key)中的「在鍵中加入其他段」)。 + 查詢參數依請求送出的順序串接(`str(request.query_params)`),**不會排序**,因此 `?page=1&limit=10` 與 `?limit=10&page=1` 是兩個不同的快取項目。若希望兩者視為同一個,請傳入自訂的 `key_builder` 將查詢字串正規化。 這個快取鍵格式讓每個維度各自獨立快取: diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 5df9b97..8a656b6 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -126,6 +126,28 @@ app.add_middleware( ) ``` +### 在鍵中加入其他段 {#adding-components-to-the-key} + +需要多一個維度(使用者 ID、租戶、語系)的自訂 `key_builder`,應呼叫 `build_cache_key(request, *components)`,而不是自行重組格式。不傳入任何段時,它回傳的正是預設的鍵;每個段會附加在查詢字串之後: + +``` +{method}|||{host}|||{path}|||{query_params}|||{component}|||... +``` + +```python +from fastapi import Request + +from fastapi_cachex import build_cache_key + + +def per_tenant_key(request: Request) -> str: + return build_cache_key(request, request.state.tenant_id) +``` + +段必須是 `str` 或 `int`(`int` 以十進位寫入,因此 `1` 與 `"1"` 是同一個段);其他型別,包括 `None`,都會引發 `TypeError`,避免缺少的 ID 悄悄讓所有這類呼叫者共用同一個 `"None"` 鍵。每個段都與 host 和路徑一樣以百分比編碼,因此含有 `|||` 的值無法讓各段錯位。空字串仍是一個段:`build_cache_key(request, "")` 不等於預設的鍵。 + +由於路徑仍是第三段,`clear_path()` 依然找得到這些鍵:不帶 `include_params` 時,會清除該路徑下查詢字串為空的所有項目,不論其他段為何;帶上它則清除該路徑的所有項目。監控路由會把其他段解碼後列在 `extra_components` 中。`default_key_builder(request)` 就是 `build_cache_key(request)`。 + Redis 與 Memcached 後端還會在每個鍵前面加上自己的前綴(預設為 `fastapi_cachex:`),讓其他應用程式可以共用同一台伺服器;`MemoryBackend` 沒有前綴。`CacheManager`(見[應用層快取](APP_CACHE.md))則使用另一個較簡單、以 `cache:` 為前綴的鍵命名空間,而不是這種以 `|||` 分隔的格式,因為它的鍵與 HTTP 請求無關。 ### 需驗證身分的端點 {#authenticated-endpoints} @@ -141,9 +163,8 @@ Redis 與 Memcached 後端還會在每個鍵前面加上自己的前綴(預設 ```python from fastapi import Request, Response +from fastapi_cachex import build_cache_key from fastapi_cachex import cache -from fastapi_cachex.types import CACHE_KEY_SEPARATOR -from fastapi_cachex.types import escape_key_component # 1. 完全不放進共用快取。 @@ -158,13 +179,8 @@ def per_user_key(request: Request) -> str: # `request.state.user_id` 由你的驗證層在確認呼叫者身分後填入; # 絕對不要直接從未經驗證的請求標頭讀取身分(見下方說明)。 user_id = getattr(request.state, "user_id", "anonymous") - return ( - f"{request.method}{CACHE_KEY_SEPARATOR}" - f"{escape_key_component(request.headers.get('host', 'unknown'))}" - f"{CACHE_KEY_SEPARATOR}" - f"{escape_key_component(request.url.path)}{CACHE_KEY_SEPARATOR}" - f"{request.query_params}{CACHE_KEY_SEPARATOR}{user_id}" - ) + # 預設的鍵再加上使用者 ID,並與 host、路徑一樣經過編碼。 + return build_cache_key(request, user_id) @app.get("/me/dashboard") diff --git a/tests/backends/test_clear_pattern_contract.py b/tests/backends/test_clear_pattern_contract.py index e41c29d..0bd4198 100644 --- a/tests/backends/test_clear_pattern_contract.py +++ b/tests/backends/test_clear_pattern_contract.py @@ -158,3 +158,25 @@ async def test_clear_path_finds_paths_with_encoded_characters( assert await backend.clear_path("/a|b/100%") == 1 assert await backend.clear_path("/a|b/100%", include_params=True) == 1 assert await backend.get_all_keys() == ["GET|||h|||/a|||b/100%25|||"] + + +@pytest.mark.parametrize("backend", ["memory", "redis"], indirect=True) +async def test_clear_path_finds_keys_with_extra_components( + backend: BaseCacheBackend, +) -> None: + """Keys from ``build_cache_key(request, ...)`` are cleared by their path (#264).""" + entry = CacheEntry(fingerprint="etag", content=b"x") + await backend.set("GET|||h|||/me|||", entry) + await backend.set("GET|||h|||/me|||||||user-1", entry) + await backend.set("GET|||h|||/me|||||||user-1|||de", entry) + await backend.set("GET|||h|||/me|||page=2|||user-1", entry) + # The path appears elsewhere in these keys, but not as the path. + await backend.set("GET|||h|||/other|||||||/me|||", entry) + await backend.set("GET|||/me|||/other|||", entry) + + assert await backend.clear_path("/me") == 3 + assert await backend.clear_path("/me", include_params=True) == 1 + assert sorted(await backend.get_all_keys()) == [ + "GET|||/me|||/other|||", + "GET|||h|||/other|||||||/me|||", + ] diff --git a/tests/test_build_cache_key.py b/tests/test_build_cache_key.py new file mode 100644 index 0000000..c74a86c --- /dev/null +++ b/tests/test_build_cache_key.py @@ -0,0 +1,175 @@ +"""``build_cache_key`` builds the default key plus escaped extra components (#264).""" + +from collections.abc import Iterator + +import pytest +from fastapi import FastAPI +from fastapi import Request +from fastapi.testclient import TestClient + +import fastapi_cachex +from fastapi_cachex import add_routes +from fastapi_cachex import build_cache_key +from fastapi_cachex import cache +from fastapi_cachex.backends import MemoryBackend +from fastapi_cachex.cache import default_key_builder +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 + + +def _request( + path: str = "/items", + query: bytes = b"", + host: str | None = "example.com", + method: str = "GET", +) -> Request: + headers = [] if host is None else [(b"host", host.encode())] + return Request( + { + "type": "http", + "method": method, + "path": path, + "raw_path": path.encode(), + "query_string": query, + "headers": headers, + } + ) + + +def _key_before_264(request: Request) -> str: + """``default_key_builder`` as it was written before ``build_cache_key``.""" + return ( + f"{request.method}{CACHE_KEY_SEPARATOR}" + f"{escape_key_component(request.headers.get('host', 'unknown'))}" + f"{CACHE_KEY_SEPARATOR}" + f"{escape_key_component(request.url.path)}{CACHE_KEY_SEPARATOR}" + f"{request.query_params}" + ) + + +REQUESTS = [ + pytest.param({}, id="plain"), + pytest.param({"query": b"b=2&a=1&a=3"}, id="query"), + pytest.param({"host": "evil|||host:8000", "path": "/a|||b/100%"}, id="escaped"), + pytest.param({"host": None}, id="no-host"), + pytest.param({"method": "HEAD", "query": b"q=%7C%7C%7C"}, id="head"), +] + + +@pytest.mark.parametrize("kwargs", REQUESTS) +def test_without_components_the_key_is_unchanged(kwargs: dict[str, object]) -> None: + request = _request(**kwargs) # type: ignore[arg-type] + + assert build_cache_key(request) == _key_before_264(request) + assert default_key_builder(request) == build_cache_key(request) + + +def test_default_key_is_pinned() -> None: + request = _request(path="/a|b", query=b"x=1", host="h:1") + + assert build_cache_key(request) == "GET|||h:1|||/a%7Cb|||x=1" + + +def test_components_are_appended_after_the_query() -> None: + request = _request(query=b"x=1") + + assert build_cache_key(request, "user-1", 42) == ( + "GET|||example.com|||/items|||x=1|||user-1|||42" + ) + + +def test_int_and_str_components_are_the_same() -> None: + request = _request() + + assert build_cache_key(request, 7) == build_cache_key(request, "7") + + +def test_empty_component_is_a_component() -> None: + request = _request() + + assert build_cache_key(request, "") == build_cache_key(request) + "|||" + assert build_cache_key(request, "") != build_cache_key(request) + + +def test_components_cannot_inject_the_separator() -> None: + """A component containing ``|||`` cannot line up with another's key.""" + request = _request() + + injected = build_cache_key(request, "a|||b") + assert injected == build_cache_key(request) + "|||a%7C%7C%7Cb" + assert injected != build_cache_key(request, "a", "b") + assert build_cache_key(request, "100%7C") != build_cache_key(request, "100|") + + +@pytest.mark.parametrize("bad", [None, True, 1.5, b"x", ["x"]]) +def test_other_component_types_are_rejected(bad: object) -> None: + with pytest.raises(TypeError, match="must be str or int"): + build_cache_key(_request(), bad) # type: ignore[arg-type] + + +def test_build_cache_key_is_exported() -> None: + assert "build_cache_key" in fastapi_cachex.__all__ + assert fastapi_cachex.build_cache_key is build_cache_key + + +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"]) + + +@pytest.fixture +def backend() -> Iterator[MemoryBackend]: + backend = MemoryBackend() + BackendProxy.set(backend) + yield backend + BackendProxy.set(None) + + +def _per_user_app() -> FastAPI: + app = FastAPI() + + def per_user_key(request: Request) -> str: + return build_cache_key(request, request.headers.get("x-test-user", "anon")) + + @app.get("/me") + @cache(ttl=60, key_builder=per_user_key) + async def me(request: Request) -> dict[str, str]: + return {"user": request.headers.get("x-test-user", "anon")} + + add_routes(app, prefix="/cache", dependencies=[]) + return app + + +async def test_per_user_entries_are_separate_and_cleared_by_path( + backend: MemoryBackend, +) -> None: + client = TestClient(_per_user_app()) + + assert client.get("/me", headers={"x-test-user": "a"}).json() == {"user": "a"} + assert client.get("/me", headers={"x-test-user": "b"}).json() == {"user": "b"} + client.get("/me", params={"page": "2"}, headers={"x-test-user": "a"}) + assert len(backend.cache) == 3 + + # Only the two entries without a query string. + assert await backend.clear_path("/me") == 2 + assert await backend.clear_path("/me", include_params=True) == 1 + assert backend.cache == {} + + +def test_monitoring_routes_show_extra_components(backend: MemoryBackend) -> None: + client = TestClient(_per_user_app()) + client.get("/me", params={"page": "2"}, headers={"x-test-user": "a|b"}) + + [hit] = client.get("/cache/cached-hits").json()["cached_hits"] + [record] = client.get("/cache/cached-records").json()["cached_records"] + + for item in (hit, record): + assert item["path"] == "/me" + assert item["query_params"] == "page=2" + assert item["extra_components"] == ["a|b"]