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

@@ -1,317 +1,539 @@
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
- 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
9
+ import time
10
+ from typing import Any, Dict, Literal, Optional
11
+ from urllib.parse import parse_qs, urlparse
12
+
13
+ from ..models.config import MisoClientConfig
14
+ from ..services.logger import LoggerService
15
+ from ..utils.data_masker import DataMasker
16
+ from ..utils.jwt_tools import decode_token
17
+ from .internal_http_client import InternalHttpClient
15
18
 
16
19
 
17
20
  class HttpClient:
18
- """HTTP client for Miso Controller communication with automatic client token management."""
19
-
20
- def __init__(self, config: MisoClientConfig):
21
+ """
22
+ Public HTTP client for Miso Controller communication with ISO 27001 compliant logging.
23
+
24
+ This class wraps InternalHttpClient and adds:
25
+ - Automatic audit logging for all requests
26
+ - Debug logging when log_level is 'debug'
27
+ - Automatic data masking for all sensitive information
28
+
29
+ All sensitive data (headers, bodies, query params) is masked using DataMasker
30
+ before logging to ensure ISO 27001 compliance.
31
+ """
32
+
33
+ def __init__(self, config: MisoClientConfig, logger: LoggerService):
21
34
  """
22
- Initialize HTTP client with configuration.
23
-
35
+ Initialize public HTTP client with configuration and logger.
36
+
24
37
  Args:
25
38
  config: MisoClient configuration
39
+ logger: LoggerService instance for audit and debug logging
26
40
  """
27
41
  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
-
42
+ self.logger = logger
43
+ self._internal_client = InternalHttpClient(config)
44
+
139
45
  async def close(self):
140
46
  """Close the HTTP client."""
141
- if self.client:
142
- await self.client.aclose()
143
- self.client = None
144
-
47
+ await self._internal_client.close()
48
+
145
49
  async def __aenter__(self):
146
50
  """Async context manager entry."""
147
51
  return self
148
-
52
+
149
53
  async def __aexit__(self, exc_type, exc_val, exc_tb):
150
54
  """Async context manager exit."""
151
55
  await self.close()
