fastapi-cachex 0.2.11__tar.gz → 0.2.12__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.11 → fastapi_cachex-0.2.12}/PKG-INFO +2 -2
- fastapi_cachex-0.2.12/fastapi_cachex/__init__.py +70 -0
- fastapi_cachex-0.2.12/fastapi_cachex/backends/config.py +40 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/backends/memcached.py +24 -17
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/backends/memory.py +10 -8
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/backends/redis.py +34 -18
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/cache.py +46 -26
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/proxy.py +2 -1
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/routes.py +25 -8
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/config.py +0 -4
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/manager.py +29 -6
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/models.py +1 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/security.py +3 -2
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/token_serializers.py +5 -1
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/state/manager.py +66 -80
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/types.py +2 -2
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/pyproject.toml +5 -5
- fastapi_cachex-0.2.11/fastapi_cachex/__init__.py +0 -27
- fastapi_cachex-0.2.11/fastapi_cachex/backends/config.py +0 -15
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/README.md +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/backends/base.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/dependencies.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/__init__.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/dependencies.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/exceptions.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/middleware.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/proxy.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/state/__init__.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/state/exceptions.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/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.2.
|
|
3
|
+
Version: 0.2.12
|
|
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
|
|
@@ -23,7 +23,7 @@ Requires-Dist: fastapi
|
|
|
23
23
|
Requires-Dist: pydantic
|
|
24
24
|
Requires-Dist: pyjwt>=2.9.0 ; extra == 'jwt'
|
|
25
25
|
Requires-Dist: pymemcache ; extra == 'memcache'
|
|
26
|
-
Requires-Dist: redis[hiredis] ; extra == 'redis'
|
|
26
|
+
Requires-Dist: redis[hiredis]>=5.3.0 ; extra == 'redis'
|
|
27
27
|
Requires-Dist: orjson ; extra == 'redis'
|
|
28
28
|
Requires-Python: >=3.10
|
|
29
29
|
Project-URL: Homepage, https://github.com/allen0099/FastAPI-CacheX
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""FastAPI-CacheX: A powerful and flexible caching extension for FastAPI."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from .cache import cache as cache
|
|
6
|
+
from .cache import default_key_builder as default_key_builder
|
|
7
|
+
from .dependencies import CacheBackend as CacheBackend
|
|
8
|
+
from .dependencies import get_cache_backend as get_cache_backend
|
|
9
|
+
from .proxy import BackendProxy as BackendProxy
|
|
10
|
+
from .routes import add_routes as add_routes
|
|
11
|
+
from .session import Session as Session
|
|
12
|
+
from .session import SessionConfig as SessionConfig
|
|
13
|
+
from .session import SessionManager as SessionManager
|
|
14
|
+
from .session import SessionManagerProxy as SessionManagerProxy
|
|
15
|
+
from .session import SessionMiddleware as SessionMiddleware
|
|
16
|
+
from .session import SessionUser as SessionUser
|
|
17
|
+
from .session import get_optional_session as get_optional_session
|
|
18
|
+
from .session import get_session as get_session
|
|
19
|
+
from .session import get_session_manager as get_session_manager
|
|
20
|
+
from .session import require_session as require_session
|
|
21
|
+
from .session.exceptions import SessionError as SessionError
|
|
22
|
+
from .session.exceptions import SessionExpiredError as SessionExpiredError
|
|
23
|
+
from .session.exceptions import SessionInvalidError as SessionInvalidError
|
|
24
|
+
from .session.exceptions import SessionNotFoundError as SessionNotFoundError
|
|
25
|
+
from .session.exceptions import SessionSecurityError as SessionSecurityError
|
|
26
|
+
from .session.exceptions import SessionTokenError as SessionTokenError
|
|
27
|
+
from .state import InvalidStateError as InvalidStateError
|
|
28
|
+
from .state import StateData as StateData
|
|
29
|
+
from .state import StateDataError as StateDataError
|
|
30
|
+
from .state import StateError as StateError
|
|
31
|
+
from .state import StateExpiredError as StateExpiredError
|
|
32
|
+
from .state import StateManager as StateManager
|
|
33
|
+
from .types import CacheKeyBuilder as CacheKeyBuilder
|
|
34
|
+
|
|
35
|
+
_package_logger = logging.getLogger("fastapi_cachex")
|
|
36
|
+
_package_logger.addHandler(
|
|
37
|
+
logging.NullHandler()
|
|
38
|
+
) # Attach a NullHandler to avoid "No handler found" warnings in user applications.
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"BackendProxy",
|
|
42
|
+
"CacheBackend",
|
|
43
|
+
"CacheKeyBuilder",
|
|
44
|
+
"InvalidStateError",
|
|
45
|
+
"Session",
|
|
46
|
+
"SessionConfig",
|
|
47
|
+
"SessionError",
|
|
48
|
+
"SessionExpiredError",
|
|
49
|
+
"SessionInvalidError",
|
|
50
|
+
"SessionManager",
|
|
51
|
+
"SessionManagerProxy",
|
|
52
|
+
"SessionMiddleware",
|
|
53
|
+
"SessionNotFoundError",
|
|
54
|
+
"SessionSecurityError",
|
|
55
|
+
"SessionTokenError",
|
|
56
|
+
"SessionUser",
|
|
57
|
+
"StateData",
|
|
58
|
+
"StateDataError",
|
|
59
|
+
"StateError",
|
|
60
|
+
"StateExpiredError",
|
|
61
|
+
"StateManager",
|
|
62
|
+
"add_routes",
|
|
63
|
+
"cache",
|
|
64
|
+
"default_key_builder",
|
|
65
|
+
"get_cache_backend",
|
|
66
|
+
"get_optional_session",
|
|
67
|
+
"get_session",
|
|
68
|
+
"get_session_manager",
|
|
69
|
+
"require_session",
|
|
70
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Configuration models for cache backends."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from pydantic import SecretStr
|
|
6
|
+
|
|
7
|
+
DEFAULT_REDIS_PREFIX = "fastapi_cachex:"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RedisConfig(BaseModel):
|
|
11
|
+
"""Configuration for Redis backend."""
|
|
12
|
+
|
|
13
|
+
host: str = Field(default="localhost", description="Redis server address")
|
|
14
|
+
port: int = Field(default=6379, ge=1, le=65535, description="Redis server port")
|
|
15
|
+
password: SecretStr | None = Field(
|
|
16
|
+
default=None, description="Redis server password"
|
|
17
|
+
)
|
|
18
|
+
db: int = Field(default=0, ge=0, description="Redis database number")
|
|
19
|
+
encoding: str = Field(default="utf-8", description="Character encoding to use")
|
|
20
|
+
socket_timeout: float = Field(
|
|
21
|
+
default=1.0, description="Timeout for socket operations in seconds"
|
|
22
|
+
)
|
|
23
|
+
socket_connect_timeout: float = Field(
|
|
24
|
+
default=1.0, description="Timeout for socket connection in seconds"
|
|
25
|
+
)
|
|
26
|
+
key_prefix: str = Field(
|
|
27
|
+
default=DEFAULT_REDIS_PREFIX,
|
|
28
|
+
description="Prefix applied to all cache keys",
|
|
29
|
+
)
|
|
30
|
+
protocol: int = Field(
|
|
31
|
+
default=2,
|
|
32
|
+
ge=2,
|
|
33
|
+
le=3,
|
|
34
|
+
description=(
|
|
35
|
+
"RESP protocol version (2 or 3). "
|
|
36
|
+
"Keep at 2 (default) unless you need RESP3 features and your hiredis "
|
|
37
|
+
"version supports it (hiredis >= 3.0 is required for RESP3). "
|
|
38
|
+
"Redis 8.0 supports RESP3 but older hiredis builds do not."
|
|
39
|
+
),
|
|
40
|
+
)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Memcached cache backend implementation."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import logging
|
|
4
5
|
import warnings
|
|
5
6
|
|
|
@@ -72,20 +73,20 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
72
73
|
Optional[ETagContent]: Cached value with ETag if exists, None otherwise
|
|
73
74
|
"""
|
|
74
75
|
prefixed_key = self._make_key(key)
|
|
75
|
-
|
|
76
|
+
loop = asyncio.get_running_loop()
|
|
77
|
+
value = await loop.run_in_executor(None, self.client.get, prefixed_key)
|
|
76
78
|
if value is None:
|
|
77
79
|
logger.debug("Memcached MISS; key=%s", key)
|
|
78
80
|
return None
|
|
79
81
|
|
|
80
82
|
# Memcached stores data as bytes; deserialize from JSON
|
|
81
83
|
try:
|
|
82
|
-
data = json.loads(value
|
|
84
|
+
data = json.loads(value)
|
|
83
85
|
logger.debug("Memcached HIT; key=%s", key)
|
|
84
86
|
return ETagContent(
|
|
85
87
|
etag=data["etag"],
|
|
86
|
-
content=data["content"].encode()
|
|
87
|
-
|
|
88
|
-
else data["content"],
|
|
88
|
+
content=data["content"].encode("latin-1"),
|
|
89
|
+
media_type=data.get("media_type"),
|
|
89
90
|
)
|
|
90
91
|
except (json.JSONDecodeError, KeyError, ValueError):
|
|
91
92
|
logger.debug("Memcached DESERIALIZE ERROR; key=%s", key)
|
|
@@ -101,16 +102,14 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
101
102
|
"""
|
|
102
103
|
prefixed_key = self._make_key(key)
|
|
103
104
|
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
content = value.content.decode()
|
|
107
|
-
else:
|
|
108
|
-
content = value.content
|
|
105
|
+
# Use latin-1 to round-trip arbitrary bytes through JSON storage
|
|
106
|
+
content = value.content.decode("latin-1")
|
|
109
107
|
|
|
110
108
|
serialized_data: str | bytes = json.dumps(
|
|
111
109
|
{
|
|
112
110
|
"etag": value.etag,
|
|
113
111
|
"content": content,
|
|
112
|
+
"media_type": value.media_type,
|
|
114
113
|
},
|
|
115
114
|
)
|
|
116
115
|
|
|
@@ -121,10 +120,11 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
121
120
|
else serialized_data.encode("utf-8")
|
|
122
121
|
)
|
|
123
122
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
expire = ttl if ttl is not None else 0
|
|
124
|
+
loop = asyncio.get_running_loop()
|
|
125
|
+
await loop.run_in_executor(
|
|
126
|
+
None,
|
|
127
|
+
lambda: self.client.set(prefixed_key, serialized_bytes, expire=expire),
|
|
128
128
|
)
|
|
129
129
|
logger.debug("Memcached SET; key=%s ttl=%s", key, ttl)
|
|
130
130
|
|
|
@@ -134,7 +134,9 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
134
134
|
Args:
|
|
135
135
|
key: Cache key to delete
|
|
136
136
|
"""
|
|
137
|
-
self.
|
|
137
|
+
prefixed = self._make_key(key)
|
|
138
|
+
loop = asyncio.get_running_loop()
|
|
139
|
+
await loop.run_in_executor(None, self.client.delete, prefixed)
|
|
138
140
|
logger.debug("Memcached DELETE; key=%s", key)
|
|
139
141
|
|
|
140
142
|
async def clear(self) -> None:
|
|
@@ -150,7 +152,8 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
150
152
|
RuntimeWarning,
|
|
151
153
|
stacklevel=2,
|
|
152
154
|
)
|
|
153
|
-
|
|
155
|
+
loop = asyncio.get_running_loop()
|
|
156
|
+
await loop.run_in_executor(None, self.client.flush_all)
|
|
154
157
|
logger.debug("Memcached CLEAR; flush_all issued")
|
|
155
158
|
|
|
156
159
|
async def clear_path(self, path: str, include_params: bool = False) -> int:
|
|
@@ -180,8 +183,12 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
180
183
|
|
|
181
184
|
# Try to delete the prefixed key (exact match only)
|
|
182
185
|
prefixed_key = self._make_key(path)
|
|
186
|
+
loop = asyncio.get_running_loop()
|
|
183
187
|
try:
|
|
184
|
-
result =
|
|
188
|
+
result = await loop.run_in_executor(
|
|
189
|
+
None,
|
|
190
|
+
lambda: self.client.delete(prefixed_key, noreply=False),
|
|
191
|
+
)
|
|
185
192
|
except Exception: # noqa: BLE001
|
|
186
193
|
return 0
|
|
187
194
|
else:
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""In-memory cache backend implementation."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
import contextlib
|
|
5
4
|
import fnmatch
|
|
6
5
|
import logging
|
|
7
6
|
import time
|
|
@@ -44,13 +43,16 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
44
43
|
def _ensure_cleanup_started(self) -> None:
|
|
45
44
|
"""Ensure cleanup task is started in proper async context."""
|
|
46
45
|
if self._cleanup_task is None or self._cleanup_task.done():
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
46
|
+
try:
|
|
47
|
+
loop = asyncio.get_running_loop()
|
|
48
|
+
except RuntimeError:
|
|
49
|
+
# No running event loop yet; defer until first real async call.
|
|
50
|
+
return
|
|
51
|
+
self._cleanup_task = loop.create_task(self._cleanup_task_impl())
|
|
52
|
+
logger.debug(
|
|
53
|
+
"Started memory backend cleanup task (interval=%s)",
|
|
54
|
+
self.cleanup_interval,
|
|
55
|
+
)
|
|
54
56
|
|
|
55
57
|
def start_cleanup(self) -> None:
|
|
56
58
|
"""Start the cleanup task if it's not already running.
|
|
@@ -5,6 +5,9 @@ from typing import TYPE_CHECKING
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
from typing import Literal
|
|
7
7
|
|
|
8
|
+
from fastapi_cachex.backends.config import (
|
|
9
|
+
DEFAULT_REDIS_PREFIX as DEFAULT_REDIS_PREFIX, # noqa: PLC0414
|
|
10
|
+
)
|
|
8
11
|
from fastapi_cachex.backends.config import RedisConfig
|
|
9
12
|
from fastapi_cachex.exceptions import CacheXError
|
|
10
13
|
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
@@ -23,8 +26,7 @@ except ImportError: # pragma: no cover
|
|
|
23
26
|
|
|
24
27
|
logger = logging.getLogger(__name__)
|
|
25
28
|
|
|
26
|
-
# Default Redis key prefix for fastapi-cachex
|
|
27
|
-
DEFAULT_REDIS_PREFIX = "fastapi_cachex:"
|
|
29
|
+
# Default Redis key prefix for fastapi-cachex — re-exported from config for convenience
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
@@ -48,6 +50,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
48
50
|
socket_timeout: float = 1.0,
|
|
49
51
|
socket_connect_timeout: float = 1.0,
|
|
50
52
|
key_prefix: str = DEFAULT_REDIS_PREFIX,
|
|
53
|
+
protocol: int = 2,
|
|
51
54
|
**kwargs: Any,
|
|
52
55
|
) -> None:
|
|
53
56
|
"""Initialize async Redis cache backend.
|
|
@@ -62,6 +65,9 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
62
65
|
socket_timeout: Timeout for socket operations (in seconds)
|
|
63
66
|
socket_connect_timeout: Timeout for socket connection (in seconds)
|
|
64
67
|
key_prefix: Prefix for all cache keys (default: 'fastapi_cachex:')
|
|
68
|
+
protocol: RESP protocol version (2 or 3). Defaults to 2 (RESP2) for
|
|
69
|
+
broadest compatibility. Use 3 only when hiredis >= 3.0 is installed
|
|
70
|
+
and Redis 8.0+ RESP3 features are required.
|
|
65
71
|
**kwargs: Additional arguments to pass to Redis client
|
|
66
72
|
"""
|
|
67
73
|
try:
|
|
@@ -76,6 +82,9 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
76
82
|
)
|
|
77
83
|
raise CacheXError(msg)
|
|
78
84
|
|
|
85
|
+
# `protocol` is not in the types-redis stubs (added in redis-py 5.x).
|
|
86
|
+
# Pass it via **kwargs so mypy doesn't complain about an unknown keyword.
|
|
87
|
+
kwargs.setdefault("protocol", protocol)
|
|
79
88
|
self.client = AsyncRedis(
|
|
80
89
|
host=host,
|
|
81
90
|
port=port,
|
|
@@ -104,6 +113,12 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
104
113
|
password=config.password.get_secret_value()
|
|
105
114
|
if config.password is not None
|
|
106
115
|
else None,
|
|
116
|
+
db=config.db,
|
|
117
|
+
encoding=config.encoding,
|
|
118
|
+
socket_timeout=config.socket_timeout,
|
|
119
|
+
socket_connect_timeout=config.socket_connect_timeout,
|
|
120
|
+
key_prefix=config.key_prefix,
|
|
121
|
+
protocol=config.protocol,
|
|
107
122
|
)
|
|
108
123
|
|
|
109
124
|
def _make_key(self, key: str) -> str:
|
|
@@ -112,15 +127,14 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
112
127
|
|
|
113
128
|
def _serialize(self, value: ETagContent) -> str:
|
|
114
129
|
"""Serialize ETagContent to JSON string."""
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
else:
|
|
118
|
-
content = value.content
|
|
130
|
+
# Use latin-1 to round-trip arbitrary bytes through JSON/UTF-8 Redis storage
|
|
131
|
+
content = value.content.decode("latin-1")
|
|
119
132
|
|
|
120
133
|
serialized: str | bytes = json.dumps(
|
|
121
134
|
{
|
|
122
135
|
"etag": value.etag,
|
|
123
136
|
"content": content,
|
|
137
|
+
"media_type": value.media_type,
|
|
124
138
|
},
|
|
125
139
|
)
|
|
126
140
|
|
|
@@ -140,11 +154,10 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
140
154
|
logger.debug("Content type in JSON: %s", type(data["content"]))
|
|
141
155
|
return ETagContent(
|
|
142
156
|
etag=data["etag"],
|
|
143
|
-
content=data["content"].encode()
|
|
144
|
-
|
|
145
|
-
else data["content"],
|
|
157
|
+
content=data["content"].encode("latin-1"),
|
|
158
|
+
media_type=data.get("media_type"),
|
|
146
159
|
)
|
|
147
|
-
except (json.JSONDecodeError, KeyError):
|
|
160
|
+
except (json.JSONDecodeError, KeyError, AttributeError):
|
|
148
161
|
return None
|
|
149
162
|
|
|
150
163
|
async def get(self, key: str) -> ETagContent | None:
|
|
@@ -158,10 +171,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
158
171
|
"""Store a response in the cache."""
|
|
159
172
|
serialized = self._serialize(value)
|
|
160
173
|
prefixed_key = self._make_key(key)
|
|
161
|
-
|
|
162
|
-
await self.client.setex(prefixed_key, ttl, serialized)
|
|
163
|
-
else:
|
|
164
|
-
await self.client.set(prefixed_key, serialized)
|
|
174
|
+
await self.client.set(prefixed_key, serialized, ex=ttl)
|
|
165
175
|
logger.debug("Redis SET; key=%s ttl=%s", key, ttl)
|
|
166
176
|
|
|
167
177
|
async def delete(self, key: str) -> None:
|
|
@@ -350,12 +360,18 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
350
360
|
all_keys = await self.get_all_keys()
|
|
351
361
|
cache_data: dict[str, tuple[ETagContent, float | None]] = {}
|
|
352
362
|
|
|
363
|
+
if not all_keys:
|
|
364
|
+
return cache_data
|
|
365
|
+
|
|
366
|
+
# Fetch all values in a single pipeline round-trip instead of N+1 GETs
|
|
367
|
+
pipe = self.client.pipeline()
|
|
353
368
|
for prefixed_key in all_keys:
|
|
354
|
-
|
|
355
|
-
|
|
369
|
+
pipe.get(prefixed_key)
|
|
370
|
+
raw_values: list[str | None] = await pipe.execute()
|
|
356
371
|
|
|
357
|
-
|
|
358
|
-
|
|
372
|
+
for prefixed_key, raw in zip(all_keys, raw_values, strict=False):
|
|
373
|
+
original_key = prefixed_key.removeprefix(self.key_prefix)
|
|
374
|
+
value = self._deserialize(raw)
|
|
359
375
|
if value is not None:
|
|
360
376
|
cache_data[original_key] = (value, None)
|
|
361
377
|
|
|
@@ -12,10 +12,10 @@ from inspect import Signature
|
|
|
12
12
|
from typing import TYPE_CHECKING
|
|
13
13
|
from typing import Any
|
|
14
14
|
from typing import Literal
|
|
15
|
+
from typing import cast
|
|
15
16
|
|
|
16
17
|
from fastapi import Request
|
|
17
18
|
from fastapi import Response
|
|
18
|
-
from fastapi.datastructures import DefaultPlaceholder
|
|
19
19
|
from starlette.status import HTTP_304_NOT_MODIFIED
|
|
20
20
|
|
|
21
21
|
from .backends import MemoryBackend
|
|
@@ -108,11 +108,12 @@ async def get_response(
|
|
|
108
108
|
msg = "Route not found in request scope"
|
|
109
109
|
raise CacheXError(msg)
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
response_class
|
|
111
|
+
# FastAPI <= 0.136.x stores a DefaultPlaceholder; >= 0.137.0 stores the class directly.
|
|
112
|
+
# getattr with a fallback handles both without importing the internal class.
|
|
113
|
+
response_class: type[Response] = cast(
|
|
114
|
+
"type[Response]",
|
|
115
|
+
getattr(route.response_class, "value", route.response_class),
|
|
116
|
+
)
|
|
116
117
|
|
|
117
118
|
# Convert non-Response result to Response using appropriate response_class
|
|
118
119
|
return response_class(content=result)
|
|
@@ -150,13 +151,10 @@ def cache(
|
|
|
150
151
|
"""
|
|
151
152
|
|
|
152
153
|
def decorator(func: HandlerCallable) -> AsyncResponseCallable:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
cache_backend = MemoryBackend()
|
|
158
|
-
BackendProxy.set(cache_backend)
|
|
159
|
-
logger.debug("No backend configured; using MemoryBackend fallback")
|
|
154
|
+
# Validate stale parameters eagerly at decoration time
|
|
155
|
+
if stale is not None and stale_ttl is None:
|
|
156
|
+
msg = "stale_ttl must be set if stale is used"
|
|
157
|
+
raise CacheXError(msg)
|
|
160
158
|
|
|
161
159
|
# Analyze the original function's signature
|
|
162
160
|
sig: Signature = inspect.signature(func)
|
|
@@ -183,14 +181,13 @@ def cache(
|
|
|
183
181
|
else:
|
|
184
182
|
request_name = found_request.name
|
|
185
183
|
|
|
186
|
-
|
|
184
|
+
def get_cache_control(cache_control: CacheControl) -> str:
|
|
187
185
|
# Set Cache-Control headers
|
|
188
186
|
if no_cache:
|
|
189
187
|
cache_control.add(DirectiveType.NO_CACHE)
|
|
190
188
|
if must_revalidate:
|
|
191
189
|
cache_control.add(DirectiveType.MUST_REVALIDATE)
|
|
192
190
|
else:
|
|
193
|
-
# Handle normal cache control cases
|
|
194
191
|
# 1. Access scope (public/private)
|
|
195
192
|
if public:
|
|
196
193
|
cache_control.add(DirectiveType.PUBLIC)
|
|
@@ -205,11 +202,7 @@ def cache(
|
|
|
205
202
|
if must_revalidate:
|
|
206
203
|
cache_control.add(DirectiveType.MUST_REVALIDATE)
|
|
207
204
|
|
|
208
|
-
# 4. Stale response handling
|
|
209
|
-
if stale is not None and stale_ttl is None:
|
|
210
|
-
msg = "stale_ttl must be set if stale is used"
|
|
211
|
-
raise CacheXError(msg)
|
|
212
|
-
|
|
205
|
+
# 4. Stale response handling (stale_ttl is validated at decoration time)
|
|
213
206
|
if stale == "revalidate":
|
|
214
207
|
cache_control.add(DirectiveType.STALE_WHILE_REVALIDATE, stale_ttl)
|
|
215
208
|
elif stale == "error":
|
|
@@ -223,6 +216,14 @@ def cache(
|
|
|
223
216
|
|
|
224
217
|
@wraps(func)
|
|
225
218
|
async def wrapper(*args: Any, **kwargs: Any) -> Response:
|
|
219
|
+
# Resolve backend on every request to support lifespan-configured backends
|
|
220
|
+
try:
|
|
221
|
+
cache_backend = BackendProxy.get()
|
|
222
|
+
except BackendNotFoundError:
|
|
223
|
+
cache_backend = MemoryBackend()
|
|
224
|
+
BackendProxy.set(cache_backend)
|
|
225
|
+
logger.debug("No backend configured; using MemoryBackend fallback")
|
|
226
|
+
|
|
226
227
|
if found_request:
|
|
227
228
|
req: Request | None = kwargs.get(request_name)
|
|
228
229
|
else:
|
|
@@ -243,7 +244,7 @@ def cache(
|
|
|
243
244
|
builder = key_builder or default_key_builder
|
|
244
245
|
cache_key = builder(req)
|
|
245
246
|
client_etag = req.headers.get("if-none-match")
|
|
246
|
-
cache_control =
|
|
247
|
+
cache_control = get_cache_control(CacheControl())
|
|
247
248
|
|
|
248
249
|
# Handle special case: no-store (highest priority)
|
|
249
250
|
if no_store:
|
|
@@ -257,15 +258,21 @@ def cache(
|
|
|
257
258
|
# Check cache and handle ETag validation
|
|
258
259
|
cached_data = await cache_backend.get(cache_key)
|
|
259
260
|
|
|
260
|
-
current_response = None
|
|
261
|
-
current_etag = None
|
|
261
|
+
current_response: Response | None = None
|
|
262
|
+
current_etag: str | None = None
|
|
263
|
+
current_body: bytes | None = None
|
|
262
264
|
|
|
263
265
|
if client_etag:
|
|
264
266
|
if no_cache:
|
|
265
267
|
# Get fresh response first if using no-cache
|
|
266
268
|
current_response = await get_response(func, req, *args, **kwargs)
|
|
269
|
+
current_body = getattr(current_response, "body", None)
|
|
270
|
+
if current_body is None:
|
|
271
|
+
# StreamingResponse/FileResponse — cannot compute ETag; serve as-is
|
|
272
|
+
current_response.headers["Cache-Control"] = cache_control
|
|
273
|
+
return current_response
|
|
267
274
|
current_etag = (
|
|
268
|
-
f'W/"{hashlib.md5(
|
|
275
|
+
f'W/"{hashlib.md5(current_body).hexdigest()}"' # noqa: S324
|
|
269
276
|
)
|
|
270
277
|
|
|
271
278
|
if client_etag == current_etag:
|
|
@@ -304,6 +311,7 @@ def cache(
|
|
|
304
311
|
return Response(
|
|
305
312
|
content=cached_data.content,
|
|
306
313
|
status_code=200,
|
|
314
|
+
media_type=cached_data.media_type,
|
|
307
315
|
headers={
|
|
308
316
|
"ETag": cached_data.etag,
|
|
309
317
|
"Cache-Control": cache_control,
|
|
@@ -313,7 +321,12 @@ def cache(
|
|
|
313
321
|
if not current_response or not current_etag:
|
|
314
322
|
# Retrieve the current response if not already done
|
|
315
323
|
current_response = await get_response(func, req, *args, **kwargs)
|
|
316
|
-
|
|
324
|
+
current_body = getattr(current_response, "body", None)
|
|
325
|
+
if current_body is None:
|
|
326
|
+
# StreamingResponse/FileResponse — cannot compute ETag; serve as-is
|
|
327
|
+
current_response.headers["Cache-Control"] = cache_control
|
|
328
|
+
return current_response
|
|
329
|
+
current_etag = f'W/"{hashlib.md5(current_body).hexdigest()}"' # noqa: S324
|
|
317
330
|
logger.debug("Cache MISS; computed fresh ETag for key=%s", cache_key)
|
|
318
331
|
|
|
319
332
|
# Set ETag header
|
|
@@ -321,10 +334,17 @@ def cache(
|
|
|
321
334
|
|
|
322
335
|
# Update cache if needed
|
|
323
336
|
if not cached_data or cached_data.etag != current_etag:
|
|
337
|
+
if current_body is None: # pragma: no cover - guaranteed by earlier guards
|
|
338
|
+
msg = "Unexpected state: response body unavailable after ETag computation"
|
|
339
|
+
raise CacheXError(msg)
|
|
324
340
|
# Store in cache if data changed
|
|
325
341
|
await cache_backend.set(
|
|
326
342
|
cache_key,
|
|
327
|
-
ETagContent(
|
|
343
|
+
ETagContent(
|
|
344
|
+
current_etag,
|
|
345
|
+
current_body,
|
|
346
|
+
current_response.media_type,
|
|
347
|
+
),
|
|
328
348
|
ttl=ttl,
|
|
329
349
|
)
|
|
330
350
|
logger.debug("Updated cache entry; key=%s ttl=%s", cache_key, ttl)
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import warnings
|
|
6
6
|
from logging import getLogger
|
|
7
7
|
from typing import Generic
|
|
8
|
+
from typing import NoReturn
|
|
8
9
|
from typing import TypeVar
|
|
9
10
|
|
|
10
11
|
from .backends import BaseCacheBackend
|
|
@@ -18,7 +19,7 @@ logger = getLogger(__name__)
|
|
|
18
19
|
class ProxyMeta(type):
|
|
19
20
|
"""Metaclass for BackendProxy to prevent instantiation."""
|
|
20
21
|
|
|
21
|
-
def __call__(cls) ->
|
|
22
|
+
def __call__(cls) -> NoReturn:
|
|
22
23
|
"""Prevent instantiation of BackendProxy."""
|
|
23
24
|
msg = "Proxy class cannot be instantiated. Use static methods instead."
|
|
24
25
|
raise TypeError(msg)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Optional routes for cache monitoring and management."""
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
|
+
from collections.abc import Sequence
|
|
4
5
|
from dataclasses import dataclass
|
|
5
6
|
from typing import TYPE_CHECKING
|
|
7
|
+
from typing import Any
|
|
6
8
|
|
|
7
9
|
from .backends import BaseCacheBackend
|
|
8
10
|
from .exceptions import BackendNotFoundError
|
|
@@ -37,7 +39,7 @@ class CacheHitSummary:
|
|
|
37
39
|
|
|
38
40
|
total_cached_entries: int
|
|
39
41
|
active_entries: int
|
|
40
|
-
|
|
42
|
+
cached_paths: list[str]
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
@dataclass
|
|
@@ -127,7 +129,7 @@ async def _get_cached_hits_handler(backend: BaseCacheBackend) -> CacheHitsRespon
|
|
|
127
129
|
if method: # Valid cache key
|
|
128
130
|
# Check if cache entry is expired
|
|
129
131
|
is_expired = expiry is not None and expiry <= now
|
|
130
|
-
ttl_remaining = round(expiry - now, 2) if expiry is not None else None
|
|
132
|
+
ttl_remaining = max(0.0, round(expiry - now, 2)) if expiry is not None else None
|
|
131
133
|
|
|
132
134
|
cached_hits.append(
|
|
133
135
|
CacheHitRecord(
|
|
@@ -155,7 +157,7 @@ async def _get_cached_hits_handler(backend: BaseCacheBackend) -> CacheHitsRespon
|
|
|
155
157
|
summary=CacheHitSummary(
|
|
156
158
|
total_cached_entries=len(cached_hits),
|
|
157
159
|
active_entries=len(valid_hits),
|
|
158
|
-
|
|
160
|
+
cached_paths=sorted(routes_hit),
|
|
159
161
|
),
|
|
160
162
|
)
|
|
161
163
|
|
|
@@ -186,7 +188,7 @@ async def _get_cached_records_handler(
|
|
|
186
188
|
content = etag_content.content
|
|
187
189
|
content_size = len(content) if isinstance(content, (bytes, str)) else 0
|
|
188
190
|
|
|
189
|
-
ttl_remaining = round(expiry - now, 2) if expiry is not None else None
|
|
191
|
+
ttl_remaining = max(0.0, round(expiry - now, 2)) if expiry is not None else None
|
|
190
192
|
|
|
191
193
|
content_preview = (
|
|
192
194
|
content[:100].decode("utf-8", errors="ignore")
|
|
@@ -229,7 +231,10 @@ async def _get_cached_records_handler(
|
|
|
229
231
|
|
|
230
232
|
|
|
231
233
|
def add_routes(
|
|
232
|
-
app: "FastAPI",
|
|
234
|
+
app: "FastAPI",
|
|
235
|
+
prefix: str = "",
|
|
236
|
+
include_in_schema: bool = False,
|
|
237
|
+
dependencies: Sequence[Any] | None = None,
|
|
233
238
|
) -> None:
|
|
234
239
|
"""Add cache monitoring routes to the FastAPI application.
|
|
235
240
|
|
|
@@ -243,6 +248,10 @@ def add_routes(
|
|
|
243
248
|
Defaults to "" (no prefix).
|
|
244
249
|
include_in_schema: Whether to include routes in OpenAPI schema.
|
|
245
250
|
Defaults to False.
|
|
251
|
+
dependencies: Optional list of FastAPI ``Depends`` objects applied to
|
|
252
|
+
all monitoring routes. Useful for adding authentication
|
|
253
|
+
or authorization guards (e.g.
|
|
254
|
+
``[Depends(verify_api_key)]``).
|
|
246
255
|
|
|
247
256
|
Example:
|
|
248
257
|
from fastapi import FastAPI
|
|
@@ -255,7 +264,11 @@ def add_routes(
|
|
|
255
264
|
add_routes(app, prefix="/api/cache") # Routes at /api/cache/cached-hits and /api/cache/cached-records
|
|
256
265
|
"""
|
|
257
266
|
|
|
258
|
-
@app.get(
|
|
267
|
+
@app.get(
|
|
268
|
+
f"{prefix}/cached-hits",
|
|
269
|
+
include_in_schema=include_in_schema,
|
|
270
|
+
dependencies=dependencies,
|
|
271
|
+
)
|
|
259
272
|
async def get_cached_hits() -> CacheHitsResponse:
|
|
260
273
|
"""Return cached hit records.
|
|
261
274
|
|
|
@@ -277,13 +290,17 @@ def add_routes(
|
|
|
277
290
|
summary=CacheHitSummary(
|
|
278
291
|
total_cached_entries=0,
|
|
279
292
|
active_entries=0,
|
|
280
|
-
|
|
293
|
+
cached_paths=[],
|
|
281
294
|
),
|
|
282
295
|
)
|
|
283
296
|
|
|
284
297
|
return await _get_cached_hits_handler(backend)
|
|
285
298
|
|
|
286
|
-
@app.get(
|
|
299
|
+
@app.get(
|
|
300
|
+
f"{prefix}/cached-records",
|
|
301
|
+
include_in_schema=include_in_schema,
|
|
302
|
+
dependencies=dependencies,
|
|
303
|
+
)
|
|
287
304
|
async def get_cached_records() -> CachedRecordsResponse:
|
|
288
305
|
"""Display currently cached records.
|
|
289
306
|
|
|
@@ -81,10 +81,6 @@ class SessionConfig(BaseModel):
|
|
|
81
81
|
default=False,
|
|
82
82
|
description="Whether to bind session to User-Agent",
|
|
83
83
|
)
|
|
84
|
-
regenerate_on_login: bool = Field(
|
|
85
|
-
default=True,
|
|
86
|
-
description="Whether to regenerate session ID on login",
|
|
87
|
-
)
|
|
88
84
|
|
|
89
85
|
# Backend settings
|
|
90
86
|
backend_key_prefix: str = Field(
|
|
@@ -138,8 +138,8 @@ class SessionManager:
|
|
|
138
138
|
# Store in backend
|
|
139
139
|
await self._save_session(session)
|
|
140
140
|
|
|
141
|
-
# Generate signed token
|
|
142
|
-
token = self._create_token(session.session_id)
|
|
141
|
+
# Generate signed token (pass expires_at so JWT exp reflects sliding expiration)
|
|
142
|
+
token = self._create_token(session.session_id, expires_at=session.expires_at)
|
|
143
143
|
logger.debug(
|
|
144
144
|
"Session created; id=%s ttl=%s ip=%s ua=%s",
|
|
145
145
|
session.session_id,
|
|
@@ -214,6 +214,22 @@ class SessionManager:
|
|
|
214
214
|
logger.debug("Session expired; id=%s", session.session_id)
|
|
215
215
|
raise SessionExpiredError(msg)
|
|
216
216
|
|
|
217
|
+
# Check absolute timeout (hard cap regardless of sliding expiration)
|
|
218
|
+
if self.config.absolute_timeout is not None:
|
|
219
|
+
absolute_expires_at = session.created_at + timedelta(
|
|
220
|
+
seconds=self.config.absolute_timeout,
|
|
221
|
+
)
|
|
222
|
+
if datetime.now(timezone.utc) >= absolute_expires_at:
|
|
223
|
+
session.status = SessionStatus.EXPIRED
|
|
224
|
+
await self._save_session(session)
|
|
225
|
+
msg = "Session has exceeded absolute timeout"
|
|
226
|
+
logger.debug(
|
|
227
|
+
"Session absolute timeout exceeded; id=%s created_at=%s",
|
|
228
|
+
session.session_id,
|
|
229
|
+
session.created_at,
|
|
230
|
+
)
|
|
231
|
+
raise SessionExpiredError(msg)
|
|
232
|
+
|
|
217
233
|
# Security checks
|
|
218
234
|
if self.config.ip_binding and not self.security.check_ip_match(
|
|
219
235
|
session,
|
|
@@ -314,8 +330,8 @@ class SessionManager:
|
|
|
314
330
|
# Save with new ID
|
|
315
331
|
await self._save_session(session)
|
|
316
332
|
|
|
317
|
-
# Create new token
|
|
318
|
-
token = self._create_token(session.session_id)
|
|
333
|
+
# Create new token (pass expires_at so JWT exp reflects current expiry)
|
|
334
|
+
token = self._create_token(session.session_id, expires_at=session.expires_at)
|
|
319
335
|
logger.debug(
|
|
320
336
|
"Session ID regenerated; old_id=%s new_id=%s", old_id, session.session_id
|
|
321
337
|
)
|
|
@@ -372,11 +388,16 @@ class SessionManager:
|
|
|
372
388
|
logger.debug("Expired sessions cleared; count=%s", count)
|
|
373
389
|
return count
|
|
374
390
|
|
|
375
|
-
def _create_token(
|
|
391
|
+
def _create_token(
|
|
392
|
+
self,
|
|
393
|
+
session_id: str,
|
|
394
|
+
expires_at: datetime | None = None,
|
|
395
|
+
) -> SessionToken:
|
|
376
396
|
"""Create a signed session token.
|
|
377
397
|
|
|
378
398
|
Args:
|
|
379
399
|
session_id: Session ID to sign
|
|
400
|
+
expires_at: Session expiry time, forwarded to JWT serializers
|
|
380
401
|
|
|
381
402
|
Returns:
|
|
382
403
|
SessionToken object
|
|
@@ -389,7 +410,9 @@ class SessionManager:
|
|
|
389
410
|
if self.config.token_format == "simple"
|
|
390
411
|
else ""
|
|
391
412
|
)
|
|
392
|
-
return SessionToken(
|
|
413
|
+
return SessionToken(
|
|
414
|
+
session_id=session_id, signature=signature, expires_at=expires_at
|
|
415
|
+
)
|
|
393
416
|
|
|
394
417
|
async def _save_session(self, session: Session) -> None:
|
|
395
418
|
"""Save session to backend.
|
|
@@ -21,7 +21,8 @@ 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
|
-
|
|
24
|
+
# Use name mangling so the key bytes are not trivially accessible via repr
|
|
25
|
+
self.__secret_key_bytes = secret_key.encode("utf-8")
|
|
25
26
|
|
|
26
27
|
logger.debug(
|
|
27
28
|
"SecurityManager initialized with secret length=%s", len(secret_key)
|
|
@@ -37,7 +38,7 @@ class SecurityManager:
|
|
|
37
38
|
The signature as a hex string
|
|
38
39
|
"""
|
|
39
40
|
return hmac.new(
|
|
40
|
-
self.
|
|
41
|
+
self.__secret_key_bytes,
|
|
41
42
|
session_id.encode("utf-8"),
|
|
42
43
|
hashlib.sha256,
|
|
43
44
|
).hexdigest()
|
|
@@ -133,7 +133,11 @@ class JWTTokenSerializer:
|
|
|
133
133
|
Uses claims `sid`, `iat`, `exp`, and optional `iss`/`aud`.
|
|
134
134
|
"""
|
|
135
135
|
iat = int(token.issued_at.timestamp())
|
|
136
|
-
|
|
136
|
+
# Use session's expires_at when available (supports sliding expiration)
|
|
137
|
+
if token.expires_at is not None:
|
|
138
|
+
exp = int(token.expires_at.timestamp())
|
|
139
|
+
else:
|
|
140
|
+
exp = iat + int(self._session_ttl)
|
|
137
141
|
|
|
138
142
|
payload: dict[str, object] = {
|
|
139
143
|
"sid": token.session_id,
|
|
@@ -9,6 +9,7 @@ from datetime import timedelta
|
|
|
9
9
|
from datetime import timezone
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
+
from fastapi_cachex.backends.base import BaseCacheBackend
|
|
12
13
|
from fastapi_cachex.proxy import BackendProxy
|
|
13
14
|
from fastapi_cachex.types import ETagContent
|
|
14
15
|
|
|
@@ -27,18 +28,67 @@ class StateManager:
|
|
|
27
28
|
"""Manages OAuth state and session state lifecycle and storage."""
|
|
28
29
|
|
|
29
30
|
def __init__(
|
|
30
|
-
self,
|
|
31
|
+
self,
|
|
32
|
+
backend: BaseCacheBackend | None = None,
|
|
33
|
+
key_prefix: str = "oauth_state:",
|
|
34
|
+
default_ttl: int = DEFAULT_STATE_TTL,
|
|
31
35
|
) -> None:
|
|
32
36
|
"""Initialize StateManager.
|
|
33
37
|
|
|
34
38
|
Args:
|
|
39
|
+
backend: Cache backend instance. If None, uses BackendProxy.get().
|
|
35
40
|
key_prefix: Prefix for state keys in cache backend
|
|
36
41
|
default_ttl: Default time-to-live in seconds for state
|
|
37
42
|
"""
|
|
38
|
-
self.backend = BackendProxy.get()
|
|
43
|
+
self.backend = backend if backend is not None else BackendProxy.get()
|
|
39
44
|
self.key_prefix = key_prefix
|
|
40
45
|
self.default_ttl = default_ttl
|
|
41
46
|
|
|
47
|
+
def _extract_json_content(self, cached: ETagContent) -> str:
|
|
48
|
+
"""Extract JSON string from a cached ETagContent value.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
cached: The ETagContent retrieved from backend
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
JSON string
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
StateDataError: If the content cannot be decoded as UTF-8 text
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
return cached.content.decode("utf-8")
|
|
61
|
+
except (AttributeError, UnicodeDecodeError) as e:
|
|
62
|
+
msg = "Unexpected state data format"
|
|
63
|
+
raise StateDataError(msg) from e
|
|
64
|
+
|
|
65
|
+
def _parse_state_data(self, json_content: str, state: str) -> StateData:
|
|
66
|
+
"""Parse JSON content into a StateData model.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
json_content: JSON string to parse
|
|
70
|
+
state: State string (used for logging)
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
StateData instance
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
StateDataError: If parsing or validation fails
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
state_dict: dict[str, Any] = json.loads(json_content)
|
|
80
|
+
except json.JSONDecodeError as e:
|
|
81
|
+
msg = f"Failed to parse state data: {e}"
|
|
82
|
+
logger.exception("Failed to parse state data; state=%s", state)
|
|
83
|
+
raise StateDataError(msg) from e
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
return StateData(**state_dict)
|
|
87
|
+
except ValueError as e:
|
|
88
|
+
msg = f"Invalid state data structure: {e}"
|
|
89
|
+
logger.exception("Failed to create StateData model; state=%s", state)
|
|
90
|
+
raise StateDataError(msg) from e
|
|
91
|
+
|
|
42
92
|
async def create_state(
|
|
43
93
|
self,
|
|
44
94
|
ttl: int | None = None,
|
|
@@ -77,7 +127,7 @@ class StateManager:
|
|
|
77
127
|
|
|
78
128
|
# Store in backend with TTL using ETagContent
|
|
79
129
|
cache_key = f"{self.key_prefix}{state}"
|
|
80
|
-
etag_content = ETagContent(etag=etag, content=json_content)
|
|
130
|
+
etag_content = ETagContent(etag=etag, content=json_content.encode("utf-8"))
|
|
81
131
|
await self.backend.set(cache_key, etag_content, ttl=effective_ttl)
|
|
82
132
|
|
|
83
133
|
logger.debug("OAuth state created; state=%s ttl=%s", state, effective_ttl)
|
|
@@ -106,34 +156,8 @@ class StateManager:
|
|
|
106
156
|
msg = "Invalid or expired state"
|
|
107
157
|
raise InvalidStateError(msg)
|
|
108
158
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if isinstance(json_content, bytes):
|
|
112
|
-
json_content = json_content.decode("utf-8")
|
|
113
|
-
elif not isinstance(json_content, str):
|
|
114
|
-
msg = "Unexpected state data format"
|
|
115
|
-
logger.error(
|
|
116
|
-
"Unexpected content type in state; state=%s type=%s",
|
|
117
|
-
state,
|
|
118
|
-
type(json_content),
|
|
119
|
-
)
|
|
120
|
-
raise StateDataError(msg)
|
|
121
|
-
|
|
122
|
-
# Parse the stored state data
|
|
123
|
-
try:
|
|
124
|
-
state_dict: dict[str, Any] = json.loads(json_content)
|
|
125
|
-
except json.JSONDecodeError as e:
|
|
126
|
-
msg = f"Failed to parse state data: {e}"
|
|
127
|
-
logger.exception("Failed to parse state data; state=%s", state)
|
|
128
|
-
raise StateDataError(msg) from e
|
|
129
|
-
|
|
130
|
-
# Validate and create StateData model
|
|
131
|
-
try:
|
|
132
|
-
state_data = StateData(**state_dict)
|
|
133
|
-
except ValueError as e:
|
|
134
|
-
msg = f"Invalid state data structure: {e}"
|
|
135
|
-
logger.exception("Failed to create StateData model; state=%s", state)
|
|
136
|
-
raise StateDataError(msg) from e
|
|
159
|
+
json_content = self._extract_json_content(cached_etag_content)
|
|
160
|
+
state_data = self._parse_state_data(json_content, state)
|
|
137
161
|
|
|
138
162
|
# Verify expiry
|
|
139
163
|
if datetime.now(timezone.utc) > state_data.expires_at:
|
|
@@ -158,43 +182,21 @@ class StateManager:
|
|
|
158
182
|
"""
|
|
159
183
|
cache_key = f"{self.key_prefix}{state}"
|
|
160
184
|
|
|
161
|
-
# Try to retrieve state data from backend
|
|
162
185
|
cached_etag_content = await self.backend.get(cache_key)
|
|
163
186
|
if cached_etag_content is None:
|
|
164
187
|
logger.debug("State validation failed - not found; state=%s", state)
|
|
165
188
|
return False
|
|
166
189
|
|
|
167
|
-
# Extract content from ETagContent
|
|
168
|
-
json_content = cached_etag_content.content
|
|
169
|
-
if isinstance(json_content, bytes):
|
|
170
|
-
try:
|
|
171
|
-
json_content = json_content.decode("utf-8")
|
|
172
|
-
except UnicodeDecodeError:
|
|
173
|
-
logger.exception(
|
|
174
|
-
"Failed to decode bytes content in state; state=%s",
|
|
175
|
-
state,
|
|
176
|
-
)
|
|
177
|
-
return False
|
|
178
|
-
elif not isinstance(json_content, str):
|
|
179
|
-
logger.error(
|
|
180
|
-
"Unexpected content type in state; state=%s type=%s",
|
|
181
|
-
state,
|
|
182
|
-
type(json_content),
|
|
183
|
-
)
|
|
184
|
-
return False
|
|
185
|
-
|
|
186
190
|
try:
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
except (json.JSONDecodeError, ValueError):
|
|
191
|
+
json_content = self._extract_json_content(cached_etag_content)
|
|
192
|
+
state_data = self._parse_state_data(json_content, state)
|
|
193
|
+
except (StateDataError, UnicodeDecodeError):
|
|
191
194
|
logger.exception(
|
|
192
195
|
"Failed to parse or validate state data; state=%s",
|
|
193
196
|
state,
|
|
194
197
|
)
|
|
195
198
|
return False
|
|
196
199
|
|
|
197
|
-
# Check expiry
|
|
198
200
|
if datetime.now(timezone.utc) > state_data.expires_at:
|
|
199
201
|
logger.debug("State validation failed - expired; state=%s", state)
|
|
200
202
|
return False
|
|
@@ -217,34 +219,13 @@ class StateManager:
|
|
|
217
219
|
if cached_etag_content is None:
|
|
218
220
|
return None
|
|
219
221
|
|
|
220
|
-
# Extract content from ETagContent
|
|
221
|
-
json_content = cached_etag_content.content
|
|
222
|
-
if isinstance(json_content, bytes):
|
|
223
|
-
try:
|
|
224
|
-
json_content = json_content.decode("utf-8")
|
|
225
|
-
except UnicodeDecodeError:
|
|
226
|
-
logger.exception(
|
|
227
|
-
"Failed to decode bytes content in state; state=%s",
|
|
228
|
-
state,
|
|
229
|
-
)
|
|
230
|
-
return None
|
|
231
|
-
elif not isinstance(json_content, str):
|
|
232
|
-
logger.error(
|
|
233
|
-
"Unexpected content type in state; state=%s type=%s",
|
|
234
|
-
state,
|
|
235
|
-
type(json_content),
|
|
236
|
-
)
|
|
237
|
-
return None
|
|
238
|
-
|
|
239
222
|
try:
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
except (json.JSONDecodeError, ValueError):
|
|
223
|
+
json_content = self._extract_json_content(cached_etag_content)
|
|
224
|
+
state_data = self._parse_state_data(json_content, state)
|
|
225
|
+
except (StateDataError, UnicodeDecodeError):
|
|
244
226
|
logger.exception("Failed to parse or validate state data; state=%s", state)
|
|
245
227
|
return None
|
|
246
228
|
|
|
247
|
-
# Check expiry
|
|
248
229
|
if datetime.now(timezone.utc) > state_data.expires_at:
|
|
249
230
|
return None
|
|
250
231
|
|
|
@@ -260,6 +241,11 @@ class StateManager:
|
|
|
260
241
|
True if state was deleted, False if it didn't exist
|
|
261
242
|
"""
|
|
262
243
|
cache_key = f"{self.key_prefix}{state}"
|
|
244
|
+
# Check existence before deleting to return accurate result
|
|
245
|
+
existing = await self.backend.get(cache_key)
|
|
246
|
+
if existing is None:
|
|
247
|
+
logger.debug("OAuth state not found for deletion; state=%s", state)
|
|
248
|
+
return False
|
|
263
249
|
await self.backend.delete(cache_key)
|
|
264
250
|
logger.debug("OAuth state deleted; state=%s", state)
|
|
265
251
|
return True
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from collections.abc import Callable
|
|
4
4
|
from dataclasses import dataclass
|
|
5
|
-
from typing import Any
|
|
6
5
|
|
|
7
6
|
from fastapi import Request
|
|
8
7
|
|
|
@@ -18,7 +17,8 @@ class ETagContent:
|
|
|
18
17
|
"""ETag and content for cache items."""
|
|
19
18
|
|
|
20
19
|
etag: str
|
|
21
|
-
content:
|
|
20
|
+
content: bytes
|
|
21
|
+
media_type: str | None = None
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
@dataclass
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-cachex"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.12"
|
|
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"
|
|
@@ -45,7 +45,7 @@ dev = [
|
|
|
45
45
|
"pytest>=8.3.5",
|
|
46
46
|
"pytest-asyncio>=0.26.0",
|
|
47
47
|
"pytest-cov>=6.1.0",
|
|
48
|
-
"redis[hiredis]>=5.
|
|
48
|
+
"redis[hiredis]>=5.3.0",
|
|
49
49
|
"ruff>=0.11.2",
|
|
50
50
|
"tox>=4.25.0",
|
|
51
51
|
"tox-uv>=1.29.0",
|
|
@@ -56,11 +56,11 @@ dev = [
|
|
|
56
56
|
|
|
57
57
|
[project.optional-dependencies]
|
|
58
58
|
memcache = ["pymemcache"]
|
|
59
|
-
redis = ["redis[hiredis]", "orjson"]
|
|
59
|
+
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.9.17,<0.
|
|
63
|
+
requires = ["uv_build>=0.9.17,<0.11.0"]
|
|
64
64
|
build-backend = "uv_build"
|
|
65
65
|
|
|
66
66
|
[tool.uv.build-backend]
|
|
@@ -110,7 +110,7 @@ keep-runtime-typing = true
|
|
|
110
110
|
"PT031" # Single statement in pytest.warns is acceptable
|
|
111
111
|
]
|
|
112
112
|
"fastapi_cachex/cache.py" = [
|
|
113
|
-
"PLR0913", "PLR0915", # Many arguments/statements needed for flexible caching logic
|
|
113
|
+
"PLR0913", "PLR0915", "PLR0911", "PLR0912", # Many arguments/statements/returns/branches needed for flexible caching logic
|
|
114
114
|
]
|
|
115
115
|
"fastapi_cachex/backends/memcached.py" = ["PLC0415"] # Optional dependency
|
|
116
116
|
"fastapi_cachex/backends/redis.py" = ["PLR0913", "PLC0415"] # Optional dependency, Redis config
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
"""FastAPI-CacheX: A powerful and flexible caching extension for FastAPI."""
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
|
|
5
|
-
from .cache import cache as cache
|
|
6
|
-
from .cache import default_key_builder as default_key_builder
|
|
7
|
-
from .dependencies import CacheBackend as CacheBackend
|
|
8
|
-
from .dependencies import get_cache_backend as get_cache_backend
|
|
9
|
-
from .proxy import BackendProxy as BackendProxy
|
|
10
|
-
from .routes import add_routes as add_routes
|
|
11
|
-
from .types import CacheKeyBuilder as CacheKeyBuilder
|
|
12
|
-
|
|
13
|
-
_package_logger = logging.getLogger("fastapi_cachex")
|
|
14
|
-
_package_logger.addHandler(
|
|
15
|
-
logging.NullHandler()
|
|
16
|
-
) # Attach a NullHandler to avoid "No handler found" warnings in user applications.
|
|
17
|
-
|
|
18
|
-
# Session management (optional feature)
|
|
19
|
-
__all__ = [
|
|
20
|
-
"BackendProxy",
|
|
21
|
-
"CacheBackend",
|
|
22
|
-
"CacheKeyBuilder",
|
|
23
|
-
"add_routes",
|
|
24
|
-
"cache",
|
|
25
|
-
"default_key_builder",
|
|
26
|
-
"get_cache_backend",
|
|
27
|
-
]
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
"""Configuration models for cache backends."""
|
|
2
|
-
|
|
3
|
-
from pydantic import BaseModel
|
|
4
|
-
from pydantic import Field
|
|
5
|
-
from pydantic import SecretStr
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class RedisConfig(BaseModel):
|
|
9
|
-
"""Configuration for Redis backend."""
|
|
10
|
-
|
|
11
|
-
host: str = Field(default="localhost", description="Redis server address")
|
|
12
|
-
port: int = Field(default=6379, description="Redis server port")
|
|
13
|
-
password: SecretStr | None = Field(
|
|
14
|
-
default=None, description="Redis server password"
|
|
15
|
-
)
|
|
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
|