fastapi-cachex 0.2.1__tar.gz → 0.2.2__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 (25) hide show
  1. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/PKG-INFO +13 -2
  2. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/README.md +12 -1
  3. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/__init__.py +13 -0
  4. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/backends/memory.py +6 -5
  5. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/backends/redis.py +7 -4
  6. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/cache.py +21 -3
  7. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/routes.py +3 -2
  8. fastapi_cachex-0.2.2/fastapi_cachex/session/__init__.py +21 -0
  9. fastapi_cachex-0.2.2/fastapi_cachex/session/config.py +69 -0
  10. fastapi_cachex-0.2.2/fastapi_cachex/session/dependencies.py +65 -0
  11. fastapi_cachex-0.2.2/fastapi_cachex/session/exceptions.py +25 -0
  12. fastapi_cachex-0.2.2/fastapi_cachex/session/manager.py +333 -0
  13. fastapi_cachex-0.2.2/fastapi_cachex/session/middleware.py +127 -0
  14. fastapi_cachex-0.2.2/fastapi_cachex/session/models.py +166 -0
  15. fastapi_cachex-0.2.2/fastapi_cachex/session/security.py +97 -0
  16. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/types.py +9 -0
  17. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/pyproject.toml +1 -1
  18. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/backends/__init__.py +0 -0
  19. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/backends/base.py +0 -0
  20. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/backends/memcached.py +0 -0
  21. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/dependencies.py +0 -0
  22. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/directives.py +0 -0
  23. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/exceptions.py +0 -0
  24. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/proxy.py +0 -0
  25. {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-cachex
3
- Version: 0.2.1
3
+ Version: 0.2.2
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: Allen
@@ -47,10 +47,11 @@ Description-Content-Type: text/markdown
47
47
 
48
48
  [English](README.md) | [繁體中文](docs/README.zh-TW.md)
49
49
 
50
- A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support.
50
+ A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support and optional session management.
51
51
 
52
52
  ## Features
53
53
 
54
+ ### HTTP Caching
54
55
  - Support for HTTP caching headers
55
56
  - `Cache-Control`
56
57
  - `ETag`
@@ -62,6 +63,15 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
62
63
  - Complete Cache-Control directive implementation
63
64
  - Easy-to-use `@cache` decorator
64
65
 
66
+ ### Session Management (Optional Extension)
67
+ - Secure session management with HMAC-SHA256 token signing
68
+ - IP address and User-Agent binding (optional security features)
69
+ - Header and bearer token support (API-first architecture)
70
+ - Automatic session renewal (sliding expiration)
71
+ - Flash messages for cross-request communication
72
+ - Multiple backend support (Redis, Memcached, In-Memory)
73
+ - Complete session lifecycle management (create, validate, refresh, invalidate)
74
+
65
75
  ### Cache-Control Directives
66
76
 
67
77
  | Directive | Supported | Description |
@@ -236,6 +246,7 @@ async def expensive_operation():
236
246
  - [Cache Flow Explanation](docs/CACHE_FLOW.md)
237
247
  - [Development Guide](docs/DEVELOPMENT.md)
238
248
  - [Contributing Guidelines](docs/CONTRIBUTING.md)
249
+ - [Session Management Guide](docs/SESSION.md) - Complete guide for session features
239
250
 
240
251
  ## License
241
252
 
@@ -14,10 +14,11 @@
14
14
 
15
15
  [English](README.md) | [繁體中文](docs/README.zh-TW.md)
16
16
 
17
- A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support.
17
+ A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support and optional session management.
18
18
 
19
19
  ## Features
20
20
 
21
+ ### HTTP Caching
21
22
  - Support for HTTP caching headers
22
23
  - `Cache-Control`
23
24
  - `ETag`
@@ -29,6 +30,15 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
29
30
  - Complete Cache-Control directive implementation
30
31
  - Easy-to-use `@cache` decorator
31
32
 
33
+ ### Session Management (Optional Extension)
34
+ - Secure session management with HMAC-SHA256 token signing
35
+ - IP address and User-Agent binding (optional security features)
36
+ - Header and bearer token support (API-first architecture)
37
+ - Automatic session renewal (sliding expiration)
38
+ - Flash messages for cross-request communication
39
+ - Multiple backend support (Redis, Memcached, In-Memory)
40
+ - Complete session lifecycle management (create, validate, refresh, invalidate)
41
+
32
42
  ### Cache-Control Directives
33
43
 
34
44
  | Directive | Supported | Description |
@@ -203,6 +213,7 @@ async def expensive_operation():
203
213
  - [Cache Flow Explanation](docs/CACHE_FLOW.md)
204
214
  - [Development Guide](docs/DEVELOPMENT.md)
205
215
  - [Contributing Guidelines](docs/CONTRIBUTING.md)
216
+ - [Session Management Guide](docs/SESSION.md) - Complete guide for session features
206
217
 
207
218
  ## License
208
219
 
@@ -1,7 +1,20 @@
1
1
  """FastAPI-CacheX: A powerful and flexible caching extension for FastAPI."""
2
2
 
3
3
  from .cache import cache as cache
4
+ from .cache import default_key_builder as default_key_builder
4
5
  from .dependencies import CacheBackend as CacheBackend
5
6
  from .dependencies import get_cache_backend as get_cache_backend
6
7
  from .proxy import BackendProxy as BackendProxy
7
8
  from .routes import add_routes as add_routes
9
+ from .types import CacheKeyBuilder as CacheKeyBuilder
10
+
11
+ # Session management (optional feature)
12
+ __all__ = [
13
+ "BackendProxy",
14
+ "CacheBackend",
15
+ "CacheKeyBuilder",
16
+ "add_routes",
17
+ "cache",
18
+ "default_key_builder",
19
+ "get_cache_backend",
20
+ ]
@@ -5,12 +5,13 @@ import contextlib
5
5
  import fnmatch
6
6
  import time
7
7
 
8
+ from fastapi_cachex.types import CACHE_KEY_SEPARATOR
8
9
  from fastapi_cachex.types import CacheItem
9
10
  from fastapi_cachex.types import ETagContent
10
11
 
11
12
  from .base import BaseCacheBackend
12
13
 
13
- # Cache keys are formatted as: method:host:path:query_params
14
+ # Cache keys are formatted as: method|||host|||path|||query_params
14
15
  # Minimum parts required to extract path component
15
16
  _MIN_KEY_PARTS = 3
16
17
  # Maximum parts to split (method, host, path, query_params)
@@ -115,8 +116,8 @@ class MemoryBackend(BaseCacheBackend):
115
116
  async with self.lock:
116
117
  keys_to_delete = []
117
118
  for key in self.cache:
118
- # Keys are formatted as: method:host:path:query_params
119
- parts = key.split(":", _MAX_KEY_PARTS)
119
+ # Keys are formatted as: method|||host|||path|||query_params
120
+ parts = key.split(CACHE_KEY_SEPARATOR, _MAX_KEY_PARTS)
120
121
  if len(parts) >= _MIN_KEY_PARTS:
121
122
  cache_path = parts[2]
122
123
  has_params = len(parts) > _MIN_KEY_PARTS
@@ -145,8 +146,8 @@ class MemoryBackend(BaseCacheBackend):
145
146
  async with self.lock:
146
147
  keys_to_delete = []
147
148
  for key in self.cache:
148
- # Extract path component (method:host:path:query_params)
149
- parts = key.split(":", _MAX_KEY_PARTS)
149
+ # Extract path component (method|||host|||path|||query_params)
150
+ parts = key.split(CACHE_KEY_SEPARATOR, _MAX_KEY_PARTS)
150
151
  if len(parts) >= _MIN_KEY_PARTS:
151
152
  cache_path = parts[2]
152
153
  if fnmatch.fnmatch(cache_path, pattern):
@@ -6,6 +6,7 @@ from typing import Literal
6
6
 
7
7
  from fastapi_cachex.backends.base import BaseCacheBackend
8
8
  from fastapi_cachex.exceptions import CacheXError
9
+ from fastapi_cachex.types import CACHE_KEY_SEPARATOR
9
10
  from fastapi_cachex.types import ETagContent
10
11
 
11
12
  if TYPE_CHECKING:
@@ -175,11 +176,13 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
175
176
  """
176
177
  # Pattern includes the HTTP method, host, and path components
177
178
  if include_params:
178
- # Clear all variations: *:path:*
179
- pattern = f"{self.key_prefix}*:{path}:*"
179
+ # Clear all variations: *|||path|||*
180
+ pattern = (
181
+ f"{self.key_prefix}*{CACHE_KEY_SEPARATOR}{path}{CACHE_KEY_SEPARATOR}*"
182
+ )
180
183
  else:
181
- # Clear only exact path (no query params): *:path
182
- pattern = f"{self.key_prefix}*:{path}"
184
+ # Clear only exact path (no query params): *|||path
185
+ pattern = f"{self.key_prefix}*{CACHE_KEY_SEPARATOR}{path}"
183
186
 
184
187
  cursor = 0
185
188
  batch_size = 100
@@ -25,6 +25,8 @@ from fastapi_cachex.exceptions import BackendNotFoundError
25
25
  from fastapi_cachex.exceptions import CacheXError
26
26
  from fastapi_cachex.exceptions import RequestNotFoundError
27
27
  from fastapi_cachex.proxy import BackendProxy
28
+ from fastapi_cachex.types import CACHE_KEY_SEPARATOR
29
+ from fastapi_cachex.types import CacheKeyBuilder
28
30
  from fastapi_cachex.types import ETagContent
29
31
 
30
32
  if TYPE_CHECKING:
@@ -36,6 +38,20 @@ SyncCallable = Callable[..., T]
36
38
  AnyCallable = Union[AsyncCallable[T], SyncCallable[T]] # noqa: UP007
37
39
 
38
40
 
41
+ def default_key_builder(request: Request) -> str:
42
+ """Default cache key builder function.
43
+
44
+ Generates cache key in format: method|||host|||path|||query_params
45
+
46
+ Args:
47
+ request: The FastAPI Request object
48
+
49
+ Returns:
50
+ Generated cache key string
51
+ """
52
+ return f"{request.method}{CACHE_KEY_SEPARATOR}{request.headers.get('host', 'unknown')}{CACHE_KEY_SEPARATOR}{request.url.path}{CACHE_KEY_SEPARATOR}{request.query_params}"
53
+
54
+
39
55
  class CacheControl:
40
56
  """Manages Cache-Control header directives."""
41
57
 
@@ -103,6 +119,7 @@ def cache( # noqa: C901
103
119
  private: bool = False,
104
120
  immutable: bool = False,
105
121
  must_revalidate: bool = False,
122
+ cache_key_builder: CacheKeyBuilder | None = None,
106
123
  ) -> Callable[[AnyCallable[Response]], AsyncCallable[Response]]:
107
124
  """Cache decorator for FastAPI route handlers.
108
125
 
@@ -116,6 +133,7 @@ def cache( # noqa: C901
116
133
  private: Whether responses are for single user only
117
134
  immutable: Whether cached responses never change
118
135
  must_revalidate: Whether to force revalidation when stale
136
+ cache_key_builder: Custom function to build cache keys. If None, uses default_cache_key_builder
119
137
 
120
138
  Returns:
121
139
  Decorator function that wraps route handlers with caching logic
@@ -207,9 +225,9 @@ def cache( # noqa: C901
207
225
  if req.method != "GET":
208
226
  return await get_response(func, req, *args, **kwargs)
209
227
 
210
- # Generate cache key: method:host:path:query_params[:vary]
211
- # Include host to avoid cross-host cache pollution
212
- cache_key = f"{req.method}:{req.headers.get('host', 'unknown')}:{req.url.path}:{req.query_params}"
228
+ # Generate cache key using custom builder or default
229
+ key_builder = cache_key_builder or default_key_builder
230
+ cache_key = key_builder(req)
213
231
  client_etag = req.headers.get("if-none-match")
214
232
  cache_control = await get_cache_control(CacheControl())
215
233
 
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
7
7
  from fastapi_cachex.backends import BaseCacheBackend
8
8
  from fastapi_cachex.exceptions import BackendNotFoundError
9
9
  from fastapi_cachex.proxy import BackendProxy
10
+ from fastapi_cachex.types import CACHE_KEY_SEPARATOR
10
11
 
11
12
  if TYPE_CHECKING:
12
13
  from fastapi import FastAPI
@@ -93,12 +94,12 @@ def _parse_cache_key(cache_key: str) -> tuple[str, str, str, str]:
93
94
  """Parse cache key into components.
94
95
 
95
96
  Args:
96
- cache_key: Cache key in format method:host:path:query_params
97
+ cache_key: Cache key in format method|||host|||path|||query_params
97
98
 
98
99
  Returns:
99
100
  Tuple of (method, host, path, query_params)
100
101
  """
101
- key_parts = cache_key.split(":", CACHE_KEY_MAX_PARTS)
102
+ key_parts = cache_key.split(CACHE_KEY_SEPARATOR, CACHE_KEY_MAX_PARTS)
102
103
  if len(key_parts) >= CACHE_KEY_MIN_PARTS:
103
104
  method, host, path = key_parts[0], key_parts[1], key_parts[2]
104
105
  query_params = key_parts[3] if len(key_parts) > CACHE_KEY_MIN_PARTS else ""
@@ -0,0 +1,21 @@
1
+ """Session management extension for FastAPI-CacheX."""
2
+
3
+ from fastapi_cachex.session.config import SessionConfig
4
+ from fastapi_cachex.session.dependencies import get_optional_session
5
+ from fastapi_cachex.session.dependencies import get_session
6
+ from fastapi_cachex.session.dependencies import require_session
7
+ from fastapi_cachex.session.manager import SessionManager
8
+ from fastapi_cachex.session.middleware import SessionMiddleware
9
+ from fastapi_cachex.session.models import Session
10
+ from fastapi_cachex.session.models import SessionUser
11
+
12
+ __all__ = [
13
+ "Session",
14
+ "SessionConfig",
15
+ "SessionManager",
16
+ "SessionMiddleware",
17
+ "SessionUser",
18
+ "get_optional_session",
19
+ "get_session",
20
+ "require_session",
21
+ ]
@@ -0,0 +1,69 @@
1
+ """Session configuration settings."""
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel
6
+ from pydantic import Field
7
+
8
+
9
+ class SessionConfig(BaseModel):
10
+ """Session configuration settings."""
11
+
12
+ # Session lifetime
13
+ session_ttl: int = Field(
14
+ default=3600,
15
+ description="Session time-to-live in seconds (default: 1 hour)",
16
+ )
17
+ absolute_timeout: int | None = Field(
18
+ default=None,
19
+ description="Absolute session timeout in seconds (None = no absolute timeout)",
20
+ )
21
+ sliding_expiration: bool = Field(
22
+ default=True,
23
+ description="Whether to refresh session expiry on each access",
24
+ )
25
+ sliding_threshold: float = Field(
26
+ default=0.5,
27
+ ge=0.0,
28
+ le=1.0,
29
+ description="Fraction of TTL that must pass before sliding refresh (0.5 = refresh after half TTL)",
30
+ )
31
+
32
+ # Token settings
33
+ header_name: str = Field(
34
+ default="X-Session-Token",
35
+ description="Custom header name for session token",
36
+ )
37
+ use_bearer_token: bool = Field(
38
+ default=True,
39
+ description="Whether to accept Authorization Bearer tokens",
40
+ )
41
+ token_source_priority: list[Literal["header", "bearer"]] = Field(
42
+ default=["header", "bearer"],
43
+ description="Priority order for token sources",
44
+ )
45
+
46
+ # Security settings
47
+ secret_key: str = Field(
48
+ ...,
49
+ min_length=32,
50
+ description="Secret key for signing session tokens (min 32 characters)",
51
+ )
52
+ ip_binding: bool = Field(
53
+ default=False,
54
+ description="Whether to bind session to client IP address",
55
+ )
56
+ user_agent_binding: bool = Field(
57
+ default=False,
58
+ description="Whether to bind session to User-Agent",
59
+ )
60
+ regenerate_on_login: bool = Field(
61
+ default=True,
62
+ description="Whether to regenerate session ID on login",
63
+ )
64
+
65
+ # Backend settings
66
+ backend_key_prefix: str = Field(
67
+ default="session:",
68
+ description="Prefix for session keys in backend storage",
69
+ )
@@ -0,0 +1,65 @@
1
+ """FastAPI dependency injection utilities for session management."""
2
+
3
+ from typing import Annotated
4
+
5
+ from fastapi import Depends
6
+ from fastapi import HTTPException
7
+ from fastapi import Request
8
+ from fastapi import status
9
+
10
+ from fastapi_cachex.session.models import Session
11
+
12
+
13
+ def get_optional_session(request: Request) -> Session | None:
14
+ """Get session from request state (optional).
15
+
16
+ Args:
17
+ request: FastAPI request object
18
+
19
+ Returns:
20
+ Session object or None if not authenticated
21
+ """
22
+ return getattr(request.state, "session", None)
23
+
24
+
25
+ def get_session(request: Request) -> Session:
26
+ """Get session from request state (required).
27
+
28
+ Args:
29
+ request: FastAPI request object
30
+
31
+ Returns:
32
+ Session object
33
+
34
+ Raises:
35
+ HTTPException: 401 if session not found
36
+ """
37
+ session: Session | None = getattr(request.state, "session", None)
38
+ if session is None:
39
+ raise HTTPException(
40
+ status_code=status.HTTP_401_UNAUTHORIZED,
41
+ detail="Authentication required",
42
+ headers={"WWW-Authenticate": "Bearer"},
43
+ )
44
+ return session
45
+
46
+
47
+ def require_session(request: Request) -> Session:
48
+ """Require authenticated session (alias for get_session).
49
+
50
+ Args:
51
+ request: FastAPI request object
52
+
53
+ Returns:
54
+ Session object
55
+
56
+ Raises:
57
+ HTTPException: 401 if session not found
58
+ """
59
+ return get_session(request)
60
+
61
+
62
+ # Type annotations for dependency injection
63
+ OptionalSession = Annotated[Session | None, Depends(get_optional_session)]
64
+ RequiredSession = Annotated[Session, Depends(get_session)]
65
+ SessionDep = Annotated[Session, Depends(require_session)]
@@ -0,0 +1,25 @@
1
+ """Session-related exceptions."""
2
+
3
+
4
+ class SessionError(Exception):
5
+ """Base exception for session errors."""
6
+
7
+
8
+ class SessionNotFoundError(SessionError):
9
+ """Raised when a session is not found."""
10
+
11
+
12
+ class SessionExpiredError(SessionError):
13
+ """Raised when a session has expired."""
14
+
15
+
16
+ class SessionInvalidError(SessionError):
17
+ """Raised when a session is invalid."""
18
+
19
+
20
+ class SessionSecurityError(SessionError):
21
+ """Raised when a session fails security checks."""
22
+
23
+
24
+ class SessionTokenError(SessionError):
25
+ """Raised when there's an issue with session token."""
@@ -0,0 +1,333 @@
1
+ """Session manager for CRUD operations."""
2
+
3
+ from datetime import datetime
4
+ from datetime import timedelta
5
+ from datetime import timezone
6
+
7
+ from fastapi_cachex.backends.base import BaseCacheBackend
8
+ from fastapi_cachex.session.config import SessionConfig
9
+ from fastapi_cachex.session.exceptions import SessionExpiredError
10
+ from fastapi_cachex.session.exceptions import SessionInvalidError
11
+ from fastapi_cachex.session.exceptions import SessionNotFoundError
12
+ from fastapi_cachex.session.exceptions import SessionSecurityError
13
+ from fastapi_cachex.session.exceptions import SessionTokenError
14
+ from fastapi_cachex.session.models import Session
15
+ from fastapi_cachex.session.models import SessionStatus
16
+ from fastapi_cachex.session.models import SessionToken
17
+ from fastapi_cachex.session.models import SessionUser
18
+ from fastapi_cachex.session.security import SecurityManager
19
+ from fastapi_cachex.types import ETagContent
20
+
21
+
22
+ class SessionManager:
23
+ """Manages session lifecycle and storage."""
24
+
25
+ def __init__(self, backend: BaseCacheBackend, config: SessionConfig) -> None:
26
+ """Initialize session manager.
27
+
28
+ Args:
29
+ backend: Cache backend for session storage
30
+ config: Session configuration
31
+ """
32
+ self.backend = backend
33
+ self.config = config
34
+ self.security = SecurityManager(config.secret_key)
35
+
36
+ def _get_backend_key(self, session_id: str) -> str:
37
+ """Get backend storage key for a session.
38
+
39
+ Args:
40
+ session_id: The session ID
41
+
42
+ Returns:
43
+ Backend storage key
44
+ """
45
+ return f"{self.config.backend_key_prefix}{session_id}"
46
+
47
+ async def create_session(
48
+ self,
49
+ user: SessionUser | None = None,
50
+ ip_address: str | None = None,
51
+ user_agent: str | None = None,
52
+ **extra_data: dict[str, object],
53
+ ) -> tuple[Session, str]:
54
+ """Create a new session.
55
+
56
+ Args:
57
+ user: Optional user data
58
+ ip_address: Client IP address (if IP binding enabled)
59
+ user_agent: Client User-Agent (if UA binding enabled)
60
+ **extra_data: Additional session data
61
+
62
+ Returns:
63
+ Tuple of (Session, token_string)
64
+ """
65
+ # Create session
66
+ session = Session(
67
+ user=user,
68
+ data=extra_data,
69
+ )
70
+
71
+ # Set expiry
72
+ if self.config.session_ttl:
73
+ session.expires_at = datetime.now(timezone.utc) + timedelta(
74
+ seconds=self.config.session_ttl,
75
+ )
76
+
77
+ # Bind IP and User-Agent if configured
78
+ if self.config.ip_binding and ip_address:
79
+ session.ip_address = ip_address
80
+ if self.config.user_agent_binding and user_agent:
81
+ session.user_agent = user_agent
82
+
83
+ # Store in backend
84
+ await self._save_session(session)
85
+
86
+ # Generate signed token
87
+ token = self._create_token(session.session_id)
88
+
89
+ return session, token.to_string()
90
+
91
+ async def get_session(
92
+ self,
93
+ token_string: str,
94
+ ip_address: str | None = None,
95
+ user_agent: str | None = None,
96
+ ) -> Session:
97
+ """Retrieve and validate a session.
98
+
99
+ Args:
100
+ token_string: Session token string
101
+ ip_address: Current request IP address
102
+ user_agent: Current request User-Agent
103
+
104
+ Returns:
105
+ Session object
106
+
107
+ Raises:
108
+ SessionTokenError: If token is invalid
109
+ SessionNotFoundError: If session not found
110
+ SessionExpiredError: If session has expired
111
+ SessionInvalidError: If session is not active
112
+ SessionSecurityError: If security checks fail
113
+ """
114
+ # Parse and verify token
115
+ try:
116
+ token = SessionToken.from_string(token_string)
117
+ except ValueError as e:
118
+ raise SessionTokenError(str(e)) from e
119
+
120
+ if not self.security.verify_signature(token.session_id, token.signature):
121
+ msg = "Invalid session signature"
122
+ raise SessionSecurityError(msg)
123
+
124
+ # Load session from backend
125
+ session = await self._load_session(token.session_id)
126
+ if not session:
127
+ msg = f"Session {token.session_id} not found"
128
+ raise SessionNotFoundError(msg)
129
+
130
+ # Validate session
131
+ if session.status != SessionStatus.ACTIVE:
132
+ msg = f"Session is {session.status}"
133
+ raise SessionInvalidError(msg)
134
+
135
+ if session.is_expired():
136
+ session.status = SessionStatus.EXPIRED
137
+ await self._save_session(session)
138
+ msg = "Session has expired"
139
+ raise SessionExpiredError(msg)
140
+
141
+ # Security checks
142
+ if self.config.ip_binding and not self.security.check_ip_match(
143
+ session,
144
+ ip_address,
145
+ ):
146
+ msg = "IP address mismatch"
147
+ raise SessionSecurityError(msg)
148
+
149
+ if self.config.user_agent_binding and not self.security.check_user_agent_match(
150
+ session,
151
+ user_agent,
152
+ ):
153
+ msg = "User-Agent mismatch"
154
+ raise SessionSecurityError(msg)
155
+
156
+ # Update last accessed and handle sliding expiration
157
+ session.update_last_accessed()
158
+
159
+ if self.config.sliding_expiration and session.expires_at:
160
+ time_remaining = (
161
+ session.expires_at - datetime.now(timezone.utc)
162
+ ).total_seconds()
163
+ threshold = self.config.session_ttl * self.config.sliding_threshold
164
+
165
+ if time_remaining < threshold:
166
+ session.renew(self.config.session_ttl)
167
+
168
+ await self._save_session(session)
169
+
170
+ return session
171
+
172
+ async def update_session(self, session: Session) -> None:
173
+ """Update an existing session.
174
+
175
+ Args:
176
+ session: Session to update
177
+ """
178
+ session.update_last_accessed()
179
+ await self._save_session(session)
180
+
181
+ async def delete_session(self, session_id: str) -> None:
182
+ """Delete a session.
183
+
184
+ Args:
185
+ session_id: Session ID to delete
186
+ """
187
+ key = self._get_backend_key(session_id)
188
+ await self.backend.delete(key)
189
+
190
+ async def invalidate_session(self, session: Session) -> None:
191
+ """Invalidate a session.
192
+
193
+ Args:
194
+ session: Session to invalidate
195
+ """
196
+ session.invalidate()
197
+ await self._save_session(session)
198
+
199
+ async def regenerate_session_id(
200
+ self,
201
+ session: Session,
202
+ ) -> tuple[Session, str]:
203
+ """Regenerate session ID (after login for security).
204
+
205
+ Args:
206
+ session: Session to regenerate
207
+
208
+ Returns:
209
+ Tuple of (updated session, new token string)
210
+ """
211
+ # Delete old session
212
+ await self.delete_session(session.session_id)
213
+
214
+ # Generate new ID
215
+ session.regenerate_id()
216
+
217
+ # Save with new ID
218
+ await self._save_session(session)
219
+
220
+ # Create new token
221
+ token = self._create_token(session.session_id)
222
+
223
+ return session, token.to_string()
224
+
225
+ async def delete_user_sessions(self, user_id: str) -> int:
226
+ """Delete all sessions for a user.
227
+
228
+ Args:
229
+ user_id: User ID
230
+
231
+ Returns:
232
+ Number of sessions deleted
233
+ """
234
+ # This requires scanning all session keys
235
+ count = 0
236
+
237
+ try:
238
+ all_keys = await self.backend.get_all_keys()
239
+ for key in all_keys:
240
+ if key.startswith(self.config.backend_key_prefix):
241
+ session = await self._load_session_by_key(key)
242
+ if session and session.user and session.user.user_id == user_id:
243
+ await self.backend.delete(key)
244
+ count += 1
245
+ except NotImplementedError:
246
+ # Backend doesn't support get_all_keys, can't delete by user
247
+ pass
248
+
249
+ return count
250
+
251
+ async def clear_expired_sessions(self) -> int:
252
+ """Clear all expired sessions.
253
+
254
+ Returns:
255
+ Number of sessions cleared
256
+ """
257
+ count = 0
258
+
259
+ try:
260
+ all_keys = await self.backend.get_all_keys()
261
+ for key in all_keys:
262
+ if key.startswith(self.config.backend_key_prefix):
263
+ session = await self._load_session_by_key(key)
264
+ if session and session.is_expired():
265
+ await self.backend.delete(key)
266
+ count += 1
267
+ except NotImplementedError:
268
+ # Backend doesn't support get_all_keys
269
+ pass
270
+
271
+ return count
272
+
273
+ def _create_token(self, session_id: str) -> SessionToken:
274
+ """Create a signed session token.
275
+
276
+ Args:
277
+ session_id: Session ID to sign
278
+
279
+ Returns:
280
+ SessionToken object
281
+ """
282
+ signature = self.security.sign_session_id(session_id)
283
+ return SessionToken(session_id=session_id, signature=signature)
284
+
285
+ async def _save_session(self, session: Session) -> None:
286
+ """Save session to backend.
287
+
288
+ Args:
289
+ session: Session to save
290
+ """
291
+ key = self._get_backend_key(session.session_id)
292
+ value = session.model_dump_json().encode("utf-8")
293
+
294
+ # Calculate TTL
295
+ ttl = None
296
+ if session.expires_at:
297
+ ttl = int((session.expires_at - datetime.now(timezone.utc)).total_seconds())
298
+ ttl = max(ttl, 1) # Ensure at least 1 second
299
+
300
+ # Store as bytes in cache backend (wrapped in ETagContent for compatibility)
301
+ etag = self.security.hash_data(value.decode("utf-8"))
302
+ await self.backend.set(key, ETagContent(etag=etag, content=value), ttl=ttl)
303
+
304
+ async def _load_session(self, session_id: str) -> Session | None:
305
+ """Load session from backend.
306
+
307
+ Args:
308
+ session_id: Session ID to load
309
+
310
+ Returns:
311
+ Session object or None if not found
312
+ """
313
+ key = self._get_backend_key(session_id)
314
+ return await self._load_session_by_key(key)
315
+
316
+ async def _load_session_by_key(self, key: str) -> Session | None:
317
+ """Load session from backend by key.
318
+
319
+ Args:
320
+ key: Backend key
321
+
322
+ Returns:
323
+ Session object or None if not found
324
+ """
325
+ cached = await self.backend.get(key)
326
+ if not cached:
327
+ return None
328
+
329
+ try:
330
+ return Session.model_validate_json(cached.content)
331
+ except (ValueError, TypeError):
332
+ # Invalid session data
333
+ return None
@@ -0,0 +1,127 @@
1
+ """Session middleware for FastAPI."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from fastapi import Request
6
+ from fastapi import Response
7
+ from starlette.middleware.base import BaseHTTPMiddleware
8
+ from starlette.middleware.base import RequestResponseEndpoint
9
+ from starlette.types import ASGIApp
10
+
11
+ from fastapi_cachex.session.config import SessionConfig
12
+ from fastapi_cachex.session.exceptions import SessionError
13
+ from fastapi_cachex.session.manager import SessionManager
14
+
15
+ if TYPE_CHECKING:
16
+ from fastapi_cachex.session.models import Session
17
+
18
+
19
+ class SessionMiddleware(BaseHTTPMiddleware):
20
+ """Middleware to handle session loading and cookie management."""
21
+
22
+ def __init__(
23
+ self,
24
+ app: ASGIApp,
25
+ session_manager: SessionManager,
26
+ config: SessionConfig,
27
+ ) -> None:
28
+ """Initialize session middleware.
29
+
30
+ Args:
31
+ app: ASGI application
32
+ session_manager: Session manager instance
33
+ config: Session configuration
34
+ """
35
+ super().__init__(app)
36
+ self.session_manager = session_manager
37
+ self.config = config
38
+
39
+ async def dispatch(
40
+ self,
41
+ request: Request,
42
+ call_next: RequestResponseEndpoint,
43
+ ) -> Response:
44
+ """Process request and handle session.
45
+
46
+ Args:
47
+ request: Incoming request
48
+ call_next: Next handler in chain
49
+
50
+ Returns:
51
+ Response
52
+ """
53
+ # Extract session token from request
54
+ token = self._extract_token(request)
55
+
56
+ # Try to load session
57
+ session: Session | None = None
58
+ if token:
59
+ try:
60
+ ip_address = self._get_client_ip(request)
61
+ user_agent = request.headers.get("user-agent")
62
+ session = await self.session_manager.get_session(
63
+ token,
64
+ ip_address=ip_address,
65
+ user_agent=user_agent,
66
+ )
67
+ except SessionError:
68
+ # Session invalid/expired, continue without session
69
+ session = None
70
+
71
+ # Store session in request state
72
+ request.state.session = session
73
+
74
+ # Process request
75
+ response: Response = await call_next(request)
76
+
77
+ return response
78
+
79
+ def _extract_token(self, request: Request) -> str | None:
80
+ """Extract session token from request.
81
+
82
+ Args:
83
+ request: Incoming request
84
+
85
+ Returns:
86
+ Session token or None
87
+ """
88
+ for source in self.config.token_source_priority:
89
+ if source == "header":
90
+ token = request.headers.get(self.config.header_name)
91
+ if token:
92
+ return token
93
+
94
+ elif source == "bearer":
95
+ if self.config.use_bearer_token:
96
+ auth_header = request.headers.get("authorization")
97
+ if auth_header and auth_header.startswith("Bearer "):
98
+ bearer_prefix_len = 7
99
+ return auth_header[bearer_prefix_len:]
100
+
101
+ return None
102
+
103
+ def _get_client_ip(self, request: Request) -> str | None:
104
+ """Get client IP address from request.
105
+
106
+ Args:
107
+ request: Incoming request
108
+
109
+ Returns:
110
+ Client IP address or None
111
+ """
112
+ # Check X-Forwarded-For header (for proxied requests)
113
+ forwarded_for = request.headers.get("x-forwarded-for")
114
+ if forwarded_for:
115
+ # Get first IP from comma-separated list
116
+ return forwarded_for.split(",")[0].strip()
117
+
118
+ # Check X-Real-IP header
119
+ real_ip = request.headers.get("x-real-ip")
120
+ if real_ip:
121
+ return real_ip
122
+
123
+ # Fallback to direct client IP
124
+ if request.client:
125
+ return request.client.host
126
+
127
+ return None
@@ -0,0 +1,166 @@
1
+ """Session data models and user structures."""
2
+
3
+ from datetime import datetime
4
+ from datetime import timedelta
5
+ from datetime import timezone
6
+ from enum import Enum
7
+ from typing import Any
8
+ from uuid import uuid4
9
+
10
+ from pydantic import BaseModel
11
+ from pydantic import Field
12
+
13
+ # Token format constant
14
+ TOKEN_PARTS_COUNT = 3
15
+
16
+
17
+ class SessionStatus(str, Enum):
18
+ """Session status enumeration."""
19
+
20
+ ACTIVE = "active"
21
+ EXPIRED = "expired"
22
+ INVALIDATED = "invalidated"
23
+
24
+
25
+ class SessionUser(BaseModel):
26
+ """Base session user model.
27
+
28
+ This can be extended by application-specific user models.
29
+ """
30
+
31
+ user_id: str
32
+ username: str | None = None
33
+ email: str | None = None
34
+ roles: list[str] = Field(default_factory=list)
35
+ permissions: list[str] = Field(default_factory=list)
36
+ metadata: dict[str, Any] = Field(default_factory=dict)
37
+
38
+ model_config = {"extra": "allow"}
39
+
40
+
41
+ class Session(BaseModel):
42
+ """Core session model containing all session data."""
43
+
44
+ session_id: str = Field(default_factory=lambda: str(uuid4()))
45
+ user: SessionUser | None = None
46
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
47
+ last_accessed: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
48
+ expires_at: datetime | None = None
49
+ status: SessionStatus = SessionStatus.ACTIVE
50
+ ip_address: str | None = None
51
+ user_agent: str | None = None
52
+ data: dict[str, Any] = Field(default_factory=dict)
53
+ flash_messages: list[dict[str, Any]] = Field(default_factory=list)
54
+
55
+ model_config = {"use_enum_values": True}
56
+
57
+ def is_valid(self) -> bool:
58
+ """Check if session is valid (active and not expired)."""
59
+ if self.status != SessionStatus.ACTIVE:
60
+ return False
61
+
62
+ return not (self.expires_at and datetime.now(timezone.utc) > self.expires_at)
63
+
64
+ def is_expired(self) -> bool:
65
+ """Check if session has expired."""
66
+ if self.expires_at is None:
67
+ return False
68
+ return datetime.now(timezone.utc) > self.expires_at
69
+
70
+ def update_last_accessed(self) -> None:
71
+ """Update the last accessed timestamp."""
72
+ self.last_accessed = datetime.now(timezone.utc)
73
+
74
+ def renew(self, ttl: int) -> None:
75
+ """Renew session expiry time.
76
+
77
+ Args:
78
+ ttl: Time-to-live in seconds
79
+ """
80
+ self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)
81
+ self.update_last_accessed()
82
+
83
+ def invalidate(self) -> None:
84
+ """Mark session as invalidated."""
85
+ self.status = SessionStatus.INVALIDATED
86
+
87
+ def regenerate_id(self) -> str:
88
+ """Regenerate session ID (for security after login).
89
+
90
+ Returns:
91
+ The new session ID
92
+ """
93
+ self.session_id = str(uuid4())
94
+ return self.session_id
95
+
96
+ def add_flash_message(self, message: str, category: str = "info") -> None:
97
+ """Add a flash message.
98
+
99
+ Args:
100
+ message: The message content
101
+ category: Message category (info, success, warning, error)
102
+ """
103
+ self.flash_messages.append(
104
+ {
105
+ "message": message,
106
+ "category": category,
107
+ "timestamp": datetime.now(timezone.utc).isoformat(),
108
+ }
109
+ )
110
+
111
+ def get_flash_messages(self, clear: bool = True) -> list[dict[str, Any]]:
112
+ """Get and optionally clear flash messages.
113
+
114
+ Args:
115
+ clear: Whether to clear messages after retrieving
116
+
117
+ Returns:
118
+ List of flash messages
119
+ """
120
+ messages = self.flash_messages.copy()
121
+ if clear:
122
+ self.flash_messages.clear()
123
+ return messages
124
+
125
+
126
+ class SessionToken(BaseModel):
127
+ """Session token containing signed data."""
128
+
129
+ session_id: str
130
+ signature: str
131
+ issued_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
132
+
133
+ def to_string(self) -> str:
134
+ """Convert token to string format.
135
+
136
+ Format: {session_id}.{signature}.{timestamp}
137
+ """
138
+ timestamp = int(self.issued_at.timestamp())
139
+ return f"{self.session_id}.{self.signature}.{timestamp}"
140
+
141
+ @classmethod
142
+ def from_string(cls, token_str: str) -> "SessionToken":
143
+ """Parse token from string format.
144
+
145
+ Args:
146
+ token_str: Token string in format {session_id}.{signature}.{timestamp}
147
+
148
+ Returns:
149
+ SessionToken instance
150
+
151
+ Raises:
152
+ ValueError: If token format is invalid
153
+ """
154
+ parts = token_str.split(".")
155
+ if len(parts) != TOKEN_PARTS_COUNT:
156
+ msg = "Invalid token format"
157
+ raise ValueError(msg)
158
+
159
+ session_id, signature, timestamp = parts
160
+ try:
161
+ issued_at = datetime.fromtimestamp(int(timestamp), tz=timezone.utc)
162
+ except (ValueError, OSError) as e:
163
+ msg = f"Invalid timestamp in token: {e}"
164
+ raise ValueError(msg) from e
165
+
166
+ return cls(session_id=session_id, signature=signature, issued_at=issued_at)
@@ -0,0 +1,97 @@
1
+ """Security utilities for session management."""
2
+
3
+ import hashlib
4
+ import hmac
5
+
6
+ from fastapi_cachex.session.models import Session
7
+
8
+
9
+ class SecurityManager:
10
+ """Handles session security operations."""
11
+
12
+ def __init__(self, secret_key: str) -> None:
13
+ """Initialize security manager.
14
+
15
+ Args:
16
+ secret_key: Secret key for signing tokens
17
+ """
18
+ if len(secret_key) < 32: # noqa: PLR2004
19
+ msg = "Secret key must be at least 32 characters"
20
+ raise ValueError(msg)
21
+ self.secret_key = secret_key.encode("utf-8")
22
+
23
+ def sign_session_id(self, session_id: str) -> str:
24
+ """Sign a session ID using HMAC-SHA256.
25
+
26
+ Args:
27
+ session_id: The session ID to sign
28
+
29
+ Returns:
30
+ The signature as a hex string
31
+ """
32
+ return hmac.new(
33
+ self.secret_key,
34
+ session_id.encode("utf-8"),
35
+ hashlib.sha256,
36
+ ).hexdigest()
37
+
38
+ def verify_signature(self, session_id: str, signature: str) -> bool:
39
+ """Verify a session signature.
40
+
41
+ Args:
42
+ session_id: The session ID
43
+ signature: The signature to verify
44
+
45
+ Returns:
46
+ True if signature is valid, False otherwise
47
+ """
48
+ expected_signature = self.sign_session_id(session_id)
49
+ # Use constant-time comparison to prevent timing attacks
50
+ return hmac.compare_digest(expected_signature, signature)
51
+
52
+ def check_ip_match(self, session: Session, current_ip: str | None) -> bool:
53
+ """Check if session IP matches current request IP.
54
+
55
+ Args:
56
+ session: The session to check
57
+ current_ip: Current request IP address
58
+
59
+ Returns:
60
+ True if IPs match or session has no IP binding
61
+ """
62
+ if session.ip_address is None:
63
+ return True
64
+ if current_ip is None:
65
+ return False
66
+ return session.ip_address == current_ip
67
+
68
+ def check_user_agent_match(
69
+ self,
70
+ session: Session,
71
+ current_user_agent: str | None,
72
+ ) -> bool:
73
+ """Check if session User-Agent matches current request.
74
+
75
+ Args:
76
+ session: The session to check
77
+ current_user_agent: Current request User-Agent
78
+
79
+ Returns:
80
+ True if User-Agents match or session has no UA binding
81
+ """
82
+ if session.user_agent is None:
83
+ return True
84
+ if current_user_agent is None:
85
+ return False
86
+ return session.user_agent == current_user_agent
87
+
88
+ def hash_data(self, data: str) -> str:
89
+ """Hash data using SHA-256.
90
+
91
+ Args:
92
+ data: Data to hash
93
+
94
+ Returns:
95
+ Hex digest of the hash
96
+ """
97
+ return hashlib.sha256(data.encode("utf-8")).hexdigest()
@@ -1,8 +1,17 @@
1
1
  """Type definitions and type aliases for FastAPI-CacheX."""
2
2
 
3
+ from collections.abc import Callable
3
4
  from dataclasses import dataclass
4
5
  from typing import Any
5
6
 
7
+ from fastapi import Request
8
+
9
+ # Cache key separator - using ||| to avoid conflicts with port numbers in host (e.g., 127.0.0.1:8000)
10
+ CACHE_KEY_SEPARATOR = "|||"
11
+
12
+ # Type for custom cache key builder function
13
+ CacheKeyBuilder = Callable[[Request], str]
14
+
6
15
 
7
16
  @dataclass
8
17
  class ETagContent:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-cachex"
3
- version = "0.2.1"
3
+ version = "0.2.2"
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"