miso-client 0.1.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.
- miso_client/__init__.py +489 -0
- miso_client/errors.py +44 -0
- miso_client/models/__init__.py +1 -0
- miso_client/models/config.py +174 -0
- miso_client/py.typed +0 -0
- miso_client/services/__init__.py +20 -0
- miso_client/services/auth.py +160 -0
- miso_client/services/cache.py +204 -0
- miso_client/services/encryption.py +93 -0
- miso_client/services/logger.py +457 -0
- miso_client/services/permission.py +208 -0
- miso_client/services/redis.py +179 -0
- miso_client/services/role.py +180 -0
- miso_client/utils/__init__.py +15 -0
- miso_client/utils/config_loader.py +87 -0
- miso_client/utils/data_masker.py +156 -0
- miso_client/utils/http_client.py +377 -0
- miso_client/utils/jwt_tools.py +78 -0
- miso_client-0.1.0.dist-info/METADATA +551 -0
- miso_client-0.1.0.dist-info/RECORD +23 -0
- miso_client-0.1.0.dist-info/WHEEL +5 -0
- miso_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- miso_client-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP client utility for controller communication.
|
|
3
|
+
|
|
4
|
+
This module provides an async HTTP client wrapper that handles communication
|
|
5
|
+
with the Miso Controller, including automatic client token management,
|
|
6
|
+
retry logic, and error handling.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import asyncio
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
from typing import Any, Dict, Optional, Literal
|
|
13
|
+
from ..models.config import MisoClientConfig, ClientTokenResponse
|
|
14
|
+
from ..errors import MisoClientError, AuthenticationError, ConnectionError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HttpClient:
|
|
18
|
+
"""HTTP client for Miso Controller communication with automatic client token management."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, config: MisoClientConfig):
|
|
21
|
+
"""
|
|
22
|
+
Initialize HTTP client with configuration.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
config: MisoClient configuration
|
|
26
|
+
"""
|
|
27
|
+
self.config = config
|
|
28
|
+
self.client: Optional[httpx.AsyncClient] = None
|
|
29
|
+
self.client_token: Optional[str] = None
|
|
30
|
+
self.token_expires_at: Optional[datetime] = None
|
|
31
|
+
self.token_refresh_lock = asyncio.Lock()
|
|
32
|
+
|
|
33
|
+
async def _initialize_client(self):
|
|
34
|
+
"""Initialize HTTP client if not already initialized."""
|
|
35
|
+
if self.client is None:
|
|
36
|
+
self.client = httpx.AsyncClient(
|
|
37
|
+
base_url=self.config.controller_url,
|
|
38
|
+
timeout=30.0,
|
|
39
|
+
headers={
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
async def _get_client_token(self) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Get client token, fetching if needed.
|
|
47
|
+
|
|
48
|
+
Proactively refreshes if token will expire within 60 seconds.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Client token string
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
AuthenticationError: If token fetch fails
|
|
55
|
+
"""
|
|
56
|
+
await self._initialize_client()
|
|
57
|
+
|
|
58
|
+
now = datetime.now()
|
|
59
|
+
|
|
60
|
+
# If token exists and not expired (with 60s buffer for proactive refresh), return it
|
|
61
|
+
if (
|
|
62
|
+
self.client_token and
|
|
63
|
+
self.token_expires_at and
|
|
64
|
+
self.token_expires_at > now + timedelta(seconds=60)
|
|
65
|
+
):
|
|
66
|
+
assert self.client_token is not None
|
|
67
|
+
return self.client_token
|
|
68
|
+
|
|
69
|
+
# Acquire lock to prevent concurrent token fetches
|
|
70
|
+
async with self.token_refresh_lock:
|
|
71
|
+
# Double-check after acquiring lock
|
|
72
|
+
if (
|
|
73
|
+
self.client_token and
|
|
74
|
+
self.token_expires_at and
|
|
75
|
+
self.token_expires_at > now + timedelta(seconds=60)
|
|
76
|
+
):
|
|
77
|
+
assert self.client_token is not None
|
|
78
|
+
return self.client_token
|
|
79
|
+
|
|
80
|
+
# Fetch new token
|
|
81
|
+
await self._fetch_client_token()
|
|
82
|
+
assert self.client_token is not None
|
|
83
|
+
return self.client_token
|
|
84
|
+
|
|
85
|
+
async def _fetch_client_token(self) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Fetch client token from controller.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
AuthenticationError: If token fetch fails
|
|
91
|
+
"""
|
|
92
|
+
await self._initialize_client()
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
# Use a temporary client to avoid interceptor recursion
|
|
96
|
+
temp_client = httpx.AsyncClient(
|
|
97
|
+
base_url=self.config.controller_url,
|
|
98
|
+
timeout=30.0,
|
|
99
|
+
headers={
|
|
100
|
+
"Content-Type": "application/json",
|
|
101
|
+
"x-client-id": self.config.client_id,
|
|
102
|
+
"x-client-secret": self.config.client_secret,
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
response = await temp_client.post("/api/auth/token")
|
|
107
|
+
await temp_client.aclose()
|
|
108
|
+
|
|
109
|
+
if response.status_code != 200:
|
|
110
|
+
raise AuthenticationError(
|
|
111
|
+
f"Failed to get client token: HTTP {response.status_code}",
|
|
112
|
+
status_code=response.status_code
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
data = response.json()
|
|
116
|
+
token_response = ClientTokenResponse(**data)
|
|
117
|
+
|
|
118
|
+
if not token_response.success or not token_response.token:
|
|
119
|
+
raise AuthenticationError("Failed to get client token: Invalid response")
|
|
120
|
+
|
|
121
|
+
self.client_token = token_response.token
|
|
122
|
+
# Set expiration with 30 second buffer before actual expiration
|
|
123
|
+
expires_in = max(0, token_response.expiresIn - 30)
|
|
124
|
+
self.token_expires_at = datetime.now() + timedelta(seconds=expires_in)
|
|
125
|
+
|
|
126
|
+
except httpx.HTTPError as e:
|
|
127
|
+
raise ConnectionError(f"Failed to get client token: {str(e)}")
|
|
128
|
+
except Exception as e:
|
|
129
|
+
if isinstance(e, (AuthenticationError, ConnectionError)):
|
|
130
|
+
raise
|
|
131
|
+
raise AuthenticationError(f"Failed to get client token: {str(e)}")
|
|
132
|
+
|
|
133
|
+
async def _ensure_client_token(self):
|
|
134
|
+
"""Ensure client token is set in headers."""
|
|
135
|
+
token = await self._get_client_token()
|
|
136
|
+
if self.client:
|
|
137
|
+
self.client.headers["x-client-token"] = token
|
|
138
|
+
|
|
139
|
+
async def close(self):
|
|
140
|
+
"""Close the HTTP client."""
|
|
141
|
+
if self.client:
|
|
142
|
+
await self.client.aclose()
|
|
143
|
+
self.client = None
|
|
144
|
+
|
|
145
|
+
async def __aenter__(self):
|
|
146
|
+
"""Async context manager entry."""
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
150
|
+
"""Async context manager exit."""
|
|
151
|
+
await self.close()
|
|
152
|
+
|
|
153
|
+
async def get(self, url: str, **kwargs) -> Any:
|
|
154
|
+
"""
|
|
155
|
+
Make GET request.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
url: Request URL
|
|
159
|
+
**kwargs: Additional httpx request parameters
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Response data (JSON parsed)
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
MisoClientError: If request fails
|
|
166
|
+
"""
|
|
167
|
+
await self._initialize_client()
|
|
168
|
+
await self._ensure_client_token()
|
|
169
|
+
try:
|
|
170
|
+
assert self.client is not None
|
|
171
|
+
response = await self.client.get(url, **kwargs)
|
|
172
|
+
|
|
173
|
+
# Handle 401 - clear token to force refresh
|
|
174
|
+
if response.status_code == 401:
|
|
175
|
+
self.client_token = None
|
|
176
|
+
self.token_expires_at = None
|
|
177
|
+
|
|
178
|
+
response.raise_for_status()
|
|
179
|
+
return response.json()
|
|
180
|
+
except httpx.HTTPStatusError as e:
|
|
181
|
+
raise MisoClientError(
|
|
182
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
183
|
+
status_code=e.response.status_code,
|
|
184
|
+
error_body=e.response.json() if e.response.headers.get("content-type", "").startswith("application/json") else {}
|
|
185
|
+
)
|
|
186
|
+
except httpx.RequestError as e:
|
|
187
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
188
|
+
|
|
189
|
+
async def post(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
|
|
190
|
+
"""
|
|
191
|
+
Make POST request.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
url: Request URL
|
|
195
|
+
data: Request data (will be JSON encoded)
|
|
196
|
+
**kwargs: Additional httpx request parameters
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Response data (JSON parsed)
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
MisoClientError: If request fails
|
|
203
|
+
"""
|
|
204
|
+
await self._initialize_client()
|
|
205
|
+
await self._ensure_client_token()
|
|
206
|
+
try:
|
|
207
|
+
assert self.client is not None
|
|
208
|
+
response = await self.client.post(url, json=data, **kwargs)
|
|
209
|
+
|
|
210
|
+
if response.status_code == 401:
|
|
211
|
+
self.client_token = None
|
|
212
|
+
self.token_expires_at = None
|
|
213
|
+
|
|
214
|
+
response.raise_for_status()
|
|
215
|
+
return response.json()
|
|
216
|
+
except httpx.HTTPStatusError as e:
|
|
217
|
+
raise MisoClientError(
|
|
218
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
219
|
+
status_code=e.response.status_code,
|
|
220
|
+
error_body=e.response.json() if e.response.headers.get("content-type", "").startswith("application/json") else {}
|
|
221
|
+
)
|
|
222
|
+
except httpx.RequestError as e:
|
|
223
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
224
|
+
|
|
225
|
+
async def put(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
|
|
226
|
+
"""
|
|
227
|
+
Make PUT request.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
url: Request URL
|
|
231
|
+
data: Request data (will be JSON encoded)
|
|
232
|
+
**kwargs: Additional httpx request parameters
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Response data (JSON parsed)
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
MisoClientError: If request fails
|
|
239
|
+
"""
|
|
240
|
+
await self._initialize_client()
|
|
241
|
+
await self._ensure_client_token()
|
|
242
|
+
try:
|
|
243
|
+
assert self.client is not None
|
|
244
|
+
response = await self.client.put(url, json=data, **kwargs)
|
|
245
|
+
|
|
246
|
+
if response.status_code == 401:
|
|
247
|
+
self.client_token = None
|
|
248
|
+
self.token_expires_at = None
|
|
249
|
+
|
|
250
|
+
response.raise_for_status()
|
|
251
|
+
return response.json()
|
|
252
|
+
except httpx.HTTPStatusError as e:
|
|
253
|
+
raise MisoClientError(
|
|
254
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
255
|
+
status_code=e.response.status_code,
|
|
256
|
+
error_body=e.response.json() if e.response.headers.get("content-type", "").startswith("application/json") else {}
|
|
257
|
+
)
|
|
258
|
+
except httpx.RequestError as e:
|
|
259
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
260
|
+
|
|
261
|
+
async def delete(self, url: str, **kwargs) -> Any:
|
|
262
|
+
"""
|
|
263
|
+
Make DELETE request.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
url: Request URL
|
|
267
|
+
**kwargs: Additional httpx request parameters
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Response data (JSON parsed)
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
MisoClientError: If request fails
|
|
274
|
+
"""
|
|
275
|
+
await self._initialize_client()
|
|
276
|
+
await self._ensure_client_token()
|
|
277
|
+
try:
|
|
278
|
+
assert self.client is not None
|
|
279
|
+
response = await self.client.delete(url, **kwargs)
|
|
280
|
+
|
|
281
|
+
if response.status_code == 401:
|
|
282
|
+
self.client_token = None
|
|
283
|
+
self.token_expires_at = None
|
|
284
|
+
|
|
285
|
+
response.raise_for_status()
|
|
286
|
+
return response.json()
|
|
287
|
+
except httpx.HTTPStatusError as e:
|
|
288
|
+
raise MisoClientError(
|
|
289
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
290
|
+
status_code=e.response.status_code,
|
|
291
|
+
error_body=e.response.json() if e.response.headers.get("content-type", "").startswith("application/json") else {}
|
|
292
|
+
)
|
|
293
|
+
except httpx.RequestError as e:
|
|
294
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
295
|
+
|
|
296
|
+
async def request(
|
|
297
|
+
self,
|
|
298
|
+
method: Literal["GET", "POST", "PUT", "DELETE"],
|
|
299
|
+
url: str,
|
|
300
|
+
data: Optional[Dict[str, Any]] = None,
|
|
301
|
+
**kwargs
|
|
302
|
+
) -> Any:
|
|
303
|
+
"""
|
|
304
|
+
Generic request method.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
method: HTTP method
|
|
308
|
+
url: Request URL
|
|
309
|
+
data: Request data (for POST/PUT)
|
|
310
|
+
**kwargs: Additional httpx request parameters
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Response data (JSON parsed)
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
MisoClientError: If request fails
|
|
317
|
+
"""
|
|
318
|
+
method_upper = method.upper()
|
|
319
|
+
if method_upper == "GET":
|
|
320
|
+
return await self.get(url, **kwargs)
|
|
321
|
+
elif method_upper == "POST":
|
|
322
|
+
return await self.post(url, data, **kwargs)
|
|
323
|
+
elif method_upper == "PUT":
|
|
324
|
+
return await self.put(url, data, **kwargs)
|
|
325
|
+
elif method_upper == "DELETE":
|
|
326
|
+
return await self.delete(url, **kwargs)
|
|
327
|
+
else:
|
|
328
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
329
|
+
|
|
330
|
+
async def authenticated_request(
|
|
331
|
+
self,
|
|
332
|
+
method: Literal["GET", "POST", "PUT", "DELETE"],
|
|
333
|
+
url: str,
|
|
334
|
+
token: str,
|
|
335
|
+
data: Optional[Dict[str, Any]] = None,
|
|
336
|
+
**kwargs
|
|
337
|
+
) -> Any:
|
|
338
|
+
"""
|
|
339
|
+
Make authenticated request with Bearer token.
|
|
340
|
+
|
|
341
|
+
IMPORTANT: Client token is sent as x-client-token header (via _ensure_client_token)
|
|
342
|
+
User token is sent as Authorization: Bearer header (this method parameter)
|
|
343
|
+
These are two separate tokens for different purposes.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
method: HTTP method
|
|
347
|
+
url: Request URL
|
|
348
|
+
token: User authentication token (sent as Bearer token)
|
|
349
|
+
data: Request data (for POST/PUT)
|
|
350
|
+
**kwargs: Additional httpx request parameters
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Response data (JSON parsed)
|
|
354
|
+
|
|
355
|
+
Raises:
|
|
356
|
+
MisoClientError: If request fails
|
|
357
|
+
"""
|
|
358
|
+
await self._ensure_client_token()
|
|
359
|
+
|
|
360
|
+
# Add Bearer token for user authentication
|
|
361
|
+
# x-client-token is automatically added by _ensure_client_token
|
|
362
|
+
headers = kwargs.get("headers", {})
|
|
363
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
364
|
+
kwargs["headers"] = headers
|
|
365
|
+
|
|
366
|
+
return await self.request(method, url, data, **kwargs)
|
|
367
|
+
|
|
368
|
+
async def get_environment_token(self) -> str:
|
|
369
|
+
"""
|
|
370
|
+
Get environment token using client credentials.
|
|
371
|
+
|
|
372
|
+
This is called automatically by HttpClient but can be called manually.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Client token string
|
|
376
|
+
"""
|
|
377
|
+
return await self._get_client_token()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JWT token utilities for safe decoding without verification.
|
|
3
|
+
|
|
4
|
+
This module provides utilities for extracting information from JWT tokens
|
|
5
|
+
without verification, used for cache optimization and context extraction.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import jwt
|
|
9
|
+
from typing import Optional, Dict, Any, cast
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
|
13
|
+
"""
|
|
14
|
+
Safely decode JWT token without verification.
|
|
15
|
+
|
|
16
|
+
This is used for extracting user information (like userId) from tokens
|
|
17
|
+
for cache optimization. The token is NOT verified - it should only be
|
|
18
|
+
used for cache key generation, not for authentication decisions.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
token: JWT token string
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Decoded token payload as dictionary, or None if decoding fails
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
# Decode without verification (no secret key needed)
|
|
28
|
+
decoded = cast(Dict[str, Any], jwt.decode(token, options={"verify_signature": False}))
|
|
29
|
+
return decoded
|
|
30
|
+
except Exception:
|
|
31
|
+
# Token is invalid or malformed
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_user_id(token: str) -> Optional[str]:
|
|
36
|
+
"""
|
|
37
|
+
Extract user ID from JWT token.
|
|
38
|
+
|
|
39
|
+
Tries common JWT claim fields: sub, userId, user_id, id
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
token: JWT token string
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
User ID string if found, None otherwise
|
|
46
|
+
"""
|
|
47
|
+
decoded = decode_token(token)
|
|
48
|
+
if not decoded:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
# Try common JWT claim fields for user ID
|
|
52
|
+
user_id = (
|
|
53
|
+
decoded.get("sub") or
|
|
54
|
+
decoded.get("userId") or
|
|
55
|
+
decoded.get("user_id") or
|
|
56
|
+
decoded.get("id")
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return str(user_id) if user_id else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def extract_session_id(token: str) -> Optional[str]:
|
|
63
|
+
"""
|
|
64
|
+
Extract session ID from JWT token.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
token: JWT token string
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Session ID string if found, None otherwise
|
|
71
|
+
"""
|
|
72
|
+
decoded = decode_token(token)
|
|
73
|
+
if not decoded:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
value = decoded.get("sid") or decoded.get("sessionId")
|
|
77
|
+
return value if isinstance(value, str) else None
|
|
78
|
+
|