miso-client 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl
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.
Potentially problematic release.
This version of miso-client might be problematic. Click here for more details.
- miso_client/__init__.py +83 -81
- miso_client/errors.py +9 -4
- miso_client/models/config.py +56 -35
- miso_client/services/__init__.py +5 -5
- miso_client/services/auth.py +65 -48
- miso_client/services/cache.py +42 -41
- miso_client/services/encryption.py +18 -17
- miso_client/services/logger.py +109 -95
- miso_client/services/permission.py +27 -36
- miso_client/services/redis.py +17 -15
- miso_client/services/role.py +25 -36
- miso_client/utils/__init__.py +3 -3
- miso_client/utils/config_loader.py +24 -16
- miso_client/utils/data_masker.py +27 -28
- miso_client/utils/http_client.py +91 -81
- miso_client/utils/jwt_tools.py +14 -17
- {miso_client-0.1.0.dist-info → miso_client-0.2.0.dist-info}/METADATA +37 -1
- miso_client-0.2.0.dist-info/RECORD +23 -0
- miso_client-0.1.0.dist-info/RECORD +0 -23
- {miso_client-0.1.0.dist-info → miso_client-0.2.0.dist-info}/WHEEL +0 -0
- {miso_client-0.1.0.dist-info → miso_client-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {miso_client-0.1.0.dist-info → miso_client-0.2.0.dist-info}/top_level.txt +0 -0
miso_client/models/config.py
CHANGED
|
@@ -5,13 +5,14 @@ This module contains Pydantic models that define the configuration structure
|
|
|
5
5
|
and data types used throughout the MisoClient SDK.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from typing import
|
|
8
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
9
|
+
|
|
9
10
|
from pydantic import BaseModel, Field
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class RedisConfig(BaseModel):
|
|
13
14
|
"""Redis connection configuration."""
|
|
14
|
-
|
|
15
|
+
|
|
15
16
|
host: str = Field(..., description="Redis host")
|
|
16
17
|
port: int = Field(default=6379, description="Redis port")
|
|
17
18
|
password: Optional[str] = Field(default=None, description="Redis password")
|
|
@@ -21,38 +22,42 @@ class RedisConfig(BaseModel):
|
|
|
21
22
|
|
|
22
23
|
class MisoClientConfig(BaseModel):
|
|
23
24
|
"""Main MisoClient configuration.
|
|
24
|
-
|
|
25
|
+
|
|
25
26
|
Required fields:
|
|
26
27
|
- controller_url: Miso Controller base URL
|
|
27
28
|
- client_id: Client identifier for authentication
|
|
28
29
|
- client_secret: Client secret for authentication
|
|
29
|
-
|
|
30
|
+
|
|
30
31
|
Optional fields:
|
|
31
32
|
- redis: Redis configuration for caching
|
|
32
33
|
- log_level: Logging level (debug, info, warn, error)
|
|
33
34
|
- cache: Cache TTL settings for roles and permissions
|
|
35
|
+
- api_key: API key for testing (bypasses OAuth2 authentication)
|
|
34
36
|
"""
|
|
35
|
-
|
|
37
|
+
|
|
36
38
|
controller_url: str = Field(..., description="Miso Controller base URL")
|
|
37
39
|
client_id: str = Field(..., description="Client identifier for authentication")
|
|
38
40
|
client_secret: str = Field(..., description="Client secret for authentication")
|
|
39
41
|
redis: Optional[RedisConfig] = Field(default=None, description="Optional Redis configuration")
|
|
40
42
|
log_level: Literal["debug", "info", "warn", "error"] = Field(
|
|
41
|
-
default="info",
|
|
42
|
-
description="Log level"
|
|
43
|
+
default="info", description="Log level"
|
|
43
44
|
)
|
|
44
45
|
cache: Optional[Dict[str, int]] = Field(
|
|
45
46
|
default=None,
|
|
46
|
-
description="Cache TTL settings: permission_ttl, role_ttl (default: 900 seconds)"
|
|
47
|
+
description="Cache TTL settings: permission_ttl, role_ttl (default: 900 seconds)",
|
|
47
48
|
)
|
|
48
|
-
|
|
49
|
+
api_key: Optional[str] = Field(
|
|
50
|
+
default=None,
|
|
51
|
+
description="API key for testing - when set, bearer tokens matching this key bypass OAuth2 validation",
|
|
52
|
+
)
|
|
53
|
+
|
|
49
54
|
@property
|
|
50
55
|
def role_ttl(self) -> int:
|
|
51
56
|
"""Get role cache TTL in seconds."""
|
|
52
57
|
if self.cache and "role_ttl" in self.cache:
|
|
53
58
|
return self.cache["role_ttl"]
|
|
54
59
|
return self.cache.get("roleTTL", 900) if self.cache else 900 # 15 minutes default
|
|
55
|
-
|
|
60
|
+
|
|
56
61
|
@property
|
|
57
62
|
def permission_ttl(self) -> int:
|
|
58
63
|
"""Get permission cache TTL in seconds."""
|
|
@@ -63,21 +68,21 @@ class MisoClientConfig(BaseModel):
|
|
|
63
68
|
|
|
64
69
|
class UserInfo(BaseModel):
|
|
65
70
|
"""User information from token validation."""
|
|
66
|
-
|
|
71
|
+
|
|
67
72
|
id: str = Field(..., description="User ID")
|
|
68
73
|
username: str = Field(..., description="Username")
|
|
69
74
|
email: Optional[str] = Field(default=None, description="User email")
|
|
70
75
|
firstName: Optional[str] = Field(default=None, alias="first_name", description="First name")
|
|
71
76
|
lastName: Optional[str] = Field(default=None, alias="last_name", description="Last name")
|
|
72
77
|
roles: Optional[List[str]] = Field(default=None, description="User roles")
|
|
73
|
-
|
|
78
|
+
|
|
74
79
|
class Config:
|
|
75
80
|
populate_by_name = True # Allow both snake_case and camelCase
|
|
76
81
|
|
|
77
82
|
|
|
78
83
|
class AuthResult(BaseModel):
|
|
79
84
|
"""Authentication result."""
|
|
80
|
-
|
|
85
|
+
|
|
81
86
|
authenticated: bool = Field(..., description="Whether authentication was successful")
|
|
82
87
|
user: Optional[UserInfo] = Field(default=None, description="User information if authenticated")
|
|
83
88
|
error: Optional[str] = Field(default=None, description="Error message if authentication failed")
|
|
@@ -85,90 +90,106 @@ class AuthResult(BaseModel):
|
|
|
85
90
|
|
|
86
91
|
class LogEntry(BaseModel):
|
|
87
92
|
"""Log entry structure."""
|
|
88
|
-
|
|
93
|
+
|
|
89
94
|
timestamp: str = Field(..., description="ISO timestamp")
|
|
90
95
|
level: Literal["error", "audit", "info", "debug"] = Field(..., description="Log level")
|
|
91
96
|
environment: str = Field(..., description="Environment name (extracted by backend)")
|
|
92
97
|
application: str = Field(..., description="Application identifier (clientId)")
|
|
93
|
-
applicationId: Optional[str] = Field(
|
|
98
|
+
applicationId: Optional[str] = Field(
|
|
99
|
+
default=None, alias="application_id", description="Application ID"
|
|
100
|
+
)
|
|
94
101
|
userId: Optional[str] = Field(default=None, alias="user_id", description="User ID if available")
|
|
95
102
|
message: str = Field(..., description="Log message")
|
|
96
103
|
context: Optional[Dict[str, Any]] = Field(default=None, description="Additional context")
|
|
97
|
-
correlationId: Optional[str] = Field(
|
|
104
|
+
correlationId: Optional[str] = Field(
|
|
105
|
+
default=None, alias="correlation_id", description="Correlation ID for tracing"
|
|
106
|
+
)
|
|
98
107
|
requestId: Optional[str] = Field(default=None, alias="request_id", description="Request ID")
|
|
99
108
|
sessionId: Optional[str] = Field(default=None, alias="session_id", description="Session ID")
|
|
100
|
-
stackTrace: Optional[str] = Field(
|
|
109
|
+
stackTrace: Optional[str] = Field(
|
|
110
|
+
default=None, alias="stack_trace", description="Stack trace for errors"
|
|
111
|
+
)
|
|
101
112
|
ipAddress: Optional[str] = Field(default=None, alias="ip_address", description="IP address")
|
|
102
113
|
userAgent: Optional[str] = Field(default=None, alias="user_agent", description="User agent")
|
|
103
114
|
hostname: Optional[str] = Field(default=None, description="Hostname")
|
|
104
|
-
|
|
115
|
+
|
|
105
116
|
class Config:
|
|
106
117
|
populate_by_name = True
|
|
107
118
|
|
|
108
119
|
|
|
109
120
|
class RoleResult(BaseModel):
|
|
110
121
|
"""Role query result."""
|
|
111
|
-
|
|
122
|
+
|
|
112
123
|
userId: str = Field(..., alias="user_id", description="User ID")
|
|
113
124
|
roles: List[str] = Field(..., description="List of user roles")
|
|
114
125
|
environment: str = Field(..., description="Environment name")
|
|
115
126
|
application: str = Field(..., description="Application name")
|
|
116
|
-
|
|
127
|
+
|
|
117
128
|
class Config:
|
|
118
129
|
populate_by_name = True
|
|
119
130
|
|
|
120
131
|
|
|
121
132
|
class PermissionResult(BaseModel):
|
|
122
133
|
"""Permission query result."""
|
|
123
|
-
|
|
134
|
+
|
|
124
135
|
userId: str = Field(..., alias="user_id", description="User ID")
|
|
125
136
|
permissions: List[str] = Field(..., description="List of user permissions")
|
|
126
137
|
environment: str = Field(..., description="Environment name")
|
|
127
138
|
application: str = Field(..., description="Application name")
|
|
128
|
-
|
|
139
|
+
|
|
129
140
|
class Config:
|
|
130
141
|
populate_by_name = True
|
|
131
142
|
|
|
132
143
|
|
|
133
144
|
class ClientTokenResponse(BaseModel):
|
|
134
145
|
"""Client token response."""
|
|
135
|
-
|
|
146
|
+
|
|
136
147
|
success: bool = Field(..., description="Whether token request was successful")
|
|
137
148
|
token: str = Field(..., description="Client token")
|
|
138
149
|
expiresIn: int = Field(..., alias="expires_in", description="Token expiration in seconds")
|
|
139
150
|
expiresAt: str = Field(..., alias="expires_at", description="Token expiration ISO timestamp")
|
|
140
|
-
|
|
151
|
+
|
|
141
152
|
class Config:
|
|
142
153
|
populate_by_name = True
|
|
143
154
|
|
|
144
155
|
|
|
145
156
|
class PerformanceMetrics(BaseModel):
|
|
146
157
|
"""Performance metrics for logging."""
|
|
147
|
-
|
|
158
|
+
|
|
148
159
|
startTime: int = Field(..., alias="start_time", description="Start time in milliseconds")
|
|
149
|
-
endTime: Optional[int] = Field(
|
|
160
|
+
endTime: Optional[int] = Field(
|
|
161
|
+
default=None, alias="end_time", description="End time in milliseconds"
|
|
162
|
+
)
|
|
150
163
|
duration: Optional[int] = Field(default=None, description="Duration in milliseconds")
|
|
151
164
|
memoryUsage: Optional[Dict[str, int]] = Field(
|
|
152
|
-
default=None,
|
|
165
|
+
default=None,
|
|
153
166
|
alias="memory_usage",
|
|
154
|
-
description="Memory usage metrics (rss, heapTotal, heapUsed, external, arrayBuffers)"
|
|
167
|
+
description="Memory usage metrics (rss, heapTotal, heapUsed, external, arrayBuffers)",
|
|
155
168
|
)
|
|
156
|
-
|
|
169
|
+
|
|
157
170
|
class Config:
|
|
158
171
|
populate_by_name = True
|
|
159
172
|
|
|
160
173
|
|
|
161
174
|
class ClientLoggingOptions(BaseModel):
|
|
162
175
|
"""Options for client logging."""
|
|
163
|
-
|
|
164
|
-
applicationId: Optional[str] = Field(
|
|
176
|
+
|
|
177
|
+
applicationId: Optional[str] = Field(
|
|
178
|
+
default=None, alias="application_id", description="Application ID"
|
|
179
|
+
)
|
|
165
180
|
userId: Optional[str] = Field(default=None, alias="user_id", description="User ID")
|
|
166
|
-
correlationId: Optional[str] = Field(
|
|
181
|
+
correlationId: Optional[str] = Field(
|
|
182
|
+
default=None, alias="correlation_id", description="Correlation ID"
|
|
183
|
+
)
|
|
167
184
|
requestId: Optional[str] = Field(default=None, alias="request_id", description="Request ID")
|
|
168
185
|
sessionId: Optional[str] = Field(default=None, alias="session_id", description="Session ID")
|
|
169
186
|
token: Optional[str] = Field(default=None, description="JWT token for context extraction")
|
|
170
|
-
maskSensitiveData: Optional[bool] = Field(
|
|
171
|
-
|
|
172
|
-
|
|
187
|
+
maskSensitiveData: Optional[bool] = Field(
|
|
188
|
+
default=None, alias="mask_sensitive_data", description="Enable data masking"
|
|
189
|
+
)
|
|
190
|
+
performanceMetrics: Optional[bool] = Field(
|
|
191
|
+
default=None, alias="performance_metrics", description="Include performance metrics"
|
|
192
|
+
)
|
|
193
|
+
|
|
173
194
|
class Config:
|
|
174
195
|
populate_by_name = True
|
miso_client/services/__init__.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"""Service implementations for MisoClient SDK."""
|
|
2
2
|
|
|
3
3
|
from .auth import AuthService
|
|
4
|
-
from .
|
|
4
|
+
from .cache import CacheService
|
|
5
|
+
from .encryption import EncryptionService
|
|
6
|
+
from .logger import LoggerChain, LoggerService
|
|
5
7
|
from .permission import PermissionService
|
|
6
|
-
from .logger import LoggerService, LoggerChain
|
|
7
8
|
from .redis import RedisService
|
|
8
|
-
from .
|
|
9
|
-
from .cache import CacheService
|
|
9
|
+
from .role import RoleService
|
|
10
10
|
|
|
11
11
|
__all__ = [
|
|
12
12
|
"AuthService",
|
|
@@ -17,4 +17,4 @@ __all__ = [
|
|
|
17
17
|
"RedisService",
|
|
18
18
|
"EncryptionService",
|
|
19
19
|
"CacheService",
|
|
20
|
-
]
|
|
20
|
+
]
|
miso_client/services/auth.py
CHANGED
|
@@ -6,18 +6,19 @@ token validation, user information retrieval, and logout functionality.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from typing import Optional
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
from ..models.config import AuthResult, UserInfo
|
|
10
11
|
from ..services.redis import RedisService
|
|
11
12
|
from ..utils.http_client import HttpClient
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class AuthService:
|
|
15
16
|
"""Authentication service for token validation and user management."""
|
|
16
|
-
|
|
17
|
+
|
|
17
18
|
def __init__(self, http_client: HttpClient, redis: RedisService):
|
|
18
19
|
"""
|
|
19
20
|
Initialize authentication service.
|
|
20
|
-
|
|
21
|
+
|
|
21
22
|
Args:
|
|
22
23
|
http_client: HTTP client instance
|
|
23
24
|
redis: Redis service instance
|
|
@@ -25,117 +26,132 @@ class AuthService:
|
|
|
25
26
|
self.config = http_client.config
|
|
26
27
|
self.http_client = http_client
|
|
27
28
|
self.redis = redis
|
|
28
|
-
|
|
29
|
+
|
|
29
30
|
async def get_environment_token(self) -> str:
|
|
30
31
|
"""
|
|
31
32
|
Get environment token using client credentials.
|
|
32
|
-
|
|
33
|
+
|
|
33
34
|
This is called automatically by HttpClient, but can be called manually if needed.
|
|
34
|
-
|
|
35
|
+
|
|
35
36
|
Returns:
|
|
36
37
|
Client token string
|
|
37
|
-
|
|
38
|
+
|
|
38
39
|
Raises:
|
|
39
40
|
AuthenticationError: If token fetch fails
|
|
40
41
|
"""
|
|
41
42
|
return await self.http_client.get_environment_token()
|
|
42
|
-
|
|
43
|
+
|
|
43
44
|
def login(self, redirect_uri: str) -> str:
|
|
44
45
|
"""
|
|
45
46
|
Initiate login flow by redirecting to controller.
|
|
46
|
-
|
|
47
|
+
|
|
47
48
|
Returns the login URL for browser redirect or manual navigation.
|
|
48
49
|
Backend will extract environment and application from client token.
|
|
49
|
-
|
|
50
|
+
|
|
50
51
|
Args:
|
|
51
52
|
redirect_uri: URI to redirect to after successful login
|
|
52
|
-
|
|
53
|
+
|
|
53
54
|
Returns:
|
|
54
55
|
Login URL string
|
|
55
56
|
"""
|
|
56
57
|
return f"{self.config.controller_url}/api/auth/login?redirect={redirect_uri}"
|
|
57
|
-
|
|
58
|
+
|
|
58
59
|
async def validate_token(self, token: str) -> bool:
|
|
59
60
|
"""
|
|
60
61
|
Validate token with controller.
|
|
61
|
-
|
|
62
|
+
|
|
63
|
+
If API_KEY is configured and token matches it, bypasses OAuth2 validation.
|
|
64
|
+
|
|
62
65
|
Args:
|
|
63
|
-
token: JWT token to validate
|
|
64
|
-
|
|
66
|
+
token: JWT token to validate (or API_KEY for testing)
|
|
67
|
+
|
|
65
68
|
Returns:
|
|
66
69
|
True if token is valid, False otherwise
|
|
67
70
|
"""
|
|
71
|
+
# Check API_KEY first (for testing)
|
|
72
|
+
if self.config.api_key and token == self.config.api_key:
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
# Fall back to OAuth2 validation
|
|
68
76
|
try:
|
|
69
77
|
result = await self.http_client.authenticated_request(
|
|
70
|
-
"POST",
|
|
71
|
-
"/api/auth/validate", # Backend knows app/env from client token
|
|
72
|
-
token
|
|
78
|
+
"POST", "/api/auth/validate", token # Backend knows app/env from client token
|
|
73
79
|
)
|
|
74
|
-
|
|
80
|
+
|
|
75
81
|
auth_result = AuthResult(**result)
|
|
76
82
|
return auth_result.authenticated
|
|
77
|
-
|
|
83
|
+
|
|
78
84
|
except Exception:
|
|
79
85
|
# Token validation failed, return false
|
|
80
86
|
return False
|
|
81
|
-
|
|
87
|
+
|
|
82
88
|
async def get_user(self, token: str) -> Optional[UserInfo]:
|
|
83
89
|
"""
|
|
84
90
|
Get user information from token.
|
|
85
|
-
|
|
91
|
+
|
|
92
|
+
If API_KEY is configured and token matches it, returns None (no user info for API key auth).
|
|
93
|
+
|
|
86
94
|
Args:
|
|
87
|
-
token: JWT token
|
|
88
|
-
|
|
95
|
+
token: JWT token (or API_KEY for testing)
|
|
96
|
+
|
|
89
97
|
Returns:
|
|
90
98
|
UserInfo if token is valid, None otherwise
|
|
91
99
|
"""
|
|
100
|
+
# Check API_KEY first (for testing)
|
|
101
|
+
if self.config.api_key and token == self.config.api_key:
|
|
102
|
+
# API key authentication doesn't provide user info
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
# Fall back to OAuth2 validation
|
|
92
106
|
try:
|
|
93
107
|
result = await self.http_client.authenticated_request(
|
|
94
|
-
"POST",
|
|
95
|
-
"/api/auth/validate",
|
|
96
|
-
token
|
|
108
|
+
"POST", "/api/auth/validate", token
|
|
97
109
|
)
|
|
98
|
-
|
|
110
|
+
|
|
99
111
|
auth_result = AuthResult(**result)
|
|
100
|
-
|
|
112
|
+
|
|
101
113
|
if auth_result.authenticated and auth_result.user:
|
|
102
114
|
return auth_result.user
|
|
103
|
-
|
|
115
|
+
|
|
104
116
|
return None
|
|
105
|
-
|
|
117
|
+
|
|
106
118
|
except Exception:
|
|
107
119
|
# Failed to get user info, return null
|
|
108
120
|
return None
|
|
109
|
-
|
|
121
|
+
|
|
110
122
|
async def get_user_info(self, token: str) -> Optional[UserInfo]:
|
|
111
123
|
"""
|
|
112
124
|
Get user information from GET /api/auth/user endpoint.
|
|
113
|
-
|
|
125
|
+
|
|
126
|
+
If API_KEY is configured and token matches it, returns None (no user info for API key auth).
|
|
127
|
+
|
|
114
128
|
Args:
|
|
115
|
-
token: JWT token
|
|
116
|
-
|
|
129
|
+
token: JWT token (or API_KEY for testing)
|
|
130
|
+
|
|
117
131
|
Returns:
|
|
118
132
|
UserInfo if token is valid, None otherwise
|
|
119
133
|
"""
|
|
134
|
+
# Check API_KEY first (for testing)
|
|
135
|
+
if self.config.api_key and token == self.config.api_key:
|
|
136
|
+
# API key authentication doesn't provide user info
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
# Fall back to OAuth2 validation
|
|
120
140
|
try:
|
|
121
|
-
user_data = await self.http_client.authenticated_request(
|
|
122
|
-
|
|
123
|
-
"/api/auth/user",
|
|
124
|
-
token
|
|
125
|
-
)
|
|
126
|
-
|
|
141
|
+
user_data = await self.http_client.authenticated_request("GET", "/api/auth/user", token)
|
|
142
|
+
|
|
127
143
|
return UserInfo(**user_data)
|
|
128
|
-
|
|
144
|
+
|
|
129
145
|
except Exception:
|
|
130
146
|
# Failed to get user info, return None
|
|
131
147
|
return None
|
|
132
|
-
|
|
148
|
+
|
|
133
149
|
async def logout(self) -> None:
|
|
134
150
|
"""
|
|
135
151
|
Logout user.
|
|
136
|
-
|
|
152
|
+
|
|
137
153
|
Backend extracts app/env from client token (no body needed).
|
|
138
|
-
|
|
154
|
+
|
|
139
155
|
Raises:
|
|
140
156
|
MisoClientError: If logout fails
|
|
141
157
|
"""
|
|
@@ -145,15 +161,16 @@ class AuthService:
|
|
|
145
161
|
except Exception as e:
|
|
146
162
|
# Logout failed, re-raise error for application to handle
|
|
147
163
|
from ..errors import MisoClientError
|
|
164
|
+
|
|
148
165
|
raise MisoClientError(f"Logout failed: {str(e)}")
|
|
149
|
-
|
|
166
|
+
|
|
150
167
|
async def is_authenticated(self, token: str) -> bool:
|
|
151
168
|
"""
|
|
152
169
|
Check if user is authenticated (has valid token).
|
|
153
|
-
|
|
170
|
+
|
|
154
171
|
Args:
|
|
155
172
|
token: JWT token
|
|
156
|
-
|
|
173
|
+
|
|
157
174
|
Returns:
|
|
158
175
|
True if user is authenticated, False otherwise
|
|
159
176
|
"""
|
miso_client/services/cache.py
CHANGED
|
@@ -8,17 +8,18 @@ in-memory TTL-based caching when Redis is unavailable.
|
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
10
|
import time
|
|
11
|
-
from typing import Any,
|
|
11
|
+
from typing import Any, Dict, Optional, Tuple
|
|
12
|
+
|
|
12
13
|
from ..services.redis import RedisService
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class CacheService:
|
|
16
17
|
"""Cache service with Redis and in-memory TTL fallback."""
|
|
17
|
-
|
|
18
|
+
|
|
18
19
|
def __init__(self, redis: Optional[RedisService] = None):
|
|
19
20
|
"""
|
|
20
21
|
Initialize cache service.
|
|
21
|
-
|
|
22
|
+
|
|
22
23
|
Args:
|
|
23
24
|
redis: Optional RedisService instance. If provided, Redis will be used
|
|
24
25
|
as primary cache with in-memory as fallback. If None, only
|
|
@@ -29,31 +30,32 @@ class CacheService:
|
|
|
29
30
|
self._memory_cache: Dict[str, Tuple[Any, float]] = {}
|
|
30
31
|
# Cleanup threshold: clean expired entries if cache exceeds this size
|
|
31
32
|
self._cleanup_threshold = 1000
|
|
32
|
-
|
|
33
|
+
|
|
33
34
|
def _is_expired(self, expiration: float) -> bool:
|
|
34
35
|
"""Check if entry has expired."""
|
|
35
36
|
return time.time() > expiration
|
|
36
|
-
|
|
37
|
+
|
|
37
38
|
def _cleanup_expired(self) -> None:
|
|
38
39
|
"""Remove expired entries from memory cache."""
|
|
39
40
|
if len(self._memory_cache) <= self._cleanup_threshold:
|
|
40
41
|
return
|
|
41
|
-
|
|
42
|
+
|
|
42
43
|
expired_keys = [
|
|
43
|
-
key
|
|
44
|
+
key
|
|
45
|
+
for key, (_, expiration) in self._memory_cache.items()
|
|
44
46
|
if self._is_expired(expiration)
|
|
45
47
|
]
|
|
46
|
-
|
|
48
|
+
|
|
47
49
|
for key in expired_keys:
|
|
48
50
|
del self._memory_cache[key]
|
|
49
|
-
|
|
51
|
+
|
|
50
52
|
def _serialize_value(self, value: Any) -> str:
|
|
51
53
|
"""
|
|
52
54
|
Serialize value to JSON string.
|
|
53
|
-
|
|
55
|
+
|
|
54
56
|
Args:
|
|
55
57
|
value: Value to serialize (can be any JSON-serializable type)
|
|
56
|
-
|
|
58
|
+
|
|
57
59
|
Returns:
|
|
58
60
|
JSON string representation
|
|
59
61
|
"""
|
|
@@ -62,23 +64,23 @@ class CacheService:
|
|
|
62
64
|
if isinstance(value, str):
|
|
63
65
|
return value
|
|
64
66
|
return json.dumps(value)
|
|
65
|
-
|
|
67
|
+
|
|
66
68
|
# For complex types, use JSON serialization with a marker
|
|
67
69
|
return json.dumps({"__cached_value__": value})
|
|
68
|
-
|
|
70
|
+
|
|
69
71
|
def _deserialize_value(self, value_str: str) -> Any:
|
|
70
72
|
"""
|
|
71
73
|
Deserialize JSON string back to original value.
|
|
72
|
-
|
|
74
|
+
|
|
73
75
|
Args:
|
|
74
76
|
value_str: JSON string to deserialize
|
|
75
|
-
|
|
77
|
+
|
|
76
78
|
Returns:
|
|
77
79
|
Deserialized value
|
|
78
80
|
"""
|
|
79
81
|
if not value_str:
|
|
80
82
|
return None
|
|
81
|
-
|
|
83
|
+
|
|
82
84
|
try:
|
|
83
85
|
# Try to parse as JSON
|
|
84
86
|
parsed = json.loads(value_str)
|
|
@@ -90,16 +92,16 @@ class CacheService:
|
|
|
90
92
|
except (json.JSONDecodeError, TypeError):
|
|
91
93
|
# If JSON parsing fails, assume it's a plain string
|
|
92
94
|
return value_str
|
|
93
|
-
|
|
95
|
+
|
|
94
96
|
async def get(self, key: str) -> Optional[Any]:
|
|
95
97
|
"""
|
|
96
98
|
Get cached value.
|
|
97
|
-
|
|
99
|
+
|
|
98
100
|
Checks Redis first (if available), then falls back to in-memory cache.
|
|
99
|
-
|
|
101
|
+
|
|
100
102
|
Args:
|
|
101
103
|
key: Cache key
|
|
102
|
-
|
|
104
|
+
|
|
103
105
|
Returns:
|
|
104
106
|
Cached value if found, None otherwise
|
|
105
107
|
"""
|
|
@@ -112,7 +114,7 @@ class CacheService:
|
|
|
112
114
|
except Exception:
|
|
113
115
|
# Redis operation failed, fall through to memory cache
|
|
114
116
|
pass
|
|
115
|
-
|
|
117
|
+
|
|
116
118
|
# Fallback to in-memory cache
|
|
117
119
|
if key in self._memory_cache:
|
|
118
120
|
value, expiration = self._memory_cache[key]
|
|
@@ -121,26 +123,26 @@ class CacheService:
|
|
|
121
123
|
else:
|
|
122
124
|
# Entry expired, remove it
|
|
123
125
|
del self._memory_cache[key]
|
|
124
|
-
|
|
126
|
+
|
|
125
127
|
return None
|
|
126
|
-
|
|
128
|
+
|
|
127
129
|
async def set(self, key: str, value: Any, ttl: int) -> bool:
|
|
128
130
|
"""
|
|
129
131
|
Set cached value with TTL.
|
|
130
|
-
|
|
132
|
+
|
|
131
133
|
Stores in both Redis (if available) and in-memory cache.
|
|
132
|
-
|
|
134
|
+
|
|
133
135
|
Args:
|
|
134
136
|
key: Cache key
|
|
135
137
|
value: Value to cache (any JSON-serializable type)
|
|
136
138
|
ttl: Time to live in seconds
|
|
137
|
-
|
|
139
|
+
|
|
138
140
|
Returns:
|
|
139
141
|
True if successful, False otherwise
|
|
140
142
|
"""
|
|
141
143
|
serialized_value = self._serialize_value(value)
|
|
142
144
|
success = False
|
|
143
|
-
|
|
145
|
+
|
|
144
146
|
# Store in Redis if available
|
|
145
147
|
if self.redis and self.redis.is_connected():
|
|
146
148
|
try:
|
|
@@ -148,57 +150,56 @@ class CacheService:
|
|
|
148
150
|
except Exception:
|
|
149
151
|
# Redis operation failed, continue to memory cache
|
|
150
152
|
pass
|
|
151
|
-
|
|
153
|
+
|
|
152
154
|
# Also store in memory cache
|
|
153
155
|
expiration = time.time() + ttl
|
|
154
156
|
self._memory_cache[key] = (value, expiration)
|
|
155
|
-
|
|
157
|
+
|
|
156
158
|
# Cleanup expired entries periodically
|
|
157
159
|
self._cleanup_expired()
|
|
158
|
-
|
|
160
|
+
|
|
159
161
|
return success or True # Return True if at least memory cache succeeded
|
|
160
|
-
|
|
162
|
+
|
|
161
163
|
async def delete(self, key: str) -> bool:
|
|
162
164
|
"""
|
|
163
165
|
Delete cached value.
|
|
164
|
-
|
|
166
|
+
|
|
165
167
|
Deletes from both Redis (if available) and in-memory cache.
|
|
166
|
-
|
|
168
|
+
|
|
167
169
|
Args:
|
|
168
170
|
key: Cache key
|
|
169
|
-
|
|
171
|
+
|
|
170
172
|
Returns:
|
|
171
173
|
True if deleted from at least one cache, False otherwise
|
|
172
174
|
"""
|
|
173
175
|
deleted = False
|
|
174
|
-
|
|
176
|
+
|
|
175
177
|
# Delete from Redis if available
|
|
176
178
|
if self.redis and self.redis.is_connected():
|
|
177
179
|
try:
|
|
178
180
|
deleted = await self.redis.delete(key)
|
|
179
181
|
except Exception:
|
|
180
182
|
pass
|
|
181
|
-
|
|
183
|
+
|
|
182
184
|
# Delete from memory cache
|
|
183
185
|
if key in self._memory_cache:
|
|
184
186
|
del self._memory_cache[key]
|
|
185
187
|
deleted = True
|
|
186
|
-
|
|
188
|
+
|
|
187
189
|
return deleted
|
|
188
|
-
|
|
190
|
+
|
|
189
191
|
async def clear(self) -> None:
|
|
190
192
|
"""
|
|
191
193
|
Clear all cached values.
|
|
192
|
-
|
|
194
|
+
|
|
193
195
|
Clears both Redis (if available) and in-memory cache.
|
|
194
196
|
Note: Redis clear operation only clears keys with the configured prefix,
|
|
195
197
|
not the entire Redis database.
|
|
196
198
|
"""
|
|
197
199
|
# Clear memory cache
|
|
198
200
|
self._memory_cache.clear()
|
|
199
|
-
|
|
201
|
+
|
|
200
202
|
# For Redis, we would need to delete all keys with the prefix
|
|
201
203
|
# This is more complex and potentially dangerous, so we'll skip it
|
|
202
204
|
# Users should use delete() for specific keys or clear Redis manually
|
|
203
205
|
# if needed
|
|
204
|
-
|