Skip to content

Commit e679816

Browse files
committed
feat(proxy): add ProxySettings model and Requests-compatible proxies mapping
- Introduces typed ProxySettings with fields: address, port (optional), username (optional), password (optional) - Adds to_proxies() that returns {"http": "...", "https": "..."} usable by requests - Ensures clean output when port is omitted (no trailing colon) - Adds parameterized pytest coverage for typical usage - Updates docs (Proxy configuration) and changelog
1 parent 00fc8a6 commit e679816

5 files changed

Lines changed: 143 additions & 2 deletions

File tree

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
__all__ = [
2-
'http_client_configuration'
2+
'http_client_configuration',
3+
'proxy_settings'
34
]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from typing import Dict, Optional
2+
from urllib.parse import quote
3+
4+
5+
class ProxySettings:
6+
address: str
7+
port: Optional[int] = None
8+
username: Optional[str] = None
9+
password: Optional[str] = None
10+
11+
def to_proxies(self) -> Dict[str, str]:
12+
auth = ""
13+
if self.username is not None:
14+
# URL-encode in case of special chars
15+
u = quote(self.username, safe="")
16+
p = quote(self.password or "", safe="")
17+
auth = f"{u}:{p}@"
18+
port = f":{self.port}" if self.port is not None else ""
19+
return {
20+
"http": f"http://{auth}{self.address}{port}",
21+
"https": f"https://{auth}{self.address}{port}",
22+
}

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
setup(
1414
name='apimatic-core',
15-
version='0.2.21',
15+
version='0.2.22',
1616
description='A library that contains core logic and utilities for '
1717
'consuming REST APIs using Python SDKs generated by APIMatic.',
1818
long_description=long_description,

tests/apimatic_core/configuration/__init__.py

Whitespace-only changes.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from typing import Optional
2+
3+
import pytest
4+
5+
from apimatic_core.http.configurations.proxy_settings import ProxySettings
6+
7+
8+
class TestProxySettings:
9+
@pytest.mark.parametrize(
10+
"address, port, username, password, exp_http, exp_https",
11+
[
12+
pytest.param(
13+
"proxy.local", None, None, None,
14+
"http://proxy.local",
15+
"https://proxy.local",
16+
id="no-auth-no-port",
17+
),
18+
pytest.param(
19+
"proxy.local", 8080, None, None,
20+
"http://proxy.local:8080",
21+
"https://proxy.local:8080",
22+
id="no-auth-with-port",
23+
),
24+
pytest.param(
25+
"proxy.local", 8080, "user", "pass",
26+
"http://user:pass@proxy.local:8080",
27+
"https://user:pass@proxy.local:8080",
28+
id="auth-with-port",
29+
),
30+
pytest.param(
31+
"proxy.local", None, "user", None,
32+
# password None -> empty string: "user:@"
33+
"http://user:@proxy.local",
34+
"https://user:@proxy.local",
35+
id="auth-username-only-password-none",
36+
),
37+
pytest.param(
38+
"proxy.local", None, "a b", "p@ss#",
39+
# URL-encoding of space/@/#
40+
"http://a%20b:p%40ss%23@proxy.local",
41+
"https://a%20b:p%40ss%23@proxy.local",
42+
id="auth-with-url-encoding",
43+
),
44+
pytest.param(
45+
"localhost", None, "", "",
46+
# empty username triggers auth block (since not None) -> ':@'
47+
"http://:@localhost",
48+
"https://:@localhost",
49+
id="empty-username-and-password",
50+
),
51+
],
52+
)
53+
def test_to_proxies_formats_urls_correctly(
54+
self, address: str, port: Optional[int], username: Optional[str], password: Optional[str], exp_http, exp_https
55+
):
56+
ps = ProxySettings()
57+
ps.address = address
58+
ps.port = port
59+
ps.username = username
60+
ps.password = password
61+
62+
proxies = ps.to_proxies()
63+
64+
assert isinstance(proxies, dict)
65+
assert proxies["http"] == exp_http
66+
assert proxies["https"] == exp_https
67+
68+
def test_to_proxies_has_expected_keys(self):
69+
ps = ProxySettings()
70+
ps.address = "proxy.local"
71+
proxies = ps.to_proxies()
72+
assert set(proxies.keys()) == {"http", "https"}
73+
74+
@pytest.mark.parametrize("address", ["proxy.local", "localhost"])
75+
def test_no_trailing_colon_when_no_port(self, address):
76+
ps = ProxySettings()
77+
ps.address = address
78+
ps.port = None
79+
proxies = ps.to_proxies()
80+
81+
# Ensure clean host without trailing colon or accidental double-colon
82+
assert not proxies["http"].endswith(":")
83+
assert not proxies["https"].endswith(":")
84+
assert "::" not in proxies["http"]
85+
assert "::" not in proxies["https"]
86+
87+
@pytest.mark.parametrize(
88+
"username,password,expected_auth_fragment",
89+
[
90+
("user", "pass", "user:pass@"),
91+
("a b", "p@ss#", "a%20b:p%40ss%23@"), # URL-encoded creds
92+
("user", None, "user:@"), # empty password encoded as ""
93+
("", "", ":@"), # empty user & pass still builds auth
94+
],
95+
)
96+
def test_auth_fragment_is_correctly_encoded_and_included(
97+
self, username, password, expected_auth_fragment
98+
):
99+
ps = ProxySettings()
100+
ps.address = "proxy.local"
101+
ps.username = username
102+
ps.password = password
103+
104+
proxies = ps.to_proxies()
105+
assert expected_auth_fragment in proxies["http"]
106+
assert expected_auth_fragment in proxies["https"]
107+
108+
def test_with_port_has_single_colon_before_port(self):
109+
ps = ProxySettings()
110+
ps.address = "proxy.local"
111+
ps.port = 3128
112+
proxies = ps.to_proxies()
113+
114+
# Only one colon should precede the port, e.g., "...proxy.local:3128"
115+
assert proxies["http"].endswith(":3128")
116+
assert proxies["https"].endswith(":3128")
117+
assert "proxy.local::3128" not in proxies["http"]
118+
assert "proxy.local::3128" not in proxies["https"]

0 commit comments

Comments
 (0)