Skip to content

Commit c447874

Browse files
committed
refactor: Improve client and update & fix tests
1 parent 2ab0add commit c447874

7 files changed

Lines changed: 399 additions & 141 deletions

File tree

mailgun/client.py

Lines changed: 88 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,24 @@
7171
from requests.models import Response
7272

7373

74+
# Public API
75+
__all__ = [
76+
"APIVersion",
77+
"Config",
78+
"BaseEndpoint",
79+
"Endpoint",
80+
"AsyncEndpoint",
81+
"Client",
82+
"AsyncClient",
83+
"ApiError",
84+
]
85+
7486
logger = logging.getLogger("mailgun.client")
7587
# Ensure logger doesn't stay silent if the user hasn't configured basicConfig
7688
if not logger.hasHandlers():
7789
logger.addHandler(logging.NullHandler())
7890

79-
HANDLERS: dict[str, Callable] = { # type: ignore[type-arg]
91+
HANDLERS: dict[str, Callable[..., str]] = { # type: ignore[type-arg]
8092
"resendmessage": handle_resend_message,
8193
"domains": handle_domains,
8294
"domainlist": handle_domainlist,
@@ -187,6 +199,7 @@ def __init__(self, api_url: str | None = None) -> None: # noqa: D107
187199
self.ex_handler: bool = True
188200
base_url_input: str = api_url or self.DEFAULT_API_URL
189201
self.api_url: str = self._sanitize_url(base_url_input)
202+
self._validate_api_url()
190203

191204
@staticmethod
192205
def _sanitize_url(raw_url: str) -> str:
@@ -197,6 +210,15 @@ def _sanitize_url(raw_url: str) -> str:
197210
raw_url = f"https://{raw_url}"
198211
return raw_url.rstrip("/")
199212

213+
def _validate_api_url(self) -> None:
214+
"""DX Guardrail & CWE-319: Warn on cleartext HTTP transmission."""
215+
parsed = urlparse(self.api_url)
216+
if parsed.scheme == "http" and parsed.hostname not in ("localhost", "127.0.0.1"):
217+
logger.warning(
218+
"SECURITY WARNING: Cleartext HTTP transmission detected in API URL. "
219+
"Use 'https://' to prevent CWE-319 vulnerabilities."
220+
)
221+
200222
@classmethod
201223
def _sanitize_key(cls, key: str) -> str:
202224
"""Normalize and validate the endpoint key."""
@@ -322,7 +344,7 @@ def build_url(
322344
domain: str | None = None,
323345
method: str | None = None,
324346
**kwargs: Any,
325-
) -> Any:
347+
) -> str:
326348
"""Build final request url using predefined handlers.
327349
328350
Note: Some urls are being built in Config class, as they can't be generated dynamically.
@@ -350,8 +372,8 @@ def api_call(
350372
headers: dict[str, str],
351373
data: Any | None = None,
352374
filters: Mapping[str, str | Any] | None = None,
353-
timeout: int = 60,
354-
files: dict[str, bytes] | None = None,
375+
timeout: int | float | tuple[float, float] = 60,
376+
files: Any | None = None,
355377
domain: str | None = None,
356378
**kwargs: Any,
357379
) -> Response | Any:
@@ -399,18 +421,20 @@ def api_call(
399421
stream=False,
400422
)
401423

402-
try:
403-
is_error = response.status_code >= 400
404-
except TypeError:
405-
is_error = False
424+
status_code = getattr(response, "status_code", 200)
425+
is_error = isinstance(status_code, int) and status_code >= 400
406426

407427
if is_error:
428+
# Prevent showing huge HTML-pages in logging
429+
raw_text = getattr(response, "text", "")
430+
error_body = raw_text[:500] + "..." if len(raw_text) > 500 else raw_text
431+
408432
logger.error(
409433
"API Error %s | %s %s | Response: %s",
410-
response.status_code,
434+
status_code,
411435
method.upper(),
412436
target_url,
413-
getattr(response, "text", ""),
437+
error_body,
414438
)
415439
else:
416440
logger.debug(
@@ -422,9 +446,9 @@ def api_call(
422446

423447
return response
424448

425-
except requests.exceptions.Timeout:
449+
except requests.exceptions.Timeout as e:
426450
logger.error("Timeout Error: %s %s", method.upper(), target_url)
427-
raise TimeoutError
451+
raise TimeoutError from e
428452
except requests.RequestException as e:
429453
logger.critical("Request Exception: %s | URL: %s", e, target_url)
430454
raise ApiError(e) from e
@@ -462,7 +486,7 @@ def create(
462486
filters: Mapping[str, str | Any] | None = None,
463487
domain: str | None = None,
464488
headers: Any = None,
465-
files: dict[str, bytes] | None = None,
489+
files: Any | None = None,
466490
**kwargs: Any,
467491
) -> Response:
468492
"""POST method for API calls.
@@ -476,18 +500,18 @@ def create(
476500
:param headers: incoming headers
477501
:type headers: dict[str, str]
478502
:param files: incoming files
479-
:type files: dict[str, Any] | None
503+
:type files: Any | None = None,
480504
:param kwargs: kwargs
481505
:type kwargs: Any
482506
:return: api_call POST request
483507
:rtype: requests.models.Response
484508
"""
485509
req_headers = self.headers.copy()
486510

487-
is_json = "application/json" in (req_headers.get("Content-Type"), headers)
511+
if headers and isinstance(headers, dict):
512+
req_headers.update(headers)
488513

489-
if is_json:
490-
req_headers["Content-Type"] = "application/json"
514+
if req_headers.get("Content-Type") == "application/json":
491515
if data is not None and not isinstance(data, (str, bytes)):
492516
data = json.dumps(data)
493517

@@ -574,13 +598,20 @@ def update(
574598
:return: api_call PUT request
575599
:rtype: requests.models.Response
576600
"""
577-
if self.headers.get("Content-Type") == "application/json":
578-
data = json.dumps(data) if data is not None else None
601+
custom_headers = kwargs.pop("headers", {})
602+
req_headers = self.headers.copy()
603+
if custom_headers and isinstance(custom_headers, dict):
604+
req_headers.update(custom_headers)
605+
606+
if req_headers.get("Content-Type") == "application/json":
607+
if data is not None and not isinstance(data, (str, bytes)):
608+
data = json.dumps(data)
609+
579610
return self.api_call(
580611
self._auth,
581612
"put",
582613
self._url,
583-
headers=self.headers,
614+
headers=req_headers,
584615
data=data,
585616
filters=filters,
586617
**kwargs,
@@ -635,6 +666,14 @@ def __getattr__(self, name: str) -> Any:
635666
url, headers = self.config[name]
636667
return Endpoint(url=url, headers=headers, auth=self.auth)
637668

669+
def __repr__(self) -> str:
670+
"""OWASP Secrets Management: Redact sensitive information from object representation.
671+
672+
Returns:
673+
str: A redacted string representation of the Client instance.
674+
"""
675+
return f"<{self.__class__.__name__} api_url={self.config.api_url!r}>"
676+
638677

639678
class AsyncEndpoint(BaseEndpoint):
640679
"""Generate async request and return response using httpx."""
@@ -671,8 +710,8 @@ async def api_call(
671710
headers: dict[str, str],
672711
data: Any | None = None,
673712
filters: Mapping[str, str | Any] | None = None,
674-
timeout: int = 60,
675-
files: dict[str, bytes] | None = None,
713+
timeout: int | float | tuple[float, float] = 60,
714+
files: Any | None = None,
676715
domain: str | None = None,
677716
**kwargs: Any,
678717
) -> HttpxResponse:
@@ -715,7 +754,7 @@ async def api_call(
715754
"timeout": timeout,
716755
}
717756

718-
# For httpx
757+
# Deprecation Warning for httpx
719758
if isinstance(data, (str, bytes)):
720759
request_kwargs["content"] = data
721760
else:
@@ -726,18 +765,20 @@ async def api_call(
726765
try:
727766
response = await self._client.request(**request_kwargs)
728767

729-
try:
730-
is_error = response.status_code >= 400
731-
except TypeError:
732-
is_error = False
768+
status_code = getattr(response, "status_code", 200)
769+
is_error = isinstance(status_code, int) and status_code >= 400
733770

734771
if is_error:
772+
# Prevent showing huge HTML-pages in logging
773+
raw_text = getattr(response, "text", "")
774+
error_body = raw_text[:500] + "..." if len(raw_text) > 500 else raw_text
775+
735776
logger.error(
736777
"API Error %s | %s %s | Response: %s",
737-
response.status_code,
778+
status_code,
738779
method.upper(),
739780
target_url,
740-
getattr(response, "text", ""),
781+
error_body,
741782
)
742783
else:
743784
logger.debug(
@@ -749,14 +790,12 @@ async def api_call(
749790

750791
return response
751792

752-
except httpx.TimeoutException:
793+
except httpx.TimeoutException as e:
753794
logger.error("Timeout Error: %s %s", method.upper(), target_url)
754-
raise TimeoutError
795+
raise TimeoutError from e
755796
except httpx.RequestError as e:
756797
logger.critical("Request Exception: %s | URL: %s", e, target_url)
757-
raise ApiError(e)
758-
except Exception as e:
759-
raise e
798+
raise ApiError(e) from e
760799

761800
async def get(
762801
self,
@@ -791,7 +830,7 @@ async def create(
791830
filters: Mapping[str, str | Any] | None = None,
792831
domain: str | None = None,
793832
headers: Any = None,
794-
files: dict[str, bytes] | None = None,
833+
files: Any | None = None,
795834
**kwargs: Any,
796835
) -> httpx.Response:
797836
"""POST method for async API calls.
@@ -805,18 +844,18 @@ async def create(
805844
:param headers: incoming headers
806845
:type headers: dict[str, str]
807846
:param files: incoming files
808-
:type files: dict[str, Any] | None
847+
:type files: Any | None = None,
809848
:param kwargs: kwargs
810849
:type kwargs: Any
811850
:return: api_call POST request
812851
:rtype: httpx.Response
813852
"""
814853
req_headers = self.headers.copy()
815854

816-
is_json = "application/json" in (req_headers.get("Content-Type"), headers)
855+
if headers and isinstance(headers, dict):
856+
req_headers.update(headers)
817857

818-
if is_json:
819-
req_headers["Content-Type"] = "application/json"
858+
if req_headers.get("Content-Type") == "application/json":
820859
if data is not None and not isinstance(data, (str, bytes)):
821860
data = json.dumps(data)
822861

@@ -903,13 +942,20 @@ async def update(
903942
:return: api_call PUT request
904943
:rtype: httpx.Response
905944
"""
906-
if self.headers.get("Content-Type") == "application/json":
907-
data = json.dumps(data)
945+
custom_headers = kwargs.pop("headers", {})
946+
req_headers = self.headers.copy()
947+
if custom_headers and isinstance(custom_headers, dict):
948+
req_headers.update(custom_headers)
949+
950+
if req_headers.get("Content-Type") == "application/json":
951+
if data is not None and not isinstance(data, (str, bytes)):
952+
data = json.dumps(data)
953+
908954
return await self.api_call(
909955
self._auth,
910956
"put",
911957
self._url,
912-
headers=self.headers,
958+
headers=req_headers,
913959
data=data,
914960
filters=filters,
915961
**kwargs,

0 commit comments

Comments
 (0)