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.
@@ -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
+