miso-client 0.1.0__py3-none-any.whl → 3.7.2__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.
Files changed (69) hide show
  1. miso_client/__init__.py +523 -130
  2. miso_client/api/__init__.py +35 -0
  3. miso_client/api/auth_api.py +367 -0
  4. miso_client/api/logs_api.py +91 -0
  5. miso_client/api/permissions_api.py +88 -0
  6. miso_client/api/roles_api.py +88 -0
  7. miso_client/api/types/__init__.py +75 -0
  8. miso_client/api/types/auth_types.py +183 -0
  9. miso_client/api/types/logs_types.py +71 -0
  10. miso_client/api/types/permissions_types.py +31 -0
  11. miso_client/api/types/roles_types.py +31 -0
  12. miso_client/errors.py +30 -4
  13. miso_client/models/__init__.py +4 -0
  14. miso_client/models/config.py +275 -72
  15. miso_client/models/error_response.py +39 -0
  16. miso_client/models/filter.py +255 -0
  17. miso_client/models/pagination.py +44 -0
  18. miso_client/models/sort.py +25 -0
  19. miso_client/services/__init__.py +6 -5
  20. miso_client/services/auth.py +496 -87
  21. miso_client/services/cache.py +42 -41
  22. miso_client/services/encryption.py +18 -17
  23. miso_client/services/logger.py +467 -328
  24. miso_client/services/logger_chain.py +288 -0
  25. miso_client/services/permission.py +130 -67
  26. miso_client/services/redis.py +28 -23
  27. miso_client/services/role.py +145 -62
  28. miso_client/utils/__init__.py +3 -3
  29. miso_client/utils/audit_log_queue.py +222 -0
  30. miso_client/utils/auth_strategy.py +88 -0
  31. miso_client/utils/auth_utils.py +65 -0
  32. miso_client/utils/circuit_breaker.py +125 -0
  33. miso_client/utils/client_token_manager.py +244 -0
  34. miso_client/utils/config_loader.py +88 -17
  35. miso_client/utils/controller_url_resolver.py +80 -0
  36. miso_client/utils/data_masker.py +104 -33
  37. miso_client/utils/environment_token.py +126 -0
  38. miso_client/utils/error_utils.py +216 -0
  39. miso_client/utils/fastapi_endpoints.py +166 -0
  40. miso_client/utils/filter.py +364 -0
  41. miso_client/utils/filter_applier.py +143 -0
  42. miso_client/utils/filter_parser.py +110 -0
  43. miso_client/utils/flask_endpoints.py +169 -0
  44. miso_client/utils/http_client.py +494 -262
  45. miso_client/utils/http_client_logging.py +352 -0
  46. miso_client/utils/http_client_logging_helpers.py +197 -0
  47. miso_client/utils/http_client_query_helpers.py +138 -0
  48. miso_client/utils/http_error_handler.py +92 -0
  49. miso_client/utils/http_log_formatter.py +115 -0
  50. miso_client/utils/http_log_masker.py +203 -0
  51. miso_client/utils/internal_http_client.py +435 -0
  52. miso_client/utils/jwt_tools.py +125 -16
  53. miso_client/utils/logger_helpers.py +206 -0
  54. miso_client/utils/logging_helpers.py +70 -0
  55. miso_client/utils/origin_validator.py +128 -0
  56. miso_client/utils/pagination.py +275 -0
  57. miso_client/utils/request_context.py +285 -0
  58. miso_client/utils/sensitive_fields_loader.py +116 -0
  59. miso_client/utils/sort.py +116 -0
  60. miso_client/utils/token_utils.py +114 -0
  61. miso_client/utils/url_validator.py +66 -0
  62. miso_client/utils/user_token_refresh.py +245 -0
  63. miso_client-3.7.2.dist-info/METADATA +1021 -0
  64. miso_client-3.7.2.dist-info/RECORD +68 -0
  65. miso_client-0.1.0.dist-info/METADATA +0 -551
  66. miso_client-0.1.0.dist-info/RECORD +0 -23
  67. {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/WHEEL +0 -0
  68. {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/licenses/LICENSE +0 -0
  69. {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/top_level.txt +0 -0
@@ -1,317 +1,284 @@
1
1
  """
2
- HTTP client utility for controller communication.
2
+ Public HTTP client utility for controller communication with ISO 27001 compliant logging.
3
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.
4
+ This module provides the public HTTP client interface that wraps InternalHttpClient
5
+ and adds automatic audit and debug logging for all HTTP requests. All sensitive
6
+ data is automatically masked using DataMasker before logging to comply with ISO 27001.
7
7
  """
8
8
 
9
- import httpx
10
9
  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
10
+ import time
11
+ from typing import Any, Dict, Literal, Optional, Union
12
+
13
+ import httpx
14
+
15
+ from ..models.config import AuthStrategy, MisoClientConfig
16
+ from ..services.logger import LoggerService
17
+ from ..utils.jwt_tools import JwtTokenCache, extract_user_id
18
+ from .http_client_logging_helpers import (
19
+ handle_logging_task_error,
20
+ log_http_request,
21
+ wait_for_logging_tasks,
22
+ )
23
+ from .http_client_query_helpers import (
24
+ add_pagination_params,
25
+ merge_filter_params,
26
+ parse_filter_query_string,
27
+ parse_paginated_response,
28
+ prepare_json_filter_body,
29
+ )
30
+ from .internal_http_client import InternalHttpClient
31
+ from .user_token_refresh import UserTokenRefreshManager
15
32
 
16
33
 
17
34
  class HttpClient:
18
- """HTTP client for Miso Controller communication with automatic client token management."""
19
-
20
- def __init__(self, config: MisoClientConfig):
35
+ """
36
+ Public HTTP client for Miso Controller communication with ISO 27001 compliant logging.
37
+
38
+ This class wraps InternalHttpClient and adds:
39
+ - Automatic audit logging for all requests
40
+ - Debug logging when log_level is 'debug'
41
+ - Automatic data masking for all sensitive information
42
+
43
+ All sensitive data (headers, bodies, query params) is masked using DataMasker
44
+ before logging to ensure ISO 27001 compliance.
45
+ """
46
+
47
+ def __init__(self, config: MisoClientConfig, logger: LoggerService):
21
48
  """
22
- Initialize HTTP client with configuration.
23
-
49
+ Initialize public HTTP client with configuration and logger.
50
+
24
51
  Args:
25
52
  config: MisoClient configuration
53
+ logger: LoggerService instance for audit and debug logging
26
54
  """
27
55
  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
-
56
+ self.logger = logger
57
+ self._internal_client = InternalHttpClient(config)
58
+ self._jwt_cache = JwtTokenCache(max_size=1000)
59
+ self._user_token_refresh = UserTokenRefreshManager()
60
+
139
61
  async def close(self):
140
62
  """Close the HTTP client."""
141
- if self.client:
142
- await self.client.aclose()
143
- self.client = None
144
-
63
+ await self._internal_client.close()
64
+
145
65
  async def __aenter__(self):
146
66
  """Async context manager entry."""
147
67
  return self
148
-
68
+
149
69
  async def __aexit__(self, exc_type, exc_val, exc_tb):
150
70
  """Async context manager exit."""
151
71
  await self.close()
152
-
153
- async def get(self, url: str, **kwargs) -> Any:
72
+
73
+ async def get_environment_token(self) -> str:
74
+ """
75
+ Get environment token using client credentials.
76
+
77
+ This is called automatically by HttpClient but can be called manually.
78
+
79
+ Returns:
80
+ Client token string
81
+ """
82
+ return await self._internal_client.get_environment_token()
83
+
84
+ def _handle_logging_task_error(self, task: asyncio.Task) -> None:
85
+ """
86
+ Handle errors in background logging tasks.
87
+
88
+ Silently swallows all exceptions to prevent logging errors from breaking requests.
89
+
90
+ Args:
91
+ task: The completed logging task
154
92
  """
155
- Make GET request.
156
-
93
+ handle_logging_task_error(task)
94
+
95
+ async def _wait_for_logging_tasks(self, timeout: float = 0.5) -> None:
96
+ """
97
+ Wait for all pending logging tasks to complete.
98
+
99
+ Useful for tests to ensure logging has finished before assertions.
100
+
157
101
  Args:
102
+ timeout: Maximum time to wait in seconds
103
+ """
104
+ if hasattr(self, "_logging_tasks") and self._logging_tasks:
105
+ await wait_for_logging_tasks(self._logging_tasks, timeout)
106
+
107
+ async def _execute_with_logging(
108
+ self,
109
+ method: str,
110
+ url: str,
111
+ request_func,
112
+ request_data: Optional[Dict[str, Any]] = None,
113
+ **kwargs,
114
+ ) -> Any:
115
+ """
116
+ Execute HTTP request with automatic audit and debug logging.
117
+
118
+ Args:
119
+ method: HTTP method name
158
120
  url: Request URL
121
+ request_func: Async function to execute the request
122
+ request_data: Request body data (optional)
159
123
  **kwargs: Additional httpx request parameters
160
-
124
+
161
125
  Returns:
162
126
  Response data (JSON parsed)
163
-
127
+
164
128
  Raises:
165
- MisoClientError: If request fails
129
+ Exception: If request fails
166
130
  """
167
- await self._initialize_client()
168
- await self._ensure_client_token()
131
+ start_time = time.perf_counter()
132
+ request_headers = kwargs.get("headers", {})
169
133
  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 {}
134
+ response = await request_func()
135
+ # Create logging task but don't await it (non-blocking)
136
+ # Store task reference to allow tests to await if needed
137
+ logging_task = asyncio.create_task(
138
+ log_http_request(
139
+ self.logger,
140
+ self.config,
141
+ self._jwt_cache,
142
+ method,
143
+ url,
144
+ response,
145
+ None,
146
+ start_time,
147
+ request_data,
148
+ request_headers,
149
+ )
185
150
  )
186
- except httpx.RequestError as e:
187
- raise ConnectionError(f"Request failed: {str(e)}")
188
-
151
+ logging_task.add_done_callback(self._handle_logging_task_error)
152
+ # Store task for potential cleanup (optional)
153
+ if not hasattr(self, "_logging_tasks"):
154
+ self._logging_tasks = set()
155
+ self._logging_tasks.add(logging_task)
156
+ logging_task.add_done_callback(lambda t: self._logging_tasks.discard(t))
157
+ return response
158
+ except Exception as e:
159
+ # Create logging task for error case
160
+ logging_task = asyncio.create_task(
161
+ log_http_request(
162
+ self.logger,
163
+ self.config,
164
+ self._jwt_cache,
165
+ method,
166
+ url,
167
+ None,
168
+ e,
169
+ start_time,
170
+ request_data,
171
+ request_headers,
172
+ )
173
+ )
174
+ logging_task.add_done_callback(self._handle_logging_task_error)
175
+ if not hasattr(self, "_logging_tasks"):
176
+ self._logging_tasks = set()
177
+ self._logging_tasks.add(logging_task)
178
+ logging_task.add_done_callback(lambda t: self._logging_tasks.discard(t))
179
+ raise
180
+
181
+ async def get(self, url: str, **kwargs) -> Any:
182
+ """
183
+ Make GET request with automatic audit and debug logging.
184
+
185
+ Args:
186
+ url: Request URL
187
+ **kwargs: Additional httpx request parameters
188
+
189
+ Returns:
190
+ Response data (JSON parsed)
191
+
192
+ Raises:
193
+ MisoClientError: If request fails
194
+ """
195
+
196
+ async def _get():
197
+ return await self._internal_client.get(url, **kwargs)
198
+
199
+ return await self._execute_with_logging("GET", url, _get, **kwargs)
200
+
189
201
  async def post(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
190
202
  """
191
- Make POST request.
192
-
203
+ Make POST request with automatic audit and debug logging.
204
+
193
205
  Args:
194
206
  url: Request URL
195
207
  data: Request data (will be JSON encoded)
196
208
  **kwargs: Additional httpx request parameters
197
-
209
+
198
210
  Returns:
199
211
  Response data (JSON parsed)
200
-
212
+
201
213
  Raises:
202
214
  MisoClientError: If request fails
203
215
  """
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
-
216
+
217
+ async def _post():
218
+ return await self._internal_client.post(url, data, **kwargs)
219
+
220
+ return await self._execute_with_logging("POST", url, _post, data, **kwargs)
221
+
225
222
  async def put(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
226
223
  """
227
- Make PUT request.
228
-
224
+ Make PUT request with automatic audit and debug logging.
225
+
229
226
  Args:
230
227
  url: Request URL
231
228
  data: Request data (will be JSON encoded)
232
229
  **kwargs: Additional httpx request parameters
233
-
230
+
234
231
  Returns:
235
232
  Response data (JSON parsed)
236
-
233
+
237
234
  Raises:
238
235
  MisoClientError: If request fails
239
236
  """
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
-
237
+
238
+ async def _put():
239
+ return await self._internal_client.put(url, data, **kwargs)
240
+
241
+ return await self._execute_with_logging("PUT", url, _put, data, **kwargs)
242
+
261
243
  async def delete(self, url: str, **kwargs) -> Any:
262
244
  """
263
- Make DELETE request.
264
-
245
+ Make DELETE request with automatic audit and debug logging.
246
+
265
247
  Args:
266
248
  url: Request URL
267
249
  **kwargs: Additional httpx request parameters
268
-
250
+
269
251
  Returns:
270
252
  Response data (JSON parsed)
271
-
253
+
272
254
  Raises:
273
255
  MisoClientError: If request fails
274
256
  """
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
-
257
+
258
+ async def _delete():
259
+ return await self._internal_client.delete(url, **kwargs)
260
+
261
+ return await self._execute_with_logging("DELETE", url, _delete, **kwargs)
262
+
296
263
  async def request(
297
264
  self,
298
265
  method: Literal["GET", "POST", "PUT", "DELETE"],
299
266
  url: str,
300
267
  data: Optional[Dict[str, Any]] = None,
301
- **kwargs
268
+ **kwargs,
302
269
  ) -> Any:
303
270
  """
304
- Generic request method.
305
-
271
+ Generic request method with automatic audit and debug logging.
272
+
306
273
  Args:
307
274
  method: HTTP method
308
275
  url: Request URL
309
276
  data: Request data (for POST/PUT)
310
277
  **kwargs: Additional httpx request parameters
311
-
278
+
312
279
  Returns:
313
280
  Response data (JSON parsed)
314
-
281
+
315
282
  Raises:
316
283
  MisoClientError: If request fails
317
284
  """
@@ -326,52 +293,317 @@ class HttpClient:
326
293
  return await self.delete(url, **kwargs)
327
294
  else:
328
295
  raise ValueError(f"Unsupported HTTP method: {method}")
329
-
296
+
297
+ def register_user_token_refresh_callback(self, user_id: str, callback: Any) -> None:
298
+ """
299
+ Register refresh callback for a user.
300
+
301
+ Args:
302
+ user_id: User ID
303
+ callback: Async function that takes old token and returns new token
304
+ """
305
+ self._user_token_refresh.register_refresh_callback(user_id, callback)
306
+
307
+ def register_user_refresh_token(self, user_id: str, refresh_token: str) -> None:
308
+ """
309
+ Register refresh token for a user.
310
+
311
+ Args:
312
+ user_id: User ID
313
+ refresh_token: Refresh token string
314
+ """
315
+ self._user_token_refresh.register_refresh_token(user_id, refresh_token)
316
+
317
+ def set_auth_service_for_refresh(self, auth_service: Any) -> None:
318
+ """
319
+ Set AuthService instance for refresh endpoint calls.
320
+
321
+ Args:
322
+ auth_service: AuthService instance
323
+ """
324
+ self._user_token_refresh.set_auth_service(auth_service)
325
+
326
+ async def _prepare_authenticated_request(self, token: str, auto_refresh: bool, **kwargs) -> str:
327
+ """
328
+ Prepare authenticated request by getting valid token and setting headers.
329
+
330
+ Args:
331
+ token: User authentication token
332
+ auto_refresh: Whether to refresh token if expired
333
+ **kwargs: Request kwargs (headers will be modified)
334
+
335
+ Returns:
336
+ Valid token to use for request
337
+ """
338
+ # Get valid token (refresh if expired)
339
+ valid_token = await self._user_token_refresh.get_valid_token(
340
+ token, refresh_if_needed=auto_refresh
341
+ )
342
+ if not valid_token:
343
+ valid_token = token # Fallback to original token
344
+
345
+ # Add Bearer token to headers for logging context
346
+ headers = kwargs.get("headers", {})
347
+ headers["Authorization"] = f"Bearer {valid_token}"
348
+ kwargs["headers"] = headers
349
+
350
+ return valid_token
351
+
352
+ async def _handle_401_refresh(
353
+ self,
354
+ method: Literal["GET", "POST", "PUT", "DELETE"],
355
+ url: str,
356
+ token: str,
357
+ data: Optional[Dict[str, Any]],
358
+ auth_strategy: Optional[AuthStrategy],
359
+ error: httpx.HTTPStatusError,
360
+ auto_refresh: bool,
361
+ **kwargs,
362
+ ) -> Any:
363
+ """
364
+ Handle 401 error by refreshing token and retrying request.
365
+
366
+ Args:
367
+ method: HTTP method
368
+ url: Request URL
369
+ token: Current token
370
+ data: Request data
371
+ auth_strategy: Authentication strategy
372
+ error: HTTPStatusError with 401 status
373
+ auto_refresh: Whether to refresh token
374
+ **kwargs: Request kwargs
375
+
376
+ Returns:
377
+ Response data from retried request
378
+
379
+ Raises:
380
+ httpx.HTTPStatusError: If refresh fails or retry fails
381
+ """
382
+ if not auto_refresh:
383
+ raise error
384
+
385
+ user_id = extract_user_id(token)
386
+ refreshed_token = await self._user_token_refresh._refresh_token(token, user_id)
387
+
388
+ if not refreshed_token:
389
+ raise error
390
+
391
+ # Retry request with refreshed token
392
+ headers = kwargs.get("headers", {})
393
+ headers["Authorization"] = f"Bearer {refreshed_token}"
394
+ kwargs["headers"] = headers
395
+
396
+ try:
397
+ return await self._internal_client.authenticated_request(
398
+ method, url, refreshed_token, data, auth_strategy, **kwargs
399
+ )
400
+ except httpx.HTTPStatusError:
401
+ # Retry failed, raise original error
402
+ raise error
403
+
330
404
  async def authenticated_request(
331
405
  self,
332
406
  method: Literal["GET", "POST", "PUT", "DELETE"],
333
407
  url: str,
334
408
  token: str,
335
409
  data: Optional[Dict[str, Any]] = None,
336
- **kwargs
410
+ auth_strategy: Optional[AuthStrategy] = None,
411
+ auto_refresh: bool = True,
412
+ **kwargs,
337
413
  ) -> Any:
338
414
  """
339
- Make authenticated request with Bearer token.
340
-
341
- IMPORTANT: Client token is sent as x-client-token header (via _ensure_client_token)
415
+ Make authenticated request with Bearer token and automatic refresh.
416
+
417
+ IMPORTANT: Client token is sent as x-client-token header (via InternalHttpClient)
342
418
  User token is sent as Authorization: Bearer header (this method parameter)
343
419
  These are two separate tokens for different purposes.
344
-
420
+
345
421
  Args:
346
422
  method: HTTP method
347
423
  url: Request URL
348
424
  token: User authentication token (sent as Bearer token)
349
425
  data: Request data (for POST/PUT)
426
+ auth_strategy: Optional authentication strategy (defaults to bearer + client-token)
427
+ auto_refresh: Whether to automatically refresh token on 401 (default: True)
350
428
  **kwargs: Additional httpx request parameters
351
-
429
+
352
430
  Returns:
353
431
  Response data (JSON parsed)
354
-
432
+
355
433
  Raises:
356
434
  MisoClientError: If request fails
357
435
  """
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:
436
+ # Prepare token and headers
437
+ valid_token = await self._prepare_authenticated_request(token, auto_refresh, **kwargs)
438
+
439
+ # Execute request with 401 handling
440
+ async def _authenticated_request():
441
+ try:
442
+ return await self._internal_client.authenticated_request(
443
+ method, url, valid_token, data, auth_strategy, **kwargs
444
+ )
445
+ except httpx.HTTPStatusError as e:
446
+ if e.response.status_code == 401:
447
+ return await self._handle_401_refresh(
448
+ method, url, valid_token, data, auth_strategy, e, auto_refresh, **kwargs
449
+ )
450
+ raise
451
+
452
+ return await self._execute_with_logging(method, url, _authenticated_request, data, **kwargs)
453
+
454
+ async def request_with_auth_strategy(
455
+ self,
456
+ method: Literal["GET", "POST", "PUT", "DELETE"],
457
+ url: str,
458
+ auth_strategy: AuthStrategy,
459
+ data: Optional[Dict[str, Any]] = None,
460
+ **kwargs,
461
+ ) -> Any:
369
462
  """
370
- Get environment token using client credentials.
371
-
372
- This is called automatically by HttpClient but can be called manually.
373
-
463
+ Make request with authentication strategy and automatic audit/debug logging.
464
+
465
+ Tries authentication methods in priority order until one succeeds.
466
+ If a method returns 401, automatically tries the next method in the strategy.
467
+
468
+ Args:
469
+ method: HTTP method
470
+ url: Request URL
471
+ auth_strategy: Authentication strategy configuration
472
+ data: Request data (for POST/PUT)
473
+ **kwargs: Additional httpx request parameters
474
+
374
475
  Returns:
375
- Client token string
476
+ Response data (JSON parsed)
477
+
478
+ Raises:
479
+ MisoClientError: If all authentication methods fail
376
480
  """
377
- return await self._get_client_token()
481
+
482
+ async def _request_with_auth_strategy():
483
+ return await self._internal_client.request_with_auth_strategy(
484
+ method, url, auth_strategy, data, **kwargs
485
+ )
486
+
487
+ return await self._execute_with_logging(
488
+ method, url, _request_with_auth_strategy, data, **kwargs
489
+ )
490
+
491
+ async def get_with_filters(
492
+ self,
493
+ url: str,
494
+ filter_builder: Optional[Any] = None,
495
+ **kwargs,
496
+ ) -> Any:
497
+ """
498
+ Make GET request with filter builder support.
499
+
500
+ Args:
501
+ url: Request URL
502
+ filter_builder: Optional FilterBuilder instance with filters
503
+ **kwargs: Additional httpx request parameters
504
+
505
+ Returns:
506
+ Response data (JSON parsed)
507
+
508
+ Raises:
509
+ MisoClientError: If request fails
510
+
511
+ Examples:
512
+ >>> from miso_client.models.filter import FilterBuilder
513
+ >>> filter_builder = FilterBuilder().add('status', 'eq', 'active')
514
+ >>> response = await client.http_client.get_with_filters('/api/items', filter_builder)
515
+ """
516
+ if filter_builder:
517
+ from ..models.filter import FilterQuery
518
+ from ..utils.filter import build_query_string
519
+
520
+ filter_query = FilterQuery(filters=filter_builder.build())
521
+ query_string = build_query_string(filter_query)
522
+
523
+ if query_string:
524
+ filter_params = parse_filter_query_string(query_string)
525
+ merge_filter_params(kwargs, filter_params)
526
+
527
+ return await self.get(url, **kwargs)
528
+
529
+ async def get_paginated(
530
+ self,
531
+ url: str,
532
+ page: Optional[int] = None,
533
+ page_size: Optional[int] = None,
534
+ **kwargs,
535
+ ) -> Any:
536
+ """
537
+ Make GET request with pagination support.
538
+
539
+ Args:
540
+ url: Request URL
541
+ page: Optional page number (1-based)
542
+ page_size: Optional number of items per page
543
+ **kwargs: Additional httpx request parameters
544
+
545
+ Returns:
546
+ PaginatedListResponse with meta and data (or raw response if format doesn't match)
547
+
548
+ Raises:
549
+ MisoClientError: If request fails
550
+
551
+ Examples:
552
+ >>> response = await client.http_client.get_paginated(
553
+ ... '/api/items', page=1, page_size=25
554
+ ... )
555
+ >>> response.meta.totalItems
556
+ 120
557
+ >>> len(response.data)
558
+ 25
559
+ """
560
+ add_pagination_params(kwargs, page, page_size)
561
+ response_data = await self.get(url, **kwargs)
562
+ return parse_paginated_response(response_data)
563
+
564
+ def clear_user_token(self, token: str) -> None:
565
+ """
566
+ Clear a user's JWT token from cache.
567
+
568
+ Args:
569
+ token: JWT token string to remove from cache
570
+ """
571
+ self._jwt_cache.clear_token(token)
572
+
573
+ async def post_with_filters(
574
+ self,
575
+ url: str,
576
+ json_filter: Optional[Union[Any, Dict[str, Any]]] = None,
577
+ json_body: Optional[Dict[str, Any]] = None,
578
+ **kwargs,
579
+ ) -> Any:
580
+ """
581
+ Make POST request with JSON filter support.
582
+
583
+ Args:
584
+ url: Request URL
585
+ json_filter: Optional JsonFilter or FilterQuery instance
586
+ json_body: Optional JSON body (filters will be merged into this)
587
+ **kwargs: Additional httpx request parameters
588
+
589
+ Returns:
590
+ Response data (JSON parsed)
591
+
592
+ Raises:
593
+ MisoClientError: If request fails
594
+
595
+ Examples:
596
+ >>> from miso_client.models.filter import JsonFilter, FilterOption
597
+ >>> json_filter = JsonFilter(
598
+ ... filters=[FilterOption(field='status', op='eq', value='active')]
599
+ ... )
600
+ >>> response = await client.http_client.post_with_filters(
601
+ ... '/api/items/search',
602
+ ... json_filter=json_filter
603
+ ... )
604
+ """
605
+ # Prepare JSON body with filter data
606
+ request_body = prepare_json_filter_body(json_filter, json_body)
607
+
608
+ # Use post method with merged body
609
+ return await self.post(url, data=request_body if request_body else None, **kwargs)