fastapi-cachex 0.3.0__tar.gz → 0.3.1__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.3.0 → fastapi_cachex-0.3.1}/PKG-INFO +3 -1
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/__init__.py +10 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/memcached.py +3 -1
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/memory.py +5 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/cache.py +42 -4
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/manager.py +64 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/routes.py +6 -2
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/__init__.py +2 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/config.py +35 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/manager.py +6 -4
- fastapi_cachex-0.3.1/fastapi_cachex/session/middleware.py +485 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/state/__init__.py +3 -0
- fastapi_cachex-0.3.1/fastapi_cachex/state/dependencies.py +28 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/state/manager.py +3 -1
- fastapi_cachex-0.3.1/fastapi_cachex/state/proxy.py +9 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/pyproject.toml +5 -1
- fastapi_cachex-0.3.0/fastapi_cachex/session/middleware.py +0 -164
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/README.md +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/base.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/config.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/redis.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/dependencies.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/manager_proxy.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/proxy.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/dependencies.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/exceptions.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/models.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/proxy.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/security.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/token_serializers.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/state/exceptions.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/state/models.py +0 -0
- {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-cachex
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -25,6 +25,7 @@ Requires-Dist: pyjwt>=2.9.0 ; extra == 'jwt'
|
|
|
25
25
|
Requires-Dist: pymemcache ; extra == 'memcache'
|
|
26
26
|
Requires-Dist: redis[hiredis]>=5.3.0 ; extra == 'redis'
|
|
27
27
|
Requires-Dist: orjson ; extra == 'redis'
|
|
28
|
+
Requires-Dist: itsdangerous ; extra == 'starlette'
|
|
28
29
|
Requires-Python: >=3.10
|
|
29
30
|
Project-URL: Homepage, https://github.com/allen0099/FastAPI-CacheX
|
|
30
31
|
Project-URL: Repository, https://github.com/allen0099/FastAPI-CacheX.git
|
|
@@ -32,6 +33,7 @@ Project-URL: Issues, https://github.com/allen0099/FastAPI-CacheX/issues
|
|
|
32
33
|
Provides-Extra: jwt
|
|
33
34
|
Provides-Extra: memcache
|
|
34
35
|
Provides-Extra: redis
|
|
36
|
+
Provides-Extra: starlette
|
|
35
37
|
Description-Content-Type: text/markdown
|
|
36
38
|
|
|
37
39
|
# FastAPI-Cache X
|
|
@@ -4,6 +4,7 @@ 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 .cache import invalidate as invalidate
|
|
7
8
|
from .dependencies import AppCache as AppCache
|
|
8
9
|
from .dependencies import CacheBackend as CacheBackend
|
|
9
10
|
from .dependencies import get_app_cache as get_app_cache
|
|
@@ -12,6 +13,7 @@ from .manager import CacheManager as CacheManager
|
|
|
12
13
|
from .manager_proxy import CacheManagerProxy as CacheManagerProxy
|
|
13
14
|
from .proxy import BackendProxy as BackendProxy
|
|
14
15
|
from .routes import add_routes as add_routes
|
|
16
|
+
from .session import FastAPICacheXSessionMiddleware as FastAPICacheXSessionMiddleware
|
|
15
17
|
from .session import Session as Session
|
|
16
18
|
from .session import SessionConfig as SessionConfig
|
|
17
19
|
from .session import SessionManager as SessionManager
|
|
@@ -34,6 +36,9 @@ from .state import StateDataError as StateDataError
|
|
|
34
36
|
from .state import StateError as StateError
|
|
35
37
|
from .state import StateExpiredError as StateExpiredError
|
|
36
38
|
from .state import StateManager as StateManager
|
|
39
|
+
from .state import StateManagerDep as StateManagerDep
|
|
40
|
+
from .state import StateManagerProxy as StateManagerProxy
|
|
41
|
+
from .state import get_state_manager as get_state_manager
|
|
37
42
|
from .types import CacheKeyBuilder as CacheKeyBuilder
|
|
38
43
|
|
|
39
44
|
_package_logger = logging.getLogger("fastapi_cachex")
|
|
@@ -48,6 +53,7 @@ __all__ = [
|
|
|
48
53
|
"CacheKeyBuilder",
|
|
49
54
|
"CacheManager",
|
|
50
55
|
"CacheManagerProxy",
|
|
56
|
+
"FastAPICacheXSessionMiddleware",
|
|
51
57
|
"InvalidStateError",
|
|
52
58
|
"Session",
|
|
53
59
|
"SessionConfig",
|
|
@@ -66,6 +72,8 @@ __all__ = [
|
|
|
66
72
|
"StateError",
|
|
67
73
|
"StateExpiredError",
|
|
68
74
|
"StateManager",
|
|
75
|
+
"StateManagerDep",
|
|
76
|
+
"StateManagerProxy",
|
|
69
77
|
"add_routes",
|
|
70
78
|
"cache",
|
|
71
79
|
"default_key_builder",
|
|
@@ -74,5 +82,7 @@ __all__ = [
|
|
|
74
82
|
"get_optional_session",
|
|
75
83
|
"get_session",
|
|
76
84
|
"get_session_manager",
|
|
85
|
+
"get_state_manager",
|
|
86
|
+
"invalidate",
|
|
77
87
|
"require_session",
|
|
78
88
|
]
|
|
@@ -177,7 +177,9 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
177
177
|
# Try to delete the prefixed key (exact match only)
|
|
178
178
|
prefixed_key = self._make_key(path)
|
|
179
179
|
try:
|
|
180
|
-
result = await asyncio.to_thread(
|
|
180
|
+
result = await asyncio.to_thread(
|
|
181
|
+
self.client.delete, prefixed_key, noreply=False
|
|
182
|
+
)
|
|
181
183
|
except Exception: # noqa: BLE001
|
|
182
184
|
return 0
|
|
183
185
|
else:
|
|
@@ -183,6 +183,11 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
183
183
|
if fnmatch.fnmatch(cache_path, pattern):
|
|
184
184
|
keys_to_delete.append(key)
|
|
185
185
|
cleared_count += 1
|
|
186
|
+
elif fnmatch.fnmatch(key, pattern):
|
|
187
|
+
# Non-HTTP-cache key (no separators, e.g. CacheManager/
|
|
188
|
+
# StateManager keys) - match against the raw key.
|
|
189
|
+
keys_to_delete.append(key)
|
|
190
|
+
cleared_count += 1
|
|
186
191
|
|
|
187
192
|
for key in keys_to_delete:
|
|
188
193
|
del self.cache[key]
|
|
@@ -61,6 +61,44 @@ def default_key_builder(request: Request) -> str:
|
|
|
61
61
|
return key
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
async def invalidate(
|
|
65
|
+
request: Request,
|
|
66
|
+
key_builder: CacheKeyBuilder | None = None,
|
|
67
|
+
) -> bool:
|
|
68
|
+
"""Invalidate the cache entry a ``@cache``-decorated route would use.
|
|
69
|
+
|
|
70
|
+
Builds the same cache key the ``@cache`` decorator would build for
|
|
71
|
+
``request`` (via ``key_builder`` or ``default_key_builder``) and deletes
|
|
72
|
+
it from the configured backend. Use this after a mutation to bust the
|
|
73
|
+
cache for a specific cached route response.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
request: The request whose cache key should be invalidated. Typically
|
|
77
|
+
a request to the same route/method as the cached one (e.g. build
|
|
78
|
+
it via ``request.app.url_path_for(...)`` for a GET route).
|
|
79
|
+
key_builder: Custom key builder used by the target route's ``@cache``
|
|
80
|
+
decorator, if any. If None, uses ``default_key_builder``.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
True if a cache entry existed and was deleted, False otherwise.
|
|
84
|
+
"""
|
|
85
|
+
builder = key_builder or default_key_builder
|
|
86
|
+
cache_key = builder(request)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
cache_backend = BackendProxy.get()
|
|
90
|
+
except BackendNotFoundError:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
existing = await cache_backend.get(cache_key)
|
|
94
|
+
if existing is None:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
await cache_backend.delete(cache_key)
|
|
98
|
+
logger.debug("Cache INVALIDATE; key=%s", cache_key)
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
64
102
|
class CacheControl:
|
|
65
103
|
"""Manages Cache-Control header directives."""
|
|
66
104
|
|
|
@@ -282,9 +320,7 @@ def cache(
|
|
|
282
320
|
# StreamingResponse/FileResponse — cannot compute ETag; serve as-is
|
|
283
321
|
current_response.headers["Cache-Control"] = cache_control
|
|
284
322
|
return current_response
|
|
285
|
-
current_etag = (
|
|
286
|
-
f'W/"{hashlib.md5(current_body).hexdigest()}"' # noqa: S324
|
|
287
|
-
)
|
|
323
|
+
current_etag = f'W/"{hashlib.md5(current_body).hexdigest()}"' # noqa: S324
|
|
288
324
|
|
|
289
325
|
if client_etag == current_etag:
|
|
290
326
|
# For no-cache, compare fresh data with client's ETag
|
|
@@ -343,7 +379,9 @@ def cache(
|
|
|
343
379
|
|
|
344
380
|
# Update cache if needed
|
|
345
381
|
if not cached_data or cached_data.fingerprint != current_etag:
|
|
346
|
-
assert
|
|
382
|
+
assert (
|
|
383
|
+
current_body is not None
|
|
384
|
+
) # guaranteed by early-return guards above
|
|
347
385
|
# Store in cache if data changed
|
|
348
386
|
await cache_backend.set(
|
|
349
387
|
cache_key,
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"""Generic application-level cache manager for FastAPI-CacheX."""
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
|
+
import inspect
|
|
4
5
|
import json
|
|
5
6
|
import logging
|
|
7
|
+
from collections.abc import Awaitable
|
|
8
|
+
from collections.abc import Callable
|
|
6
9
|
from typing import Any
|
|
7
10
|
|
|
8
11
|
from .backends.base import BaseCacheBackend
|
|
@@ -117,6 +120,67 @@ class CacheManager:
|
|
|
117
120
|
"""
|
|
118
121
|
return await self.backend.get(self._cache_key(key)) is not None
|
|
119
122
|
|
|
123
|
+
async def get_or_set(
|
|
124
|
+
self,
|
|
125
|
+
key: str,
|
|
126
|
+
factory: Callable[[], Any] | Callable[[], Awaitable[Any]],
|
|
127
|
+
ttl: int | None = None,
|
|
128
|
+
) -> Any:
|
|
129
|
+
"""Get a cached value, computing and storing it via ``factory`` on a miss.
|
|
130
|
+
|
|
131
|
+
``factory`` is only invoked when ``key`` is missing, expired, or its
|
|
132
|
+
stored content cannot be decoded; on a hit the cached value is
|
|
133
|
+
returned directly. This method does not provide stampede protection:
|
|
134
|
+
concurrent misses for the same key may each invoke ``factory``.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
key: Logical cache key (without the manager's prefix).
|
|
138
|
+
factory: Zero-argument callable (sync or async) that produces the
|
|
139
|
+
JSON-serializable value to cache on a miss.
|
|
140
|
+
ttl: Time-to-live in seconds for a newly created value. If None,
|
|
141
|
+
uses ``self.default_ttl``.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The cached value (existing or newly created).
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
TypeError: If the value produced by ``factory`` is not JSON-serializable.
|
|
148
|
+
"""
|
|
149
|
+
sentinel = object()
|
|
150
|
+
cached = await self.get(key, default=sentinel)
|
|
151
|
+
if cached is not sentinel:
|
|
152
|
+
return cached
|
|
153
|
+
|
|
154
|
+
if inspect.iscoroutinefunction(factory):
|
|
155
|
+
value = await factory()
|
|
156
|
+
else:
|
|
157
|
+
value = factory()
|
|
158
|
+
|
|
159
|
+
await self.set(key, value, ttl=ttl)
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
async def clear_pattern(self, pattern: str) -> int:
|
|
163
|
+
"""Clear all keys under this manager's namespace matching a glob pattern.
|
|
164
|
+
|
|
165
|
+
Delegates to the backend's native ``clear_pattern`` (e.g. Redis ``SCAN``),
|
|
166
|
+
which can be more efficient than ``clear_prefix``'s full key-space scan.
|
|
167
|
+
Note that backends without key-enumeration support (e.g. Memcached)
|
|
168
|
+
cannot honor this and will return 0 with a ``RuntimeWarning``.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
pattern: Glob pattern (relative to ``self.key_prefix``) to match
|
|
172
|
+
against, e.g. ``"user:*"``.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Number of cache entries cleared.
|
|
176
|
+
"""
|
|
177
|
+
match_pattern = self._cache_key(pattern)
|
|
178
|
+
cleared = await self.backend.clear_pattern(match_pattern)
|
|
179
|
+
logger.debug(
|
|
180
|
+
"Cache CLEAR_PATTERN; pattern=%s removed=%s", match_pattern, cleared
|
|
181
|
+
)
|
|
182
|
+
return cleared
|
|
183
|
+
|
|
120
184
|
async def clear_prefix(self, prefix: str | None = None) -> int:
|
|
121
185
|
"""Clear all keys under this manager's namespace matching a sub-prefix.
|
|
122
186
|
|
|
@@ -129,7 +129,9 @@ async def _get_cached_hits_handler(backend: BaseCacheBackend) -> CacheHitsRespon
|
|
|
129
129
|
if method: # Valid cache key
|
|
130
130
|
# Check if cache entry is expired
|
|
131
131
|
is_expired = expiry is not None and expiry <= now
|
|
132
|
-
ttl_remaining =
|
|
132
|
+
ttl_remaining = (
|
|
133
|
+
max(0.0, round(expiry - now, 2)) if expiry is not None else None
|
|
134
|
+
)
|
|
133
135
|
|
|
134
136
|
cached_hits.append(
|
|
135
137
|
CacheHitRecord(
|
|
@@ -188,7 +190,9 @@ async def _get_cached_records_handler(
|
|
|
188
190
|
content = entry.content
|
|
189
191
|
content_size = len(content) if isinstance(content, (bytes, str)) else 0
|
|
190
192
|
|
|
191
|
-
ttl_remaining =
|
|
193
|
+
ttl_remaining = (
|
|
194
|
+
max(0.0, round(expiry - now, 2)) if expiry is not None else None
|
|
195
|
+
)
|
|
192
196
|
|
|
193
197
|
content_preview = (
|
|
194
198
|
content[:100].decode("utf-8", errors="ignore")
|
|
@@ -6,12 +6,14 @@ from .dependencies import get_session
|
|
|
6
6
|
from .dependencies import get_session_manager
|
|
7
7
|
from .dependencies import require_session
|
|
8
8
|
from .manager import SessionManager
|
|
9
|
+
from .middleware import FastAPICacheXSessionMiddleware
|
|
9
10
|
from .middleware import SessionMiddleware
|
|
10
11
|
from .models import Session
|
|
11
12
|
from .models import SessionUser
|
|
12
13
|
from .proxy import SessionManagerProxy
|
|
13
14
|
|
|
14
15
|
__all__ = [
|
|
16
|
+
"FastAPICacheXSessionMiddleware",
|
|
15
17
|
"Session",
|
|
16
18
|
"SessionConfig",
|
|
17
19
|
"SessionManager",
|
|
@@ -3,13 +3,18 @@
|
|
|
3
3
|
from typing import Literal
|
|
4
4
|
|
|
5
5
|
from pydantic import BaseModel
|
|
6
|
+
from pydantic import ConfigDict
|
|
6
7
|
from pydantic import Field
|
|
7
8
|
from pydantic import SecretStr
|
|
8
9
|
|
|
10
|
+
SameSitePolicy = Literal["lax", "strict", "none"]
|
|
11
|
+
|
|
9
12
|
|
|
10
13
|
class SessionConfig(BaseModel):
|
|
11
14
|
"""Session configuration settings."""
|
|
12
15
|
|
|
16
|
+
model_config = ConfigDict(extra="forbid")
|
|
17
|
+
|
|
13
18
|
# Session lifetime
|
|
14
19
|
session_ttl: int = Field(
|
|
15
20
|
default=3600,
|
|
@@ -87,3 +92,33 @@ class SessionConfig(BaseModel):
|
|
|
87
92
|
default="session:",
|
|
88
93
|
description="Prefix for session keys in backend storage",
|
|
89
94
|
)
|
|
95
|
+
|
|
96
|
+
# Cookie settings (FastAPICacheXSessionMiddleware only)
|
|
97
|
+
cookie_name: str = Field(
|
|
98
|
+
default="session",
|
|
99
|
+
description="Name of the cookie used to store the session token "
|
|
100
|
+
"(FastAPICacheXSessionMiddleware only)",
|
|
101
|
+
)
|
|
102
|
+
cookie_max_age: int | None = Field(
|
|
103
|
+
default=14 * 24 * 60 * 60,
|
|
104
|
+
description="Max-Age (seconds) for the session cookie; None disables "
|
|
105
|
+
"Max-Age/Expires (session cookie deleted when browser closes)",
|
|
106
|
+
)
|
|
107
|
+
cookie_path: str = Field(
|
|
108
|
+
default="/",
|
|
109
|
+
description="Path attribute for the session cookie",
|
|
110
|
+
)
|
|
111
|
+
cookie_same_site: SameSitePolicy = Field(
|
|
112
|
+
default="lax",
|
|
113
|
+
description="SameSite attribute for the session cookie",
|
|
114
|
+
)
|
|
115
|
+
cookie_https_only: bool = Field(
|
|
116
|
+
default=False,
|
|
117
|
+
description="Whether to set the Secure flag on the session cookie "
|
|
118
|
+
"(cookie only sent over HTTPS)",
|
|
119
|
+
)
|
|
120
|
+
cookie_domain: str | None = Field(
|
|
121
|
+
default=None,
|
|
122
|
+
description="Domain attribute for the session cookie; None omits the "
|
|
123
|
+
"Domain attribute",
|
|
124
|
+
)
|
|
@@ -76,7 +76,7 @@ class SessionManager:
|
|
|
76
76
|
user: SessionUser,
|
|
77
77
|
ip_address: str | None = None,
|
|
78
78
|
user_agent: str | None = None,
|
|
79
|
-
**extra_data:
|
|
79
|
+
**extra_data: object,
|
|
80
80
|
) -> tuple[Session, str]:
|
|
81
81
|
"""Create a new session for an authenticated user.
|
|
82
82
|
|
|
@@ -100,7 +100,7 @@ class SessionManager:
|
|
|
100
100
|
self,
|
|
101
101
|
ip_address: str | None = None,
|
|
102
102
|
user_agent: str | None = None,
|
|
103
|
-
**extra_data:
|
|
103
|
+
**extra_data: object,
|
|
104
104
|
) -> tuple[Session, str]:
|
|
105
105
|
"""Create a new session without user information."""
|
|
106
106
|
return await self._create_session(
|
|
@@ -115,7 +115,7 @@ class SessionManager:
|
|
|
115
115
|
user: SessionUser | None,
|
|
116
116
|
ip_address: str | None = None,
|
|
117
117
|
user_agent: str | None = None,
|
|
118
|
-
**extra_data:
|
|
118
|
+
**extra_data: object,
|
|
119
119
|
) -> tuple[Session, str]:
|
|
120
120
|
"""Internal helper to create and persist a session."""
|
|
121
121
|
session = Session(
|
|
@@ -449,7 +449,9 @@ class SessionManager:
|
|
|
449
449
|
ttl = max(ttl, 1) # Ensure at least 1 second
|
|
450
450
|
|
|
451
451
|
fingerprint = self.security.hash_data(value.decode("utf-8"))
|
|
452
|
-
await self.backend.set(
|
|
452
|
+
await self.backend.set(
|
|
453
|
+
key, CacheEntry(fingerprint=fingerprint, content=value), ttl=ttl
|
|
454
|
+
)
|
|
453
455
|
logger.debug("Session saved; id=%s ttl=%s", session.session_id, ttl)
|
|
454
456
|
|
|
455
457
|
async def _load_session(self, session_id: str) -> Session | None:
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""Session middleware for FastAPI."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import Request
|
|
9
|
+
from fastapi import Response
|
|
10
|
+
from starlette.datastructures import MutableHeaders
|
|
11
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
12
|
+
from starlette.middleware.base import RequestResponseEndpoint
|
|
13
|
+
from starlette.requests import HTTPConnection
|
|
14
|
+
from starlette.types import ASGIApp
|
|
15
|
+
from starlette.types import Message
|
|
16
|
+
from starlette.types import Receive
|
|
17
|
+
from starlette.types import Scope
|
|
18
|
+
from starlette.types import Send
|
|
19
|
+
|
|
20
|
+
from .config import SessionConfig
|
|
21
|
+
from .exceptions import SessionError
|
|
22
|
+
from .manager import SessionManager
|
|
23
|
+
from .proxy import SessionManagerProxy
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
# starlette.middleware.sessions unconditionally imports itsdangerous, an
|
|
27
|
+
# optional dependency (fastapi-cachex[starlette]) only needed at runtime by
|
|
28
|
+
# FastAPICacheXSessionMiddleware; import it lazily there instead (see its
|
|
29
|
+
# __init__) so the rest of this module doesn't require it.
|
|
30
|
+
from starlette.middleware.sessions import Session as StarletteSession
|
|
31
|
+
|
|
32
|
+
from .models import Session
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_client_ip(connection: HTTPConnection) -> str | None:
|
|
38
|
+
"""Get client IP address from an HTTP connection.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
connection: Incoming HTTP connection (or a `Request`, which IS-A
|
|
42
|
+
`HTTPConnection`)
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Client IP address or None
|
|
46
|
+
"""
|
|
47
|
+
# Check X-Forwarded-For header (for proxied requests)
|
|
48
|
+
forwarded_for = connection.headers.get("x-forwarded-for")
|
|
49
|
+
if forwarded_for:
|
|
50
|
+
# Get first IP from comma-separated list
|
|
51
|
+
ip = forwarded_for.split(",")[0].strip()
|
|
52
|
+
logger.debug("Client IP from X-Forwarded-For: %s", ip)
|
|
53
|
+
return ip
|
|
54
|
+
|
|
55
|
+
# Check X-Real-IP header
|
|
56
|
+
real_ip = connection.headers.get("x-real-ip")
|
|
57
|
+
if real_ip:
|
|
58
|
+
logger.debug("Client IP from X-Real-IP: %s", real_ip)
|
|
59
|
+
return real_ip
|
|
60
|
+
|
|
61
|
+
# Fallback to direct client IP
|
|
62
|
+
if connection.client:
|
|
63
|
+
logger.debug("Client IP from connection: %s", connection.client.host)
|
|
64
|
+
return connection.client.host
|
|
65
|
+
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _extract_header_token(
|
|
70
|
+
connection: HTTPConnection, config: SessionConfig
|
|
71
|
+
) -> str | None:
|
|
72
|
+
"""Extract a session token from request headers.
|
|
73
|
+
|
|
74
|
+
Honours ``SessionConfig.token_source_priority``: checks the configured
|
|
75
|
+
custom header (``config.header_name``) and/or an ``Authorization: Bearer``
|
|
76
|
+
token. This is the header/bearer transport shared with ``SessionMiddleware``.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
connection: Incoming HTTP connection (or a `Request`, which IS-A
|
|
80
|
+
`HTTPConnection`)
|
|
81
|
+
config: Session configuration
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Session token or None
|
|
85
|
+
"""
|
|
86
|
+
for source in config.token_source_priority:
|
|
87
|
+
if source == "header":
|
|
88
|
+
token = connection.headers.get(config.header_name)
|
|
89
|
+
if token:
|
|
90
|
+
logger.debug("Token extracted from header")
|
|
91
|
+
return token
|
|
92
|
+
|
|
93
|
+
elif source == "bearer":
|
|
94
|
+
if config.use_bearer_token:
|
|
95
|
+
auth_header = connection.headers.get("authorization")
|
|
96
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
97
|
+
bearer_prefix_len = 7
|
|
98
|
+
token_value = auth_header[bearer_prefix_len:]
|
|
99
|
+
logger.debug("Token extracted from bearer auth")
|
|
100
|
+
return token_value
|
|
101
|
+
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _stash_session_manager(app: Any, manager: SessionManager) -> None:
|
|
106
|
+
"""Register the session manager on ``app.state`` for dependency injection.
|
|
107
|
+
|
|
108
|
+
Stored under a private key on the first request only; ``get_session_manager``
|
|
109
|
+
reads it back.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
app: The Starlette application (``scope["app"]`` / ``request.app``)
|
|
113
|
+
manager: Session manager to register
|
|
114
|
+
"""
|
|
115
|
+
if not hasattr(app.state, "__fastapi_cachex_session_manager"):
|
|
116
|
+
setattr(app.state, "__fastapi_cachex_session_manager", manager)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class SessionMiddleware(BaseHTTPMiddleware):
|
|
120
|
+
"""Middleware to handle session loading and token extraction.
|
|
121
|
+
|
|
122
|
+
Extracts the session token from the request (via a custom header and/or
|
|
123
|
+
an ``Authorization: Bearer`` header, per ``SessionConfig.token_source_priority``)
|
|
124
|
+
and loads the corresponding session into ``request.state``. Cookie-based
|
|
125
|
+
token transport is not currently supported.
|
|
126
|
+
|
|
127
|
+
.. deprecated:: 0.3.1
|
|
128
|
+
Use :class:`FastAPICacheXSessionMiddleware` instead. Will be removed in
|
|
129
|
+
version 0.3.5.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
app: ASGIApp,
|
|
135
|
+
session_manager: SessionManager | None = None,
|
|
136
|
+
config: SessionConfig | None = None,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Initialize session middleware.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
app: ASGI application
|
|
142
|
+
session_manager: Session manager instance
|
|
143
|
+
config: Session configuration
|
|
144
|
+
"""
|
|
145
|
+
warnings.warn(
|
|
146
|
+
"SessionMiddleware is deprecated, use FastAPICacheXSessionMiddleware. "
|
|
147
|
+
"Will be removed in version 0.3.5.",
|
|
148
|
+
DeprecationWarning,
|
|
149
|
+
stacklevel=2,
|
|
150
|
+
)
|
|
151
|
+
super().__init__(app)
|
|
152
|
+
self.session_manager = session_manager or SessionManagerProxy.get()
|
|
153
|
+
|
|
154
|
+
if config is None:
|
|
155
|
+
config = self.session_manager.config
|
|
156
|
+
|
|
157
|
+
self.config = config
|
|
158
|
+
|
|
159
|
+
logger.debug(
|
|
160
|
+
"SessionMiddleware initialized; header=%s bearer=%s",
|
|
161
|
+
config.header_name,
|
|
162
|
+
config.use_bearer_token,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
async def dispatch(
|
|
166
|
+
self,
|
|
167
|
+
request: Request,
|
|
168
|
+
call_next: RequestResponseEndpoint,
|
|
169
|
+
) -> Response:
|
|
170
|
+
"""Process request and handle session.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
request: Incoming request
|
|
174
|
+
call_next: Next handler in chain
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Response
|
|
178
|
+
"""
|
|
179
|
+
# Store session manager in app state for dependency injection (first request only)
|
|
180
|
+
# This allows developers to use get_session_manager() dependency
|
|
181
|
+
if not hasattr(request.app.state, "__fastapi_cachex_session_manager"):
|
|
182
|
+
setattr(
|
|
183
|
+
request.app.state,
|
|
184
|
+
"__fastapi_cachex_session_manager",
|
|
185
|
+
self.session_manager,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Extract session token from request
|
|
189
|
+
token = self._extract_token(request)
|
|
190
|
+
|
|
191
|
+
# Try to load session
|
|
192
|
+
session: Session | None = None
|
|
193
|
+
renewed_token: str | None = None
|
|
194
|
+
if token:
|
|
195
|
+
try:
|
|
196
|
+
ip_address = self._get_client_ip(request)
|
|
197
|
+
user_agent = request.headers.get("user-agent")
|
|
198
|
+
session, renewed_token = await self.session_manager.get_session(
|
|
199
|
+
token,
|
|
200
|
+
ip_address=ip_address,
|
|
201
|
+
user_agent=user_agent,
|
|
202
|
+
)
|
|
203
|
+
logger.debug("Session loaded in middleware; id=%s", session.session_id)
|
|
204
|
+
except SessionError:
|
|
205
|
+
# Session invalid/expired, continue without session
|
|
206
|
+
session = None
|
|
207
|
+
logger.debug("Session failed to load; token invalid/expired")
|
|
208
|
+
|
|
209
|
+
# Store session in request state
|
|
210
|
+
setattr(request.state, "__fastapi_cachex_session", session)
|
|
211
|
+
|
|
212
|
+
# Process request
|
|
213
|
+
response: Response = await call_next(request)
|
|
214
|
+
|
|
215
|
+
# Propagate renewed token to client so its JWT exp stays in sync
|
|
216
|
+
if renewed_token is not None:
|
|
217
|
+
response.headers[self.config.header_name] = renewed_token
|
|
218
|
+
|
|
219
|
+
return response
|
|
220
|
+
|
|
221
|
+
def _extract_token(self, request: Request) -> str | None:
|
|
222
|
+
"""Extract session token from request.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
request: Incoming request
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Session token or None
|
|
229
|
+
"""
|
|
230
|
+
return _extract_header_token(request, self.config)
|
|
231
|
+
|
|
232
|
+
def _get_client_ip(self, request: Request) -> str | None:
|
|
233
|
+
"""Get client IP address from request.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
request: Incoming request
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Client IP address or None
|
|
240
|
+
"""
|
|
241
|
+
return _get_client_ip(request)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class FastAPICacheXSessionMiddleware:
|
|
245
|
+
"""Drop-in-compatible replacement for Starlette's ``SessionMiddleware``.
|
|
246
|
+
|
|
247
|
+
Provides the same ``request.session`` / ``scope["session"]`` dict-like
|
|
248
|
+
interface as ``starlette.middleware.sessions.SessionMiddleware``, but the
|
|
249
|
+
session payload is persisted via the configured ``SessionManager``/cache
|
|
250
|
+
backend instead of being encoded into the cookie itself. Only a signed
|
|
251
|
+
session token is stored client-side, in the cookie named by
|
|
252
|
+
``SessionConfig.cookie_name``.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
def __init__(
|
|
256
|
+
self,
|
|
257
|
+
app: ASGIApp,
|
|
258
|
+
session_manager: SessionManager | None = None,
|
|
259
|
+
config: SessionConfig | None = None,
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Initialize the Starlette-aligned session middleware.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
app: ASGI application
|
|
265
|
+
session_manager: Session manager instance
|
|
266
|
+
config: Session configuration
|
|
267
|
+
"""
|
|
268
|
+
try:
|
|
269
|
+
from starlette.middleware.sessions import Session as _StarletteSession
|
|
270
|
+
except ImportError as e: # pragma: no cover
|
|
271
|
+
msg = (
|
|
272
|
+
"FastAPICacheXSessionMiddleware requires itsdangerous; "
|
|
273
|
+
"install fastapi-cachex[starlette]"
|
|
274
|
+
)
|
|
275
|
+
raise ImportError(msg) from e
|
|
276
|
+
self._session_cls: type[StarletteSession] = _StarletteSession
|
|
277
|
+
|
|
278
|
+
self.app = app
|
|
279
|
+
self.session_manager = session_manager or SessionManagerProxy.get()
|
|
280
|
+
self.config = config or self.session_manager.config
|
|
281
|
+
|
|
282
|
+
security_flags = f"httponly; samesite={self.config.cookie_same_site}"
|
|
283
|
+
if self.config.cookie_https_only:
|
|
284
|
+
security_flags += "; secure"
|
|
285
|
+
if self.config.cookie_domain is not None:
|
|
286
|
+
security_flags += f"; domain={self.config.cookie_domain}"
|
|
287
|
+
self._security_flags = security_flags
|
|
288
|
+
|
|
289
|
+
logger.debug(
|
|
290
|
+
"FastAPICacheXSessionMiddleware initialized; cookie=%s path=%s",
|
|
291
|
+
self.config.cookie_name,
|
|
292
|
+
self.config.cookie_path,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
296
|
+
"""Load the session for the connection and persist it on response.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
scope: ASGI connection scope
|
|
300
|
+
receive: ASGI receive callable
|
|
301
|
+
send: ASGI send callable
|
|
302
|
+
"""
|
|
303
|
+
if scope["type"] not in ("http", "websocket"):
|
|
304
|
+
await self.app(scope, receive, send)
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
# Store session manager in app state for dependency injection (first request only)
|
|
308
|
+
_stash_session_manager(scope["app"], self.session_manager)
|
|
309
|
+
|
|
310
|
+
connection = HTTPConnection(scope)
|
|
311
|
+
initial_session_was_empty = True
|
|
312
|
+
loaded_token: str | None = None
|
|
313
|
+
renewed_token: str | None = None
|
|
314
|
+
backend_session: Session | None = None
|
|
315
|
+
|
|
316
|
+
# Resolve the incoming session token: prefer the header/bearer transport
|
|
317
|
+
# (e.g. X-Session-Token, as used by SessionMiddleware) and fall back to
|
|
318
|
+
# the session cookie, so header-based clients authenticate here too.
|
|
319
|
+
# `header_token` is captured so the response is routed by transport: a
|
|
320
|
+
# header-sourced token is echoed back via the response header, otherwise
|
|
321
|
+
# via Set-Cookie (see send_wrapper).
|
|
322
|
+
header_token = _extract_header_token(connection, self.config)
|
|
323
|
+
token_value = header_token or connection.cookies.get(self.config.cookie_name)
|
|
324
|
+
if token_value:
|
|
325
|
+
try:
|
|
326
|
+
ip_address = _get_client_ip(connection)
|
|
327
|
+
user_agent = connection.headers.get("user-agent")
|
|
328
|
+
backend_session, renewed_token = await self.session_manager.get_session(
|
|
329
|
+
token_value,
|
|
330
|
+
ip_address=ip_address,
|
|
331
|
+
user_agent=user_agent,
|
|
332
|
+
)
|
|
333
|
+
loaded_token = renewed_token or token_value
|
|
334
|
+
scope["session"] = self._session_cls(backend_session.data)
|
|
335
|
+
initial_session_was_empty = not backend_session.data
|
|
336
|
+
except SessionError:
|
|
337
|
+
logger.debug(
|
|
338
|
+
"FastAPICacheXSessionMiddleware: token invalid/expired; "
|
|
339
|
+
"starting empty session",
|
|
340
|
+
)
|
|
341
|
+
scope["session"] = self._session_cls()
|
|
342
|
+
else:
|
|
343
|
+
scope["session"] = self._session_cls()
|
|
344
|
+
|
|
345
|
+
scope.setdefault("state", {})["__fastapi_cachex_session"] = backend_session
|
|
346
|
+
|
|
347
|
+
# Route the response by how the token arrived: header-sourced tokens are
|
|
348
|
+
# echoed back via the response header (no cookies); cookie-sourced (or
|
|
349
|
+
# brand-new) sessions use Set-Cookie.
|
|
350
|
+
from_header = header_token is not None
|
|
351
|
+
|
|
352
|
+
async def send_wrapper(message: Message) -> None:
|
|
353
|
+
if message["type"] == "http.response.start":
|
|
354
|
+
session: StarletteSession = scope["session"]
|
|
355
|
+
headers = MutableHeaders(scope=message)
|
|
356
|
+
|
|
357
|
+
if session.accessed:
|
|
358
|
+
headers.add_vary_header("Cookie")
|
|
359
|
+
|
|
360
|
+
if session.modified and session:
|
|
361
|
+
cookie_token, new_token = await self._write_session(
|
|
362
|
+
session,
|
|
363
|
+
connection,
|
|
364
|
+
backend_session,
|
|
365
|
+
loaded_token,
|
|
366
|
+
renewed_token,
|
|
367
|
+
)
|
|
368
|
+
# Header clients only need a genuinely new/renewed token (an
|
|
369
|
+
# unchanged one is already held); cookie clients always get a
|
|
370
|
+
# refreshed cookie.
|
|
371
|
+
token_to_emit = new_token if from_header else cookie_token
|
|
372
|
+
if token_to_emit is not None:
|
|
373
|
+
self._emit_token(
|
|
374
|
+
headers, token_to_emit, from_header=from_header
|
|
375
|
+
)
|
|
376
|
+
elif session.modified and not initial_session_was_empty:
|
|
377
|
+
# Cleared -> delete backend session. backend_session is always
|
|
378
|
+
# set when initial_session_was_empty is False (both are only set
|
|
379
|
+
# together, after a successful get_session() call above).
|
|
380
|
+
assert backend_session is not None # noqa: S101
|
|
381
|
+
await self.session_manager.delete_session(
|
|
382
|
+
backend_session.session_id
|
|
383
|
+
)
|
|
384
|
+
if not from_header:
|
|
385
|
+
# Cookie transport: expire the cookie. A header-based client
|
|
386
|
+
# simply drops its now-dangling token (record is deleted).
|
|
387
|
+
headers.append("Set-Cookie", self._build_clear_cookie_header())
|
|
388
|
+
elif renewed_token is not None:
|
|
389
|
+
# Sliding expiration renewed the token even though the dict
|
|
390
|
+
# itself was untouched; propagate it via the same transport.
|
|
391
|
+
self._emit_token(headers, renewed_token, from_header=from_header)
|
|
392
|
+
|
|
393
|
+
await send(message)
|
|
394
|
+
|
|
395
|
+
await self.app(scope, receive, send_wrapper)
|
|
396
|
+
|
|
397
|
+
def _emit_token(
|
|
398
|
+
self, headers: MutableHeaders, token: str, *, from_header: bool
|
|
399
|
+
) -> None:
|
|
400
|
+
"""Send a session token to the client via its transport.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
headers: Mutable response headers to write to
|
|
404
|
+
token: Session token string to deliver
|
|
405
|
+
from_header: If True, echo via the configured response header;
|
|
406
|
+
otherwise (re)set it as a Set-Cookie header.
|
|
407
|
+
"""
|
|
408
|
+
if from_header:
|
|
409
|
+
headers.append(self.config.header_name, token)
|
|
410
|
+
else:
|
|
411
|
+
headers.append("Set-Cookie", self._build_set_cookie_header(token))
|
|
412
|
+
|
|
413
|
+
async def _write_session(
|
|
414
|
+
self,
|
|
415
|
+
session: "StarletteSession",
|
|
416
|
+
connection: HTTPConnection,
|
|
417
|
+
backend_session: "Session | None",
|
|
418
|
+
loaded_token: str | None,
|
|
419
|
+
renewed_token: str | None,
|
|
420
|
+
) -> tuple[str, str | None]:
|
|
421
|
+
"""Create-or-update the backend session for a modified, non-empty dict.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
session: The Starlette session dict for this request
|
|
425
|
+
connection: Incoming HTTP connection (for IP / User-Agent binding)
|
|
426
|
+
backend_session: Loaded backend session, or None for a new session
|
|
427
|
+
loaded_token: Token for the loaded session (None when creating anew)
|
|
428
|
+
renewed_token: Sliding-expiration renewed token, if any
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
``(cookie_token, new_token)`` where ``cookie_token`` is the token to
|
|
432
|
+
(re)set as a cookie, and ``new_token`` is a genuinely new/renewed token
|
|
433
|
+
to hand a header-based client (``None`` when the token is unchanged).
|
|
434
|
+
"""
|
|
435
|
+
if backend_session is None:
|
|
436
|
+
(
|
|
437
|
+
backend_session,
|
|
438
|
+
loaded_token,
|
|
439
|
+
) = await self.session_manager.create_anonymous_session(
|
|
440
|
+
ip_address=_get_client_ip(connection),
|
|
441
|
+
user_agent=connection.headers.get("user-agent"),
|
|
442
|
+
)
|
|
443
|
+
new_token: str | None = loaded_token
|
|
444
|
+
else:
|
|
445
|
+
# backend_session and loaded_token are always set together (after a
|
|
446
|
+
# successful get_session() call).
|
|
447
|
+
assert loaded_token is not None # noqa: S101
|
|
448
|
+
new_token = renewed_token
|
|
449
|
+
backend_session.data = dict(session)
|
|
450
|
+
await self.session_manager.update_session(backend_session)
|
|
451
|
+
return loaded_token, new_token
|
|
452
|
+
|
|
453
|
+
def _build_set_cookie_header(self, token: str) -> str:
|
|
454
|
+
"""Build a `Set-Cookie` header value carrying the session token.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
token: Signed session token string
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
Set-Cookie header value
|
|
461
|
+
"""
|
|
462
|
+
max_age = (
|
|
463
|
+
f"Max-Age={self.config.cookie_max_age}; "
|
|
464
|
+
if self.config.cookie_max_age
|
|
465
|
+
else ""
|
|
466
|
+
)
|
|
467
|
+
return (
|
|
468
|
+
f"{self.config.cookie_name}={token}; "
|
|
469
|
+
f"path={self.config.cookie_path}; "
|
|
470
|
+
f"{max_age}"
|
|
471
|
+
f"{self._security_flags}"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def _build_clear_cookie_header(self) -> str:
|
|
475
|
+
"""Build a `Set-Cookie` header value that expires the session cookie.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
Set-Cookie header value
|
|
479
|
+
"""
|
|
480
|
+
return (
|
|
481
|
+
f"{self.config.cookie_name}=; "
|
|
482
|
+
f"path={self.config.cookie_path}; "
|
|
483
|
+
f"expires=Thu, 01 Jan 1970 00:00:00 GMT; "
|
|
484
|
+
f"{self._security_flags}"
|
|
485
|
+
)
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"""State management extension for FastAPI-CacheX."""
|
|
2
2
|
|
|
3
|
+
from .dependencies import StateManagerDep as StateManagerDep
|
|
4
|
+
from .dependencies import get_state_manager as get_state_manager
|
|
3
5
|
from .exceptions import InvalidStateError as InvalidStateError
|
|
4
6
|
from .exceptions import StateDataError as StateDataError
|
|
5
7
|
from .exceptions import StateError as StateError
|
|
6
8
|
from .exceptions import StateExpiredError as StateExpiredError
|
|
7
9
|
from .manager import StateManager as StateManager
|
|
8
10
|
from .models import StateData as StateData
|
|
11
|
+
from .proxy import StateManagerProxy as StateManagerProxy
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""FastAPI dependency injection utilities for state management."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends
|
|
6
|
+
|
|
7
|
+
from fastapi_cachex.exceptions import BackendNotFoundError
|
|
8
|
+
|
|
9
|
+
from .manager import StateManager
|
|
10
|
+
from .proxy import StateManagerProxy
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_state_manager() -> StateManager:
|
|
14
|
+
"""Dependency to get the application StateManager instance.
|
|
15
|
+
|
|
16
|
+
Lazily creates and registers a default StateManager (backed by
|
|
17
|
+
BackendProxy) the first time it's requested, unless one was already
|
|
18
|
+
set via StateManagerProxy.set(...).
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
return StateManagerProxy.get()
|
|
22
|
+
except BackendNotFoundError:
|
|
23
|
+
manager = StateManager()
|
|
24
|
+
StateManagerProxy.set(manager)
|
|
25
|
+
return manager
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
StateManagerDep = Annotated[StateManager, Depends(get_state_manager)]
|
|
@@ -125,7 +125,9 @@ class StateManager:
|
|
|
125
125
|
fingerprint = hashlib.sha256(json_content.encode()).hexdigest()
|
|
126
126
|
|
|
127
127
|
cache_key = f"{self.key_prefix}{state}"
|
|
128
|
-
entry = CacheEntry(
|
|
128
|
+
entry = CacheEntry(
|
|
129
|
+
fingerprint=fingerprint, content=json_content.encode("utf-8")
|
|
130
|
+
)
|
|
129
131
|
await self.backend.set(cache_key, entry, ttl=effective_ttl)
|
|
130
132
|
|
|
131
133
|
logger.debug("OAuth state created; state=%s ttl=%s", state, effective_ttl)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""FastAPI CacheX Proxy for state manager management."""
|
|
2
|
+
|
|
3
|
+
from fastapi_cachex.proxy import ProxyBase
|
|
4
|
+
|
|
5
|
+
from .manager import StateManager
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StateManagerProxy(ProxyBase[StateManager]):
|
|
9
|
+
"""FastAPI CacheX Proxy for StateManager instance management."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-cachex"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.1"
|
|
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"
|
|
@@ -58,6 +58,7 @@ dev = [
|
|
|
58
58
|
memcache = ["pymemcache"]
|
|
59
59
|
redis = ["redis[hiredis]>=5.3.0", "orjson"]
|
|
60
60
|
jwt = ["PyJWT>=2.9.0"]
|
|
61
|
+
starlette = ["itsdangerous"]
|
|
61
62
|
|
|
62
63
|
[build-system]
|
|
63
64
|
requires = ["uv_build>=0.11.26,<0.12"]
|
|
@@ -111,10 +112,13 @@ keep-runtime-typing = true
|
|
|
111
112
|
]
|
|
112
113
|
"fastapi_cachex/cache.py" = [
|
|
113
114
|
"PLR0913", "PLR0915", "PLR0911", "PLR0912", # Many arguments/statements/returns/branches needed for flexible caching logic
|
|
115
|
+
"S101", # Internal invariant guard, not a validation shortcut
|
|
114
116
|
]
|
|
115
117
|
"fastapi_cachex/backends/memcached.py" = ["PLC0415"] # Optional dependency
|
|
116
118
|
"fastapi_cachex/backends/redis.py" = ["PLR0913", "PLC0415"] # Optional dependency, Redis config
|
|
117
119
|
"fastapi_cachex/proxy.py" = ["PLW0603"] # Global backend management by design
|
|
120
|
+
"fastapi_cachex/session/manager.py" = ["PLR0915"] # Session/security validation branches needed
|
|
121
|
+
"fastapi_cachex/session/middleware.py" = ["PLC0415"] # Optional dependency (itsdangerous)
|
|
118
122
|
|
|
119
123
|
[tool.ruff.format]
|
|
120
124
|
docstring-code-format = true
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
"""Session middleware for FastAPI."""
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
from typing import TYPE_CHECKING
|
|
5
|
-
|
|
6
|
-
from fastapi import Request
|
|
7
|
-
from fastapi import Response
|
|
8
|
-
from starlette.middleware.base import BaseHTTPMiddleware
|
|
9
|
-
from starlette.middleware.base import RequestResponseEndpoint
|
|
10
|
-
from starlette.types import ASGIApp
|
|
11
|
-
|
|
12
|
-
from .config import SessionConfig
|
|
13
|
-
from .exceptions import SessionError
|
|
14
|
-
from .manager import SessionManager
|
|
15
|
-
from .proxy import SessionManagerProxy
|
|
16
|
-
|
|
17
|
-
if TYPE_CHECKING:
|
|
18
|
-
from .models import Session
|
|
19
|
-
|
|
20
|
-
logger = logging.getLogger(__name__)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class SessionMiddleware(BaseHTTPMiddleware):
|
|
24
|
-
"""Middleware to handle session loading and cookie management."""
|
|
25
|
-
|
|
26
|
-
def __init__(
|
|
27
|
-
self,
|
|
28
|
-
app: ASGIApp,
|
|
29
|
-
session_manager: SessionManager | None = None,
|
|
30
|
-
config: SessionConfig | None = None,
|
|
31
|
-
) -> None:
|
|
32
|
-
"""Initialize session middleware.
|
|
33
|
-
|
|
34
|
-
Args:
|
|
35
|
-
app: ASGI application
|
|
36
|
-
session_manager: Session manager instance
|
|
37
|
-
config: Session configuration
|
|
38
|
-
"""
|
|
39
|
-
super().__init__(app)
|
|
40
|
-
self.session_manager = session_manager or SessionManagerProxy.get()
|
|
41
|
-
|
|
42
|
-
if config is None:
|
|
43
|
-
config = self.session_manager.config
|
|
44
|
-
|
|
45
|
-
self.config = config
|
|
46
|
-
|
|
47
|
-
logger.debug(
|
|
48
|
-
"SessionMiddleware initialized; header=%s bearer=%s",
|
|
49
|
-
config.header_name,
|
|
50
|
-
config.use_bearer_token,
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
async def dispatch(
|
|
54
|
-
self,
|
|
55
|
-
request: Request,
|
|
56
|
-
call_next: RequestResponseEndpoint,
|
|
57
|
-
) -> Response:
|
|
58
|
-
"""Process request and handle session.
|
|
59
|
-
|
|
60
|
-
Args:
|
|
61
|
-
request: Incoming request
|
|
62
|
-
call_next: Next handler in chain
|
|
63
|
-
|
|
64
|
-
Returns:
|
|
65
|
-
Response
|
|
66
|
-
"""
|
|
67
|
-
# Store session manager in app state for dependency injection (first request only)
|
|
68
|
-
# This allows developers to use get_session_manager() dependency
|
|
69
|
-
if not hasattr(request.app.state, "__fastapi_cachex_session_manager"):
|
|
70
|
-
setattr(
|
|
71
|
-
request.app.state,
|
|
72
|
-
"__fastapi_cachex_session_manager",
|
|
73
|
-
self.session_manager,
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
# Extract session token from request
|
|
77
|
-
token = self._extract_token(request)
|
|
78
|
-
|
|
79
|
-
# Try to load session
|
|
80
|
-
session: Session | None = None
|
|
81
|
-
renewed_token: str | None = None
|
|
82
|
-
if token:
|
|
83
|
-
try:
|
|
84
|
-
ip_address = self._get_client_ip(request)
|
|
85
|
-
user_agent = request.headers.get("user-agent")
|
|
86
|
-
session, renewed_token = await self.session_manager.get_session(
|
|
87
|
-
token,
|
|
88
|
-
ip_address=ip_address,
|
|
89
|
-
user_agent=user_agent,
|
|
90
|
-
)
|
|
91
|
-
logger.debug("Session loaded in middleware; id=%s", session.session_id)
|
|
92
|
-
except SessionError:
|
|
93
|
-
# Session invalid/expired, continue without session
|
|
94
|
-
session = None
|
|
95
|
-
logger.debug("Session failed to load; token invalid/expired")
|
|
96
|
-
|
|
97
|
-
# Store session in request state
|
|
98
|
-
setattr(request.state, "__fastapi_cachex_session", session)
|
|
99
|
-
|
|
100
|
-
# Process request
|
|
101
|
-
response: Response = await call_next(request)
|
|
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
|
-
|
|
107
|
-
return response
|
|
108
|
-
|
|
109
|
-
def _extract_token(self, request: Request) -> str | None:
|
|
110
|
-
"""Extract session token from request.
|
|
111
|
-
|
|
112
|
-
Args:
|
|
113
|
-
request: Incoming request
|
|
114
|
-
|
|
115
|
-
Returns:
|
|
116
|
-
Session token or None
|
|
117
|
-
"""
|
|
118
|
-
for source in self.config.token_source_priority:
|
|
119
|
-
if source == "header":
|
|
120
|
-
token = request.headers.get(self.config.header_name)
|
|
121
|
-
if token:
|
|
122
|
-
logger.debug("Token extracted from header")
|
|
123
|
-
return token
|
|
124
|
-
|
|
125
|
-
elif source == "bearer":
|
|
126
|
-
if self.config.use_bearer_token:
|
|
127
|
-
auth_header = request.headers.get("authorization")
|
|
128
|
-
if auth_header and auth_header.startswith("Bearer "):
|
|
129
|
-
bearer_prefix_len = 7
|
|
130
|
-
token_value = auth_header[bearer_prefix_len:]
|
|
131
|
-
logger.debug("Token extracted from bearer auth")
|
|
132
|
-
return token_value
|
|
133
|
-
|
|
134
|
-
return None
|
|
135
|
-
|
|
136
|
-
def _get_client_ip(self, request: Request) -> str | None:
|
|
137
|
-
"""Get client IP address from request.
|
|
138
|
-
|
|
139
|
-
Args:
|
|
140
|
-
request: Incoming request
|
|
141
|
-
|
|
142
|
-
Returns:
|
|
143
|
-
Client IP address or None
|
|
144
|
-
"""
|
|
145
|
-
# Check X-Forwarded-For header (for proxied requests)
|
|
146
|
-
forwarded_for = request.headers.get("x-forwarded-for")
|
|
147
|
-
if forwarded_for:
|
|
148
|
-
# Get first IP from comma-separated list
|
|
149
|
-
ip = forwarded_for.split(",")[0].strip()
|
|
150
|
-
logger.debug("Client IP from X-Forwarded-For: %s", ip)
|
|
151
|
-
return ip
|
|
152
|
-
|
|
153
|
-
# Check X-Real-IP header
|
|
154
|
-
real_ip = request.headers.get("x-real-ip")
|
|
155
|
-
if real_ip:
|
|
156
|
-
logger.debug("Client IP from X-Real-IP: %s", real_ip)
|
|
157
|
-
return real_ip
|
|
158
|
-
|
|
159
|
-
# Fallback to direct client IP
|
|
160
|
-
if request.client:
|
|
161
|
-
logger.debug("Client IP from connection: %s", request.client.host)
|
|
162
|
-
return request.client.host
|
|
163
|
-
|
|
164
|
-
return None
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|