152
-
56
+
57
+ async def get_environment_token(self) -> str:
58
+ """
59
+ Get environment token using client credentials.
60
+
61
+ This is called automatically by HttpClient but can be called manually.
62
+
63
+ Returns:
64
+ Client token string
65
+ """
66
+ return await self._internal_client.get_environment_token()
67
+
68
+ def _should_skip_logging(self, url: str) -> bool:
69
+ """
70
+ Check if logging should be skipped for this URL.
71
+
72
+ Skips logging for /api/logs and /api/auth/token to prevent infinite loops.
73
+
74
+ Args:
75
+ url: Request URL
76
+
77
+ Returns:
78
+ True if logging should be skipped, False otherwise
79
+ """
80
+ # Skip logging for log endpoint (prevent infinite audit loops)
81
+ if url == "/api/logs" or url.startswith("/api/logs"):
82
+ return True
83
+
84
+ # Skip logging for token endpoint (client token fetch, prevent loops)
85
+ if url == "/api/auth/token" or url.startswith("/api/auth/token"):
86
+ return True
87
+
88
+ return False
89
+
90
+ def _extract_user_id_from_headers(self, headers: Dict[str, Any]) -> Optional[str]:
91
+ """
92
+ Extract user ID from JWT token in Authorization header.
93
+
94
+ Args:
95
+ headers: Request headers dictionary
96
+
97
+ Returns:
98
+ User ID if found, None otherwise
99
+ """
100
+ auth_header = headers.get("authorization") or headers.get("Authorization")
101
+ if not auth_header or not isinstance(auth_header, str):
102
+ return None
103
+
104
+ # Extract token (Bearer <token> format)
105
+ if auth_header.startswith("Bearer "):
106
+ token = auth_header[7:]
107
+ else:
108
+ token = auth_header
109
+
110
+ try:
111
+ decoded = decode_token(token)
112
+ if decoded:
113
+ return decoded.get("sub") or decoded.get("userId") or decoded.get("user_id")
114
+ except Exception:
115
+ pass
116
+
117
+ return None
118
+
119
+ async def _log_http_request_audit(
120
+ self,
121
+ method: str,
122
+ url: str,
123
+ response: Optional[Any] = None,
124
+ error: Optional[Exception] = None,
125
+ start_time: float = 0.0,
126
+ request_data: Optional[Dict[str, Any]] = None,
127
+ request_headers: Optional[Dict[str, Any]] = None,
128
+ **kwargs,
129
+ ) -> None:
130
+ """
131
+ Log HTTP request audit event with ISO 27001 compliant data masking.
132
+
133
+ Args:
134
+ method: HTTP method
135
+ url: Request URL
136
+ response: Response data (if successful)
137
+ error: Exception (if request failed)
138
+ start_time: Request start time
139
+ request_data: Request body data
140
+ request_headers: Request headers
141
+ **kwargs: Additional request parameters
142
+ """
143
+ try:
144
+ # Skip logging for certain endpoints
145
+ if self._should_skip_logging(url):
146
+ return
147
+
148
+ # Calculate duration
149
+ duration_ms = int((time.perf_counter() - start_time) * 1000)
150
+
151
+ # Extract status code
152
+ status_code: Optional[int] = None
153
+ response_size: Optional[int] = None
154
+ if response is not None:
155
+ # Response is already parsed JSON from InternalHttpClient
156
+ # We don't have direct access to status code from parsed response
157
+ # But we can infer success (no error means success)
158
+ status_code = 200 # Default assumption if response exists
159
+ # Estimate response size
160
+ try:
161
+ response_str = str(response)
162
+ response_size = len(response_str.encode("utf-8"))
163
+ except Exception:
164
+ pass
165
+
166
+ if error is not None:
167
+ # Extract status code from error if available
168
+ if hasattr(error, "status_code"):
169
+ status_code = error.status_code
170
+ else:
171
+ status_code = 500 # Default for errors
172
+
173
+ # Extract user ID from headers
174
+ user_id: Optional[str] = None
175
+ if request_headers:
176
+ user_id = self._extract_user_id_from_headers(request_headers)
177
+
178
+ # Calculate request size
179
+ request_size: Optional[int] = None
180
+ if request_data is not None:
181
+ try:
182
+ request_str = str(request_data)
183
+ request_size = len(request_str.encode("utf-8"))
184
+ except Exception:
185
+ pass
186
+
187
+ # Mask sensitive data in error message
188
+ error_message: Optional[str] = None
189
+ if error is not None:
190
+ error_message = str(error)
191
+ # Mask error message if it contains sensitive data
192
+ try:
193
+ # Try to mask if error message looks like it contains structured data
194
+ if isinstance(error_message, str) and any(
195
+ keyword in error_message.lower()
196
+ for keyword in ["password", "token", "secret", "key"]
197
+ ):
198
+ error_message = DataMasker.MASKED_VALUE
199
+ except Exception:
200
+ pass
201
+
202
+ # Build audit context (all sensitive data must be masked)
203
+ audit_context: Dict[str, Any] = {
204
+ "method": method,
205
+ "url": url,
206
+ "statusCode": status_code,
207
+ "duration": duration_ms,
208
+ }
209
+
210
+ if user_id:
211
+ audit_context["userId"] = user_id
212
+ if request_size is not None:
213
+ audit_context["requestSize"] = request_size
214
+ if response_size is not None:
215
+ audit_context["responseSize"] = response_size
216
+ if error_message:
217
+ audit_context["error"] = error_message
218
+
219
+ # Log audit event
220
+ action = f"http.request.{method.upper()}"
221
+ await self.logger.audit(action, url, audit_context)
222
+
223
+ # Log debug details if log level is debug
224
+ if self.config.log_level == "debug":
225
+ await self._log_http_request_debug(
226
+ method,
227
+ url,
228
+ response,
229
+ error,
230
+ duration_ms,
231
+ status_code,
232
+ user_id,
233
+ request_data,
234
+ request_headers,
235
+ **kwargs,
236
+ )
237
+
238
+ except Exception:
239
+ # Silently swallow all logging errors - never break HTTP requests
240
+ pass
241
+
242
+ async def _log_http_request_debug(
243
+ self,
244
+ method: str,
245
+ url: str,
246
+ response: Optional[Any],
247
+ error: Optional[Exception],
248
+ duration_ms: int,
249
+ status_code: Optional[int],
250
+ user_id: Optional[str],
251
+ request_data: Optional[Dict[str, Any]],
252
+ request_headers: Optional[Dict[str, Any]],
253
+ **kwargs,
254
+ ) -> None:
255
+ """
256
+ Log detailed debug information for HTTP request.
257
+
258
+ All sensitive data is masked before logging.
259
+
260
+ Args:
261
+ method: HTTP method
262
+ url: Request URL
263
+ response: Response data
264
+ error: Exception if request failed
265
+ duration_ms: Request duration in milliseconds
266
+ status_code: HTTP status code
267
+ user_id: User ID if available
268
+ request_data: Request body data
269
+ request_headers: Request headers
270
+ **kwargs: Additional request parameters
271
+ """
272
+ try:
273
+ # Mask request headers
274
+ masked_request_headers: Optional[Dict[str, Any]] = None
275
+ if request_headers:
276
+ masked_request_headers = DataMasker.mask_sensitive_data(request_headers)
277
+
278
+ # Mask request body
279
+ masked_request_body: Optional[Any] = None
280
+ if request_data is not None:
281
+ masked_request_body = DataMasker.mask_sensitive_data(request_data)
282
+
283
+ # Mask response body (limit to first 1000 characters)
284
+ # Note: Response headers not available from InternalHttpClient (returns parsed JSON)
285
+ masked_response_body: Optional[str] = None
286
+ if response is not None:
287
+ try:
288
+ response_str = str(response)
289
+ # Limit to first 1000 characters
290
+ if len(response_str) > 1000:
291
+ response_str = response_str[:1000] + "..."
292
+ # Mask sensitive data
293
+ try:
294
+ # Try to mask if response is a dict
295
+ if isinstance(response, dict):
296
+ masked_dict = DataMasker.mask_sensitive_data(response)
297
+ masked_response_body = str(masked_dict)
298
+ else:
299
+ masked_response_body = response_str
300
+ except Exception:
301
+ masked_response_body = response_str
302
+ except Exception:
303
+ pass
304
+
305
+ # Extract query parameters from URL and mask
306
+ query_params: Optional[Dict[str, Any]] = None
307
+ try:
308
+ parsed_url = urlparse(url)
309
+ if parsed_url.query:
310
+ query_dict = parse_qs(parsed_url.query)
311
+ # Convert lists to single values for simplicity
312
+ query_simple: Dict[str, Any] = {
313
+ k: v[0] if len(v) == 1 else v for k, v in query_dict.items()
314
+ }
315
+ query_params = DataMasker.mask_sensitive_data(query_simple)
316
+ except Exception:
317
+ pass
318
+
319
+ # Build debug context (all sensitive data must be masked)
320
+ debug_context: Dict[str, Any] = {
321
+ "method": method,
322
+ "url": url,
323
+ "statusCode": status_code,
324
+ "duration": duration_ms,
325
+ "baseURL": self.config.controller_url,
326
+ "timeout": 30.0, # Default timeout
327
+ }
328
+
329
+ if user_id:
330
+ debug_context["userId"] = user_id
331
+ if masked_request_headers:
332
+ debug_context["requestHeaders"] = masked_request_headers
333
+ if masked_request_body is not None:
334
+ debug_context["requestBody"] = masked_request_body
335
+ if masked_response_body:
336
+ debug_context["responseBody"] = masked_response_body
337
+ if query_params:
338
+ debug_context["queryParams"] = query_params
339
+
340
+ # Log debug message
341
+ message = f"HTTP {method} {url} - Status: {status_code}, Duration: {duration_ms}ms"
342
+ await self.logger.debug(message, debug_context)
343
+
344
+ except Exception:
345
+ # Silently swallow all logging errors - never break HTTP requests
346
+ pass
347
+
153
348
  async def get(self, url: str, **kwargs) -> Any:
