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.
- miso_client/__init__.py +523 -130
- miso_client/api/__init__.py +35 -0
- miso_client/api/auth_api.py +367 -0
- miso_client/api/logs_api.py +91 -0
- miso_client/api/permissions_api.py +88 -0
- miso_client/api/roles_api.py +88 -0
- miso_client/api/types/__init__.py +75 -0
- miso_client/api/types/auth_types.py +183 -0
- miso_client/api/types/logs_types.py +71 -0
- miso_client/api/types/permissions_types.py +31 -0
- miso_client/api/types/roles_types.py +31 -0
- miso_client/errors.py +30 -4
- miso_client/models/__init__.py +4 -0
- miso_client/models/config.py +275 -72
- miso_client/models/error_response.py +39 -0
- miso_client/models/filter.py +255 -0
- miso_client/models/pagination.py +44 -0
- miso_client/models/sort.py +25 -0
- miso_client/services/__init__.py +6 -5
- miso_client/services/auth.py +496 -87
- miso_client/services/cache.py +42 -41
- miso_client/services/encryption.py +18 -17
- miso_client/services/logger.py +467 -328
- miso_client/services/logger_chain.py +288 -0
- miso_client/services/permission.py +130 -67
- miso_client/services/redis.py +28 -23
- miso_client/services/role.py +145 -62
- miso_client/utils/__init__.py +3 -3
- miso_client/utils/audit_log_queue.py +222 -0
- miso_client/utils/auth_strategy.py +88 -0
- miso_client/utils/auth_utils.py +65 -0
- miso_client/utils/circuit_breaker.py +125 -0
- miso_client/utils/client_token_manager.py +244 -0
- miso_client/utils/config_loader.py +88 -17
- miso_client/utils/controller_url_resolver.py +80 -0
- miso_client/utils/data_masker.py +104 -33
- miso_client/utils/environment_token.py +126 -0
- miso_client/utils/error_utils.py +216 -0
- miso_client/utils/fastapi_endpoints.py +166 -0
- miso_client/utils/filter.py +364 -0
- miso_client/utils/filter_applier.py +143 -0
- miso_client/utils/filter_parser.py +110 -0
- miso_client/utils/flask_endpoints.py +169 -0
- miso_client/utils/http_client.py +494 -262
- miso_client/utils/http_client_logging.py +352 -0
- miso_client/utils/http_client_logging_helpers.py +197 -0
- miso_client/utils/http_client_query_helpers.py +138 -0
- miso_client/utils/http_error_handler.py +92 -0
- miso_client/utils/http_log_formatter.py +115 -0
- miso_client/utils/http_log_masker.py +203 -0
- miso_client/utils/internal_http_client.py +435 -0
- miso_client/utils/jwt_tools.py +125 -16
- miso_client/utils/logger_helpers.py +206 -0
- miso_client/utils/logging_helpers.py +70 -0
- miso_client/utils/origin_validator.py +128 -0
- miso_client/utils/pagination.py +275 -0
- miso_client/utils/request_context.py +285 -0
- miso_client/utils/sensitive_fields_loader.py +116 -0
- miso_client/utils/sort.py +116 -0
- miso_client/utils/token_utils.py +114 -0
- miso_client/utils/url_validator.py +66 -0
- miso_client/utils/user_token_refresh.py +245 -0
- miso_client-3.7.2.dist-info/METADATA +1021 -0
- miso_client-3.7.2.dist-info/RECORD +68 -0
- miso_client-0.1.0.dist-info/METADATA +0 -551
- miso_client-0.1.0.dist-info/RECORD +0 -23
- {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/WHEEL +0 -0
- {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/licenses/LICENSE +0 -0
- {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/top_level.txt +0 -0
miso_client/utils/http_client.py
CHANGED
|
@@ -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
|
|
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 httpx
|
|
10
9
|
import asyncio
|
|
11
|
-
|
|
12
|
-
from typing import Any, Dict, Optional,
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
"""
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
29
|
-
self.
|
|
30
|
-
self.
|
|
31
|
-
self.
|
|
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
|
-
|
|
142
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
129
|
+
Exception: If request fails
|
|
166
130
|
"""
|
|
167
|
-
|
|
168
|
-
|
|
131
|
+
start_time = time.perf_counter()
|
|
132
|
+
request_headers = kwargs.get("headers", {})
|
|
169
133
|
try:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
#
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
476
|
+
Response data (JSON parsed)
|
|
477
|
+
|
|
478
|
+
Raises:
|
|
479
|
+
MisoClientError: If all authentication methods fail
|
|
376
480
|
"""
|
|
377
|
-
|
|
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)
|