diff --git a/README.md b/README.md index dd0513d..1e35281 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,36 @@ df = client.api_to_dataframe(data) print(df) ``` +### Fluent authentication configuration + +The builder offers a fluent interface to configure authentication strategies. Any +auth provider implementing the `AuthProvider` interface can be applied via +`with_auth(...)` right before sending requests. + +```python +from datetime import datetime, timedelta + +from api_to_dataframe import ClientBuilder +from api_to_dataframe.models.auth import ApiKeyAuth, BearerTokenAuth + + +client = ClientBuilder(endpoint="https://api.example.com").with_auth( + ApiKeyAuth("X-Api-Key", "static-key") +) + + +def fetch_token(): + """Return a short-lived token and its expiry timestamp.""" + return "dynamic-token", datetime.utcnow() + timedelta(minutes=5) + + +secure_client = ClientBuilder(endpoint="https://secure.example.com").with_auth( + BearerTokenAuth(fetch_token, refresh_margin=60) +) + +payload = secure_client.get_api_data() +``` + ## Important notes: * **Opcionals Parameters:** The params timeout, retry_strategy and headers are opcionals. * **Default Params Value:** By default the quantity of retries is 3 and the time between retries is 1 second, but you can define manually. diff --git a/src/api_to_dataframe/controller/client_builder.py b/src/api_to_dataframe/controller/client_builder.py index 107b453..3aa94b2 100644 --- a/src/api_to_dataframe/controller/client_builder.py +++ b/src/api_to_dataframe/controller/client_builder.py @@ -1,3 +1,6 @@ +from typing import Optional + +from api_to_dataframe.models.auth import AuthProvider from api_to_dataframe.models.retainer import retry_strategies, Strategies from api_to_dataframe.models.get_data import GetData from api_to_dataframe.utils.logger import logger @@ -53,9 +56,10 @@ def __init__( # pylint: disable=too-many-positional-arguments,too-many-argument self.endpoint = endpoint self.retry_strategy = retry_strategy self.connection_timeout = connection_timeout - self.headers = headers + self.headers = dict(headers) self.retries = retries self.delay = initial_delay + self._auth_provider: Optional[AuthProvider] = None @retry_strategies def get_api_data(self): @@ -69,14 +73,21 @@ def get_api_data(self): Returns: dict: The JSON response from the API as a dictionary. """ + headers = self._compose_headers() + response = GetData.get_response( endpoint=self.endpoint, - headers=self.headers, + headers=headers, connection_timeout=self.connection_timeout, ) return response.json() + def with_auth(self, auth_provider: AuthProvider): + """Attach an authentication provider used to enrich request headers.""" + self._auth_provider = auth_provider + return self + @staticmethod def api_to_dataframe(response: dict): """ @@ -93,3 +104,10 @@ def api_to_dataframe(response: dict): DataFrame: A pandas DataFrame containing the data from the API response. """ return GetData.to_dataframe(response) + + def _compose_headers(self) -> dict: + """Compose request headers including optional authentication details.""" + composed_headers = dict(self.headers) + if self._auth_provider is not None: + composed_headers = self._auth_provider.apply(composed_headers) + return composed_headers diff --git a/src/api_to_dataframe/models/auth.py b/src/api_to_dataframe/models/auth.py new file mode 100644 index 0000000..98c7f7d --- /dev/null +++ b/src/api_to_dataframe/models/auth.py @@ -0,0 +1,119 @@ +"""Authentication providers to enrich API requests with authorization headers.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import datetime, timedelta +from typing import Callable, Dict, Optional, Tuple + +AuthHeaders = Dict[str, str] +TokenWithExpiry = Tuple[str, Optional[datetime]] + + +class AuthProvider(ABC): + """Represents a strategy capable of injecting authentication information.""" + + @abstractmethod + def apply(self, headers: Optional[AuthHeaders] = None) -> AuthHeaders: + """Return request headers containing the required authentication details.""" + + +class ApiKeyAuth(AuthProvider): + """Static API key authentication added to a configurable header.""" + + def __init__(self, header_name: str, api_key: str): + """Store the header name and API key used for authenticated requests.""" + self.header_name = header_name + self.api_key = api_key + + def apply(self, headers: Optional[AuthHeaders] = None) -> AuthHeaders: + """Return headers with the API key injected into the configured header.""" + composed_headers = dict(headers or {}) + composed_headers[self.header_name] = self.api_key + return composed_headers + + +class BearerTokenAuth(AuthProvider): + """Bearer token authentication supporting automatic token refresh.""" + + def __init__( + self, + token_supplier: Callable[[], TokenWithExpiry], + *, + header_name: str = "Authorization", + scheme: str = "Bearer", + refresh_margin: float = 0, + clock: Callable[[], datetime] = datetime.utcnow, + ): + """Configure the bearer token strategy and how tokens are supplied.""" + self._token_supplier = token_supplier + self.header_name = header_name + self.scheme = scheme + self._token: Optional[str] = None + self._expires_at: Optional[datetime] = None + self._refresh_margin = timedelta(seconds=refresh_margin) + self._clock = clock + + def _ensure_token(self) -> None: + """Fetch or refresh the bearer token when it is missing or expired.""" + if self._token is None or self._should_refresh(): + self._token, self._expires_at = self._token_supplier() + + def _should_refresh(self) -> bool: + """Determine whether a new token is required based on the expiry time.""" + if self._expires_at is None: + return False + return self._expires_at <= self._clock() + self._refresh_margin + + def apply(self, headers: Optional[AuthHeaders] = None) -> AuthHeaders: + """Return headers containing a valid bearer token with optional refresh.""" + self._ensure_token() + composed_headers = dict(headers or {}) + composed_headers[self.header_name] = f"{self.scheme} {self._token}" + return composed_headers + + +class OAuth2ClientCredentials(AuthProvider): + """OAuth2 Client Credentials authentication with token renewal support.""" + + def __init__( + self, + client_id: str, + client_secret: str, + token_fetcher: Callable[[str, str, Optional[str]], TokenWithExpiry], + *, + scope: Optional[str] = None, + header_name: str = "Authorization", + refresh_margin: float = 30, + clock: Callable[[], datetime] = datetime.utcnow, + ): + """Store client credentials and the callable responsible for new tokens.""" + self.client_id = client_id + self.client_secret = client_secret + self.scope = scope + self._token_fetcher = token_fetcher + self.header_name = header_name + self._refresh_margin = timedelta(seconds=refresh_margin) + self._clock = clock + self._token: Optional[str] = None + self._expires_at: Optional[datetime] = None + + def _ensure_token(self) -> None: + """Obtain a valid OAuth2 access token using the configured fetcher.""" + if self._token is None or self._should_refresh(): + self._token, self._expires_at = self._token_fetcher( + self.client_id, self.client_secret, self.scope + ) + + def _should_refresh(self) -> bool: + """Determine whether the current OAuth2 token needs to be refreshed.""" + if self._expires_at is None: + return False + return self._expires_at <= self._clock() + self._refresh_margin + + def apply(self, headers: Optional[AuthHeaders] = None) -> AuthHeaders: + """Return headers augmented with a fresh OAuth2 bearer token.""" + self._ensure_token() + composed_headers = dict(headers or {}) + composed_headers[self.header_name] = f"Bearer {self._token}" + return composed_headers + diff --git a/tests/test_controller_client_builder.py b/tests/test_controller_client_builder.py index 8af9bf5..082d468 100644 --- a/tests/test_controller_client_builder.py +++ b/tests/test_controller_client_builder.py @@ -3,6 +3,7 @@ import responses from api_to_dataframe import ClientBuilder, RetryStrategies +from api_to_dataframe.models.auth import ApiKeyAuth @pytest.fixture() @@ -13,14 +14,6 @@ def client_setup(): return new_client -@pytest.fixture() -def response_setup(): - new_client = ClientBuilder( - endpoint="https://economia.awesomeapi.com.br/last/USD-BRL" - ) - return new_client.get_api_data() - - def test_constructor_raises(): with pytest.raises(ValueError): ClientBuilder(endpoint="") @@ -87,15 +80,39 @@ def test_constructor_with_retry_strategy(): assert client.delay == 2 +@responses.activate def test_response_to_json(client_setup): # pylint: disable=redefined-outer-name + """Test JSON response retrieval using a mocked HTTP endpoint.""" + expected_payload = {"rate": 5.2} + responses.add( + responses.GET, + client_setup.endpoint, + json=expected_payload, + status=200, + ) + new_client = client_setup response = new_client.get_api_data() # pylint: disable=protected-access - assert isinstance(response, dict) + + assert response == expected_payload -def test_to_dataframe(response_setup): # pylint: disable=redefined-outer-name - df = ClientBuilder.api_to_dataframe(response_setup) +@responses.activate +def test_to_dataframe(client_setup): # pylint: disable=redefined-outer-name + """Test DataFrame conversion using mocked API data.""" + expected_payload = {"id": [1, 2], "value": ["a", "b"]} + responses.add( + responses.GET, + client_setup.endpoint, + json=expected_payload, + status=200, + ) + + response_data = client_setup.get_api_data() + df = ClientBuilder.api_to_dataframe(response_data) + assert isinstance(df, pd.DataFrame) + assert not df.empty @responses.activate @@ -118,3 +135,20 @@ def test_get_api_data_with_mocked_response(): assert response == expected_data assert len(responses.calls) == 1 assert responses.calls[0].request.url == endpoint + + +@responses.activate +def test_client_builder_with_auth_headers(): + """Ensure ClientBuilder augments request headers using the auth provider.""" + endpoint = "https://api.test.com/data" + responses.add(responses.GET, endpoint, json={"status": "ok"}, status=200) + + client = ClientBuilder(endpoint=endpoint, headers={"Accept": "application/json"}) + client.with_auth(ApiKeyAuth("X-Api-Key", "secret")) + + payload = client.get_api_data() + + sent_headers = responses.calls[0].request.headers + assert payload == {"status": "ok"} + assert sent_headers["X-Api-Key"] == "secret" + assert sent_headers["Accept"] == "application/json" diff --git a/tests/test_models_auth.py b/tests/test_models_auth.py new file mode 100644 index 0000000..cee26f5 --- /dev/null +++ b/tests/test_models_auth.py @@ -0,0 +1,134 @@ +"""Unit tests for authentication providers.""" +from datetime import datetime, timedelta + +import pytest + +from api_to_dataframe.models.auth import ( + ApiKeyAuth, + BearerTokenAuth, + OAuth2ClientCredentials, +) + + +class DummyClock: + """Test helper that simulates the passage of time.""" + + def __init__(self, start: datetime): + """Initialize the dummy clock with the provided start time.""" + self._current = start + + def now(self) -> datetime: + """Return the current mocked time.""" + return self._current + + def advance(self, seconds: float) -> None: + """Advance the clock by the specified number of seconds.""" + self._current += timedelta(seconds=seconds) + + +class TokenSupplier: + """Helper callable that returns sequential tokens for testing.""" + + def __init__(self, tokens): + """Store the list of tokens that will be returned sequentially.""" + self._tokens = list(tokens) + self.calls = 0 + + def __call__(self): + """Return the next token with expiry information.""" + if not self._tokens: + raise AssertionError("TokenSupplier invoked more times than expected") + self.calls += 1 + return self._tokens.pop(0) + + +class OAuthFetcher: + """Helper callable to emulate OAuth token retrieval with validation.""" + + def __init__(self, expected_id: str, expected_secret: str, expected_scope: str, tokens): + """Store expected credentials and queued tokens for assertions.""" + self.expected_id = expected_id + self.expected_secret = expected_secret + self.expected_scope = expected_scope + self._tokens = list(tokens) + self.calls = 0 + + def __call__(self, client_id: str, client_secret: str, scope: str): + """Return the next OAuth token ensuring credentials are forwarded.""" + assert client_id == self.expected_id + assert client_secret == self.expected_secret + assert scope == self.expected_scope + if not self._tokens: + raise AssertionError("OAuthFetcher invoked more times than expected") + self.calls += 1 + return self._tokens.pop(0) + + +def test_api_key_auth_injects_header(): + """Ensure ApiKeyAuth injects a static header without mutating input.""" + provider = ApiKeyAuth("X-Api-Key", "secret") + base_headers = {"Accept": "application/json"} + + result = provider.apply(base_headers) + + assert result["X-Api-Key"] == "secret" + assert base_headers == {"Accept": "application/json"} + + +def test_bearer_token_auth_refreshes_when_expired(): + """Ensure BearerTokenAuth refreshes the token once the previous one expires.""" + start = datetime(2024, 1, 1, 0, 0, 0) + clock = DummyClock(start) + supplier = TokenSupplier( + [ + ("token-one", start + timedelta(seconds=5)), + ("token-two", start + timedelta(seconds=50)), + ] + ) + provider = BearerTokenAuth( + supplier, + refresh_margin=0, + clock=clock.now, + ) + + headers_first = provider.apply({}) + clock.advance(10) + headers_second = provider.apply({}) + + assert headers_first["Authorization"] == "Bearer token-one" + assert headers_second["Authorization"] == "Bearer token-two" + assert supplier.calls == 2 + + +def test_oauth_client_credentials_refreshes_token(): + """Ensure OAuth2ClientCredentials fetches a new token after expiration.""" + start = datetime(2024, 1, 1, 0, 0, 0) + clock = DummyClock(start) + fetcher = OAuthFetcher( + "client-id", + "client-secret", + "scope.read", + [ + ("oauth-token-one", start + timedelta(seconds=5)), + ("oauth-token-two", start + timedelta(seconds=50)), + ], + ) + provider = OAuth2ClientCredentials( + "client-id", + "client-secret", + fetcher, + scope="scope.read", + refresh_margin=0, + clock=clock.now, + ) + + headers_first = provider.apply({}) + clock.advance(10) + headers_second = provider.apply({}) + + assert headers_first["Authorization"] == "Bearer oauth-token-one" + assert headers_second["Authorization"] == "Bearer oauth-token-two" + assert fetcher.calls == 2 + + with pytest.raises(AssertionError): + fetcher("client-id", "client-secret", "scope.read")