fastapi-cachex 0.2.12__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/PKG-INFO +41 -1
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/README.md +40 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/__init__.py +8 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/base.py +3 -3
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/memcached.py +14 -25
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/memory.py +5 -5
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/redis.py +21 -21
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/cache.py +24 -17
- fastapi_cachex-0.3.0/fastapi_cachex/dependencies.py +37 -0
- fastapi_cachex-0.3.0/fastapi_cachex/manager.py +147 -0
- fastapi_cachex-0.3.0/fastapi_cachex/manager_proxy.py +8 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/routes.py +5 -5
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/manager.py +30 -13
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/middleware.py +6 -1
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/security.py +2 -3
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/token_serializers.py +1 -1
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/state/manager.py +18 -20
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/types.py +5 -5
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/pyproject.toml +2 -2
- fastapi_cachex-0.2.12/fastapi_cachex/dependencies.py +0 -16
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/config.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/proxy.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/__init__.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/config.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/dependencies.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/exceptions.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/models.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/proxy.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/state/__init__.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/state/exceptions.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/state/models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-cachex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends.
|
|
5
5
|
Keywords: fastapi,cache,etag,cache-control,redis,memcached,in-memory
|
|
6
6
|
Author: allen0099
|
|
@@ -146,6 +146,44 @@ async def remove_cache(cache: CacheBackend):
|
|
|
146
146
|
await cache.clear_pattern("/path/to/clear/*") # Clear cache for a specific pattern
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
+
### Application-Level Caching (Manual Get/Set)
|
|
150
|
+
|
|
151
|
+
Beyond HTTP response caching via `@cache`, you can cache arbitrary JSON-serializable
|
|
152
|
+
Python values directly in your business logic using `CacheManager`. It's a thin,
|
|
153
|
+
namespaced wrapper around whichever backend is configured via `BackendProxy`.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from fastapi_cachex import AppCache, CacheManager
|
|
157
|
+
|
|
158
|
+
@app.get("/expensive")
|
|
159
|
+
async def expensive_operation(cache: AppCache):
|
|
160
|
+
result = await cache.get("expensive:result")
|
|
161
|
+
if result is None:
|
|
162
|
+
result = perform_expensive_calculation()
|
|
163
|
+
await cache.set("expensive:result", result, ttl=300)
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# Or instantiate directly, e.g. outside of a request:
|
|
168
|
+
manager = CacheManager(key_prefix="myapp:", default_ttl=60)
|
|
169
|
+
await manager.set("user:42", {"name": "Alice"})
|
|
170
|
+
user = await manager.get("user:42") # {"name": "Alice"}
|
|
171
|
+
await manager.delete("user:42")
|
|
172
|
+
await manager.clear_prefix() # clear everything under "myapp:"
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`CacheManager.get()` returns `None` (or a supplied `default=`) on a cache miss —
|
|
176
|
+
it never raises for missing or corrupted entries. `CacheManager` keys live under
|
|
177
|
+
their own `cache:`-prefixed namespace by default, separate from the HTTP route
|
|
178
|
+
cache and OAuth state, so `clear()`/`clear_prefix()` never touch unrelated cache
|
|
179
|
+
entries.
|
|
180
|
+
|
|
181
|
+
**Note**: `clear()`/`clear_prefix()` are implemented via the backend's
|
|
182
|
+
`get_all_keys()`. Since Memcached doesn't support key enumeration (see
|
|
183
|
+
[Memcached limitations](#memcached)), these two methods are no-ops on a
|
|
184
|
+
Memcached backend — `get()`/`set()`/`delete()`/`has()` work normally. Use
|
|
185
|
+
Redis or the in-memory backend if you need bulk clearing.
|
|
186
|
+
|
|
149
187
|
## Backend Configuration
|
|
150
188
|
|
|
151
189
|
FastAPI-CacheX supports multiple caching backends. You can easily switch between them using the `BackendProxy`.
|
|
@@ -166,6 +204,8 @@ This ensures that:
|
|
|
166
204
|
|
|
167
205
|
All backends automatically namespace keys with a prefix (e.g., `fastapi_cachex:`) to avoid conflicts with other applications.
|
|
168
206
|
|
|
207
|
+
`CacheManager` (see [Application-Level Caching](#application-level-caching-manual-getset)) uses a separate, simpler `cache:`-prefixed key namespace instead of this `|||`-separated format, since its keys aren't tied to HTTP requests.
|
|
208
|
+
|
|
169
209
|
### Cache Hit Behavior
|
|
170
210
|
|
|
171
211
|
When a cached entry is valid (within TTL):
|
|
@@ -110,6 +110,44 @@ async def remove_cache(cache: CacheBackend):
|
|
|
110
110
|
await cache.clear_pattern("/path/to/clear/*") # Clear cache for a specific pattern
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
+
### Application-Level Caching (Manual Get/Set)
|
|
114
|
+
|
|
115
|
+
Beyond HTTP response caching via `@cache`, you can cache arbitrary JSON-serializable
|
|
116
|
+
Python values directly in your business logic using `CacheManager`. It's a thin,
|
|
117
|
+
namespaced wrapper around whichever backend is configured via `BackendProxy`.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from fastapi_cachex import AppCache, CacheManager
|
|
121
|
+
|
|
122
|
+
@app.get("/expensive")
|
|
123
|
+
async def expensive_operation(cache: AppCache):
|
|
124
|
+
result = await cache.get("expensive:result")
|
|
125
|
+
if result is None:
|
|
126
|
+
result = perform_expensive_calculation()
|
|
127
|
+
await cache.set("expensive:result", result, ttl=300)
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Or instantiate directly, e.g. outside of a request:
|
|
132
|
+
manager = CacheManager(key_prefix="myapp:", default_ttl=60)
|
|
133
|
+
await manager.set("user:42", {"name": "Alice"})
|
|
134
|
+
user = await manager.get("user:42") # {"name": "Alice"}
|
|
135
|
+
await manager.delete("user:42")
|
|
136
|
+
await manager.clear_prefix() # clear everything under "myapp:"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`CacheManager.get()` returns `None` (or a supplied `default=`) on a cache miss —
|
|
140
|
+
it never raises for missing or corrupted entries. `CacheManager` keys live under
|
|
141
|
+
their own `cache:`-prefixed namespace by default, separate from the HTTP route
|
|
142
|
+
cache and OAuth state, so `clear()`/`clear_prefix()` never touch unrelated cache
|
|
143
|
+
entries.
|
|
144
|
+
|
|
145
|
+
**Note**: `clear()`/`clear_prefix()` are implemented via the backend's
|
|
146
|
+
`get_all_keys()`. Since Memcached doesn't support key enumeration (see
|
|
147
|
+
[Memcached limitations](#memcached)), these two methods are no-ops on a
|
|
148
|
+
Memcached backend — `get()`/`set()`/`delete()`/`has()` work normally. Use
|
|
149
|
+
Redis or the in-memory backend if you need bulk clearing.
|
|
150
|
+
|
|
113
151
|
## Backend Configuration
|
|
114
152
|
|
|
115
153
|
FastAPI-CacheX supports multiple caching backends. You can easily switch between them using the `BackendProxy`.
|
|
@@ -130,6 +168,8 @@ This ensures that:
|
|
|
130
168
|
|
|
131
169
|
All backends automatically namespace keys with a prefix (e.g., `fastapi_cachex:`) to avoid conflicts with other applications.
|
|
132
170
|
|
|
171
|
+
`CacheManager` (see [Application-Level Caching](#application-level-caching-manual-getset)) uses a separate, simpler `cache:`-prefixed key namespace instead of this `|||`-separated format, since its keys aren't tied to HTTP requests.
|
|
172
|
+
|
|
133
173
|
### Cache Hit Behavior
|
|
134
174
|
|
|
135
175
|
When a cached entry is valid (within TTL):
|
|
@@ -4,8 +4,12 @@ import logging
|
|
|
4
4
|
|
|
5
5
|
from .cache import cache as cache
|
|
6
6
|
from .cache import default_key_builder as default_key_builder
|
|
7
|
+
from .dependencies import AppCache as AppCache
|
|
7
8
|
from .dependencies import CacheBackend as CacheBackend
|
|
9
|
+
from .dependencies import get_app_cache as get_app_cache
|
|
8
10
|
from .dependencies import get_cache_backend as get_cache_backend
|
|
11
|
+
from .manager import CacheManager as CacheManager
|
|
12
|
+
from .manager_proxy import CacheManagerProxy as CacheManagerProxy
|
|
9
13
|
from .proxy import BackendProxy as BackendProxy
|
|
10
14
|
from .routes import add_routes as add_routes
|
|
11
15
|
from .session import Session as Session
|
|
@@ -38,9 +42,12 @@ _package_logger.addHandler(
|
|
|
38
42
|
) # Attach a NullHandler to avoid "No handler found" warnings in user applications.
|
|
39
43
|
|
|
40
44
|
__all__ = [
|
|
45
|
+
"AppCache",
|
|
41
46
|
"BackendProxy",
|
|
42
47
|
"CacheBackend",
|
|
43
48
|
"CacheKeyBuilder",
|
|
49
|
+
"CacheManager",
|
|
50
|
+
"CacheManagerProxy",
|
|
44
51
|
"InvalidStateError",
|
|
45
52
|
"Session",
|
|
46
53
|
"SessionConfig",
|
|
@@ -62,6 +69,7 @@ __all__ = [
|
|
|
62
69
|
"add_routes",
|
|
63
70
|
"cache",
|
|
64
71
|
"default_key_builder",
|
|
72
|
+
"get_app_cache",
|
|
65
73
|
"get_cache_backend",
|
|
66
74
|
"get_optional_session",
|
|
67
75
|
"get_session",
|
|
@@ -4,18 +4,18 @@ from abc import ABC
|
|
|
4
4
|
from abc import abstractmethod
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
-
from fastapi_cachex.types import
|
|
7
|
+
from fastapi_cachex.types import CacheEntry
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class BaseCacheBackend(ABC):
|
|
11
11
|
"""Base class for all cache backends."""
|
|
12
12
|
|
|
13
13
|
@abstractmethod
|
|
14
|
-
async def get(self, key: str) ->
|
|
14
|
+
async def get(self, key: str) -> CacheEntry | None:
|
|
15
15
|
"""Retrieve a cached response."""
|
|
16
16
|
|
|
17
17
|
@abstractmethod
|
|
18
|
-
async def set(self, key: str, value:
|
|
18
|
+
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
|
|
19
19
|
"""Store a response in the cache."""
|
|
20
20
|
|
|
21
21
|
@abstractmethod
|
|
@@ -5,7 +5,7 @@ import logging
|
|
|
5
5
|
import warnings
|
|
6
6
|
|
|
7
7
|
from fastapi_cachex.exceptions import CacheXError
|
|
8
|
-
from fastapi_cachex.types import
|
|
8
|
+
from fastapi_cachex.types import CacheEntry
|
|
9
9
|
|
|
10
10
|
from .base import BaseCacheBackend
|
|
11
11
|
|
|
@@ -63,18 +63,17 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
63
63
|
"""Add prefix to cache key."""
|
|
64
64
|
return f"{self.key_prefix}{key}"
|
|
65
65
|
|
|
66
|
-
async def get(self, key: str) ->
|
|
66
|
+
async def get(self, key: str) -> CacheEntry | None:
|
|
67
67
|
"""Get value from cache.
|
|
68
68
|
|
|
69
69
|
Args:
|
|
70
70
|
key: Cache key to retrieve
|
|
71
71
|
|
|
72
72
|
Returns:
|
|
73
|
-
|
|
73
|
+
Cached entry if found, None otherwise
|
|
74
74
|
"""
|
|
75
75
|
prefixed_key = self._make_key(key)
|
|
76
|
-
|
|
77
|
-
value = await loop.run_in_executor(None, self.client.get, prefixed_key)
|
|
76
|
+
value = await asyncio.to_thread(self.client.get, prefixed_key)
|
|
78
77
|
if value is None:
|
|
79
78
|
logger.debug("Memcached MISS; key=%s", key)
|
|
80
79
|
return None
|
|
@@ -83,8 +82,8 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
83
82
|
try:
|
|
84
83
|
data = json.loads(value)
|
|
85
84
|
logger.debug("Memcached HIT; key=%s", key)
|
|
86
|
-
return
|
|
87
|
-
|
|
85
|
+
return CacheEntry(
|
|
86
|
+
fingerprint=data["fingerprint"],
|
|
88
87
|
content=data["content"].encode("latin-1"),
|
|
89
88
|
media_type=data.get("media_type"),
|
|
90
89
|
)
|
|
@@ -92,12 +91,12 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
92
91
|
logger.debug("Memcached DESERIALIZE ERROR; key=%s", key)
|
|
93
92
|
return None
|
|
94
93
|
|
|
95
|
-
async def set(self, key: str, value:
|
|
94
|
+
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
|
|
96
95
|
"""Set value in cache.
|
|
97
96
|
|
|
98
97
|
Args:
|
|
99
98
|
key: Cache key
|
|
100
|
-
value:
|
|
99
|
+
value: CacheEntry instance to store
|
|
101
100
|
ttl: Time to live in seconds
|
|
102
101
|
"""
|
|
103
102
|
prefixed_key = self._make_key(key)
|
|
@@ -107,7 +106,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
107
106
|
|
|
108
107
|
serialized_data: str | bytes = json.dumps(
|
|
109
108
|
{
|
|
110
|
-
"
|
|
109
|
+
"fingerprint": value.fingerprint,
|
|
111
110
|
"content": content,
|
|
112
111
|
"media_type": value.media_type,
|
|
113
112
|
},
|
|
@@ -121,11 +120,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
121
120
|
)
|
|
122
121
|
|
|
123
122
|
expire = ttl if ttl is not None else 0
|
|
124
|
-
|
|
125
|
-
await loop.run_in_executor(
|
|
126
|
-
None,
|
|
127
|
-
lambda: self.client.set(prefixed_key, serialized_bytes, expire=expire),
|
|
128
|
-
)
|
|
123
|
+
await asyncio.to_thread(self.client.set, prefixed_key, serialized_bytes, expire)
|
|
129
124
|
logger.debug("Memcached SET; key=%s ttl=%s", key, ttl)
|
|
130
125
|
|
|
131
126
|
async def delete(self, key: str) -> None:
|
|
@@ -135,8 +130,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
135
130
|
key: Cache key to delete
|
|
136
131
|
"""
|
|
137
132
|
prefixed = self._make_key(key)
|
|
138
|
-
|
|
139
|
-
await loop.run_in_executor(None, self.client.delete, prefixed)
|
|
133
|
+
await asyncio.to_thread(self.client.delete, prefixed)
|
|
140
134
|
logger.debug("Memcached DELETE; key=%s", key)
|
|
141
135
|
|
|
142
136
|
async def clear(self) -> None:
|
|
@@ -152,8 +146,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
152
146
|
RuntimeWarning,
|
|
153
147
|
stacklevel=2,
|
|
154
148
|
)
|
|
155
|
-
|
|
156
|
-
await loop.run_in_executor(None, self.client.flush_all)
|
|
149
|
+
await asyncio.to_thread(self.client.flush_all)
|
|
157
150
|
logger.debug("Memcached CLEAR; flush_all issued")
|
|
158
151
|
|
|
159
152
|
async def clear_path(self, path: str, include_params: bool = False) -> int:
|
|
@@ -183,12 +176,8 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
183
176
|
|
|
184
177
|
# Try to delete the prefixed key (exact match only)
|
|
185
178
|
prefixed_key = self._make_key(path)
|
|
186
|
-
loop = asyncio.get_running_loop()
|
|
187
179
|
try:
|
|
188
|
-
result = await
|
|
189
|
-
None,
|
|
190
|
-
lambda: self.client.delete(prefixed_key, noreply=False),
|
|
191
|
-
)
|
|
180
|
+
result = await asyncio.to_thread(self.client.delete, prefixed_key, False)
|
|
192
181
|
except Exception: # noqa: BLE001
|
|
193
182
|
return 0
|
|
194
183
|
else:
|
|
@@ -245,7 +234,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
245
234
|
logger.debug("Memcached GET_ALL_KEYS unsupported; returning empty list")
|
|
246
235
|
return []
|
|
247
236
|
|
|
248
|
-
async def get_cache_data(self) -> dict[str, tuple[
|
|
237
|
+
async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
|
|
249
238
|
"""Get all cache data with expiry information.
|
|
250
239
|
|
|
251
240
|
Note: Memcached does not support key enumeration or pattern matching.
|
|
@@ -6,8 +6,8 @@ import logging
|
|
|
6
6
|
import time
|
|
7
7
|
|
|
8
8
|
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
9
|
+
from fastapi_cachex.types import CacheEntry
|
|
9
10
|
from fastapi_cachex.types import CacheItem
|
|
10
|
-
from fastapi_cachex.types import ETagContent
|
|
11
11
|
|
|
12
12
|
from .base import BaseCacheBackend
|
|
13
13
|
|
|
@@ -68,7 +68,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
68
68
|
self._cleanup_task = None
|
|
69
69
|
logger.debug("Stopped memory backend cleanup task")
|
|
70
70
|
|
|
71
|
-
async def get(self, key: str) ->
|
|
71
|
+
async def get(self, key: str) -> CacheEntry | None:
|
|
72
72
|
"""Retrieve a cached response.
|
|
73
73
|
|
|
74
74
|
Expired entries are skipped and return None.
|
|
@@ -89,7 +89,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
89
89
|
logger.debug("Memory cache MISS; key=%s", key)
|
|
90
90
|
return None
|
|
91
91
|
|
|
92
|
-
async def set(self, key: str, value:
|
|
92
|
+
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
|
|
93
93
|
"""Store a response in the cache.
|
|
94
94
|
|
|
95
95
|
Args:
|
|
@@ -201,11 +201,11 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
201
201
|
async with self.lock:
|
|
202
202
|
return list(self.cache.keys())
|
|
203
203
|
|
|
204
|
-
async def get_cache_data(self) -> dict[str, tuple[
|
|
204
|
+
async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
|
|
205
205
|
"""Get all cache data with expiry information.
|
|
206
206
|
|
|
207
207
|
Returns:
|
|
208
|
-
Dictionary mapping cache keys to (
|
|
208
|
+
Dictionary mapping cache keys to (CacheEntry, expiry) tuples
|
|
209
209
|
"""
|
|
210
210
|
async with self.lock:
|
|
211
211
|
return {key: (item.value, item.expiry) for key, item in self.cache.items()}
|
|
@@ -11,7 +11,7 @@ from fastapi_cachex.backends.config import (
|
|
|
11
11
|
from fastapi_cachex.backends.config import RedisConfig
|
|
12
12
|
from fastapi_cachex.exceptions import CacheXError
|
|
13
13
|
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
14
|
-
from fastapi_cachex.types import
|
|
14
|
+
from fastapi_cachex.types import CacheEntry
|
|
15
15
|
|
|
16
16
|
from .base import BaseCacheBackend
|
|
17
17
|
|
|
@@ -125,14 +125,14 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
125
125
|
"""Add prefix to cache key."""
|
|
126
126
|
return f"{self.key_prefix}{key}"
|
|
127
127
|
|
|
128
|
-
def _serialize(self, value:
|
|
129
|
-
"""Serialize
|
|
128
|
+
def _serialize(self, value: CacheEntry) -> str:
|
|
129
|
+
"""Serialize CacheEntry to JSON string."""
|
|
130
130
|
# Use latin-1 to round-trip arbitrary bytes through JSON/UTF-8 Redis storage
|
|
131
131
|
content = value.content.decode("latin-1")
|
|
132
132
|
|
|
133
133
|
serialized: str | bytes = json.dumps(
|
|
134
134
|
{
|
|
135
|
-
"
|
|
135
|
+
"fingerprint": value.fingerprint,
|
|
136
136
|
"content": content,
|
|
137
137
|
"media_type": value.media_type,
|
|
138
138
|
},
|
|
@@ -141,8 +141,8 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
141
141
|
# orjson returns bytes, stdlib json returns str
|
|
142
142
|
return serialized.decode() if isinstance(serialized, bytes) else serialized
|
|
143
143
|
|
|
144
|
-
def _deserialize(self, value: str | None) ->
|
|
145
|
-
"""Deserialize JSON string to
|
|
144
|
+
def _deserialize(self, value: str | None) -> CacheEntry | None:
|
|
145
|
+
"""Deserialize JSON string to CacheEntry.
|
|
146
146
|
|
|
147
147
|
Converts string content back to bytes to maintain consistency with
|
|
148
148
|
other backends and standard Response.body type (bytes).
|
|
@@ -152,22 +152,22 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
152
152
|
try:
|
|
153
153
|
data = json.loads(value)
|
|
154
154
|
logger.debug("Content type in JSON: %s", type(data["content"]))
|
|
155
|
-
return
|
|
156
|
-
|
|
155
|
+
return CacheEntry(
|
|
156
|
+
fingerprint=data["fingerprint"],
|
|
157
157
|
content=data["content"].encode("latin-1"),
|
|
158
158
|
media_type=data.get("media_type"),
|
|
159
159
|
)
|
|
160
160
|
except (json.JSONDecodeError, KeyError, AttributeError):
|
|
161
161
|
return None
|
|
162
162
|
|
|
163
|
-
async def get(self, key: str) ->
|
|
163
|
+
async def get(self, key: str) -> CacheEntry | None:
|
|
164
164
|
"""Retrieve a cached response."""
|
|
165
165
|
result = await self.client.get(self._make_key(key))
|
|
166
166
|
value = self._deserialize(result)
|
|
167
167
|
logger.debug("Redis %s; key=%s", "HIT" if value else "MISS", key)
|
|
168
168
|
return value
|
|
169
169
|
|
|
170
|
-
async def set(self, key: str, value:
|
|
170
|
+
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
|
|
171
171
|
"""Store a response in the cache."""
|
|
172
172
|
serialized = self._serialize(value)
|
|
173
173
|
prefixed_key = self._make_key(key)
|
|
@@ -327,7 +327,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
327
327
|
"""Get all cache keys in the backend.
|
|
328
328
|
|
|
329
329
|
Returns:
|
|
330
|
-
List of
|
|
330
|
+
List of logical cache keys (without the backend key prefix)
|
|
331
331
|
"""
|
|
332
332
|
pattern = f"{self.key_prefix}*"
|
|
333
333
|
cursor = 0
|
|
@@ -346,34 +346,34 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
346
346
|
if cursor == 0:
|
|
347
347
|
break
|
|
348
348
|
|
|
349
|
-
|
|
350
|
-
|
|
349
|
+
logical_keys = [k.removeprefix(self.key_prefix) for k in all_keys]
|
|
350
|
+
logger.debug("Redis GET_ALL_KEYS; count=%s", len(logical_keys))
|
|
351
|
+
return logical_keys
|
|
351
352
|
|
|
352
|
-
async def get_cache_data(self) -> dict[str, tuple[
|
|
353
|
+
async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
|
|
353
354
|
"""Get all cache data with expiry information.
|
|
354
355
|
|
|
355
356
|
Returns:
|
|
356
|
-
Dictionary mapping cache keys to (
|
|
357
|
+
Dictionary mapping cache keys to (CacheEntry, expiry) tuples.
|
|
357
358
|
Note: Redis stores TTL but not absolute expiry time, so this
|
|
358
359
|
returns None for expiry (no expiry tracking in Redis backend).
|
|
359
360
|
"""
|
|
360
361
|
all_keys = await self.get_all_keys()
|
|
361
|
-
cache_data: dict[str, tuple[
|
|
362
|
+
cache_data: dict[str, tuple[CacheEntry, float | None]] = {}
|
|
362
363
|
|
|
363
364
|
if not all_keys:
|
|
364
365
|
return cache_data
|
|
365
366
|
|
|
366
367
|
# Fetch all values in a single pipeline round-trip instead of N+1 GETs
|
|
367
368
|
pipe = self.client.pipeline()
|
|
368
|
-
for
|
|
369
|
-
pipe.get(
|
|
369
|
+
for key in all_keys:
|
|
370
|
+
pipe.get(self._make_key(key))
|
|
370
371
|
raw_values: list[str | None] = await pipe.execute()
|
|
371
372
|
|
|
372
|
-
for
|
|
373
|
-
original_key = prefixed_key.removeprefix(self.key_prefix)
|
|
373
|
+
for key, raw in zip(all_keys, raw_values, strict=False):
|
|
374
374
|
value = self._deserialize(raw)
|
|
375
375
|
if value is not None:
|
|
376
|
-
cache_data[
|
|
376
|
+
cache_data[key] = (value, None)
|
|
377
377
|
|
|
378
378
|
logger.debug("Redis GET_CACHE_DATA; keys=%s", len(cache_data))
|
|
379
379
|
return cache_data
|
|
@@ -25,8 +25,8 @@ from .exceptions import CacheXError
|
|
|
25
25
|
from .exceptions import RequestNotFoundError
|
|
26
26
|
from .proxy import BackendProxy
|
|
27
27
|
from .types import CACHE_KEY_SEPARATOR
|
|
28
|
+
from .types import CacheEntry
|
|
28
29
|
from .types import CacheKeyBuilder
|
|
29
|
-
from .types import ETagContent
|
|
30
30
|
|
|
31
31
|
if TYPE_CHECKING:
|
|
32
32
|
from fastapi.routing import APIRoute
|
|
@@ -85,6 +85,11 @@ class CacheControl:
|
|
|
85
85
|
return ", ".join(self.directives)
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def _get_response_body(response: Response) -> bytes | None:
|
|
89
|
+
"""Return response body bytes, or None for streaming/file responses."""
|
|
90
|
+
return getattr(response, "body", None)
|
|
91
|
+
|
|
92
|
+
|
|
88
93
|
async def get_response(
|
|
89
94
|
__func: HandlerCallable,
|
|
90
95
|
__request: Request,
|
|
@@ -151,10 +156,16 @@ def cache(
|
|
|
151
156
|
"""
|
|
152
157
|
|
|
153
158
|
def decorator(func: HandlerCallable) -> AsyncResponseCallable:
|
|
154
|
-
# Validate
|
|
159
|
+
# Validate parameters eagerly at decoration time
|
|
155
160
|
if stale is not None and stale_ttl is None:
|
|
156
161
|
msg = "stale_ttl must be set if stale is used"
|
|
157
162
|
raise CacheXError(msg)
|
|
163
|
+
if stale_ttl is not None and stale is None:
|
|
164
|
+
msg = "stale must be set if stale_ttl is used"
|
|
165
|
+
raise CacheXError(msg)
|
|
166
|
+
if public and private:
|
|
167
|
+
msg = "public and private are mutually exclusive"
|
|
168
|
+
raise CacheXError(msg)
|
|
158
169
|
|
|
159
170
|
# Analyze the original function's signature
|
|
160
171
|
sig: Signature = inspect.signature(func)
|
|
@@ -266,7 +277,7 @@ def cache(
|
|
|
266
277
|
if no_cache:
|
|
267
278
|
# Get fresh response first if using no-cache
|
|
268
279
|
current_response = await get_response(func, req, *args, **kwargs)
|
|
269
|
-
current_body =
|
|
280
|
+
current_body = _get_response_body(current_response)
|
|
270
281
|
if current_body is None:
|
|
271
282
|
# StreamingResponse/FileResponse — cannot compute ETag; serve as-is
|
|
272
283
|
current_response.headers["Cache-Control"] = cache_control
|
|
@@ -287,9 +298,7 @@ def cache(
|
|
|
287
298
|
)
|
|
288
299
|
|
|
289
300
|
# Compare with cached ETag - if match, return 304
|
|
290
|
-
elif
|
|
291
|
-
cached_data and client_etag == cached_data.etag
|
|
292
|
-
): # pragma: no branch
|
|
301
|
+
elif cached_data and client_etag == cached_data.fingerprint:
|
|
293
302
|
# Cache hit with matching ETag: return 304 Not Modified
|
|
294
303
|
logger.debug(
|
|
295
304
|
"304 Not Modified (cached ETag match); key=%s", cache_key
|
|
@@ -297,7 +306,7 @@ def cache(
|
|
|
297
306
|
return Response(
|
|
298
307
|
status_code=HTTP_304_NOT_MODIFIED,
|
|
299
308
|
headers={
|
|
300
|
-
"ETag": cached_data.
|
|
309
|
+
"ETag": cached_data.fingerprint,
|
|
301
310
|
"Cache-Control": cache_control,
|
|
302
311
|
},
|
|
303
312
|
)
|
|
@@ -313,7 +322,7 @@ def cache(
|
|
|
313
322
|
status_code=200,
|
|
314
323
|
media_type=cached_data.media_type,
|
|
315
324
|
headers={
|
|
316
|
-
"ETag": cached_data.
|
|
325
|
+
"ETag": cached_data.fingerprint,
|
|
317
326
|
"Cache-Control": cache_control,
|
|
318
327
|
},
|
|
319
328
|
)
|
|
@@ -321,7 +330,7 @@ def cache(
|
|
|
321
330
|
if not current_response or not current_etag:
|
|
322
331
|
# Retrieve the current response if not already done
|
|
323
332
|
current_response = await get_response(func, req, *args, **kwargs)
|
|
324
|
-
current_body =
|
|
333
|
+
current_body = _get_response_body(current_response)
|
|
325
334
|
if current_body is None:
|
|
326
335
|
# StreamingResponse/FileResponse — cannot compute ETag; serve as-is
|
|
327
336
|
current_response.headers["Cache-Control"] = cache_control
|
|
@@ -333,17 +342,15 @@ def cache(
|
|
|
333
342
|
current_response.headers["ETag"] = current_etag
|
|
334
343
|
|
|
335
344
|
# Update cache if needed
|
|
336
|
-
if not cached_data or cached_data.
|
|
337
|
-
|
|
338
|
-
msg = "Unexpected state: response body unavailable after ETag computation"
|
|
339
|
-
raise CacheXError(msg)
|
|
345
|
+
if not cached_data or cached_data.fingerprint != current_etag:
|
|
346
|
+
assert current_body is not None # guaranteed by early-return guards above
|
|
340
347
|
# Store in cache if data changed
|
|
341
348
|
await cache_backend.set(
|
|
342
349
|
cache_key,
|
|
343
|
-
|
|
344
|
-
current_etag,
|
|
345
|
-
current_body,
|
|
346
|
-
current_response.media_type,
|
|
350
|
+
CacheEntry(
|
|
351
|
+
fingerprint=current_etag,
|
|
352
|
+
content=current_body,
|
|
353
|
+
media_type=current_response.media_type,
|
|
347
354
|
),
|
|
348
355
|
ttl=ttl,
|
|
349
356
|
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""FastAPI dependency injection utilities for cache control."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends
|
|
6
|
+
|
|
7
|
+
from .backends.base import BaseCacheBackend
|
|
8
|
+
from .exceptions import BackendNotFoundError
|
|
9
|
+
from .manager import CacheManager
|
|
10
|
+
from .manager_proxy import CacheManagerProxy
|
|
11
|
+
from .proxy import BackendProxy
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_cache_backend() -> BaseCacheBackend:
|
|
15
|
+
"""Dependency to get the current cache backend instance."""
|
|
16
|
+
return BackendProxy.get()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
CacheBackend = Annotated[BaseCacheBackend, Depends(get_cache_backend)]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_app_cache() -> CacheManager:
|
|
23
|
+
"""Dependency to get the application CacheManager instance.
|
|
24
|
+
|
|
25
|
+
Lazily creates and registers a default CacheManager (backed by
|
|
26
|
+
BackendProxy) the first time it's requested, unless one was already
|
|
27
|
+
set via CacheManagerProxy.set(...).
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
return CacheManagerProxy.get()
|
|
31
|
+
except BackendNotFoundError:
|
|
32
|
+
manager = CacheManager()
|
|
33
|
+
CacheManagerProxy.set(manager)
|
|
34
|
+
return manager
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
AppCache = Annotated[CacheManager, Depends(get_app_cache)]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Generic application-level cache manager for FastAPI-CacheX."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .backends.base import BaseCacheBackend
|
|
9
|
+
from .proxy import BackendProxy
|
|
10
|
+
from .types import CacheEntry
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_DECODE_ERRORS = (AttributeError, UnicodeDecodeError, json.JSONDecodeError)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CacheManager:
|
|
18
|
+
"""Provides convenient get/set/delete access to the configured cache backend.
|
|
19
|
+
|
|
20
|
+
Unlike the ``@cache`` decorator (which caches HTTP response bodies) or
|
|
21
|
+
``StateManager``/``SessionManager`` (which manage OAuth state and sessions),
|
|
22
|
+
``CacheManager`` is a thin, JSON-serializing wrapper for caching arbitrary
|
|
23
|
+
application values under a dedicated key namespace.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
backend: BaseCacheBackend | None = None,
|
|
29
|
+
key_prefix: str = "cache:",
|
|
30
|
+
default_ttl: int | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Initialize CacheManager.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
backend: Cache backend instance. If None, uses BackendProxy.get().
|
|
36
|
+
key_prefix: Prefix prepended to all logical keys in the cache backend.
|
|
37
|
+
default_ttl: Default TTL (seconds) applied when set() is called
|
|
38
|
+
without an explicit ttl. None means no expiry by default.
|
|
39
|
+
"""
|
|
40
|
+
self.backend = backend if backend is not None else BackendProxy.get()
|
|
41
|
+
self.key_prefix = key_prefix
|
|
42
|
+
self.default_ttl = default_ttl
|
|
43
|
+
|
|
44
|
+
def _cache_key(self, key: str) -> str:
|
|
45
|
+
return f"{self.key_prefix}{key}"
|
|
46
|
+
|
|
47
|
+
async def get(self, key: str, default: Any = None) -> Any:
|
|
48
|
+
"""Retrieve and JSON-decode a cached value.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
key: Logical cache key (without the manager's prefix).
|
|
52
|
+
default: Value returned when the key is missing, expired, or the
|
|
53
|
+
stored content cannot be decoded.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The cached value, or ``default`` on a miss or decode failure.
|
|
57
|
+
"""
|
|
58
|
+
cached = await self.backend.get(self._cache_key(key))
|
|
59
|
+
if cached is None:
|
|
60
|
+
return default
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
json_content = cached.content.decode("utf-8")
|
|
64
|
+
return json.loads(json_content)
|
|
65
|
+
except _DECODE_ERRORS:
|
|
66
|
+
logger.warning("Failed to decode cached value; key=%s", key)
|
|
67
|
+
return default
|
|
68
|
+
|
|
69
|
+
async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
|
|
70
|
+
"""JSON-encode and store a value in the cache.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
key: Logical cache key (without the manager's prefix).
|
|
74
|
+
value: A JSON-serializable Python value.
|
|
75
|
+
ttl: Time-to-live in seconds. If None, uses ``self.default_ttl``
|
|
76
|
+
(which itself defaults to no expiry).
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
TypeError: If ``value`` is not JSON-serializable.
|
|
80
|
+
"""
|
|
81
|
+
effective_ttl = ttl if ttl is not None else self.default_ttl
|
|
82
|
+
|
|
83
|
+
json_content = json.dumps(value)
|
|
84
|
+
fingerprint = hashlib.sha256(json_content.encode()).hexdigest()
|
|
85
|
+
entry = CacheEntry(
|
|
86
|
+
fingerprint=fingerprint, content=json_content.encode("utf-8")
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
await self.backend.set(self._cache_key(key), entry, ttl=effective_ttl)
|
|
90
|
+
logger.debug("Cache SET; key=%s ttl=%s", key, effective_ttl)
|
|
91
|
+
|
|
92
|
+
async def delete(self, key: str) -> bool:
|
|
93
|
+
"""Remove a value from the cache.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
key: Logical cache key (without the manager's prefix).
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
True if the key existed and was deleted, False otherwise.
|
|
100
|
+
"""
|
|
101
|
+
cache_key = self._cache_key(key)
|
|
102
|
+
existing = await self.backend.get(cache_key)
|
|
103
|
+
if existing is None:
|
|
104
|
+
return False
|
|
105
|
+
await self.backend.delete(cache_key)
|
|
106
|
+
logger.debug("Cache DELETE; key=%s", key)
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
async def has(self, key: str) -> bool:
|
|
110
|
+
"""Check whether a key exists in the cache without decoding its value.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
key: Logical cache key (without the manager's prefix).
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if the key exists and has not expired.
|
|
117
|
+
"""
|
|
118
|
+
return await self.backend.get(self._cache_key(key)) is not None
|
|
119
|
+
|
|
120
|
+
async def clear_prefix(self, prefix: str | None = None) -> int:
|
|
121
|
+
"""Clear all keys under this manager's namespace matching a sub-prefix.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
prefix: Optional additional prefix (relative to ``self.key_prefix``)
|
|
125
|
+
to restrict which keys are cleared. If None, clears everything
|
|
126
|
+
under ``self.key_prefix``.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Number of cache entries cleared.
|
|
130
|
+
"""
|
|
131
|
+
match_prefix = self._cache_key(prefix or "")
|
|
132
|
+
keys = await self.backend.get_all_keys()
|
|
133
|
+
matching_keys = [key for key in keys if key.startswith(match_prefix)]
|
|
134
|
+
for key in matching_keys:
|
|
135
|
+
await self.backend.delete(key)
|
|
136
|
+
logger.debug(
|
|
137
|
+
"Cache CLEAR_PREFIX; prefix=%s removed=%s", match_prefix, len(matching_keys)
|
|
138
|
+
)
|
|
139
|
+
return len(matching_keys)
|
|
140
|
+
|
|
141
|
+
async def clear(self) -> int:
|
|
142
|
+
"""Clear all keys under this manager's namespace.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Number of cache entries cleared.
|
|
146
|
+
"""
|
|
147
|
+
return await self.clear_prefix()
|
|
@@ -124,7 +124,7 @@ async def _get_cached_hits_handler(backend: BaseCacheBackend) -> CacheHitsRespon
|
|
|
124
124
|
now = time.time()
|
|
125
125
|
cached_hits: list[CacheHitRecord] = []
|
|
126
126
|
|
|
127
|
-
for cache_key, (
|
|
127
|
+
for cache_key, (entry, expiry) in cache_data.items():
|
|
128
128
|
method, host, path, query_params = _parse_cache_key(cache_key)
|
|
129
129
|
if method: # Valid cache key
|
|
130
130
|
# Check if cache entry is expired
|
|
@@ -138,7 +138,7 @@ async def _get_cached_hits_handler(backend: BaseCacheBackend) -> CacheHitsRespon
|
|
|
138
138
|
host=host,
|
|
139
139
|
path=path,
|
|
140
140
|
query_params=query_params,
|
|
141
|
-
etag=
|
|
141
|
+
etag=entry.fingerprint,
|
|
142
142
|
is_expired=is_expired,
|
|
143
143
|
ttl_remaining=ttl_remaining,
|
|
144
144
|
)
|
|
@@ -178,14 +178,14 @@ async def _get_cached_records_handler(
|
|
|
178
178
|
now = time.time()
|
|
179
179
|
cached_records: list[CachedRecord] = []
|
|
180
180
|
|
|
181
|
-
for cache_key, (
|
|
181
|
+
for cache_key, (entry, expiry) in cache_data.items():
|
|
182
182
|
method, host, path, query_params = _parse_cache_key(cache_key)
|
|
183
183
|
if method: # Valid cache key
|
|
184
184
|
# Check if cache entry is expired
|
|
185
185
|
is_expired = expiry is not None and expiry <= now
|
|
186
186
|
|
|
187
187
|
# Get content size
|
|
188
|
-
content =
|
|
188
|
+
content = entry.content
|
|
189
189
|
content_size = len(content) if isinstance(content, (bytes, str)) else 0
|
|
190
190
|
|
|
191
191
|
ttl_remaining = max(0.0, round(expiry - now, 2)) if expiry is not None else None
|
|
@@ -203,7 +203,7 @@ async def _get_cached_records_handler(
|
|
|
203
203
|
host=host,
|
|
204
204
|
path=path,
|
|
205
205
|
query_params=query_params,
|
|
206
|
-
etag=
|
|
206
|
+
etag=entry.fingerprint,
|
|
207
207
|
content_type=type(content).__name__,
|
|
208
208
|
content_size=content_size,
|
|
209
209
|
is_expired=is_expired,
|
|
@@ -6,7 +6,7 @@ from datetime import timedelta
|
|
|
6
6
|
from datetime import timezone
|
|
7
7
|
|
|
8
8
|
from fastapi_cachex.backends.base import BaseCacheBackend
|
|
9
|
-
from fastapi_cachex.types import
|
|
9
|
+
from fastapi_cachex.types import CacheEntry
|
|
10
10
|
|
|
11
11
|
from .config import SessionConfig
|
|
12
12
|
from .exceptions import SessionExpiredError
|
|
@@ -130,10 +130,22 @@ class SessionManager:
|
|
|
130
130
|
)
|
|
131
131
|
|
|
132
132
|
# Bind IP and User-Agent if configured
|
|
133
|
-
if self.config.ip_binding
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
if self.config.ip_binding:
|
|
134
|
+
if ip_address:
|
|
135
|
+
session.ip_address = ip_address
|
|
136
|
+
else:
|
|
137
|
+
logger.warning(
|
|
138
|
+
"ip_binding is enabled but no IP address available; "
|
|
139
|
+
"session created without IP binding"
|
|
140
|
+
)
|
|
141
|
+
if self.config.user_agent_binding:
|
|
142
|
+
if user_agent:
|
|
143
|
+
session.user_agent = user_agent
|
|
144
|
+
else:
|
|
145
|
+
logger.warning(
|
|
146
|
+
"user_agent_binding is enabled but no User-Agent available; "
|
|
147
|
+
"session created without UA binding"
|
|
148
|
+
)
|
|
137
149
|
|
|
138
150
|
# Store in backend
|
|
139
151
|
await self._save_session(session)
|
|
@@ -155,7 +167,7 @@ class SessionManager:
|
|
|
155
167
|
token_string: str,
|
|
156
168
|
ip_address: str | None = None,
|
|
157
169
|
user_agent: str | None = None,
|
|
158
|
-
) -> Session:
|
|
170
|
+
) -> tuple[Session, str | None]:
|
|
159
171
|
"""Retrieve and validate a session.
|
|
160
172
|
|
|
161
173
|
Args:
|
|
@@ -164,7 +176,9 @@ class SessionManager:
|
|
|
164
176
|
user_agent: Current request User-Agent
|
|
165
177
|
|
|
166
178
|
Returns:
|
|
167
|
-
|
|
179
|
+
Tuple of (session, new_token_string). new_token_string is non-None
|
|
180
|
+
when sliding expiration triggered a renewal; the caller should
|
|
181
|
+
propagate it to the client (e.g. via a response header).
|
|
168
182
|
|
|
169
183
|
Raises:
|
|
170
184
|
SessionTokenError: If token is invalid
|
|
@@ -260,6 +274,7 @@ class SessionManager:
|
|
|
260
274
|
# Update last accessed and handle sliding expiration
|
|
261
275
|
session.update_last_accessed()
|
|
262
276
|
|
|
277
|
+
renewed_token: str | None = None
|
|
263
278
|
if self.config.sliding_expiration and session.expires_at:
|
|
264
279
|
time_remaining = (
|
|
265
280
|
session.expires_at - datetime.now(timezone.utc)
|
|
@@ -268,6 +283,10 @@ class SessionManager:
|
|
|
268
283
|
|
|
269
284
|
if time_remaining < threshold:
|
|
270
285
|
session.renew(self.config.session_ttl)
|
|
286
|
+
token = self._create_token(
|
|
287
|
+
session.session_id, expires_at=session.expires_at
|
|
288
|
+
)
|
|
289
|
+
renewed_token = self._serializer.to_string(token)
|
|
271
290
|
logger.debug(
|
|
272
291
|
"Session renewed (sliding expiration); id=%s ttl=%s",
|
|
273
292
|
session.session_id,
|
|
@@ -276,7 +295,7 @@ class SessionManager:
|
|
|
276
295
|
|
|
277
296
|
await self._save_session(session)
|
|
278
297
|
|
|
279
|
-
return session
|
|
298
|
+
return session, renewed_token
|
|
280
299
|
|
|
281
300
|
async def update_session(self, session: Session) -> None:
|
|
282
301
|
"""Update an existing session.
|
|
@@ -429,9 +448,8 @@ class SessionManager:
|
|
|
429
448
|
ttl = int((session.expires_at - datetime.now(timezone.utc)).total_seconds())
|
|
430
449
|
ttl = max(ttl, 1) # Ensure at least 1 second
|
|
431
450
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
await self.backend.set(key, ETagContent(etag=etag, content=value), ttl=ttl)
|
|
451
|
+
fingerprint = self.security.hash_data(value.decode("utf-8"))
|
|
452
|
+
await self.backend.set(key, CacheEntry(fingerprint=fingerprint, content=value), ttl=ttl)
|
|
435
453
|
logger.debug("Session saved; id=%s ttl=%s", session.session_id, ttl)
|
|
436
454
|
|
|
437
455
|
async def _load_session(self, session_id: str) -> Session | None:
|
|
@@ -462,7 +480,6 @@ class SessionManager:
|
|
|
462
480
|
|
|
463
481
|
try:
|
|
464
482
|
return Session.model_validate_json(cached.content)
|
|
465
|
-
except (ValueError, TypeError):
|
|
466
|
-
# Invalid session data
|
|
483
|
+
except (ValueError, TypeError):
|
|
467
484
|
logger.debug("Session load DESERIALIZE ERROR; key=%s", key)
|
|
468
485
|
return None
|
|
@@ -78,11 +78,12 @@ class SessionMiddleware(BaseHTTPMiddleware):
|
|
|
78
78
|
|
|
79
79
|
# Try to load session
|
|
80
80
|
session: Session | None = None
|
|
81
|
+
renewed_token: str | None = None
|
|
81
82
|
if token:
|
|
82
83
|
try:
|
|
83
84
|
ip_address = self._get_client_ip(request)
|
|
84
85
|
user_agent = request.headers.get("user-agent")
|
|
85
|
-
session = await self.session_manager.get_session(
|
|
86
|
+
session, renewed_token = await self.session_manager.get_session(
|
|
86
87
|
token,
|
|
87
88
|
ip_address=ip_address,
|
|
88
89
|
user_agent=user_agent,
|
|
@@ -99,6 +100,10 @@ class SessionMiddleware(BaseHTTPMiddleware):
|
|
|
99
100
|
# Process request
|
|
100
101
|
response: Response = await call_next(request)
|
|
101
102
|
|
|
103
|
+
# Propagate renewed token to client so its JWT exp stays in sync
|
|
104
|
+
if renewed_token is not None:
|
|
105
|
+
response.headers[self.config.header_name] = renewed_token
|
|
106
|
+
|
|
102
107
|
return response
|
|
103
108
|
|
|
104
109
|
def _extract_token(self, request: Request) -> str | None:
|
|
@@ -21,8 +21,7 @@ class SecurityManager:
|
|
|
21
21
|
if len(secret_key) < 32: # noqa: PLR2004
|
|
22
22
|
msg = "Secret key must be at least 32 characters"
|
|
23
23
|
raise ValueError(msg)
|
|
24
|
-
|
|
25
|
-
self.__secret_key_bytes = secret_key.encode("utf-8")
|
|
24
|
+
self._secret_key_bytes = secret_key.encode("utf-8")
|
|
26
25
|
|
|
27
26
|
logger.debug(
|
|
28
27
|
"SecurityManager initialized with secret length=%s", len(secret_key)
|
|
@@ -38,7 +37,7 @@ class SecurityManager:
|
|
|
38
37
|
The signature as a hex string
|
|
39
38
|
"""
|
|
40
39
|
return hmac.new(
|
|
41
|
-
self.
|
|
40
|
+
self._secret_key_bytes,
|
|
42
41
|
session_id.encode("utf-8"),
|
|
43
42
|
hashlib.sha256,
|
|
44
43
|
).hexdigest()
|
|
@@ -80,7 +80,7 @@ class SimpleTokenSerializer:
|
|
|
80
80
|
session_id, signature, timestamp = parts
|
|
81
81
|
try:
|
|
82
82
|
issued_at = datetime.fromtimestamp(int(timestamp), tz=timezone.utc)
|
|
83
|
-
except (ValueError, OSError) as e:
|
|
83
|
+
except (ValueError, OSError, OverflowError) as e:
|
|
84
84
|
msg = f"Invalid timestamp in token: {e}"
|
|
85
85
|
raise ValueError(msg) from e
|
|
86
86
|
|
|
@@ -11,7 +11,7 @@ from typing import Any
|
|
|
11
11
|
|
|
12
12
|
from fastapi_cachex.backends.base import BaseCacheBackend
|
|
13
13
|
from fastapi_cachex.proxy import BackendProxy
|
|
14
|
-
from fastapi_cachex.types import
|
|
14
|
+
from fastapi_cachex.types import CacheEntry
|
|
15
15
|
|
|
16
16
|
from .exceptions import InvalidStateError
|
|
17
17
|
from .exceptions import StateDataError
|
|
@@ -44,11 +44,11 @@ class StateManager:
|
|
|
44
44
|
self.key_prefix = key_prefix
|
|
45
45
|
self.default_ttl = default_ttl
|
|
46
46
|
|
|
47
|
-
def _extract_json_content(self, cached:
|
|
48
|
-
"""Extract JSON string from a cached
|
|
47
|
+
def _extract_json_content(self, cached: CacheEntry) -> str:
|
|
48
|
+
"""Extract JSON string from a cached CacheEntry.
|
|
49
49
|
|
|
50
50
|
Args:
|
|
51
|
-
cached: The
|
|
51
|
+
cached: The CacheEntry retrieved from backend
|
|
52
52
|
|
|
53
53
|
Returns:
|
|
54
54
|
JSON string
|
|
@@ -122,13 +122,11 @@ class StateManager:
|
|
|
122
122
|
# Serialize to JSON
|
|
123
123
|
json_content = json.dumps(state_data.model_dump(mode="json"))
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
etag = hashlib.sha256(json_content.encode()).hexdigest()
|
|
125
|
+
fingerprint = hashlib.sha256(json_content.encode()).hexdigest()
|
|
127
126
|
|
|
128
|
-
# Store in backend with TTL using ETagContent
|
|
129
127
|
cache_key = f"{self.key_prefix}{state}"
|
|
130
|
-
|
|
131
|
-
await self.backend.set(cache_key,
|
|
128
|
+
entry = CacheEntry(fingerprint=fingerprint, content=json_content.encode("utf-8"))
|
|
129
|
+
await self.backend.set(cache_key, entry, ttl=effective_ttl)
|
|
132
130
|
|
|
133
131
|
logger.debug("OAuth state created; state=%s ttl=%s", state, effective_ttl)
|
|
134
132
|
return state
|
|
@@ -150,13 +148,13 @@ class StateManager:
|
|
|
150
148
|
cache_key = f"{self.key_prefix}{state}"
|
|
151
149
|
|
|
152
150
|
# Retrieve state data from backend
|
|
153
|
-
|
|
154
|
-
if
|
|
151
|
+
cached = await self.backend.get(cache_key)
|
|
152
|
+
if cached is None:
|
|
155
153
|
logger.warning("OAuth state not found or expired; state=%s", state)
|
|
156
154
|
msg = "Invalid or expired state"
|
|
157
155
|
raise InvalidStateError(msg)
|
|
158
156
|
|
|
159
|
-
json_content = self._extract_json_content(
|
|
157
|
+
json_content = self._extract_json_content(cached)
|
|
160
158
|
state_data = self._parse_state_data(json_content, state)
|
|
161
159
|
|
|
162
160
|
# Verify expiry
|
|
@@ -182,15 +180,15 @@ class StateManager:
|
|
|
182
180
|
"""
|
|
183
181
|
cache_key = f"{self.key_prefix}{state}"
|
|
184
182
|
|
|
185
|
-
|
|
186
|
-
if
|
|
183
|
+
cached = await self.backend.get(cache_key)
|
|
184
|
+
if cached is None:
|
|
187
185
|
logger.debug("State validation failed - not found; state=%s", state)
|
|
188
186
|
return False
|
|
189
187
|
|
|
190
188
|
try:
|
|
191
|
-
json_content = self._extract_json_content(
|
|
189
|
+
json_content = self._extract_json_content(cached)
|
|
192
190
|
state_data = self._parse_state_data(json_content, state)
|
|
193
|
-
except
|
|
191
|
+
except StateDataError:
|
|
194
192
|
logger.exception(
|
|
195
193
|
"Failed to parse or validate state data; state=%s",
|
|
196
194
|
state,
|
|
@@ -215,14 +213,14 @@ class StateManager:
|
|
|
215
213
|
"""
|
|
216
214
|
cache_key = f"{self.key_prefix}{state}"
|
|
217
215
|
|
|
218
|
-
|
|
219
|
-
if
|
|
216
|
+
cached = await self.backend.get(cache_key)
|
|
217
|
+
if cached is None:
|
|
220
218
|
return None
|
|
221
219
|
|
|
222
220
|
try:
|
|
223
|
-
json_content = self._extract_json_content(
|
|
221
|
+
json_content = self._extract_json_content(cached)
|
|
224
222
|
state_data = self._parse_state_data(json_content, state)
|
|
225
|
-
except
|
|
223
|
+
except StateDataError:
|
|
226
224
|
logger.exception("Failed to parse or validate state data; state=%s", state)
|
|
227
225
|
return None
|
|
228
226
|
|
|
@@ -13,10 +13,10 @@ CacheKeyBuilder = Callable[[Request], str]
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
@dataclass
|
|
16
|
-
class
|
|
17
|
-
"""
|
|
16
|
+
class CacheEntry:
|
|
17
|
+
"""Cache entry storing a fingerprint, raw content bytes, and an optional media type."""
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
fingerprint: str
|
|
20
20
|
content: bytes
|
|
21
21
|
media_type: str | None = None
|
|
22
22
|
|
|
@@ -26,9 +26,9 @@ class CacheItem:
|
|
|
26
26
|
"""Cache item with optional expiry time.
|
|
27
27
|
|
|
28
28
|
Args:
|
|
29
|
-
value: The cached
|
|
29
|
+
value: The cached entry
|
|
30
30
|
expiry: Epoch timestamp when this cache item expires (None = never expires)
|
|
31
31
|
"""
|
|
32
32
|
|
|
33
|
-
value:
|
|
33
|
+
value: CacheEntry
|
|
34
34
|
expiry: float | None = None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-cachex"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -60,7 +60,7 @@ redis = ["redis[hiredis]>=5.3.0", "orjson"]
|
|
|
60
60
|
jwt = ["PyJWT>=2.9.0"]
|
|
61
61
|
|
|
62
62
|
[build-system]
|
|
63
|
-
requires = ["uv_build>=0.
|
|
63
|
+
requires = ["uv_build>=0.11.26,<0.12"]
|
|
64
64
|
build-backend = "uv_build"
|
|
65
65
|
|
|
66
66
|
[tool.uv.build-backend]
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
"""FastAPI dependency injection utilities for cache control."""
|
|
2
|
-
|
|
3
|
-
from typing import Annotated
|
|
4
|
-
|
|
5
|
-
from fastapi import Depends
|
|
6
|
-
|
|
7
|
-
from .backends.base import BaseCacheBackend
|
|
8
|
-
from .proxy import BackendProxy
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def get_cache_backend() -> BaseCacheBackend:
|
|
12
|
-
"""Dependency to get the current cache backend instance."""
|
|
13
|
-
return BackendProxy.get()
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
CacheBackend = Annotated[BaseCacheBackend, Depends(get_cache_backend)]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|