Skip to content

Commit faad310

Browse files
committed
MPT-12360 Async HTTP Client
1 parent de70be9 commit faad310

File tree

7 files changed

+138
-32
lines changed

7 files changed

+138
-32
lines changed

mpt_api_client/http/client.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import os
22

3-
import httpx
3+
from httpx import AsyncClient, AsyncHTTPTransport, Client, HTTPTransport
44

55

6-
class HTTPClient(httpx.Client):
7-
"""A client for interacting with SoftwareOne Marketplace Platform API."""
6+
class HTTPClient(Client):
7+
"""Sync HTTP client for interacting with SoftwareOne Marketplace Platform API."""
88

99
def __init__(
1010
self,
@@ -33,9 +33,49 @@ def __init__(
3333
"User-Agent": "swo-marketplace-client/1.0",
3434
"Authorization": f"Bearer {api_token}",
3535
}
36-
super().__init__(
36+
Client.__init__(
37+
self,
3738
base_url=base_url,
3839
headers=base_headers,
3940
timeout=timeout,
40-
transport=httpx.HTTPTransport(retries=retries),
41+
transport=HTTPTransport(retries=retries),
42+
)
43+
44+
45+
class HTTPClientAsync(AsyncClient):
46+
"""Async HTTP client for interacting with SoftwareOne Marketplace Platform API."""
47+
48+
def __init__(
49+
self,
50+
*,
51+
base_url: str | None = None,
52+
api_token: str | None = None,
53+
timeout: float = 5.0,
54+
retries: int = 0,
55+
):
56+
api_token = api_token or os.getenv("MPT_TOKEN")
57+
if not api_token:
58+
raise ValueError(
59+
"API token is required. "
60+
"Set it up as env variable MPT_TOKEN or pass it as `api_token` "
61+
"argument to MPTClient."
62+
)
63+
64+
base_url = base_url or os.getenv("MPT_URL")
65+
if not base_url:
66+
raise ValueError(
67+
"Base URL is required. "
68+
"Set it up as env variable MPT_URL or pass it as `base_url` "
69+
"argument to MPTClient."
70+
)
71+
base_headers = {
72+
"User-Agent": "swo-marketplace-client/1.0",
73+
"Authorization": f"Bearer {api_token}",
74+
}
75+
AsyncClient.__init__(
76+
self,
77+
base_url=base_url,
78+
headers=base_headers,
79+
timeout=timeout,
80+
transport=AsyncHTTPTransport(retries=retries),
4181
)

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ dev = [
3232
"mypy==1.15.*",
3333
"pre-commit==4.2.*",
3434
"pytest==8.3.*",
35+
"pytest-asyncio==1.1.*",
3536
"pytest-cov==6.1.*",
3637
"pytest-deadfixtures==2.2.*",
3738
"pytest-mock==3.14.*",
@@ -59,6 +60,7 @@ testpaths = "tests"
5960
pythonpath = "."
6061
addopts = "--cov=mpt_api_client --cov-report=term-missing --cov-report=html --cov-report=xml"
6162
log_cli = false
63+
asyncio_mode = "auto"
6264
filterwarnings = [
6365
"ignore:Support for class-based `config` is deprecated:DeprecationWarning",
6466
"ignore:pkg_resources is deprecated as an API:DeprecationWarning",

tests/http/collection/test_collection_client_init.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,18 @@ def sample_rql_query():
1515
return RQLQuery(status="active")
1616

1717

18-
def test_init_defaults(mpt_client):
19-
collection_client = DummyCollectionClient(client=mpt_client)
18+
def test_init_defaults(http_client):
19+
collection_client = DummyCollectionClient(client=http_client)
2020

2121
assert collection_client.query_rql is None
2222
assert collection_client.query_order_by is None
2323
assert collection_client.query_select is None
2424
assert collection_client.build_url() == "/api/v1/test"
2525

2626

27-
def test_init_with_filter(mpt_client, sample_rql_query):
27+
def test_init_with_filter(http_client, sample_rql_query):
2828
collection_client = DummyCollectionClient(
29-
client=mpt_client,
29+
client=http_client,
3030
query_rql=sample_rql_query,
3131
)
3232

tests/http/conftest.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from mpt_api_client.http.client import HTTPClient
3+
from mpt_api_client.http.client import HTTPClient, HTTPClientAsync
44
from mpt_api_client.http.collection import CollectionBaseClient
55
from mpt_api_client.http.resource import ResourceBaseClient
66
from mpt_api_client.models import Collection
@@ -30,15 +30,20 @@ def api_token():
3030

3131

3232
@pytest.fixture
33-
def mpt_client(api_url, api_token):
33+
def http_client(api_url, api_token):
3434
return HTTPClient(base_url=api_url, api_token=api_token)
3535

3636

3737
@pytest.fixture
38-
def resource_client(mpt_client):
39-
return DummyResourceClient(client=mpt_client, resource_id="RES-123")
38+
def http_client_async(api_url, api_token):
39+
return HTTPClientAsync(base_url=api_url, api_token=api_token)
4040

4141

4242
@pytest.fixture
43-
def collection_client(mpt_client) -> DummyCollectionClient:
44-
return DummyCollectionClient(client=mpt_client)
43+
def resource_client(http_client):
44+
return DummyResourceClient(client=http_client, resource_id="RES-123")
45+
46+
47+
@pytest.fixture
48+
def collection_client(http_client) -> DummyCollectionClient:
49+
return DummyCollectionClient(client=http_client)

tests/http/test_async_client.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import pytest
2+
import respx
3+
from httpx import ConnectTimeout, Response, codes
4+
5+
from mpt_api_client.http.client import HTTPClientAsync
6+
from tests.conftest import API_TOKEN, API_URL
7+
8+
9+
def test_mpt_client_initialization():
10+
client = HTTPClientAsync(base_url=API_URL, api_token=API_TOKEN)
11+
12+
assert client.base_url == API_URL
13+
assert client.headers["Authorization"] == "Bearer test-token"
14+
assert client.headers["User-Agent"] == "swo-marketplace-client/1.0"
15+
16+
17+
def test_env_initialization(monkeypatch):
18+
monkeypatch.setenv("MPT_TOKEN", API_TOKEN)
19+
monkeypatch.setenv("MPT_URL", API_URL)
20+
21+
client = HTTPClientAsync()
22+
23+
assert client.base_url == API_URL
24+
assert client.headers["Authorization"] == f"Bearer {API_TOKEN}"
25+
26+
27+
def test_mpt_client_without_token():
28+
with pytest.raises(ValueError):
29+
HTTPClientAsync(base_url=API_URL)
30+
31+
32+
def test_mpt_client_without_url():
33+
with pytest.raises(ValueError):
34+
HTTPClientAsync(api_token=API_TOKEN)
35+
36+
37+
@respx.mock
38+
async def test_mock_call_success(http_client_async):
39+
success_route = respx.get(f"{API_URL}/").mock(
40+
return_value=Response(200, json={"message": "Hello, World!"})
41+
)
42+
43+
success_response = await http_client_async.get("/")
44+
45+
assert success_response.status_code == codes.OK
46+
assert success_response.json() == {"message": "Hello, World!"}
47+
assert success_route.called
48+
49+
50+
@respx.mock
51+
async def test_mock_call_failure(http_client_async):
52+
timeout_route = respx.get(f"{API_URL}/timeout").mock(side_effect=ConnectTimeout("Mock Timeout"))
53+
54+
with pytest.raises(ConnectTimeout):
55+
await http_client_async.get("/timeout")
56+
57+
assert timeout_route.called

tests/http/test_client.py

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,35 +35,23 @@ def test_mpt_client_without_url():
3535

3636

3737
@respx.mock
38-
def test_mock_call_success(mpt_client):
38+
def test_mock_call_success(http_client):
3939
success_route = respx.get(f"{API_URL}/").mock(
4040
return_value=Response(200, json={"message": "Hello, World!"})
4141
)
4242

43-
success_response = mpt_client.get("/")
43+
success_response = http_client.get("/")
4444

4545
assert success_response.status_code == codes.OK
4646
assert success_response.json() == {"message": "Hello, World!"}
4747
assert success_route.called
4848

4949

5050
@respx.mock
51-
def test_mock_call_failure(mpt_client):
51+
def test_mock_call_failure(http_client):
5252
timeout_route = respx.get(f"{API_URL}/timeout").mock(side_effect=ConnectTimeout("Mock Timeout"))
5353

5454
with pytest.raises(ConnectTimeout):
55-
mpt_client.get("/timeout")
55+
http_client.get("/timeout")
5656

5757
assert timeout_route.called
58-
59-
60-
@respx.mock
61-
def test_mock_call_failure_with_retries(mpt_client):
62-
not_found_route = respx.get(f"{API_URL}/not-found").mock(
63-
side_effect=Response(codes.NOT_FOUND, json={"message": "Not Found"})
64-
)
65-
66-
not_found_response = mpt_client.get("/not-found")
67-
68-
assert not_found_response.status_code == codes.NOT_FOUND
69-
assert not_found_route.called

uv.lock

Lines changed: 15 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)