Skip to content

Commit 7d4b6b3

Browse files
Add authentication providers and fluent auth configuration
1 parent 0086240 commit 7d4b6b3

File tree

5 files changed

+348
-13
lines changed

5 files changed

+348
-13
lines changed

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,36 @@ df = client.api_to_dataframe(data)
7878
print(df)
7979
```
8080

81+
### Fluent authentication configuration
82+
83+
The builder offers a fluent interface to configure authentication strategies. Any
84+
auth provider implementing the `AuthProvider` interface can be applied via
85+
`with_auth(...)` right before sending requests.
86+
87+
```python
88+
from datetime import datetime, timedelta
89+
90+
from api_to_dataframe import ClientBuilder
91+
from api_to_dataframe.models.auth import ApiKeyAuth, BearerTokenAuth
92+
93+
94+
client = ClientBuilder(endpoint="https://api.example.com").with_auth(
95+
ApiKeyAuth("X-Api-Key", "static-key")
96+
)
97+
98+
99+
def fetch_token():
100+
"""Return a short-lived token and its expiry timestamp."""
101+
return "dynamic-token", datetime.utcnow() + timedelta(minutes=5)
102+
103+
104+
secure_client = ClientBuilder(endpoint="https://secure.example.com").with_auth(
105+
BearerTokenAuth(fetch_token, refresh_margin=60)
106+
)
107+
108+
payload = secure_client.get_api_data()
109+
```
110+
81111
## Important notes:
82112
* **Opcionals Parameters:** The params timeout, retry_strategy and headers are opcionals.
83113
* **Default Params Value:** By default the quantity of retries is 3 and the time between retries is 1 second, but you can define manually.

src/api_to_dataframe/controller/client_builder.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from typing import Optional
2+
3+
from api_to_dataframe.models.auth import AuthProvider
14
from api_to_dataframe.models.retainer import retry_strategies, Strategies
25
from api_to_dataframe.models.get_data import GetData
36
from api_to_dataframe.utils.logger import logger
@@ -53,9 +56,10 @@ def __init__( # pylint: disable=too-many-positional-arguments,too-many-argument
5356
self.endpoint = endpoint
5457
self.retry_strategy = retry_strategy
5558
self.connection_timeout = connection_timeout
56-
self.headers = headers
59+
self.headers = dict(headers)
5760
self.retries = retries
5861
self.delay = initial_delay
62+
self._auth_provider: Optional[AuthProvider] = None
5963

6064
@retry_strategies
6165
def get_api_data(self):
@@ -69,14 +73,21 @@ def get_api_data(self):
6973
Returns:
7074
dict: The JSON response from the API as a dictionary.
7175
"""
76+
headers = self._compose_headers()
77+
7278
response = GetData.get_response(
7379
endpoint=self.endpoint,
74-
headers=self.headers,
80+
headers=headers,
7581
connection_timeout=self.connection_timeout,
7682
)
7783

7884
return response.json()
7985

86+
def with_auth(self, auth_provider: AuthProvider):
87+
"""Attach an authentication provider used to enrich request headers."""
88+
self._auth_provider = auth_provider
89+
return self
90+
8091
@staticmethod
8192
def api_to_dataframe(response: dict):
8293
"""
@@ -93,3 +104,10 @@ def api_to_dataframe(response: dict):
93104
DataFrame: A pandas DataFrame containing the data from the API response.
94105
"""
95106
return GetData.to_dataframe(response)
107+
108+
def _compose_headers(self) -> dict:
109+
"""Compose request headers including optional authentication details."""
110+
composed_headers = dict(self.headers)
111+
if self._auth_provider is not None:
112+
composed_headers = self._auth_provider.apply(composed_headers)
113+
return composed_headers
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Authentication providers to enrich API requests with authorization headers."""
2+
from __future__ import annotations
3+
4+
from abc import ABC, abstractmethod
5+
from datetime import datetime, timedelta
6+
from typing import Callable, Dict, Optional, Tuple
7+
8+
AuthHeaders = Dict[str, str]
9+
TokenWithExpiry = Tuple[str, Optional[datetime]]
10+
11+
12+
class AuthProvider(ABC):
13+
"""Represents a strategy capable of injecting authentication information."""
14+
15+
@abstractmethod
16+
def apply(self, headers: Optional[AuthHeaders] = None) -> AuthHeaders:
17+
"""Return request headers containing the required authentication details."""
18+
19+
20+
class ApiKeyAuth(AuthProvider):
21+
"""Static API key authentication added to a configurable header."""
22+
23+
def __init__(self, header_name: str, api_key: str):
24+
"""Store the header name and API key used for authenticated requests."""
25+
self.header_name = header_name
26+
self.api_key = api_key
27+
28+
def apply(self, headers: Optional[AuthHeaders] = None) -> AuthHeaders:
29+
"""Return headers with the API key injected into the configured header."""
30+
composed_headers = dict(headers or {})
31+
composed_headers[self.header_name] = self.api_key
32+
return composed_headers
33+
34+
35+
class BearerTokenAuth(AuthProvider):
36+
"""Bearer token authentication supporting automatic token refresh."""
37+
38+
def __init__(
39+
self,
40+
token_supplier: Callable[[], TokenWithExpiry],
41+
*,
42+
header_name: str = "Authorization",
43+
scheme: str = "Bearer",
44+
refresh_margin: float = 0,
45+
clock: Callable[[], datetime] = datetime.utcnow,
46+
):
47+
"""Configure the bearer token strategy and how tokens are supplied."""
48+
self._token_supplier = token_supplier
49+
self.header_name = header_name
50+
self.scheme = scheme
51+
self._token: Optional[str] = None
52+
self._expires_at: Optional[datetime] = None
53+
self._refresh_margin = timedelta(seconds=refresh_margin)
54+
self._clock = clock
55+
56+
def _ensure_token(self) -> None:
57+
"""Fetch or refresh the bearer token when it is missing or expired."""
58+
if self._token is None or self._should_refresh():
59+
self._token, self._expires_at = self._token_supplier()
60+
61+
def _should_refresh(self) -> bool:
62+
"""Determine whether a new token is required based on the expiry time."""
63+
if self._expires_at is None:
64+
return False
65+
return self._expires_at <= self._clock() + self._refresh_margin
66+
67+
def apply(self, headers: Optional[AuthHeaders] = None) -> AuthHeaders:
68+
"""Return headers containing a valid bearer token with optional refresh."""
69+
self._ensure_token()
70+
composed_headers = dict(headers or {})
71+
composed_headers[self.header_name] = f"{self.scheme} {self._token}"
72+
return composed_headers
73+
74+
75+
class OAuth2ClientCredentials(AuthProvider):
76+
"""OAuth2 Client Credentials authentication with token renewal support."""
77+
78+
def __init__(
79+
self,
80+
client_id: str,
81+
client_secret: str,
82+
token_fetcher: Callable[[str, str, Optional[str]], TokenWithExpiry],
83+
*,
84+
scope: Optional[str] = None,
85+
header_name: str = "Authorization",
86+
refresh_margin: float = 30,
87+
clock: Callable[[], datetime] = datetime.utcnow,
88+
):
89+
"""Store client credentials and the callable responsible for new tokens."""
90+
self.client_id = client_id
91+
self.client_secret = client_secret
92+
self.scope = scope
93+
self._token_fetcher = token_fetcher
94+
self.header_name = header_name
95+
self._refresh_margin = timedelta(seconds=refresh_margin)
96+
self._clock = clock
97+
self._token: Optional[str] = None
98+
self._expires_at: Optional[datetime] = None
99+
100+
def _ensure_token(self) -> None:
101+
"""Obtain a valid OAuth2 access token using the configured fetcher."""
102+
if self._token is None or self._should_refresh():
103+
self._token, self._expires_at = self._token_fetcher(
104+
self.client_id, self.client_secret, self.scope
105+
)
106+
107+
def _should_refresh(self) -> bool:
108+
"""Determine whether the current OAuth2 token needs to be refreshed."""
109+
if self._expires_at is None:
110+
return False
111+
return self._expires_at <= self._clock() + self._refresh_margin
112+
113+
def apply(self, headers: Optional[AuthHeaders] = None) -> AuthHeaders:
114+
"""Return headers augmented with a fresh OAuth2 bearer token."""
115+
self._ensure_token()
116+
composed_headers = dict(headers or {})
117+
composed_headers[self.header_name] = f"Bearer {self._token}"
118+
return composed_headers
119+

tests/test_controller_client_builder.py

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import responses
44

55
from api_to_dataframe import ClientBuilder, RetryStrategies
6+
from api_to_dataframe.models.auth import ApiKeyAuth
67

78

89
@pytest.fixture()
@@ -13,14 +14,6 @@ def client_setup():
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():
2518
with pytest.raises(ValueError):
2619
ClientBuilder(endpoint="")
@@ -87,15 +80,39 @@ def test_constructor_with_retry_strategy():
8780
assert client.delay == 2
8881

8982

83+
@responses.activate
9084
def test_response_to_json(client_setup): # pylint: disable=redefined-outer-name
85+
"""Test JSON response retrieval using a mocked HTTP endpoint."""
86+
expected_payload = {"rate": 5.2}
87+
responses.add(
88+
responses.GET,
89+
client_setup.endpoint,
90+
json=expected_payload,
91+
status=200,
92+
)
93+
9194
new_client = client_setup
9295
response = new_client.get_api_data() # pylint: disable=protected-access
93-
assert isinstance(response, dict)
96+
97+
assert response == expected_payload
9498

9599

96-
def test_to_dataframe(response_setup): # pylint: disable=redefined-outer-name
97-
df = ClientBuilder.api_to_dataframe(response_setup)
100+
@responses.activate
101+
def test_to_dataframe(client_setup): # pylint: disable=redefined-outer-name
102+
"""Test DataFrame conversion using mocked API data."""
103+
expected_payload = {"id": [1, 2], "value": ["a", "b"]}
104+
responses.add(
105+
responses.GET,
106+
client_setup.endpoint,
107+
json=expected_payload,
108+
status=200,
109+
)
110+
111+
response_data = client_setup.get_api_data()
112+
df = ClientBuilder.api_to_dataframe(response_data)
113+
98114
assert isinstance(df, pd.DataFrame)
115+
assert not df.empty
99116

100117

101118
@responses.activate
@@ -118,3 +135,20 @@ def test_get_api_data_with_mocked_response():
118135
assert response == expected_data
119136
assert len(responses.calls) == 1
120137
assert responses.calls[0].request.url == endpoint
138+
139+
140+
@responses.activate
141+
def test_client_builder_with_auth_headers():
142+
"""Ensure ClientBuilder augments request headers using the auth provider."""
143+
endpoint = "https://api.test.com/data"
144+
responses.add(responses.GET, endpoint, json={"status": "ok"}, status=200)
145+
146+
client = ClientBuilder(endpoint=endpoint, headers={"Accept": "application/json"})
147+
client.with_auth(ApiKeyAuth("X-Api-Key", "secret"))
148+
149+
payload = client.get_api_data()
150+
151+
sent_headers = responses.calls[0].request.headers
152+
assert payload == {"status": "ok"}
153+
assert sent_headers["X-Api-Key"] == "secret"
154+
assert sent_headers["Accept"] == "application/json"

0 commit comments

Comments
 (0)