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.
Files changed (37) hide show
  1. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/PKG-INFO +3 -1
  2. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/__init__.py +10 -0
  3. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/memcached.py +3 -1
  4. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/memory.py +5 -0
  5. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/cache.py +42 -4
  6. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/manager.py +64 -0
  7. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/routes.py +6 -2
  8. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/__init__.py +2 -0
  9. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/config.py +35 -0
  10. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/manager.py +6 -4
  11. fastapi_cachex-0.3.1/fastapi_cachex/session/middleware.py +485 -0
  12. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/state/__init__.py +3 -0
  13. fastapi_cachex-0.3.1/fastapi_cachex/state/dependencies.py +28 -0
  14. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/state/manager.py +3 -1
  15. fastapi_cachex-0.3.1/fastapi_cachex/state/proxy.py +9 -0
  16. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/pyproject.toml +5 -1
  17. fastapi_cachex-0.3.0/fastapi_cachex/session/middleware.py +0 -164
  18. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/README.md +0 -0
  19. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/__init__.py +0 -0
  20. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/base.py +0 -0
  21. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/config.py +0 -0
  22. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/redis.py +0 -0
  23. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/dependencies.py +0 -0
  24. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/directives.py +0 -0
  25. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/exceptions.py +0 -0
  26. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/manager_proxy.py +0 -0
  27. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/proxy.py +0 -0
  28. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/py.typed +0 -0
  29. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/dependencies.py +0 -0
  30. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/exceptions.py +0 -0
  31. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/models.py +0 -0
  32. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/proxy.py +0 -0
  33. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/security.py +0 -0
  34. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/session/token_serializers.py +0 -0
  35. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/state/exceptions.py +0 -0
  36. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.1}/fastapi_cachex/state/models.py +0 -0
  37. {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.0
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(self.client.delete, prefixed_key, False)
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 current_body is not None # guaranteed by early-return guards above
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 = max(0.0, round(expiry - now, 2)) if expiry is not None else None
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 = max(0.0, round(expiry - now, 2)) if expiry is not None else None
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: dict[str, object],
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: dict[str, object],
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: dict[str, object],
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(key, CacheEntry(fingerprint=fingerprint, content=value), ttl=ttl)
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(fingerprint=fingerprint, content=json_content.encode("utf-8"))
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.0"
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