sfq 0.0.31__py3-none-any.whl → 0.0.33__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.
- sfq/__init__.py +223 -886
- sfq/_cometd.py +7 -10
- sfq/auth.py +401 -0
- sfq/crud.py +446 -0
- sfq/exceptions.py +54 -0
- sfq/http_client.py +319 -0
- sfq/query.py +398 -0
- sfq/soap.py +181 -0
- sfq/utils.py +196 -0
- {sfq-0.0.31.dist-info → sfq-0.0.33.dist-info}/METADATA +1 -1
- sfq-0.0.33.dist-info/RECORD +13 -0
- sfq-0.0.31.dist-info/RECORD +0 -6
- {sfq-0.0.31.dist-info → sfq-0.0.33.dist-info}/WHEEL +0 -0
sfq/query.py
ADDED
@@ -0,0 +1,398 @@
|
|
1
|
+
"""
|
2
|
+
Query client module for the SFQ library.
|
3
|
+
|
4
|
+
This module handles SOQL query operations, query result pagination,
|
5
|
+
composite query operations with threading, and sObject prefix management.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import json
|
9
|
+
from collections import OrderedDict
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
11
|
+
from typing import Any, Dict, List, Literal, Optional
|
12
|
+
from urllib.parse import quote
|
13
|
+
|
14
|
+
from .utils import get_logger
|
15
|
+
|
16
|
+
logger = get_logger(__name__)
|
17
|
+
|
18
|
+
|
19
|
+
class QueryClient:
|
20
|
+
"""
|
21
|
+
Manages SOQL query operations for Salesforce API communication.
|
22
|
+
|
23
|
+
This class encapsulates all query-related functionality including
|
24
|
+
standard SOQL queries, tooling API queries, composite batch queries,
|
25
|
+
query result pagination, and sObject prefix management.
|
26
|
+
"""
|
27
|
+
|
28
|
+
def __init__(
|
29
|
+
self,
|
30
|
+
http_client: "HTTPClient", # Forward reference to avoid circular import # noqa: F821
|
31
|
+
api_version: str = "v64.0",
|
32
|
+
) -> None:
|
33
|
+
"""
|
34
|
+
Initialize the QueryClient with HTTP client and API version.
|
35
|
+
|
36
|
+
:param http_client: HTTPClient instance for making requests
|
37
|
+
:param api_version: Salesforce API version to use
|
38
|
+
"""
|
39
|
+
self.http_client = http_client
|
40
|
+
self.api_version = api_version
|
41
|
+
|
42
|
+
def query(self, query: str, tooling: bool = False) -> Optional[Dict[str, Any]]:
|
43
|
+
"""
|
44
|
+
Execute a SOQL query using the REST or Tooling API.
|
45
|
+
|
46
|
+
:param query: The SOQL query string
|
47
|
+
:param tooling: If True, use the Tooling API endpoint
|
48
|
+
:return: Parsed JSON response with paginated results or None on failure
|
49
|
+
"""
|
50
|
+
endpoint = f"/services/data/{self.api_version}/"
|
51
|
+
endpoint += "tooling/query" if tooling else "query"
|
52
|
+
query_string = f"?q={quote(query)}"
|
53
|
+
endpoint += query_string
|
54
|
+
|
55
|
+
try:
|
56
|
+
status_code, data = self.http_client.send_authenticated_request(
|
57
|
+
method="GET",
|
58
|
+
endpoint=endpoint,
|
59
|
+
)
|
60
|
+
|
61
|
+
if status_code == 200:
|
62
|
+
result = json.loads(data)
|
63
|
+
# Paginate the results to get all records
|
64
|
+
paginated = self._paginate_query_result(result)
|
65
|
+
|
66
|
+
logger.debug(
|
67
|
+
"Query successful, returned %s records: %r",
|
68
|
+
paginated.get("totalSize"),
|
69
|
+
query,
|
70
|
+
)
|
71
|
+
logger.trace("Query full response: %s", paginated)
|
72
|
+
return paginated
|
73
|
+
else:
|
74
|
+
logger.debug("Query failed: %r", query)
|
75
|
+
logger.error(
|
76
|
+
"Query failed with HTTP status %s",
|
77
|
+
status_code,
|
78
|
+
)
|
79
|
+
logger.debug("Query response: %s", data)
|
80
|
+
|
81
|
+
except Exception as err:
|
82
|
+
logger.exception("Exception during query: %s", err)
|
83
|
+
|
84
|
+
return None
|
85
|
+
|
86
|
+
def tooling_query(self, query: str) -> Optional[Dict[str, Any]]:
|
87
|
+
"""
|
88
|
+
Execute a SOQL query using the Tooling API.
|
89
|
+
|
90
|
+
This is a convenience method that calls query() with tooling=True.
|
91
|
+
|
92
|
+
:param query: The SOQL query string
|
93
|
+
:return: Parsed JSON response with paginated results or None on failure
|
94
|
+
"""
|
95
|
+
return self.query(query, tooling=True)
|
96
|
+
|
97
|
+
def _paginate_query_result(self, initial_result: dict) -> dict:
|
98
|
+
"""
|
99
|
+
Helper to paginate Salesforce query results.
|
100
|
+
|
101
|
+
This method automatically follows nextRecordsUrl to retrieve all
|
102
|
+
records from a query result, combining them into a single response.
|
103
|
+
|
104
|
+
:param initial_result: The initial query result from Salesforce
|
105
|
+
:return: Dictionary with all records combined and pagination info updated
|
106
|
+
"""
|
107
|
+
records = list(initial_result.get("records", []))
|
108
|
+
done = initial_result.get("done", True)
|
109
|
+
next_url = initial_result.get("nextRecordsUrl")
|
110
|
+
total_size = initial_result.get("totalSize", len(records))
|
111
|
+
|
112
|
+
# Get headers for subsequent requests
|
113
|
+
headers = self.http_client.get_common_headers(include_auth=True)
|
114
|
+
|
115
|
+
while not done and next_url:
|
116
|
+
status_code, data = self.http_client.send_request(
|
117
|
+
method="GET",
|
118
|
+
endpoint=next_url,
|
119
|
+
headers=headers,
|
120
|
+
)
|
121
|
+
|
122
|
+
if status_code == 200:
|
123
|
+
next_result = json.loads(data)
|
124
|
+
records.extend(next_result.get("records", []))
|
125
|
+
done = next_result.get("done", True)
|
126
|
+
next_url = next_result.get("nextRecordsUrl")
|
127
|
+
total_size = next_result.get("totalSize", total_size)
|
128
|
+
else:
|
129
|
+
logger.error("Failed to fetch next records: %s", data)
|
130
|
+
break
|
131
|
+
|
132
|
+
# Create the paginated result
|
133
|
+
paginated = dict(initial_result)
|
134
|
+
paginated["records"] = records
|
135
|
+
paginated["done"] = done
|
136
|
+
paginated["totalSize"] = total_size
|
137
|
+
|
138
|
+
# Remove nextRecordsUrl since we've fetched all records
|
139
|
+
if "nextRecordsUrl" in paginated:
|
140
|
+
del paginated["nextRecordsUrl"]
|
141
|
+
|
142
|
+
return paginated
|
143
|
+
|
144
|
+
def cquery(
|
145
|
+
self,
|
146
|
+
query_dict: Dict[str, str],
|
147
|
+
batch_size: int = 25,
|
148
|
+
max_workers: Optional[int] = None,
|
149
|
+
) -> Optional[Dict[str, Any]]:
|
150
|
+
"""
|
151
|
+
Execute multiple SOQL queries using the Composite Batch API with threading.
|
152
|
+
|
153
|
+
This method reduces network overhead by batching multiple queries into
|
154
|
+
composite API calls and using threading for concurrent execution.
|
155
|
+
Each query (subrequest) is counted as a unique API request against
|
156
|
+
Salesforce governance limits.
|
157
|
+
|
158
|
+
:param query_dict: Dictionary of SOQL queries with keys as logical names (referenceId) and values as SOQL queries
|
159
|
+
:param batch_size: Number of queries to include in each batch (default is 25, max is 25)
|
160
|
+
:param max_workers: Maximum number of threads for concurrent execution (default is None)
|
161
|
+
:return: Dictionary mapping the original keys to their corresponding batch response or None on failure
|
162
|
+
"""
|
163
|
+
if not query_dict:
|
164
|
+
logger.warning("No queries to execute.")
|
165
|
+
return None
|
166
|
+
|
167
|
+
def _execute_batch(
|
168
|
+
batch_keys: List[str], batch_queries: List[str]
|
169
|
+
) -> Dict[str, Any]:
|
170
|
+
"""
|
171
|
+
Execute a single batch of queries using the Composite Batch API.
|
172
|
+
|
173
|
+
:param batch_keys: List of query keys for this batch
|
174
|
+
:param batch_queries: List of query strings for this batch
|
175
|
+
:return: Dictionary mapping keys to their results
|
176
|
+
"""
|
177
|
+
endpoint = f"/services/data/{self.api_version}/composite/batch"
|
178
|
+
|
179
|
+
payload = {
|
180
|
+
"haltOnError": False,
|
181
|
+
"batchRequests": [
|
182
|
+
{
|
183
|
+
"method": "GET",
|
184
|
+
"url": f"/services/data/{self.api_version}/query?q={quote(query)}",
|
185
|
+
}
|
186
|
+
for query in batch_queries
|
187
|
+
],
|
188
|
+
}
|
189
|
+
|
190
|
+
status_code, data = self.http_client.send_authenticated_request(
|
191
|
+
method="POST",
|
192
|
+
endpoint=endpoint,
|
193
|
+
body=json.dumps(payload),
|
194
|
+
)
|
195
|
+
|
196
|
+
batch_results = {}
|
197
|
+
if status_code == 200:
|
198
|
+
logger.debug("Composite query successful.")
|
199
|
+
logger.trace("Composite query full response: %s", data)
|
200
|
+
|
201
|
+
results = json.loads(data).get("results", [])
|
202
|
+
for i, result in enumerate(results):
|
203
|
+
key = batch_keys[i]
|
204
|
+
if result.get("statusCode") == 200 and "result" in result:
|
205
|
+
# Paginate individual query results
|
206
|
+
paginated = self._paginate_query_result(result["result"])
|
207
|
+
batch_results[key] = paginated
|
208
|
+
else:
|
209
|
+
logger.error("Query failed for key %s: %s", key, result)
|
210
|
+
batch_results[key] = result
|
211
|
+
else:
|
212
|
+
logger.error(
|
213
|
+
"Composite query failed with HTTP status %s (%s)",
|
214
|
+
status_code,
|
215
|
+
data,
|
216
|
+
)
|
217
|
+
# Return error for all queries in this batch
|
218
|
+
for key in batch_keys:
|
219
|
+
batch_results[key] = data
|
220
|
+
logger.trace("Composite query response: %s", data)
|
221
|
+
|
222
|
+
return batch_results
|
223
|
+
|
224
|
+
# Prepare batches
|
225
|
+
keys = list(query_dict.keys())
|
226
|
+
results_dict = OrderedDict()
|
227
|
+
|
228
|
+
# Execute batches with threading
|
229
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
230
|
+
futures = []
|
231
|
+
|
232
|
+
# Create batches of queries
|
233
|
+
for i in range(0, len(keys), batch_size):
|
234
|
+
batch_keys = keys[i : i + batch_size]
|
235
|
+
batch_queries = [query_dict[key] for key in batch_keys]
|
236
|
+
futures.append(
|
237
|
+
executor.submit(_execute_batch, batch_keys, batch_queries)
|
238
|
+
)
|
239
|
+
|
240
|
+
# Collect results as they complete
|
241
|
+
for future in as_completed(futures):
|
242
|
+
results_dict.update(future.result())
|
243
|
+
|
244
|
+
logger.trace("Composite query results: %s", results_dict)
|
245
|
+
return results_dict
|
246
|
+
|
247
|
+
def get_sobject_prefixes(
|
248
|
+
self, key_type: Literal["id", "name"] = "id"
|
249
|
+
) -> Optional[Dict[str, str]]:
|
250
|
+
"""
|
251
|
+
Fetch all key prefixes from the Salesforce instance and map them to sObject names or vice versa.
|
252
|
+
|
253
|
+
This method retrieves the sObject metadata to build a mapping between
|
254
|
+
3-character key prefixes (like "001" for Account) and sObject API names.
|
255
|
+
|
256
|
+
:param key_type: The type of key to return. Either 'id' (prefix->name) or 'name' (name->prefix)
|
257
|
+
:return: Dictionary mapping key prefixes to sObject names (or vice versa) or None on failure
|
258
|
+
"""
|
259
|
+
valid_key_types = {"id", "name"}
|
260
|
+
if key_type not in valid_key_types:
|
261
|
+
logger.error(
|
262
|
+
"Invalid key type: %s, must be one of: %s",
|
263
|
+
key_type,
|
264
|
+
", ".join(valid_key_types),
|
265
|
+
)
|
266
|
+
return None
|
267
|
+
|
268
|
+
endpoint = f"/services/data/{self.api_version}/sobjects/"
|
269
|
+
|
270
|
+
try:
|
271
|
+
logger.trace("Request endpoint: %s", endpoint)
|
272
|
+
|
273
|
+
status_code, data = self.http_client.send_authenticated_request(
|
274
|
+
method="GET",
|
275
|
+
endpoint=endpoint,
|
276
|
+
)
|
277
|
+
|
278
|
+
if status_code == 200:
|
279
|
+
logger.debug("Key prefixes API request successful.")
|
280
|
+
logger.trace("Response body: %s", data)
|
281
|
+
|
282
|
+
prefixes = {}
|
283
|
+
sobjects_data = json.loads(data)
|
284
|
+
|
285
|
+
for sobject in sobjects_data.get("sobjects", []):
|
286
|
+
key_prefix = sobject.get("keyPrefix")
|
287
|
+
name = sobject.get("name")
|
288
|
+
|
289
|
+
# Skip sObjects without key prefix or name
|
290
|
+
if not key_prefix or not name:
|
291
|
+
continue
|
292
|
+
|
293
|
+
if key_type == "id":
|
294
|
+
prefixes[key_prefix] = name
|
295
|
+
elif key_type == "name":
|
296
|
+
prefixes[name] = key_prefix
|
297
|
+
|
298
|
+
logger.debug("Key prefixes: %s", prefixes)
|
299
|
+
return prefixes
|
300
|
+
|
301
|
+
logger.error(
|
302
|
+
"Key prefixes API request failed: %s",
|
303
|
+
status_code,
|
304
|
+
)
|
305
|
+
logger.debug("Response body: %s", data)
|
306
|
+
|
307
|
+
except Exception as err:
|
308
|
+
logger.exception("Exception during key prefixes API request: %s", err)
|
309
|
+
|
310
|
+
return None
|
311
|
+
|
312
|
+
def get_sobject_name_from_id(self, record_id: str) -> Optional[str]:
|
313
|
+
"""
|
314
|
+
Get the sObject name from a record ID using the key prefix.
|
315
|
+
|
316
|
+
This is a convenience method that extracts the 3-character prefix
|
317
|
+
from a record ID and looks up the corresponding sObject name.
|
318
|
+
|
319
|
+
:param record_id: The Salesforce record ID (15 or 18 characters)
|
320
|
+
:return: sObject API name or None if not found
|
321
|
+
"""
|
322
|
+
if not record_id or len(record_id) < 3:
|
323
|
+
logger.error("Invalid record ID: %s", record_id)
|
324
|
+
return None
|
325
|
+
|
326
|
+
# Extract the 3-character prefix
|
327
|
+
prefix = record_id[:3]
|
328
|
+
|
329
|
+
# Get the prefix mapping
|
330
|
+
prefixes = self.get_sobject_prefixes(key_type="id")
|
331
|
+
if not prefixes:
|
332
|
+
return None
|
333
|
+
|
334
|
+
return prefixes.get(prefix)
|
335
|
+
|
336
|
+
def get_key_prefix_for_sobject(self, sobject_name: str) -> Optional[str]:
|
337
|
+
"""
|
338
|
+
Get the 3-character key prefix for a given sObject name.
|
339
|
+
|
340
|
+
:param sobject_name: The sObject API name (e.g., "Account", "Contact")
|
341
|
+
:return: 3-character key prefix or None if not found
|
342
|
+
"""
|
343
|
+
if not sobject_name:
|
344
|
+
logger.error("sObject name cannot be empty")
|
345
|
+
return None
|
346
|
+
|
347
|
+
# Get the name-to-prefix mapping
|
348
|
+
prefixes = self.get_sobject_prefixes(key_type="name")
|
349
|
+
if not prefixes:
|
350
|
+
return None
|
351
|
+
|
352
|
+
return prefixes.get(sobject_name)
|
353
|
+
|
354
|
+
def validate_query_syntax(self, query: str) -> bool:
|
355
|
+
"""
|
356
|
+
Perform basic validation of SOQL query syntax.
|
357
|
+
|
358
|
+
This method performs simple checks to catch common syntax errors
|
359
|
+
before sending the query to Salesforce.
|
360
|
+
|
361
|
+
:param query: The SOQL query string to validate
|
362
|
+
:return: True if basic validation passes, False otherwise
|
363
|
+
"""
|
364
|
+
if not query or not query.strip():
|
365
|
+
logger.error("Query cannot be empty")
|
366
|
+
return False
|
367
|
+
|
368
|
+
query_upper = query.upper().strip()
|
369
|
+
|
370
|
+
# Check for required SELECT keyword
|
371
|
+
if not query_upper.startswith("SELECT"):
|
372
|
+
logger.error("Query must start with SELECT")
|
373
|
+
return False
|
374
|
+
|
375
|
+
# Check for required FROM keyword
|
376
|
+
if " FROM " not in query_upper:
|
377
|
+
logger.error("Query must contain FROM clause")
|
378
|
+
return False
|
379
|
+
|
380
|
+
# Check for balanced parentheses
|
381
|
+
if query.count("(") != query.count(")"):
|
382
|
+
logger.error("Unbalanced parentheses in query")
|
383
|
+
return False
|
384
|
+
|
385
|
+
# Check for balanced quotes
|
386
|
+
single_quotes = query.count("'")
|
387
|
+
if single_quotes % 2 != 0:
|
388
|
+
logger.error("Unbalanced single quotes in query")
|
389
|
+
return False
|
390
|
+
|
391
|
+
return True
|
392
|
+
|
393
|
+
def __repr__(self) -> str:
|
394
|
+
"""String representation of QueryClient for debugging."""
|
395
|
+
return (
|
396
|
+
f"QueryClient(api_version='{self.api_version}', "
|
397
|
+
f"http_client={type(self.http_client).__name__})"
|
398
|
+
)
|
sfq/soap.py
ADDED
@@ -0,0 +1,181 @@
|
|
1
|
+
"""
|
2
|
+
SOAP client module for Salesforce API operations.
|
3
|
+
|
4
|
+
This module provides SOAP envelope generation, XML parsing, and result field extraction
|
5
|
+
functionality for interacting with Salesforce's SOAP APIs (Enterprise and Tooling).
|
6
|
+
"""
|
7
|
+
|
8
|
+
import xml.etree.ElementTree as ET
|
9
|
+
from typing import Any, Dict, List, Optional, Union
|
10
|
+
|
11
|
+
from .utils import get_logger
|
12
|
+
|
13
|
+
logger = get_logger("sfq.soap")
|
14
|
+
|
15
|
+
|
16
|
+
class SOAPClient:
|
17
|
+
"""
|
18
|
+
SOAP client for Salesforce API operations.
|
19
|
+
|
20
|
+
Handles SOAP envelope generation, XML parsing, and result field extraction
|
21
|
+
for both Enterprise and Tooling APIs.
|
22
|
+
"""
|
23
|
+
|
24
|
+
def __init__(self, http_client, api_version: str = "v64.0"):
|
25
|
+
"""
|
26
|
+
Initialize the SOAP client.
|
27
|
+
|
28
|
+
:param http_client: HTTPClient instance for making requests
|
29
|
+
:param api_version: Salesforce API version (e.g., "v64.0")
|
30
|
+
"""
|
31
|
+
self.http_client = http_client
|
32
|
+
self.api_version = api_version
|
33
|
+
|
34
|
+
def generate_soap_envelope(self, header: str, body: str, api_type: str) -> str:
|
35
|
+
"""
|
36
|
+
Generate a full SOAP envelope with all required namespaces for Salesforce API.
|
37
|
+
|
38
|
+
:param header: SOAP header content
|
39
|
+
:param body: SOAP body content
|
40
|
+
:param api_type: API type - either "enterprise" or "tooling"
|
41
|
+
:return: Complete SOAP envelope as XML string
|
42
|
+
:raises ValueError: If api_type is not "enterprise" or "tooling"
|
43
|
+
"""
|
44
|
+
if api_type == "enterprise":
|
45
|
+
return (
|
46
|
+
'<?xml version="1.0" encoding="UTF-8"?>'
|
47
|
+
"<soapenv:Envelope "
|
48
|
+
'xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" '
|
49
|
+
'xmlns:xsd="http://www.w3.org/2001/XMLSchema" '
|
50
|
+
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
|
51
|
+
'xmlns="urn:enterprise.soap.sforce.com" '
|
52
|
+
'xmlns:sf="urn:sobject.enterprise.soap.sforce.com">'
|
53
|
+
f"{header}{body}"
|
54
|
+
"</soapenv:Envelope>"
|
55
|
+
)
|
56
|
+
elif api_type == "tooling":
|
57
|
+
return (
|
58
|
+
'<?xml version="1.0" encoding="UTF-8"?>'
|
59
|
+
"<soapenv:Envelope "
|
60
|
+
'xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" '
|
61
|
+
'xmlns:xsd="http://www.w3.org/2001/XMLSchema" '
|
62
|
+
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
|
63
|
+
'xmlns="urn:tooling.soap.sforce.com" '
|
64
|
+
'xmlns:mns="urn:metadata.tooling.soap.sforce.com" '
|
65
|
+
'xmlns:sf="urn:sobject.tooling.soap.sforce.com">'
|
66
|
+
f"{header}{body}"
|
67
|
+
"</soapenv:Envelope>"
|
68
|
+
)
|
69
|
+
else:
|
70
|
+
raise ValueError(
|
71
|
+
f"Unsupported API type: {api_type}. Must be 'enterprise' or 'tooling'."
|
72
|
+
)
|
73
|
+
|
74
|
+
def generate_soap_header(self, session_id: str) -> str:
|
75
|
+
"""
|
76
|
+
Generate the SOAP header for Salesforce API requests.
|
77
|
+
|
78
|
+
:param session_id: OAuth access token to use as session ID
|
79
|
+
:return: SOAP header XML string
|
80
|
+
"""
|
81
|
+
return f"<soapenv:Header><SessionHeader><sessionId>{session_id}</sessionId></SessionHeader></soapenv:Header>"
|
82
|
+
|
83
|
+
def generate_soap_body(
|
84
|
+
self,
|
85
|
+
sobject: str,
|
86
|
+
method: str,
|
87
|
+
data: Union[Dict[str, Any], List[Dict[str, Any]]],
|
88
|
+
) -> str:
|
89
|
+
"""
|
90
|
+
Generate a SOAP request body for one or more records.
|
91
|
+
|
92
|
+
:param sobject: Salesforce object type (e.g., "Account", "Contact")
|
93
|
+
:param method: SOAP method name (e.g., "create", "update")
|
94
|
+
:param data: Single record dict or list of record dicts
|
95
|
+
:return: SOAP body XML string
|
96
|
+
"""
|
97
|
+
# Accept both a single dict and a list of dicts
|
98
|
+
if isinstance(data, dict):
|
99
|
+
records = [data]
|
100
|
+
else:
|
101
|
+
records = data
|
102
|
+
|
103
|
+
sobjects = "".join(
|
104
|
+
f'<sObjects xsi:type="{sobject}">'
|
105
|
+
+ "".join(f"<{k}>{v}</{k}>" for k, v in record.items())
|
106
|
+
+ "</sObjects>"
|
107
|
+
for record in records
|
108
|
+
)
|
109
|
+
return f"<soapenv:Body><{method}>{sobjects}</{method}></soapenv:Body>"
|
110
|
+
|
111
|
+
def extract_soap_result_fields(
|
112
|
+
self, xml_string: str
|
113
|
+
) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]:
|
114
|
+
"""
|
115
|
+
Parse SOAP XML response and extract all child fields from <result> elements as dict(s).
|
116
|
+
|
117
|
+
:param xml_string: SOAP response XML string
|
118
|
+
:return: Single dict for one result, list of dicts for multiple results, or None on parse error
|
119
|
+
"""
|
120
|
+
|
121
|
+
def strip_namespace(tag):
|
122
|
+
"""Remove XML namespace from tag name."""
|
123
|
+
return tag.split("}", 1)[-1] if "}" in tag else tag
|
124
|
+
|
125
|
+
try:
|
126
|
+
root = ET.fromstring(xml_string)
|
127
|
+
results = []
|
128
|
+
|
129
|
+
# Find all elements that end with "result" (handles namespaced tags)
|
130
|
+
for result in root.iter():
|
131
|
+
if result.tag.endswith("result"):
|
132
|
+
result_dict = {}
|
133
|
+
for child in result:
|
134
|
+
result_dict[strip_namespace(child.tag)] = child.text
|
135
|
+
results.append(result_dict)
|
136
|
+
|
137
|
+
if not results:
|
138
|
+
return None
|
139
|
+
if len(results) == 1:
|
140
|
+
return results[0]
|
141
|
+
return results
|
142
|
+
|
143
|
+
except ET.ParseError as e:
|
144
|
+
logger.error("Failed to parse SOAP XML: %s", e)
|
145
|
+
return None
|
146
|
+
|
147
|
+
def xml_to_dict(self, xml_string: str) -> Optional[Dict[str, Any]]:
|
148
|
+
"""
|
149
|
+
Convert an XML string to a JSON-like dictionary.
|
150
|
+
|
151
|
+
:param xml_string: The XML string to convert
|
152
|
+
:return: A dictionary representation of the XML or None on failure
|
153
|
+
"""
|
154
|
+
try:
|
155
|
+
root = ET.fromstring(xml_string)
|
156
|
+
return self._xml_element_to_dict(root)
|
157
|
+
except ET.ParseError as e:
|
158
|
+
logger.error("Failed to parse XML: %s", e)
|
159
|
+
return None
|
160
|
+
|
161
|
+
def _xml_element_to_dict(self, element: ET.Element) -> Union[str, Dict[str, Any]]:
|
162
|
+
"""
|
163
|
+
Recursively convert an XML Element to a dictionary.
|
164
|
+
|
165
|
+
:param element: The XML Element to convert
|
166
|
+
:return: A dictionary representation of the XML Element or text content
|
167
|
+
"""
|
168
|
+
if len(element) == 0:
|
169
|
+
return element.text or ""
|
170
|
+
|
171
|
+
result = {}
|
172
|
+
for child in element:
|
173
|
+
child_dict = self._xml_element_to_dict(child)
|
174
|
+
if child.tag not in result:
|
175
|
+
result[child.tag] = child_dict
|
176
|
+
else:
|
177
|
+
# Handle multiple children with same tag name
|
178
|
+
if not isinstance(result[child.tag], list):
|
179
|
+
result[child.tag] = [result[child.tag]]
|
180
|
+
result[child.tag].append(child_dict)
|
181
|
+
return result
|