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.
- miso_client/__init__.py +104 -84
- miso_client/errors.py +30 -4
- miso_client/models/__init__.py +4 -0
- miso_client/models/config.py +56 -35
- miso_client/models/error_response.py +41 -0
- miso_client/services/__init__.py +5 -5
- miso_client/services/auth.py +65 -48
- miso_client/services/cache.py +42 -41
- miso_client/services/encryption.py +18 -17
- miso_client/services/logger.py +115 -100
- miso_client/services/permission.py +27 -36
- miso_client/services/redis.py +17 -15
- miso_client/services/role.py +25 -36
- miso_client/utils/__init__.py +3 -3
- miso_client/utils/config_loader.py +24 -16
- miso_client/utils/data_masker.py +104 -33
- miso_client/utils/http_client.py +462 -254
- miso_client/utils/internal_http_client.py +471 -0
- miso_client/utils/jwt_tools.py +14 -17
- miso_client/utils/sensitive_fields_loader.py +116 -0
- {miso_client-0.1.0.dist-info → miso_client-0.4.0.dist-info}/METADATA +165 -3
- miso_client-0.4.0.dist-info/RECORD +26 -0
- miso_client-0.1.0.dist-info/RECORD +0 -23
- {miso_client-0.1.0.dist-info → miso_client-0.4.0.dist-info}/WHEEL +0 -0
- {miso_client-0.1.0.dist-info → miso_client-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {miso_client-0.1.0.dist-info → miso_client-0.4.0.dist-info}/top_level.txt +0 -0
miso_client/utils/http_client.py
CHANGED
|
@@ -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
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
10
|
-
import
|
|
11
|
-
from
|
|
12
|
-
|
|
13
|
-
from ..models.config import MisoClientConfig
|
|
14
|
-
from ..
|
|
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
|
-
"""
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
29
|
-
self.
|
|
30
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
168
|
-
|
|
362
|
+
start_time = time.perf_counter()
|
|
363
|
+
request_headers = kwargs.get("headers", {})
|
|
169
364
|
try:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
205
|
-
|
|
405
|
+
start_time = time.perf_counter()
|
|
406
|
+
request_headers = kwargs.get("headers", {})
|
|
206
407
|
try:
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
241
|
-
|
|
448
|
+
start_time = time.perf_counter()
|
|
449
|
+
request_headers = kwargs.get("headers", {})
|
|
242
450
|
try:
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
276
|
-
|
|
490
|
+
start_time = time.perf_counter()
|
|
491
|
+
request_headers = kwargs.get("headers", {})
|
|
277
492
|
try:
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
|
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
|
-
|
|
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()
|