Skip to content

Commit 49ff189

Browse files
feat: support configurable request methods
1 parent 0086240 commit 49ff189

File tree

4 files changed

+416
-45
lines changed

4 files changed

+416
-45
lines changed

src/api_to_dataframe/controller/client_builder.py

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
from api_to_dataframe.models.retainer import retry_strategies, Strategies
1+
from typing import Any, Dict, Optional
2+
23
from api_to_dataframe.models.get_data import GetData
4+
from api_to_dataframe.models.retainer import Strategies, retry_strategies
35
from api_to_dataframe.utils.logger import logger
46

57

6-
class ClientBuilder:
8+
class ClientBuilder: # pylint: disable=too-many-instance-attributes
79
def __init__( # pylint: disable=too-many-positional-arguments,too-many-arguments
810
self,
911
endpoint: str,
10-
headers: dict = None,
12+
headers: Optional[Dict[str, str]] = None,
1113
retry_strategy: Strategies = Strategies.NO_RETRY_STRATEGY,
1214
retries: int = 3,
1315
initial_delay: int = 1,
@@ -57,6 +59,78 @@ def __init__( # pylint: disable=too-many-positional-arguments,too-many-argument
5759
self.retries = retries
5860
self.delay = initial_delay
5961

62+
self._method: str = "GET"
63+
self._params: Optional[Dict[str, Any]] = None
64+
self._json_payload: Optional[Any] = None
65+
self._data_payload: Optional[Any] = None
66+
self._files_payload: Optional[Any] = None
67+
self._auth: Optional[Any] = None
68+
self._session: Optional[Any] = None
69+
70+
def with_method(self, method: str):
71+
"""Configure the HTTP method for the request."""
72+
73+
if not isinstance(method, str) or not method.strip():
74+
error_msg = "method must be a non-empty string"
75+
logger.error(error_msg)
76+
raise ValueError(error_msg)
77+
78+
self._method = method.strip().upper()
79+
return self
80+
81+
def with_params(self, params: Optional[Dict[str, Any]]):
82+
"""Configure query parameters to be sent with the request."""
83+
84+
if params is not None and not isinstance(params, dict):
85+
error_msg = "params must be a dictionary or None"
86+
logger.error(error_msg)
87+
raise ValueError(error_msg)
88+
89+
self._params = params
90+
return self
91+
92+
def with_payload(
93+
self,
94+
*,
95+
json: Optional[Any] = None,
96+
data: Optional[Any] = None,
97+
files: Optional[Any] = None,
98+
):
99+
"""Configure payload content for the request body."""
100+
101+
self._json_payload = json
102+
self._data_payload = data
103+
self._files_payload = files
104+
return self
105+
106+
def with_auth(self, auth: Optional[Any]):
107+
"""Configure authentication details for the request."""
108+
109+
self._auth = auth
110+
return self
111+
112+
def with_session(self, session: Optional[Any]):
113+
"""Configure a custom session implementation for the request."""
114+
115+
if session is not None and not hasattr(session, "request"):
116+
error_msg = "session must provide a request method"
117+
logger.error(error_msg)
118+
raise ValueError(error_msg)
119+
120+
self._session = session
121+
return self
122+
123+
def with_headers(self, headers: Optional[Dict[str, str]]):
124+
"""Override headers after initialization while keeping fluent style."""
125+
126+
if headers is not None and not isinstance(headers, dict):
127+
error_msg = "headers must be a dictionary or None"
128+
logger.error(error_msg)
129+
raise ValueError(error_msg)
130+
131+
self.headers = headers or {}
132+
return self
133+
60134
@retry_strategies
61135
def get_api_data(self):
62136
"""
@@ -73,6 +147,13 @@ def get_api_data(self):
73147
endpoint=self.endpoint,
74148
headers=self.headers,
75149
connection_timeout=self.connection_timeout,
150+
method=self._method,
151+
params=self._params,
152+
json=self._json_payload,
153+
data=self._data_payload,
154+
files=self._files_payload,
155+
auth=self._auth,
156+
session=self._session,
76157
)
77158

78159
return response.json()

src/api_to_dataframe/models/get_data.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,53 @@
1-
import requests
1+
from typing import Any, Dict, Optional
2+
23
import pandas as pd
4+
import requests
5+
36
from api_to_dataframe.utils.logger import logger
47

58

69
class GetData:
710
@staticmethod
8-
def get_response(endpoint: str, headers: dict, connection_timeout: int):
9-
# Make the request
10-
response = requests.get(endpoint, timeout=connection_timeout, headers=headers)
11+
def get_response( # pylint: disable=too-many-arguments,too-many-positional-arguments
12+
endpoint: str,
13+
headers: Optional[Dict[str, str]],
14+
connection_timeout: int,
15+
method: str = "GET",
16+
params: Optional[Dict[str, Any]] = None,
17+
json: Optional[Any] = None,
18+
data: Optional[Any] = None,
19+
files: Optional[Any] = None,
20+
auth: Optional[Any] = None,
21+
session: Optional[requests.Session] = None,
22+
):
23+
"""Execute an HTTP request and return the raw response."""
24+
25+
if not isinstance(method, str) or not method.strip():
26+
error_msg = "method must be a non-empty string"
27+
logger.error(error_msg)
28+
raise ValueError(error_msg)
29+
30+
request_callable = session.request if session else requests.request
31+
32+
response = request_callable(
33+
method=method.upper(),
34+
url=endpoint,
35+
timeout=connection_timeout,
36+
headers=headers,
37+
params=params,
38+
json=json,
39+
data=data,
40+
files=files,
41+
auth=auth,
42+
)
1143

12-
# Attempt to raise for status to catch errors
1344
response.raise_for_status()
1445

1546
return response
1647

1748
@staticmethod
1849
def to_dataframe(response):
50+
"""Convert an API response payload into a pandas DataFrame."""
1951
df = pd.DataFrame(response)
2052

2153
# Check if DataFrame is empty

tests/test_controller_client_builder.py

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,15 @@
77

88
@pytest.fixture()
99
def client_setup():
10+
"""Create a default ClientBuilder instance for tests."""
1011
new_client = ClientBuilder(
1112
endpoint="https://economia.awesomeapi.com.br/last/USD-BRL"
1213
)
1314
return new_client
1415

1516

16-
@pytest.fixture()
17-
def response_setup():
18-
new_client = ClientBuilder(
19-
endpoint="https://economia.awesomeapi.com.br/last/USD-BRL"
20-
)
21-
return new_client.get_api_data()
22-
23-
2417
def test_constructor_raises():
18+
"""Ensure constructor validations raise errors for invalid inputs."""
2519
with pytest.raises(ValueError):
2620
ClientBuilder(endpoint="")
2721

@@ -59,13 +53,14 @@ def test_constructor_raises():
5953

6054

6155
def test_constructor_with_param(client_setup): # pylint: disable=redefined-outer-name
56+
"""Ensure constructor stores the provided endpoint value."""
6257
expected_result = "https://economia.awesomeapi.com.br/last/USD-BRL"
6358
new_client = client_setup
6459
assert new_client.endpoint == expected_result
6560

6661

6762
def test_constructor_with_headers():
68-
"""Test ClientBuilder with custom headers"""
63+
"""Test ClientBuilder with custom headers."""
6964
custom_headers = {"Authorization": "Bearer token123", "Content-Type": "application/json"}
7065
client = ClientBuilder(
7166
endpoint="https://economia.awesomeapi.com.br/last/USD-BRL",
@@ -75,7 +70,7 @@ def test_constructor_with_headers():
7570

7671

7772
def test_constructor_with_retry_strategy():
78-
"""Test ClientBuilder with different retry strategies"""
73+
"""Test ClientBuilder with different retry strategies."""
7974
client = ClientBuilder(
8075
endpoint="https://economia.awesomeapi.com.br/last/USD-BRL",
8176
retry_strategy=RetryStrategies.LINEAR_RETRY_STRATEGY,
@@ -87,20 +82,34 @@ def test_constructor_with_retry_strategy():
8782
assert client.delay == 2
8883

8984

85+
@responses.activate
9086
def test_response_to_json(client_setup): # pylint: disable=redefined-outer-name
87+
"""Ensure API responses are converted to JSON objects."""
9188
new_client = client_setup
89+
expected_response = {"key": "value"}
90+
91+
responses.add(
92+
responses.GET,
93+
new_client.endpoint,
94+
json=expected_response,
95+
status=200,
96+
)
97+
9298
response = new_client.get_api_data() # pylint: disable=protected-access
9399
assert isinstance(response, dict)
100+
assert response == expected_response
94101

95102

96-
def test_to_dataframe(response_setup): # pylint: disable=redefined-outer-name
97-
df = ClientBuilder.api_to_dataframe(response_setup)
103+
def test_to_dataframe():
104+
"""Ensure responses can be converted into DataFrames."""
105+
sample_response = [{"currency": "USD", "bid": "5.0"}]
106+
df = ClientBuilder.api_to_dataframe(sample_response)
98107
assert isinstance(df, pd.DataFrame)
99108

100109

101110
@responses.activate
102111
def test_get_api_data_with_mocked_response():
103-
"""Test get_api_data with mocked API response"""
112+
"""Test get_api_data with mocked API response."""
104113
endpoint = "https://api.test.com/data"
105114
expected_data = {"key": "value", "nested": {"id": 123}}
106115

@@ -118,3 +127,77 @@ def test_get_api_data_with_mocked_response():
118127
assert response == expected_data
119128
assert len(responses.calls) == 1
120129
assert responses.calls[0].request.url == endpoint
130+
131+
132+
def test_with_method_configures_request_method():
133+
"""Ensure with_method configures the HTTP method for requests."""
134+
135+
client = ClientBuilder(endpoint="https://api.test.com").with_method("post")
136+
assert client._method == "POST" # pylint: disable=protected-access
137+
138+
139+
def test_with_params_validates_input_type():
140+
"""Ensure with_params only accepts dictionaries."""
141+
142+
client = ClientBuilder(endpoint="https://api.test.com")
143+
assert client.with_params({"page": 1}) is client
144+
145+
with pytest.raises(ValueError):
146+
client.with_params([("page", 1)])
147+
148+
149+
def test_with_payload_replaces_existing_payload():
150+
"""Ensure with_payload stores json, data and file payloads."""
151+
152+
client = ClientBuilder(endpoint="https://api.test.com")
153+
client.with_payload(json={"name": "Jane"}, data=None)
154+
155+
assert client._json_payload == {"name": "Jane"} # pylint: disable=protected-access
156+
assert client._data_payload is None # pylint: disable=protected-access
157+
158+
159+
def test_with_session_validates_interface():
160+
"""Ensure with_session requires an object exposing request."""
161+
162+
client = ClientBuilder(endpoint="https://api.test.com")
163+
164+
class DummySession:
165+
"""Expose a request method to mimic a real session."""
166+
167+
def request(self, **kwargs): # pragma: no cover - dummy implementation
168+
return kwargs
169+
170+
session = DummySession()
171+
assert client.with_session(session) is client
172+
173+
with pytest.raises(ValueError):
174+
client.with_session(object())
175+
176+
177+
@responses.activate
178+
def test_fluent_configuration_executes_request():
179+
"""Ensure fluent configuration works when executing requests."""
180+
181+
endpoint = "https://api.test.com/submit"
182+
payload = {"name": "John"}
183+
expected_response = {"status": "created"}
184+
185+
responses.add(
186+
responses.POST,
187+
endpoint,
188+
json=expected_response,
189+
status=201,
190+
)
191+
192+
client = (
193+
ClientBuilder(endpoint=endpoint)
194+
.with_method("POST")
195+
.with_payload(json=payload)
196+
.with_params({"verbose": "true"})
197+
)
198+
199+
response = client.get_api_data()
200+
201+
assert response == expected_response
202+
assert responses.calls[0].request.method == "POST"
203+
assert "verbose=true" in responses.calls[0].request.url

0 commit comments

Comments
 (0)