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/data_masker.py
CHANGED
|
@@ -5,16 +5,18 @@ Implements ISO 27001 data protection controls by masking sensitive fields
|
|
|
5
5
|
in log entries and context data.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from typing import Any, Set
|
|
8
|
+
from typing import Any, Optional, Set
|
|
9
|
+
|
|
10
|
+
from .sensitive_fields_loader import get_sensitive_fields_array
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
class DataMasker:
|
|
12
14
|
"""Static class for masking sensitive data."""
|
|
13
|
-
|
|
15
|
+
|
|
14
16
|
MASKED_VALUE = "***MASKED***"
|
|
15
|
-
|
|
16
|
-
#
|
|
17
|
-
|
|
17
|
+
|
|
18
|
+
# Hardcoded set of sensitive field names (normalized) - fallback if JSON cannot be loaded
|
|
19
|
+
_hardcoded_sensitive_fields: Set[str] = {
|
|
18
20
|
"password",
|
|
19
21
|
"passwd",
|
|
20
22
|
"pwd",
|
|
@@ -37,58 +39,128 @@ class DataMasker:
|
|
|
37
39
|
"privatekey",
|
|
38
40
|
"secretkey",
|
|
39
41
|
}
|
|
40
|
-
|
|
42
|
+
|
|
43
|
+
# Cached merged sensitive fields (loaded on first use)
|
|
44
|
+
_sensitive_fields: Optional[Set[str]] = None
|
|
45
|
+
_config_loaded: bool = False
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def _load_config(cls, config_path: Optional[str] = None) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Load sensitive fields configuration from JSON and merge with hardcoded defaults.
|
|
51
|
+
|
|
52
|
+
This method is called automatically on first use. It loads JSON configuration
|
|
53
|
+
and merges it with hardcoded defaults, ensuring backward compatibility.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
config_path: Optional custom path to JSON config file
|
|
57
|
+
"""
|
|
58
|
+
if cls._config_loaded:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Start with hardcoded fields as base
|
|
62
|
+
merged_fields = set(cls._hardcoded_sensitive_fields)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
# Try to load fields from JSON configuration
|
|
66
|
+
json_fields = get_sensitive_fields_array(config_path)
|
|
67
|
+
if json_fields:
|
|
68
|
+
# Normalize and add JSON fields (same normalization as hardcoded fields)
|
|
69
|
+
for field in json_fields:
|
|
70
|
+
if isinstance(field, str):
|
|
71
|
+
# Normalize: lowercase and remove underscores/hyphens
|
|
72
|
+
normalized = field.lower().replace("_", "").replace("-", "")
|
|
73
|
+
merged_fields.add(normalized)
|
|
74
|
+
except Exception:
|
|
75
|
+
# If JSON loading fails, fall back to hardcoded fields only
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
cls._sensitive_fields = merged_fields
|
|
79
|
+
cls._config_loaded = True
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def _get_sensitive_fields(cls) -> Set[str]:
|
|
83
|
+
"""
|
|
84
|
+
Get the set of sensitive fields (loads config on first call).
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Set of normalized sensitive field names
|
|
88
|
+
"""
|
|
89
|
+
if not cls._config_loaded:
|
|
90
|
+
cls._load_config()
|
|
91
|
+
assert cls._sensitive_fields is not None
|
|
92
|
+
return cls._sensitive_fields
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def set_config_path(cls, config_path: str) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Set custom path for sensitive fields configuration.
|
|
98
|
+
|
|
99
|
+
Must be called before first use of DataMasker methods if custom path is needed.
|
|
100
|
+
Otherwise, default path or environment variable will be used.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
config_path: Path to JSON configuration file
|
|
104
|
+
"""
|
|
105
|
+
# Reset cache to force reload with new path
|
|
106
|
+
cls._config_loaded = False
|
|
107
|
+
cls._sensitive_fields = None
|
|
108
|
+
cls._load_config(config_path)
|
|
109
|
+
|
|
41
110
|
@classmethod
|
|
42
111
|
def is_sensitive_field(cls, key: str) -> bool:
|
|
43
112
|
"""
|
|
44
113
|
Check if a field name indicates sensitive data.
|
|
45
|
-
|
|
114
|
+
|
|
46
115
|
Args:
|
|
47
116
|
key: Field name to check
|
|
48
|
-
|
|
117
|
+
|
|
49
118
|
Returns:
|
|
50
119
|
True if field is sensitive, False otherwise
|
|
51
120
|
"""
|
|
52
121
|
# Normalize key: lowercase and remove underscores/hyphens
|
|
53
122
|
normalized_key = key.lower().replace("_", "").replace("-", "")
|
|
54
|
-
|
|
123
|
+
|
|
124
|
+
# Get sensitive fields (loads config on first use)
|
|
125
|
+
sensitive_fields = cls._get_sensitive_fields()
|
|
126
|
+
|
|
55
127
|
# Check exact match
|
|
56
|
-
if normalized_key in
|
|
128
|
+
if normalized_key in sensitive_fields:
|
|
57
129
|
return True
|
|
58
|
-
|
|
130
|
+
|
|
59
131
|
# Check if field contains sensitive keywords
|
|
60
|
-
for sensitive_field in
|
|
132
|
+
for sensitive_field in sensitive_fields:
|
|
61
133
|
if sensitive_field in normalized_key:
|
|
62
134
|
return True
|
|
63
|
-
|
|
135
|
+
|
|
64
136
|
return False
|
|
65
|
-
|
|
137
|
+
|
|
66
138
|
@classmethod
|
|
67
139
|
def mask_sensitive_data(cls, data: Any) -> Any:
|
|
68
140
|
"""
|
|
69
141
|
Mask sensitive data in objects, arrays, or primitives.
|
|
70
|
-
|
|
142
|
+
|
|
71
143
|
Returns a masked copy without modifying the original.
|
|
72
144
|
Recursively processes nested objects and arrays.
|
|
73
|
-
|
|
145
|
+
|
|
74
146
|
Args:
|
|
75
147
|
data: Data to mask (dict, list, or primitive)
|
|
76
|
-
|
|
148
|
+
|
|
77
149
|
Returns:
|
|
78
150
|
Masked copy of the data
|
|
79
151
|
"""
|
|
80
152
|
# Handle null and undefined
|
|
81
153
|
if data is None:
|
|
82
154
|
return data
|
|
83
|
-
|
|
155
|
+
|
|
84
156
|
# Handle primitives (string, number, boolean)
|
|
85
157
|
if not isinstance(data, (dict, list)):
|
|
86
158
|
return data
|
|
87
|
-
|
|
159
|
+
|
|
88
160
|
# Handle arrays
|
|
89
161
|
if isinstance(data, list):
|
|
90
162
|
return [cls.mask_sensitive_data(item) for item in data]
|
|
91
|
-
|
|
163
|
+
|
|
92
164
|
# Handle objects/dicts
|
|
93
165
|
masked: dict[str, Any] = {}
|
|
94
166
|
for key, value in data.items():
|
|
@@ -101,49 +173,49 @@ class DataMasker:
|
|
|
101
173
|
else:
|
|
102
174
|
# Keep non-sensitive value as-is
|
|
103
175
|
masked[key] = value
|
|
104
|
-
|
|
176
|
+
|
|
105
177
|
return masked
|
|
106
|
-
|
|
178
|
+
|
|
107
179
|
@classmethod
|
|
108
180
|
def mask_value(cls, value: str, show_first: int = 0, show_last: int = 0) -> str:
|
|
109
181
|
"""
|
|
110
182
|
Mask specific value (useful for masking individual strings).
|
|
111
|
-
|
|
183
|
+
|
|
112
184
|
Args:
|
|
113
185
|
value: String value to mask
|
|
114
186
|
show_first: Number of characters to show at the start
|
|
115
187
|
show_last: Number of characters to show at the end
|
|
116
|
-
|
|
188
|
+
|
|
117
189
|
Returns:
|
|
118
190
|
Masked string value
|
|
119
191
|
"""
|
|
120
192
|
if not value or len(value) <= show_first + show_last:
|
|
121
193
|
return cls.MASKED_VALUE
|
|
122
|
-
|
|
194
|
+
|
|
123
195
|
first = value[:show_first] if show_first > 0 else ""
|
|
124
196
|
last = value[-show_last:] if show_last > 0 else ""
|
|
125
197
|
masked_length = max(8, len(value) - show_first - show_last)
|
|
126
198
|
masked = "*" * masked_length
|
|
127
|
-
|
|
199
|
+
|
|
128
200
|
return f"{first}{masked}{last}"
|
|
129
|
-
|
|
201
|
+
|
|
130
202
|
@classmethod
|
|
131
203
|
def contains_sensitive_data(cls, data: Any) -> bool:
|
|
132
204
|
"""
|
|
133
205
|
Check if data contains sensitive information.
|
|
134
|
-
|
|
206
|
+
|
|
135
207
|
Args:
|
|
136
208
|
data: Data to check
|
|
137
|
-
|
|
209
|
+
|
|
138
210
|
Returns:
|
|
139
211
|
True if data contains sensitive fields, False otherwise
|
|
140
212
|
"""
|
|
141
213
|
if data is None or not isinstance(data, (dict, list)):
|
|
142
214
|
return False
|
|
143
|
-
|
|
215
|
+
|
|
144
216
|
if isinstance(data, list):
|
|
145
217
|
return any(cls.contains_sensitive_data(item) for item in data)
|
|
146
|
-
|
|
218
|
+
|
|
147
219
|
# Check object keys
|
|
148
220
|
for key, value in data.items():
|
|
149
221
|
if cls.is_sensitive_field(key):
|
|
@@ -151,6 +223,5 @@ class DataMasker:
|
|
|
151
223
|
if isinstance(value, (dict, list)):
|
|
152
224
|
if cls.contains_sensitive_data(value):
|
|
153
225
|
return True
|
|
154
|
-
|
|
155
|
-
return False
|
|
156
226
|
|
|
227
|
+
return False
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Server-side environment token wrapper with origin validation and audit logging.
|
|
3
|
+
|
|
4
|
+
This module provides a secure server-side wrapper for fetching environment tokens
|
|
5
|
+
with origin validation and ISO 27001 compliant audit logging.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..errors import AuthenticationError
|
|
11
|
+
from ..services.logger import LoggerService
|
|
12
|
+
from .data_masker import DataMasker
|
|
13
|
+
from .origin_validator import validate_origin
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def get_environment_token(miso_client: Any, headers: Any) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Get environment token with origin validation and audit logging.
|
|
19
|
+
|
|
20
|
+
This is a server-side wrapper that validates request origin before calling
|
|
21
|
+
the controller, and logs audit events with ISO 27001 compliant data masking.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
miso_client: MisoClient instance
|
|
25
|
+
headers: Request headers dict or object with headers attribute
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Client token string
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
AuthenticationError: If origin validation fails or token fetch fails
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
>>> from miso_client import MisoClient, MisoClientConfig
|
|
35
|
+
>>> config = MisoClientConfig(...)
|
|
36
|
+
>>> client = MisoClient(config)
|
|
37
|
+
>>> headers = {"origin": "http://localhost:3000"}
|
|
38
|
+
>>> token = await get_environment_token(client, headers)
|
|
39
|
+
"""
|
|
40
|
+
config = miso_client.config
|
|
41
|
+
logger: LoggerService = miso_client.logger
|
|
42
|
+
|
|
43
|
+
# Validate origin if allowedOrigins is configured
|
|
44
|
+
if config.allowedOrigins:
|
|
45
|
+
validation_result = validate_origin(headers, config.allowedOrigins)
|
|
46
|
+
if not validation_result["valid"]:
|
|
47
|
+
error_message = validation_result.get("error", "Origin validation failed")
|
|
48
|
+
|
|
49
|
+
# Log error and audit event before raising exception
|
|
50
|
+
masked_config = {
|
|
51
|
+
"clientId": config.client_id,
|
|
52
|
+
"clientSecret": DataMasker.mask_sensitive_data(config.client_secret),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await logger.error(
|
|
56
|
+
"Origin validation failed for environment token request",
|
|
57
|
+
context={
|
|
58
|
+
"error": error_message,
|
|
59
|
+
"allowedOrigins": config.allowedOrigins,
|
|
60
|
+
"clientId": config.client_id,
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
await logger.audit(
|
|
65
|
+
"auth.environment_token.origin_validation_failed",
|
|
66
|
+
resource="/api/v1/auth/token",
|
|
67
|
+
context={
|
|
68
|
+
"error": error_message,
|
|
69
|
+
"allowedOrigins": config.allowedOrigins,
|
|
70
|
+
**masked_config,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
raise AuthenticationError(f"Origin validation failed: {error_message}")
|
|
75
|
+
|
|
76
|
+
# Log audit event before calling controller (with masked credentials)
|
|
77
|
+
masked_config = {
|
|
78
|
+
"clientId": config.client_id,
|
|
79
|
+
"clientSecret": DataMasker.mask_sensitive_data(config.client_secret),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await logger.audit(
|
|
83
|
+
"auth.environment_token.request",
|
|
84
|
+
resource=config.clientTokenUri or "/api/v1/auth/token",
|
|
85
|
+
context=masked_config,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
# Call auth service to get environment token
|
|
90
|
+
token: str = await miso_client.auth.get_environment_token()
|
|
91
|
+
|
|
92
|
+
# Log successful token fetch
|
|
93
|
+
await logger.audit(
|
|
94
|
+
"auth.environment_token.success",
|
|
95
|
+
resource=config.clientTokenUri or "/api/v1/auth/token",
|
|
96
|
+
context={
|
|
97
|
+
"clientId": config.client_id,
|
|
98
|
+
"tokenLength": len(token) if token else 0,
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return token
|
|
103
|
+
|
|
104
|
+
except Exception as error:
|
|
105
|
+
# Log error and audit event
|
|
106
|
+
await logger.error(
|
|
107
|
+
"Failed to get environment token",
|
|
108
|
+
context={
|
|
109
|
+
"error": str(error),
|
|
110
|
+
"clientId": config.client_id,
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
await logger.audit(
|
|
115
|
+
"auth.environment_token.failure",
|
|
116
|
+
resource=config.clientTokenUri or "/api/v1/auth/token",
|
|
117
|
+
context={
|
|
118
|
+
"error": str(error),
|
|
119
|
+
**masked_config,
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Re-raise as AuthenticationError
|
|
124
|
+
if isinstance(error, AuthenticationError):
|
|
125
|
+
raise
|
|
126
|
+
raise AuthenticationError(f"Failed to get environment token: {str(error)}") from error
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Error utilities for MisoClient SDK.
|
|
3
|
+
|
|
4
|
+
This module provides error transformation utilities for handling
|
|
5
|
+
camelCase error responses from the API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from ..errors import MisoClientError
|
|
11
|
+
from ..models.error_response import ErrorResponse
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiErrorException(Exception):
|
|
15
|
+
"""
|
|
16
|
+
Exception class for camelCase error responses.
|
|
17
|
+
|
|
18
|
+
Used with camelCase ErrorResponse format matching TypeScript SDK.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, error: ErrorResponse):
|
|
22
|
+
"""
|
|
23
|
+
Initialize ApiErrorException.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
error: ErrorResponse object with camelCase properties
|
|
27
|
+
"""
|
|
28
|
+
super().__init__(error.title or "API Error")
|
|
29
|
+
self.name = "ApiErrorException"
|
|
30
|
+
self.statusCode = error.statusCode
|
|
31
|
+
self.correlationId = error.correlationId
|
|
32
|
+
self.type = error.type
|
|
33
|
+
self.instance = error.instance
|
|
34
|
+
self.errors = error.errors
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def transformError(error_data: dict) -> ErrorResponse:
|
|
38
|
+
"""
|
|
39
|
+
Transform arbitrary error into standardized camelCase ErrorResponse.
|
|
40
|
+
|
|
41
|
+
Converts error data dictionary to ErrorResponse object with camelCase field names.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
error_data: Dictionary with error data (must be camelCase)
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
ErrorResponse object with standardized format
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
>>> error_data = {
|
|
51
|
+
... 'errors': ['Error message'],
|
|
52
|
+
... 'type': '/Errors/Bad Input',
|
|
53
|
+
... 'title': 'Bad Request',
|
|
54
|
+
... 'statusCode': 400,
|
|
55
|
+
... 'instance': '/api/endpoint'
|
|
56
|
+
... }
|
|
57
|
+
>>> error_response = transformError(error_data)
|
|
58
|
+
>>> error_response.statusCode
|
|
59
|
+
400
|
|
60
|
+
"""
|
|
61
|
+
return ErrorResponse(**error_data)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Alias for backward compatibility
|
|
65
|
+
transform_error_to_snake_case = transformError
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def handleApiError(
|
|
69
|
+
response_data: dict, status_code: int, instance: Optional[str] = None
|
|
70
|
+
) -> ApiErrorException:
|
|
71
|
+
"""
|
|
72
|
+
Handle API error and raise camelCase ApiErrorException.
|
|
73
|
+
|
|
74
|
+
Creates ApiErrorException from camelCase API response.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
response_data: Error response data from API (must be camelCase)
|
|
78
|
+
status_code: HTTP status code (overrides statusCode in response_data)
|
|
79
|
+
instance: Optional request instance URI (overrides instance in response_data)
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
ApiErrorException with camelCase error format
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
ApiErrorException: Always raises this exception
|
|
86
|
+
|
|
87
|
+
Examples:
|
|
88
|
+
>>> response_data = {
|
|
89
|
+
... 'errors': ['Validation failed'],
|
|
90
|
+
... 'type': '/Errors/Validation',
|
|
91
|
+
... 'title': 'Validation Error',
|
|
92
|
+
... 'statusCode': 422
|
|
93
|
+
... }
|
|
94
|
+
>>> try:
|
|
95
|
+
... handleApiError(response_data, 422, '/api/endpoint')
|
|
96
|
+
... except ApiErrorException as e:
|
|
97
|
+
... e.statusCode
|
|
98
|
+
422
|
|
99
|
+
"""
|
|
100
|
+
# Create a copy to avoid mutating the original
|
|
101
|
+
data = response_data.copy()
|
|
102
|
+
|
|
103
|
+
# Override instance if provided
|
|
104
|
+
if instance:
|
|
105
|
+
data["instance"] = instance
|
|
106
|
+
|
|
107
|
+
# Override statusCode if provided
|
|
108
|
+
data["statusCode"] = status_code
|
|
109
|
+
|
|
110
|
+
# Ensure title has a default if missing
|
|
111
|
+
if "title" not in data:
|
|
112
|
+
data["title"] = None
|
|
113
|
+
|
|
114
|
+
# Transform to ErrorResponse
|
|
115
|
+
error_response = transformError(data)
|
|
116
|
+
|
|
117
|
+
# Raise ApiErrorException
|
|
118
|
+
raise ApiErrorException(error_response)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def handle_api_error_snake_case(
|
|
122
|
+
response_data: dict, status_code: int, instance: Optional[str] = None
|
|
123
|
+
) -> MisoClientError:
|
|
124
|
+
"""
|
|
125
|
+
Handle errors with camelCase response format (legacy function).
|
|
126
|
+
|
|
127
|
+
Creates MisoClientError with ErrorResponse from camelCase API response.
|
|
128
|
+
This is kept for backward compatibility. New code should use handleApiError().
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
response_data: Error response data from API (must be camelCase)
|
|
132
|
+
status_code: HTTP status code (overrides statusCode in response_data)
|
|
133
|
+
instance: Optional request instance URI (overrides instance in response_data)
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
MisoClientError with structured ErrorResponse
|
|
137
|
+
|
|
138
|
+
Examples:
|
|
139
|
+
>>> response_data = {
|
|
140
|
+
... 'errors': ['Validation failed'],
|
|
141
|
+
... 'type': '/Errors/Validation',
|
|
142
|
+
... 'title': 'Validation Error',
|
|
143
|
+
... 'statusCode': 422
|
|
144
|
+
... }
|
|
145
|
+
>>> error = handle_api_error_snake_case(response_data, 422, '/api/endpoint')
|
|
146
|
+
>>> error.error_response.statusCode
|
|
147
|
+
422
|
|
148
|
+
"""
|
|
149
|
+
# Create a copy to avoid mutating the original
|
|
150
|
+
data = response_data.copy()
|
|
151
|
+
|
|
152
|
+
# Override instance if provided
|
|
153
|
+
if instance:
|
|
154
|
+
data["instance"] = instance
|
|
155
|
+
|
|
156
|
+
# Override statusCode if provided
|
|
157
|
+
data["statusCode"] = status_code
|
|
158
|
+
|
|
159
|
+
# Ensure title has a default if missing
|
|
160
|
+
if "title" not in data:
|
|
161
|
+
data["title"] = None
|
|
162
|
+
|
|
163
|
+
# Transform to ErrorResponse
|
|
164
|
+
error_response = transformError(data)
|
|
165
|
+
|
|
166
|
+
# Create error message from errors list
|
|
167
|
+
if error_response.errors:
|
|
168
|
+
if len(error_response.errors) == 1:
|
|
169
|
+
message = error_response.errors[0]
|
|
170
|
+
else:
|
|
171
|
+
title_prefix = f"{error_response.title}: " if error_response.title else ""
|
|
172
|
+
message = f"{title_prefix}{'; '.join(error_response.errors)}"
|
|
173
|
+
else:
|
|
174
|
+
message = error_response.title or "API Error"
|
|
175
|
+
|
|
176
|
+
# Create MisoClientError with ErrorResponse
|
|
177
|
+
return MisoClientError(
|
|
178
|
+
message=message,
|
|
179
|
+
status_code=status_code,
|
|
180
|
+
error_response=error_response,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def extract_correlation_id_from_error(error: Exception) -> Optional[str]:
|
|
185
|
+
"""
|
|
186
|
+
Extract correlation ID from exception if available.
|
|
187
|
+
|
|
188
|
+
Checks MisoClientError.error_response.correlationId and ApiErrorException.correlationId.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
error: Exception object
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Correlation ID string if found, None otherwise
|
|
195
|
+
|
|
196
|
+
Examples:
|
|
197
|
+
>>> error = MisoClientError("Error", error_response=ErrorResponse(
|
|
198
|
+
... errors=["Error"], type="/Errors/Test", statusCode=400,
|
|
199
|
+
... correlationId="req-123"
|
|
200
|
+
... ))
|
|
201
|
+
>>> extract_correlation_id_from_error(error)
|
|
202
|
+
'req-123'
|
|
203
|
+
"""
|
|
204
|
+
# Check MisoClientError with error_response
|
|
205
|
+
if isinstance(error, MisoClientError) and error.error_response:
|
|
206
|
+
correlation_id = error.error_response.correlationId
|
|
207
|
+
if correlation_id is not None:
|
|
208
|
+
return str(correlation_id)
|
|
209
|
+
|
|
210
|
+
# Check ApiErrorException with correlationId property
|
|
211
|
+
if isinstance(error, ApiErrorException):
|
|
212
|
+
correlation_id = error.correlationId
|
|
213
|
+
if correlation_id is not None:
|
|
214
|
+
return str(correlation_id)
|
|
215
|
+
|
|
216
|
+
return None
|