miso-client 0.2.0__py3-none-any.whl → 0.5.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.

@@ -0,0 +1,471 @@
1
+ """
2
+ Internal HTTP client utility for controller communication.
3
+
4
+ This module provides the internal HTTP client implementation with automatic client
5
+ token management. This class is not meant to be used directly - use the public
6
+ HttpClient class instead which adds ISO 27001 compliant audit and debug logging.
7
+ """
8
+
9
+ import asyncio
10
+ from datetime import datetime, timedelta
11
+ from typing import Any, Dict, Literal, Optional
12
+
13
+ import httpx
14
+
15
+ from ..errors import AuthenticationError, ConnectionError, MisoClientError
16
+ from ..models.config import ClientTokenResponse, MisoClientConfig
17
+ from ..models.error_response import ErrorResponse
18
+
19
+
20
+ class InternalHttpClient:
21
+ """
22
+ Internal HTTP client for Miso Controller communication with automatic client token management.
23
+
24
+ This class contains the core HTTP functionality without logging.
25
+ It is wrapped by the public HttpClient class which adds audit and debug logging.
26
+ """
27
+
28
+ def __init__(self, config: MisoClientConfig):
29
+ """
30
+ Initialize internal HTTP client with configuration.
31
+
32
+ Args:
33
+ config: MisoClient configuration
34
+ """
35
+ self.config = config
36
+ self.client: Optional[httpx.AsyncClient] = None
37
+ self.client_token: Optional[str] = None
38
+ self.token_expires_at: Optional[datetime] = None
39
+ self.token_refresh_lock = asyncio.Lock()
40
+
41
+ async def _initialize_client(self):
42
+ """Initialize HTTP client if not already initialized."""
43
+ if self.client is None:
44
+ self.client = httpx.AsyncClient(
45
+ base_url=self.config.controller_url,
46
+ timeout=30.0,
47
+ headers={
48
+ "Content-Type": "application/json",
49
+ },
50
+ )
51
+
52
+ async def _get_client_token(self) -> str:
53
+ """
54
+ Get client token, fetching if needed.
55
+
56
+ Proactively refreshes if token will expire within 60 seconds.
57
+
58
+ Returns:
59
+ Client token string
60
+
61
+ Raises:
62
+ AuthenticationError: If token fetch fails
63
+ """
64
+ await self._initialize_client()
65
+
66
+ now = datetime.now()
67
+
68
+ # If token exists and not expired (with 60s buffer for proactive refresh), return it
69
+ if (
70
+ self.client_token
71
+ and self.token_expires_at
72
+ and self.token_expires_at > now + timedelta(seconds=60)
73
+ ):
74
+ assert self.client_token is not None
75
+ return self.client_token
76
+
77
+ # Acquire lock to prevent concurrent token fetches
78
+ async with self.token_refresh_lock:
79
+ # Double-check after acquiring lock
80
+ if (
81
+ self.client_token
82
+ and self.token_expires_at
83
+ and self.token_expires_at > now + timedelta(seconds=60)
84
+ ):
85
+ assert self.client_token is not None
86
+ return self.client_token
87
+
88
+ # Fetch new token
89
+ await self._fetch_client_token()
90
+ assert self.client_token is not None
91
+ return self.client_token
92
+
93
+ async def _fetch_client_token(self) -> None:
94
+ """
95
+ Fetch client token from controller.
96
+
97
+ Raises:
98
+ AuthenticationError: If token fetch fails
99
+ """
100
+ await self._initialize_client()
101
+
102
+ try:
103
+ # Use a temporary client to avoid interceptor recursion
104
+ temp_client = httpx.AsyncClient(
105
+ base_url=self.config.controller_url,
106
+ timeout=30.0,
107
+ headers={
108
+ "Content-Type": "application/json",
109
+ "x-client-id": self.config.client_id,
110
+ "x-client-secret": self.config.client_secret,
111
+ },
112
+ )
113
+
114
+ response = await temp_client.post("/api/auth/token")
115
+ await temp_client.aclose()
116
+
117
+ if response.status_code != 200:
118
+ raise AuthenticationError(
119
+ f"Failed to get client token: HTTP {response.status_code}",
120
+ status_code=response.status_code,
121
+ )
122
+
123
+ data = response.json()
124
+ token_response = ClientTokenResponse(**data)
125
+
126
+ if not token_response.success or not token_response.token:
127
+ raise AuthenticationError("Failed to get client token: Invalid response")
128
+
129
+ self.client_token = token_response.token
130
+ # Set expiration with 30 second buffer before actual expiration
131
+ expires_in = max(0, token_response.expiresIn - 30)
132
+ self.token_expires_at = datetime.now() + timedelta(seconds=expires_in)
133
+
134
+ except httpx.HTTPError as e:
135
+ raise ConnectionError(f"Failed to get client token: {str(e)}")
136
+ except Exception as e:
137
+ if isinstance(e, (AuthenticationError, ConnectionError)):
138
+ raise
139
+ raise AuthenticationError(f"Failed to get client token: {str(e)}")
140
+
141
+ async def _ensure_client_token(self):
142
+ """Ensure client token is set in headers."""
143
+ token = await self._get_client_token()
144
+ if self.client:
145
+ self.client.headers["x-client-token"] = token
146
+
147
+ def _parse_error_response(self, response: httpx.Response, url: str) -> Optional[ErrorResponse]:
148
+ """
149
+ Parse structured error response from HTTP response.
150
+
151
+ Args:
152
+ response: HTTP response object
153
+ url: Request URL (used for instance URI if not in response)
154
+
155
+ Returns:
156
+ ErrorResponse if response matches structure, None otherwise
157
+ """
158
+ if not response.headers.get("content-type", "").startswith("application/json"):
159
+ return None
160
+
161
+ try:
162
+ response_data = response.json()
163
+ # Check if response matches ErrorResponse structure
164
+ if (
165
+ isinstance(response_data, dict)
166
+ and "errors" in response_data
167
+ and "type" in response_data
168
+ and "title" in response_data
169
+ and "statusCode" in response_data
170
+ ):
171
+ # Set instance from URL if not provided
172
+ if "instance" not in response_data or not response_data["instance"]:
173
+ response_data["instance"] = url
174
+ return ErrorResponse(**response_data)
175
+ except (ValueError, TypeError, KeyError):
176
+ # JSON parsing failed or structure doesn't match
177
+ pass
178
+
179
+ return None
180
+
181
+ async def close(self):
182
+ """Close the HTTP client."""
183
+ if self.client:
184
+ await self.client.aclose()
185
+ self.client = None
186
+
187
+ async def __aenter__(self):
188
+ """Async context manager entry."""
189
+ return self
190
+
191
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
192
+ """Async context manager exit."""
193
+ await self.close()
194
+
195
+ async def get(self, url: str, **kwargs) -> Any:
196
+ """
197
+ Make GET request.
198
+
199
+ Args:
200
+ url: Request URL
201
+ **kwargs: Additional httpx request parameters
202
+
203
+ Returns:
204
+ Response data (JSON parsed)
205
+
206
+ Raises:
207
+ MisoClientError: If request fails
208
+ """
209
+ await self._initialize_client()
210
+ await self._ensure_client_token()
211
+ try:
212
+ assert self.client is not None
213
+ response = await self.client.get(url, **kwargs)
214
+
215
+ # Handle 401 - clear token to force refresh
216
+ if response.status_code == 401:
217
+ self.client_token = None
218
+ self.token_expires_at = None
219
+
220
+ response.raise_for_status()
221
+ return response.json()
222
+ except httpx.HTTPStatusError as e:
223
+ # Try to parse structured error response
224
+ error_response = self._parse_error_response(e.response, url)
225
+ error_body = {}
226
+ if (
227
+ e.response.headers.get("content-type", "").startswith("application/json")
228
+ and not error_response
229
+ ):
230
+ try:
231
+ error_body = e.response.json()
232
+ except (ValueError, TypeError):
233
+ pass
234
+
235
+ raise MisoClientError(
236
+ f"HTTP {e.response.status_code}: {e.response.text}",
237
+ status_code=e.response.status_code,
238
+ error_body=error_body,
239
+ error_response=error_response,
240
+ )
241
+ except httpx.RequestError as e:
242
+ raise ConnectionError(f"Request failed: {str(e)}")
243
+
244
+ async def post(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
245
+ """
246
+ Make POST request.
247
+
248
+ Args:
249
+ url: Request URL
250
+ data: Request data (will be JSON encoded)
251
+ **kwargs: Additional httpx request parameters
252
+
253
+ Returns:
254
+ Response data (JSON parsed)
255
+
256
+ Raises:
257
+ MisoClientError: If request fails
258
+ """
259
+ await self._initialize_client()
260
+ await self._ensure_client_token()
261
+ try:
262
+ assert self.client is not None
263
+ response = await self.client.post(url, json=data, **kwargs)
264
+
265
+ if response.status_code == 401:
266
+ self.client_token = None
267
+ self.token_expires_at = None
268
+
269
+ response.raise_for_status()
270
+ return response.json()
271
+ except httpx.HTTPStatusError as e:
272
+ # Try to parse structured error response
273
+ error_response = self._parse_error_response(e.response, url)
274
+ error_body = {}
275
+ if (
276
+ e.response.headers.get("content-type", "").startswith("application/json")
277
+ and not error_response
278
+ ):
279
+ try:
280
+ error_body = e.response.json()
281
+ except (ValueError, TypeError):
282
+ pass
283
+
284
+ raise MisoClientError(
285
+ f"HTTP {e.response.status_code}: {e.response.text}",
286
+ status_code=e.response.status_code,
287
+ error_body=error_body,
288
+ error_response=error_response,
289
+ )
290
+ except httpx.RequestError as e:
291
+ raise ConnectionError(f"Request failed: {str(e)}")
292
+
293
+ async def put(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
294
+ """
295
+ Make PUT request.
296
+
297
+ Args:
298
+ url: Request URL
299
+ data: Request data (will be JSON encoded)
300
+ **kwargs: Additional httpx request parameters
301
+
302
+ Returns:
303
+ Response data (JSON parsed)
304
+
305
+ Raises:
306
+ MisoClientError: If request fails
307
+ """
308
+ await self._initialize_client()
309
+ await self._ensure_client_token()
310
+ try:
311
+ assert self.client is not None
312
+ response = await self.client.put(url, json=data, **kwargs)
313
+
314
+ if response.status_code == 401:
315
+ self.client_token = None
316
+ self.token_expires_at = None
317
+
318
+ response.raise_for_status()
319
+ return response.json()
320
+ except httpx.HTTPStatusError as e:
321
+ # Try to parse structured error response
322
+ error_response = self._parse_error_response(e.response, url)
323
+ error_body = {}
324
+ if (
325
+ e.response.headers.get("content-type", "").startswith("application/json")
326
+ and not error_response
327
+ ):
328
+ try:
329
+ error_body = e.response.json()
330
+ except (ValueError, TypeError):
331
+ pass
332
+
333
+ raise MisoClientError(
334
+ f"HTTP {e.response.status_code}: {e.response.text}",
335
+ status_code=e.response.status_code,
336
+ error_body=error_body,
337
+ error_response=error_response,
338
+ )
339
+ except httpx.RequestError as e:
340
+ raise ConnectionError(f"Request failed: {str(e)}")
341
+
342
+ async def delete(self, url: str, **kwargs) -> Any:
343
+ """
344
+ Make DELETE request.
345
+
346
+ Args:
347
+ url: Request URL
348
+ **kwargs: Additional httpx request parameters
349
+
350
+ Returns:
351
+ Response data (JSON parsed)
352
+
353
+ Raises:
354
+ MisoClientError: If request fails
355
+ """
356
+ await self._initialize_client()
357
+ await self._ensure_client_token()
358
+ try:
359
+ assert self.client is not None
360
+ response = await self.client.delete(url, **kwargs)
361
+
362
+ if response.status_code == 401:
363
+ self.client_token = None
364
+ self.token_expires_at = None
365
+
366
+ response.raise_for_status()
367
+ return response.json()
368
+ except httpx.HTTPStatusError as e:
369
+ # Try to parse structured error response
370
+ error_response = self._parse_error_response(e.response, url)
371
+ error_body = {}
372
+ if (
373
+ e.response.headers.get("content-type", "").startswith("application/json")
374
+ and not error_response
375
+ ):
376
+ try:
377
+ error_body = e.response.json()
378
+ except (ValueError, TypeError):
379
+ pass
380
+
381
+ raise MisoClientError(
382
+ f"HTTP {e.response.status_code}: {e.response.text}",
383
+ status_code=e.response.status_code,
384
+ error_body=error_body,
385
+ error_response=error_response,
386
+ )
387
+ except httpx.RequestError as e:
388
+ raise ConnectionError(f"Request failed: {str(e)}")
389
+
390
+ async def request(
391
+ self,
392
+ method: Literal["GET", "POST", "PUT", "DELETE"],
393
+ url: str,
394
+ data: Optional[Dict[str, Any]] = None,
395
+ **kwargs,
396
+ ) -> Any:
397
+ """
398
+ Generic request method.
399
+
400
+ Args:
401
+ method: HTTP method
402
+ url: Request URL
403
+ data: Request data (for POST/PUT)
404
+ **kwargs: Additional httpx request parameters
405
+
406
+ Returns:
407
+ Response data (JSON parsed)
408
+
409
+ Raises:
410
+ MisoClientError: If request fails
411
+ """
412
+ method_upper = method.upper()
413
+ if method_upper == "GET":
414
+ return await self.get(url, **kwargs)
415
+ elif method_upper == "POST":
416
+ return await self.post(url, data, **kwargs)
417
+ elif method_upper == "PUT":
418
+ return await self.put(url, data, **kwargs)
419
+ elif method_upper == "DELETE":
420
+ return await self.delete(url, **kwargs)
421
+ else:
422
+ raise ValueError(f"Unsupported HTTP method: {method}")
423
+
424
+ async def authenticated_request(
425
+ self,
426
+ method: Literal["GET", "POST", "PUT", "DELETE"],
427
+ url: str,
428
+ token: str,
429
+ data: Optional[Dict[str, Any]] = None,
430
+ **kwargs,
431
+ ) -> Any:
432
+ """
433
+ Make authenticated request with Bearer token.
434
+
435
+ IMPORTANT: Client token is sent as x-client-token header (via _ensure_client_token)
436
+ User token is sent as Authorization: Bearer header (this method parameter)
437
+ These are two separate tokens for different purposes.
438
+
439
+ Args:
440
+ method: HTTP method
441
+ url: Request URL
442
+ token: User authentication token (sent as Bearer token)
443
+ data: Request data (for POST/PUT)
444
+ **kwargs: Additional httpx request parameters
445
+
446
+ Returns:
447
+ Response data (JSON parsed)
448
+
449
+ Raises:
450
+ MisoClientError: If request fails
451
+ """
452
+ await self._ensure_client_token()
453
+
454
+ # Add Bearer token for user authentication
455
+ # x-client-token is automatically added by _ensure_client_token
456
+ headers = kwargs.get("headers", {})
457
+ headers["Authorization"] = f"Bearer {token}"
458
+ kwargs["headers"] = headers
459
+
460
+ return await self.request(method, url, data, **kwargs)
461
+
462
+ async def get_environment_token(self) -> str:
463
+ """
464
+ Get environment token using client credentials.
465
+
466
+ This is called automatically by HttpClient but can be called manually.
467
+
468
+ Returns:
469
+ Client token string
470
+ """
471
+ return await self._get_client_token()
@@ -0,0 +1,157 @@
1
+ """
2
+ Pagination utilities for MisoClient SDK.
3
+
4
+ This module provides reusable pagination utilities for parsing pagination parameters,
5
+ creating meta objects, and working with paginated responses.
6
+ """
7
+
8
+ from typing import List, Tuple, TypeVar
9
+
10
+ from ..models.pagination import Meta, PaginatedListResponse
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ def parse_pagination_params(params: dict) -> Tuple[int, int]:
16
+ """
17
+ Parse query parameters to pagination values.
18
+
19
+ Parses `page` and `page_size` query parameters into `current_page` and `page_size`.
20
+ Both are 1-based (page starts at 1).
21
+
22
+ Args:
23
+ params: Dictionary with query parameters (e.g., {'page': '1', 'page_size': '25'})
24
+
25
+ Returns:
26
+ Tuple of (current_page, page_size) as integers
27
+
28
+ Examples:
29
+ >>> parse_pagination_params({'page': '1', 'page_size': '25'})
30
+ (1, 25)
31
+ >>> parse_pagination_params({'page': '2'})
32
+ (2, 25) # Default page_size is 25
33
+ """
34
+ # Default values
35
+ default_page = 1
36
+ default_page_size = 25
37
+
38
+ # Parse page (must be >= 1)
39
+ page_str = params.get("page") or params.get("current_page")
40
+ if page_str is None:
41
+ current_page = default_page
42
+ else:
43
+ try:
44
+ current_page = int(page_str)
45
+ if current_page < 1:
46
+ current_page = default_page
47
+ except (ValueError, TypeError):
48
+ current_page = default_page
49
+
50
+ # Parse page_size (must be >= 1)
51
+ page_size_str = params.get("page_size") or params.get("pageSize")
52
+ if page_size_str is None:
53
+ page_size = default_page_size
54
+ else:
55
+ try:
56
+ page_size = int(page_size_str)
57
+ if page_size < 1:
58
+ page_size = default_page_size
59
+ except (ValueError, TypeError):
60
+ page_size = default_page_size
61
+
62
+ return (current_page, page_size)
63
+
64
+
65
+ def create_meta_object(total_items: int, current_page: int, page_size: int, type: str) -> Meta:
66
+ """
67
+ Construct Meta object from pagination parameters.
68
+
69
+ Args:
70
+ total_items: Total number of items across all pages
71
+ current_page: Current page number (1-based)
72
+ page_size: Number of items per page
73
+ type: Resource type identifier (e.g., 'item', 'user', 'group')
74
+
75
+ Returns:
76
+ Meta object with pagination metadata
77
+
78
+ Examples:
79
+ >>> meta = create_meta_object(120, 1, 25, 'item')
80
+ >>> meta.total_items
81
+ 120
82
+ >>> meta.current_page
83
+ 1
84
+ """
85
+ return Meta(
86
+ total_items=total_items,
87
+ current_page=current_page,
88
+ page_size=page_size,
89
+ type=type,
90
+ )
91
+
92
+
93
+ def apply_pagination_to_array(items: List[T], current_page: int, page_size: int) -> List[T]:
94
+ """
95
+ Apply pagination to array (for testing/mocks).
96
+
97
+ Args:
98
+ items: Array of items to paginate
99
+ current_page: Current page number (1-based)
100
+ page_size: Number of items per page
101
+
102
+ Returns:
103
+ Paginated subset of items for the specified page
104
+
105
+ Examples:
106
+ >>> items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
107
+ >>> apply_pagination_to_array(items, 1, 3)
108
+ [1, 2, 3]
109
+ >>> apply_pagination_to_array(items, 2, 3)
110
+ [4, 5, 6]
111
+ """
112
+ if not items:
113
+ return []
114
+
115
+ if current_page < 1:
116
+ current_page = 1
117
+ if page_size < 1:
118
+ page_size = 25
119
+
120
+ # Calculate start and end indices
121
+ start_index = (current_page - 1) * page_size
122
+ end_index = start_index + page_size
123
+
124
+ # Return paginated subset
125
+ return items[start_index:end_index]
126
+
127
+
128
+ def create_paginated_list_response(
129
+ items: List[T],
130
+ total_items: int,
131
+ current_page: int,
132
+ page_size: int,
133
+ type: str,
134
+ ) -> PaginatedListResponse[T]:
135
+ """
136
+ Wrap array + meta into standard paginated response.
137
+
138
+ Args:
139
+ items: Array of items for current page
140
+ total_items: Total number of items across all pages
141
+ current_page: Current page number (1-based)
142
+ page_size: Number of items per page
143
+ type: Resource type identifier (e.g., 'item', 'user', 'group')
144
+
145
+ Returns:
146
+ PaginatedListResponse with meta and data
147
+
148
+ Examples:
149
+ >>> items = [{'id': 1}, {'id': 2}]
150
+ >>> response = create_paginated_list_response(items, 10, 1, 2, 'item')
151
+ >>> response.meta.total_items
152
+ 10
153
+ >>> len(response.data)
154
+ 2
155
+ """
156
+ meta = create_meta_object(total_items, current_page, page_size, type)
157
+ return PaginatedListResponse(meta=meta, data=items)