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/http_client.py ADDED
@@ -0,0 +1,319 @@
1
+ """
2
+ HTTP client module for the SFQ library.
3
+
4
+ This module handles HTTP/HTTPS connection management, request/response handling,
5
+ proxy support, and unified request processing with logging and error handling.
6
+ """
7
+
8
+ import http.client
9
+ import json
10
+ from typing import Dict, Optional, Tuple
11
+
12
+ from .auth import AuthManager
13
+ from .exceptions import ConfigurationError
14
+ from .utils import format_headers_for_logging, get_logger, log_api_usage
15
+
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ class HTTPClient:
20
+ """
21
+ Manages HTTP/HTTPS connections and requests for Salesforce API communication.
22
+
23
+ This class encapsulates all HTTP-related functionality including connection
24
+ creation, proxy support, request/response handling, and logging. It works
25
+ in conjunction with AuthManager to provide authenticated API access.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ auth_manager: AuthManager,
31
+ user_agent: str = "sfq/0.0.33",
32
+ sforce_client: str = "_auto",
33
+ high_api_usage_threshold: int = 80,
34
+ ) -> None:
35
+ """
36
+ Initialize the HTTPClient with authentication and configuration.
37
+
38
+ :param auth_manager: AuthManager instance for authentication
39
+ :param user_agent: Custom User-Agent string for requests
40
+ :param sforce_client: Custom application identifier for Sforce-Call-Options
41
+ :param high_api_usage_threshold: Threshold for high API usage warnings
42
+ """
43
+ self.auth_manager = auth_manager
44
+ self.user_agent = user_agent
45
+ self.sforce_client = str(sforce_client).replace(",", "") # Remove commas
46
+ self.high_api_usage_threshold = high_api_usage_threshold
47
+
48
+ # Auto-configure sforce_client if needed
49
+ if sforce_client == "_auto":
50
+ self.sforce_client = user_agent
51
+
52
+ def create_connection(self, netloc: str) -> http.client.HTTPConnection:
53
+ """
54
+ Create an HTTP/HTTPS connection with optional proxy support.
55
+
56
+ :param netloc: The target host and port (e.g., "example.com:443")
57
+ :return: An HTTPConnection or HTTPSConnection object
58
+ :raises ConfigurationError: If proxy configuration is invalid
59
+ """
60
+ proxy_config = self.auth_manager.get_proxy_config()
61
+
62
+ if proxy_config:
63
+ try:
64
+ proxy_hostname, proxy_port = (
65
+ self.auth_manager.get_proxy_hostname_and_port()
66
+ )
67
+ logger.trace("Using proxy: %s", proxy_config)
68
+
69
+ # Create HTTPS connection through proxy
70
+ conn = http.client.HTTPSConnection(proxy_hostname, proxy_port)
71
+ conn.set_tunnel(netloc)
72
+ logger.trace("Using proxy tunnel to %s", netloc)
73
+
74
+ return conn
75
+
76
+ except Exception as e:
77
+ raise ConfigurationError(f"Failed to create proxy connection: {e}")
78
+ else:
79
+ # Direct HTTPS connection
80
+ conn = http.client.HTTPSConnection(netloc)
81
+ logger.trace("Direct connection to %s", netloc)
82
+ return conn
83
+
84
+ def get_common_headers(
85
+ self, include_auth: bool = True, recursive_call: bool = False
86
+ ) -> Dict[str, str]:
87
+ """
88
+ Generate common headers for API requests.
89
+
90
+ :param include_auth: Whether to include Authorization header
91
+ :param recursive_call: Whether this is a recursive call (for token refresh)
92
+ :return: Dictionary of common headers
93
+ """
94
+ logger.trace("Generating common headers...")
95
+ headers = {
96
+ "User-Agent": self.user_agent,
97
+ "Sforce-Call-Options": f"client={self.sforce_client}",
98
+ "Accept": "application/json",
99
+ "Content-Type": "application/json",
100
+ }
101
+
102
+ if include_auth and not recursive_call:
103
+ logger.trace("Including auth headers...")
104
+ # Ensure we have a valid token before adding auth headers
105
+ if self.auth_manager.needs_token_refresh():
106
+ # This will be handled by the calling code that has access to token refresh
107
+ logger.trace("Token refresh needed before adding auth headers")
108
+ self.refresh_token_and_update_auth()
109
+
110
+ if self.auth_manager.access_token:
111
+ headers.update(self.auth_manager.get_auth_headers())
112
+ return headers
113
+
114
+ def send_request(
115
+ self,
116
+ method: str,
117
+ endpoint: str,
118
+ headers: Dict[str, str],
119
+ body: Optional[str] = None,
120
+ ) -> Tuple[Optional[int], Optional[str]]:
121
+ """
122
+ Send an HTTP request with built-in logging and error handling.
123
+
124
+ :param method: HTTP method (GET, POST, PATCH, DELETE, etc.)
125
+ :param endpoint: Target API endpoint path
126
+ :param headers: HTTP headers dictionary
127
+ :param body: Optional request body
128
+ :return: Tuple of (status_code, response_body) or (None, None) on failure
129
+ """
130
+ try:
131
+ # Get the instance netloc for connection
132
+ netloc = self.auth_manager.get_instance_netloc()
133
+ conn = self.create_connection(netloc)
134
+
135
+ # Log request details
136
+ logger.trace("Request method: %s", method)
137
+ logger.trace("Request endpoint: %s", endpoint)
138
+ logger.trace("Request headers: %s", headers)
139
+ if body:
140
+ logger.trace("Request body: %s", body)
141
+
142
+ # Send the request
143
+ conn.request(method, endpoint, body=body, headers=headers)
144
+ response = conn.getresponse()
145
+
146
+ # Process response headers and extract data
147
+ self._process_response_headers(response)
148
+ data = response.read().decode("utf-8")
149
+
150
+ # Log response details
151
+ logger.trace("Response status: %s", response.status)
152
+ logger.trace("Response body: %s", data)
153
+
154
+ return response.status, data
155
+
156
+ except Exception as err:
157
+ logger.exception("HTTP request failed: %s", err)
158
+ return None, None
159
+
160
+ finally:
161
+ try:
162
+ logger.trace("Closing connection...")
163
+ conn.close()
164
+ except Exception:
165
+ pass # Ignore connection close errors
166
+
167
+ def _process_response_headers(self, response: http.client.HTTPResponse) -> None:
168
+ """
169
+ Process HTTP response headers for logging and API usage tracking.
170
+
171
+ :param response: The HTTP response object
172
+ """
173
+ logger.trace(
174
+ "Response status: %s, reason: %s", response.status, response.reason
175
+ )
176
+
177
+ # Get and log headers (filtered for sensitive data)
178
+ headers = response.getheaders()
179
+ filtered_headers = format_headers_for_logging(headers)
180
+ logger.trace("Response headers: %s", filtered_headers)
181
+
182
+ # Process specific headers
183
+ for key, value in headers:
184
+ if key == "Sforce-Limit-Info":
185
+ log_api_usage(value, self.high_api_usage_threshold)
186
+
187
+ def send_authenticated_request(
188
+ self,
189
+ method: str,
190
+ endpoint: str,
191
+ body: Optional[str] = None,
192
+ additional_headers: Optional[Dict[str, str]] = None,
193
+ ) -> Tuple[Optional[int], Optional[str]]:
194
+ """
195
+ Send an authenticated HTTP request with automatic token refresh.
196
+
197
+ This is a convenience method that handles common request patterns
198
+ with authentication and standard headers.
199
+
200
+ :param method: HTTP method
201
+ :param endpoint: API endpoint path
202
+ :param body: Optional request body
203
+ :param additional_headers: Optional additional headers to include
204
+ :return: Tuple of (status_code, response_body) or (None, None) on failure
205
+ """
206
+ headers = self.get_common_headers(include_auth=True)
207
+
208
+ if additional_headers:
209
+ headers.update(additional_headers)
210
+
211
+ return self.send_request(method, endpoint, headers, body)
212
+
213
+ def send_token_request(
214
+ self,
215
+ payload: Dict[str, str],
216
+ token_endpoint: str,
217
+ ) -> Tuple[Optional[int], Optional[str]]:
218
+ """
219
+ Send a token refresh request with appropriate headers.
220
+
221
+ :param payload: Token request payload
222
+ :param token_endpoint: Token endpoint path
223
+ :return: Tuple of (status_code, response_body) or (None, None) on failure
224
+ """
225
+ headers = self.get_common_headers(include_auth=False, recursive_call=True)
226
+ headers.update(self.auth_manager.get_token_request_headers())
227
+
228
+ body = self.auth_manager.format_token_request_body(payload)
229
+
230
+ return self.send_request("POST", token_endpoint, headers, body)
231
+
232
+ def refresh_token_and_update_auth(self) -> Optional[str]:
233
+ """
234
+ Perform a complete token refresh cycle and update the auth manager.
235
+
236
+ This method handles the full token refresh process including:
237
+ - Preparing the token request payload
238
+ - Sending the token request
239
+ - Processing the response and updating auth manager state
240
+
241
+ :return: New access token if successful, None if failed
242
+ """
243
+ if not self.auth_manager.needs_token_refresh():
244
+ return self.auth_manager.access_token
245
+
246
+ logger.trace("Access token expired or missing, refreshing...")
247
+
248
+ # Prepare token request payload
249
+ payload = self.auth_manager._prepare_token_payload()
250
+
251
+ # Send token request
252
+ status, data = self.send_token_request(
253
+ payload, self.auth_manager.token_endpoint
254
+ )
255
+
256
+ if status == 200 and data:
257
+ try:
258
+ token_data = json.loads(data)
259
+
260
+ # Process the token response through auth manager
261
+ if self.auth_manager.process_token_response(token_data):
262
+ logger.trace("Token refresh successful.")
263
+ return self.auth_manager.access_token
264
+ else:
265
+ logger.error("Failed to process token response.")
266
+ return None
267
+
268
+ except json.JSONDecodeError as e:
269
+ logger.error("Failed to parse token response: %s", e)
270
+ return None
271
+ else:
272
+ if status:
273
+ logger.error("Token refresh failed: %s", status)
274
+ logger.debug("Response body: %s", data)
275
+ else:
276
+ logger.error("Token refresh request failed completely")
277
+ return None
278
+
279
+ def get_instance_url(self) -> str:
280
+ """
281
+ Get the Salesforce instance URL.
282
+
283
+ :return: The instance URL
284
+ """
285
+ return self.auth_manager.instance_url
286
+
287
+ def get_api_version(self) -> str:
288
+ """
289
+ Get the API version being used.
290
+
291
+ :return: The API version string
292
+ """
293
+ return self.auth_manager.api_version
294
+
295
+ def is_connection_healthy(self) -> bool:
296
+ """
297
+ Check if the HTTP client can establish connections.
298
+
299
+ This method performs a basic connectivity check without making
300
+ actual API calls.
301
+
302
+ :return: True if connection can be established, False otherwise
303
+ """
304
+ try:
305
+ netloc = self.auth_manager.get_instance_netloc()
306
+ conn = self.create_connection(netloc)
307
+ conn.close()
308
+ return True
309
+ except Exception as e:
310
+ logger.debug("Connection health check failed: %s", e)
311
+ return False
312
+
313
+ def __repr__(self) -> str:
314
+ """String representation of HTTPClient for debugging."""
315
+ return (
316
+ f"HTTPClient(instance_url='{self.auth_manager.instance_url}', "
317
+ f"user_agent='{self.user_agent}', "
318
+ f"proxy={bool(self.auth_manager.get_proxy_config())})"
319
+ )