diff --git a/README.md b/README.md index 08f71f7..e5eff48 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,18 @@ pip install apimatic-core |---------------------------------------------------------------------------|-----------------------------------------------------------------------------| | [`HttpResponseFactory`](apimatic_core/factories/http_response_factory.py) | A factory class to create an HTTP Response | +## HTTP Configurations +| Name | Description | +|---------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------| +| [`HttpClientConfiguration`](apimatic_core/http/configurations/http_client_configuration.py) | A class used for configuring the SDK by a user | +| [`ProxySettings`](apimatic_core/http/configurations/proxy_settings.py) | ProxySettings encapsulates HTTP proxy configuration for Requests, e.g. address, port and optional basic authentication for HTTP and HTTPS | + ## HTTP | Name | Description | |---------------------------------------------------------------------------------------------|-------------------------------------------------------------| -| [`HttpCallBack`](apimatic_core/factories/http_response_factory.py) | A factory class to create an HTTP Response | -| [`HttpClientConfiguration`](apimatic_core/http/configurations/http_client_configuration.py) | A class used for configuring the SDK by a user | -| [`HttpRequest`](apimatic_core/http/request/http_request.py) | A class which contains information about the HTTP Response | -| [`ApiResponse`](apimatic_core/http/response/api_response.py) | A wrapper class for Api Response | +| [`HttpCallBack`](apimatic_core/http/http_callback.py) | A class to register hooks for the API request and response | +| [`HttpRequest`](apimatic_core/http/request/http_request.py) | A class which contains information about the HTTP Request | +| [`ApiResponse`](apimatic_core/http/response/api_response.py) | A wrapper class for the complete Http Response including raw body and headers etc. | | [`HttpResponse`](apimatic_core/http/response/http_response.py) | A class which contains information about the HTTP Response | ## Logging Configuration diff --git a/apimatic_core/http/configurations/__init__.py b/apimatic_core/http/configurations/__init__.py index 9cc094b..79c209c 100644 --- a/apimatic_core/http/configurations/__init__.py +++ b/apimatic_core/http/configurations/__init__.py @@ -1,3 +1,4 @@ __all__ = [ - 'http_client_configuration' + 'http_client_configuration', + 'proxy_settings' ] \ No newline at end of file diff --git a/apimatic_core/http/configurations/http_client_configuration.py b/apimatic_core/http/configurations/http_client_configuration.py index d2b3935..80a001f 100644 --- a/apimatic_core/http/configurations/http_client_configuration.py +++ b/apimatic_core/http/configurations/http_client_configuration.py @@ -50,10 +50,15 @@ def retry_methods(self): def logging_configuration(self): return self._logging_configuration + @property + def proxy_settings(self): + return self._proxy_settings + def __init__(self, http_client_instance=None, override_http_client_configuration=False, http_call_back=None, timeout=60, max_retries=0, backoff_factor=2, - retry_statuses=None, retry_methods=None, logging_configuration=None): + retry_statuses=None, retry_methods=None, logging_configuration=None, + proxy_settings=None): if retry_statuses is None: retry_statuses = [408, 413, 429, 500, 502, 503, 504, 521, 522, 524] @@ -93,6 +98,8 @@ def __init__(self, http_client_instance=None, self._logging_configuration = logging_configuration + self._proxy_settings = proxy_settings + def set_http_client(self, http_client): self._http_client = http_client @@ -103,6 +110,6 @@ def clone(self, http_callback=None): http_call_back=http_callback or self.http_callback, timeout=self.timeout, max_retries=self.max_retries, backoff_factor=self.backoff_factor, retry_statuses=self.retry_statuses, retry_methods=self.retry_methods, - logging_configuration=self.logging_configuration) + logging_configuration=self.logging_configuration, proxy_settings=self.proxy_settings) http_client_instance.set_http_client(self.http_client) return http_client_instance \ No newline at end of file diff --git a/apimatic_core/http/configurations/proxy_settings.py b/apimatic_core/http/configurations/proxy_settings.py new file mode 100644 index 0000000..00d2e05 --- /dev/null +++ b/apimatic_core/http/configurations/proxy_settings.py @@ -0,0 +1,81 @@ +from typing import Dict, Optional +from urllib.parse import quote + + +class ProxySettings: + """ + A simple data model for configuring HTTP(S) proxy settings. + """ + + HTTP_SCHEME: str = "http://" + HTTPS_SCHEME: str = "https://" + + address: str + port: Optional[int] + username: Optional[str] + password: Optional[str] + + def __init__(self, address: str, port: Optional[int] = None, + username: Optional[str] = None, password: Optional[str] = None) -> None: + """ + Parameters + ---------- + address : str + Hostname or IP of the proxy. + port : int, optional + Port of the proxy server. + username : str, optional + Username for authentication. + password : str, optional + Password for authentication. + """ + self.address = address + self.port = port + self.username = username + self.password = password + + def __repr__(self) -> str: + """ + Developer-friendly representation. + """ + return ( + f"ProxySettings(address={self.address!r}, " + f"port={self.port!r}, " + f"username={self.username!r}, " + f"password={'***' if self.password else None})" + ) + + def __str__(self) -> str: + """ + Human-friendly string for display/logging. + """ + user_info = f"{self.username}:***@" if self.username else "" + port = f":{self.port}" if self.port else "" + return f"{user_info}{self.address}{port}" + + def _sanitize_address(self) -> str: + addr = (self.address or "").strip() + # Trim scheme if present + if addr.startswith(self.HTTP_SCHEME): + addr = addr[len(self.HTTP_SCHEME):] + elif addr.startswith(self.HTTPS_SCHEME): + addr = addr[len(self.HTTPS_SCHEME):] + # Drop trailing slash if user typed a URL-like form + return addr.rstrip("/") + + def to_proxies(self) -> Dict[str, str]: + """ + Build a `requests`-compatible proxies dictionary. + """ + host = self._sanitize_address() + auth = "" + if self.username is not None: + # URL-encode in case of special chars + u = quote(self.username, safe="") + p = quote(self.password or "", safe="") + auth = f"{u}:{p}@" + port = f":{self.port}" if self.port is not None else "" + return { + "http": f"{self.HTTP_SCHEME}{auth}{host}{port}", + "https": f"{self.HTTPS_SCHEME}{auth}{host}{port}", + } diff --git a/setup.py b/setup.py index ba49cd0..87c4e51 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ setup( name='apimatic-core', - version='0.2.21', + version='0.2.22', description='A library that contains core logic and utilities for ' 'consuming REST APIs using Python SDKs generated by APIMatic.', long_description=long_description, diff --git a/tests/apimatic_core/configuration/__init__.py b/tests/apimatic_core/configuration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/apimatic_core/configuration/test_proxy_settings.py b/tests/apimatic_core/configuration/test_proxy_settings.py new file mode 100644 index 0000000..b64e56b --- /dev/null +++ b/tests/apimatic_core/configuration/test_proxy_settings.py @@ -0,0 +1,94 @@ +import pytest + +from apimatic_core.http.configurations.proxy_settings import ProxySettings + + +class TestProxySettings: + def test_has_expected_keys(self): + ps = ProxySettings(address="proxy.local") + proxies = ps.to_proxies() + assert set(proxies.keys()) == {"http", "https"} + + @pytest.mark.parametrize( + "address, port, username, password, exp_http, exp_https", + [ + pytest.param( + "proxy.local", None, None, None, + "http://proxy.local", + "https://proxy.local", + id="no-auth-no-port", + ), + pytest.param( + "proxy.local", 8080, None, None, + "http://proxy.local:8080", + "https://proxy.local:8080", + id="no-auth-with-port", + ), + pytest.param( + "proxy.local", 8080, "user", "pass", + "http://user:pass@proxy.local:8080", + "https://user:pass@proxy.local:8080", + id="auth-with-port", + ), + pytest.param( + "proxy.local", None, "user", None, + # password None -> empty string: "user:@" + "http://user:@proxy.local", + "https://user:@proxy.local", + id="auth-username-only-password-none", + ), + pytest.param( + "proxy.local", None, "a b", "p@ss#", + # URL-encoding of space/@/# + "http://a%20b:p%40ss%23@proxy.local", + "https://a%20b:p%40ss%23@proxy.local", + id="auth-with-url-encoding", + ), + pytest.param( + "localhost", None, "", "", + # empty username triggers auth block (since not None) -> ':@' + "http://:@localhost", + "https://:@localhost", + id="empty-username-and-password", + ), + ], + ) + def test_formats(self, address, port, username, password, exp_http, exp_https): + ps = ProxySettings(address, port, username, password) + proxies = ps.to_proxies() + assert proxies["http"] == exp_http + assert proxies["https"] == exp_https + + @pytest.mark.parametrize("address", ["proxy.local", "localhost"]) + def test_no_trailing_colon_when_no_port(self, address): + ps = ProxySettings(address) + proxies = ps.to_proxies() + assert not proxies["http"].endswith(":") + assert not proxies["https"].endswith(":") + assert "::" not in proxies["http"] + assert "::" not in proxies["https"] + + def test_single_colon_before_port(self): + ps = ProxySettings(address="proxy.local", port=3128) + proxies = ps.to_proxies() + assert proxies["http"].endswith(":3128") + assert proxies["https"].endswith(":3128") + assert "proxy.local::3128" not in proxies["http"] + assert "proxy.local::3128" not in proxies["https"] + + # --- NEW: scheme trimming cases (reflecting the reverted, simpler behavior) --- + + def test_trims_http_scheme_no_port(self): + ps = ProxySettings(address="http://proxy.local") + proxies = ps.to_proxies() + assert proxies["http"] == "http://proxy.local" + assert proxies["https"] == "https://proxy.local" + + def test_trims_https_scheme_trailing_slash_with_port_and_auth(self): + ps = ProxySettings(address="https://proxy.local/", port=8080, username="user", password="secret") + proxies = ps.to_proxies() + assert proxies["http"] == "http://user:secret@proxy.local:8080" + assert proxies["https"] == "https://user:secret@proxy.local:8080" + assert not proxies["http"].endswith(":") + assert not proxies["https"].endswith(":") +