154
349
  """
155
- Make GET request.
156
-
350
+ Make GET request with automatic audit and debug logging.
351
+
157
352
  Args:
158
353
  url: Request URL
159
354
  **kwargs: Additional httpx request parameters
160
-
355
+
161
356
  Returns:
162
357
  Response data (JSON parsed)
163
-
358
+
164
359
  Raises:
165
360
  MisoClientError: If request fails
166
361
  """
167
- await self._initialize_client()
168
- await self._ensure_client_token()
362
+ start_time = time.perf_counter()
363
+ request_headers = kwargs.get("headers", {})
169
364
  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 {}
365
+ response = await self._internal_client.get(url, **kwargs)
366
+ await self._log_http_request_audit(
367
+ "GET",
368
+ url,
369
+ response=response,
370
+ error=None,
371
+ start_time=start_time,
372
+ request_data=None,
373
+ request_headers=request_headers,
374
+ **kwargs,
185
375
  )
186
- except httpx.RequestError as e:
187
- raise ConnectionError(f"Request failed: {str(e)}")
188
-
376
+ return response
377
+ except Exception as e:
378
+ await self._log_http_request_audit(
379
+ "GET",
380
+ url,
381
+ response=None,
382
+ error=e,
383
+ start_time=start_time,
384
+ request_data=None,
385
+ request_headers=request_headers,
386
+ **kwargs,
387
+ )
388
+ raise
389
+
189
390
  async def post(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
190
391
  """
191
- Make POST request.
192
-
392
+ Make POST request with automatic audit and debug logging.
393
+
193
394
  Args:
194
395
  url: Request URL
195
396
  data: Request data (will be JSON encoded)
196
397
  **kwargs: Additional httpx request parameters
197
-
398
+
198
399
  Returns:
199
400
  Response data (JSON parsed)
200
-
401
+
201
402
  Raises:
202
403
  MisoClientError: If request fails
203
404
  """
204
- await self._initialize_client()
205
- await self._ensure_client_token()
405
+ start_time = time.perf_counter()
406
+ request_headers = kwargs.get("headers", {})
206
407
  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 {}
408
+ response = await self._internal_client.post(url, data, **kwargs)
409
+ await self._log_http_request_audit(
410
+ "POST",
411
+ url,
412
+ response=response,
413
+ error=None,
414
+ start_time=start_time,
415
+ request_data=data,
416
+ request_headers=request_headers,
417
+ **kwargs,
221
418
  )
222
- except httpx.RequestError as e:
223
- raise ConnectionError(f"Request failed: {str(e)}")
224
-
419
+ return response
420
+ except Exception as e:
421
+ await self._log_http_request_audit(
422
+ "POST",
423
+ url,
424
+ response=None,
425
+ error=e,
426
+ start_time=start_time,
427
+ request_data=data,
428
+ request_headers=request_headers,
429
+ **kwargs,
430
+ )
431
+ raise
432
+
225
433
  async def put(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
226
434
  """
227
- Make PUT request.
228
-
435
+ Make PUT request with automatic audit and debug logging.
436
+
229
437
  Args:
230
438
  url: Request URL
231
439
  data: Request data (will be JSON encoded)
232
440
  **kwargs: Additional httpx request parameters
233
-
441
+
234
442
  Returns:
235
443
  Response data (JSON parsed)
236
-
444
+
237
445
  Raises:
238
446
  MisoClientError: If request fails
239
447
  """
240
- await self._initialize_client()
241
- await self._ensure_client_token()
448
+ start_time = time.perf_counter()
449
+ request_headers = kwargs.get("headers", {})
242
450
  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 {}
451
+ response = await self._internal_client.put(url, data, **kwargs)
452
+ await self._log_http_request_audit(
453
+ "PUT",
454
+ url,
455
+ response=response,
456
+ error=None,
457
+ start_time=start_time,
458
+ request_data=data,
459
+ request_headers=request_headers,
460
+ **kwargs,
257
461
  )
258
- except httpx.RequestError as e:
259
- raise ConnectionError(f"Request failed: {str(e)}")
260
-
462
+ return response
463
+ except Exception as e:
464
+ await self._log_http_request_audit(
465
+ "PUT",
466
+ url,
467
+ response=None,
468
+ error=e,
469
+ start_time=start_time,
470
+ request_data=data,
471
+ request_headers=request_headers,
472
+ **kwargs,
473
+ )
474
+ raise
475
+
261
476
  async def delete(self, url: str, **kwargs) -> Any:
262
477
  """
263
- Make DELETE request.
264
-
478
+ Make DELETE request with automatic audit and debug logging.
479
+
265
480
  Args:
266
481
  url: Request URL
267
482
  **kwargs: Additional httpx request parameters
268
-
483
+
269
484
  Returns:
270
485
  Response data (JSON parsed)
271
-
486
+
272
487
  Raises:
273
488
  MisoClientError: If request fails
274
489
  """
275
- await self._initialize_client()
276
- await self._ensure_client_token()
490
+ start_time = time.perf_counter()
491
+ request_headers = kwargs.get("headers", {})
277
492
  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 {}
493
+ response = await self._internal_client.delete(url, **kwargs)
494
+ await self._log_http_request_audit(
495
+ "DELETE",
496
+ url,
497
+ response=response,
498
+ error=None,
499
+ start_time=start_time,
500
+ request_data=None,
501
+ request_headers=request_headers,
502
+ **kwargs,
503
+ )
504
+ return response
505
+ except Exception as e:
506
+ await self._log_http_request_audit(
507
+ "DELETE",
508
+ url,
509
+ response=None,
510
+ error=e,
511
+ start_time=start_time,
512
+ request_data=None,
513
+ request_headers=request_headers,
514
+ **kwargs,
292
515
  )
293
- except httpx.RequestError as e:
294
- raise ConnectionError(f"Request failed: {str(e)}")
295
-
516
+ raise
517
+
296
518
  async def request(
297
519
  self,
298
520
  method: Literal["GET", "POST", "PUT", "DELETE"],
299
521
  url: str,
300
522
  data: Optional[Dict[str, Any]] = None,
301
- **kwargs
523
+ **kwargs,
302
524
  ) -> Any:
303
525
  """
304
- Generic request method.
305
-
526
+ Generic request method with automatic audit and debug logging.
527
+
306
528
  Args:
307
529
  method: HTTP method
308
530
  url: Request URL
309
531
  data: Request data (for POST/PUT)
310
532
  **kwargs: Additional httpx request parameters
311
-
533
+
312
534
  Returns:
313
535
  Response data (JSON parsed)
314
-
536
+
315
537
  Raises:
316
538
  MisoClientError: If request fails
317
539
  """
@@ -326,52 +548,38 @@ class HttpClient:
326
548
  return await self.delete(url, **kwargs)
327
549
  else:
328
550
  raise ValueError(f"Unsupported HTTP method: {method}")
329
-
551
+
330
552
  async def authenticated_request(
331
553
  self,
332
554
  method: Literal["GET", "POST", "PUT", "DELETE"],
333
555
  url: str,
334
556
  token: str,
335
557
  data: Optional[Dict[str, Any]] = None,
336
- **kwargs
558
+ **kwargs,
337
559
  ) -> Any:
338
560
  """
339
- Make authenticated request with Bearer token.
340
-
341
- IMPORTANT: Client token is sent as x-client-token header (via _ensure_client_token)
561
+ Make authenticated request with Bearer token and automatic audit/debug logging.
562
+
563
+ IMPORTANT: Client token is sent as x-client-token header (via InternalHttpClient)
342
564
  User token is sent as Authorization: Bearer header (this method parameter)
343
565
  These are two separate tokens for different purposes.
344
-
566
+
345
567
  Args:
346
568
  method: HTTP method
347
569
  url: Request URL
348
570
  token: User authentication token (sent as Bearer token)
349
571
  data: Request data (for POST/PUT)
350
572
  **kwargs: Additional httpx request parameters
351
-
573
+
352
574
  Returns:
353
575
  Response data (JSON parsed)
354
-
576
+
355
577
  Raises:
356
578
  MisoClientError: If request fails
357
579
  """
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
580
+ # Add Bearer token to headers for logging context
362
581
  headers = kwargs.get("headers", {})
363
582
  headers["Authorization"] = f"Bearer {token}"
364
583
  kwargs["headers"] = headers
365
-
584
+
366
585
  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()