diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..16520fc --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +ignore = E501 \ No newline at end of file diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 2d4165d..d2983ed 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -15,86 +15,94 @@ jobs: steps: - uses: actions/checkout@v3 - name: set up python - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: "3.10" + - name: set up poetry + run: curl -sSL https://install.python-poetry.org | python3 - + - name: configure gitlab auth + run: poetry config http-basic.gitlab __token__ ${{ secrets.GITLAB_PYPI_TOKEN }} - name: install dependencies - run: pip install .[test] - - name: lint - uses: wearerequired/lint-action@v2 - with: - continue_on_error: false - black: true - flake8: true + run: poetry install --no-interaction + - name: run isort before black + run: | + poetry run isort . --check-only # Run isort first + poetry run black . --check # Then black + poetry run flake8 . + bandit: name: bandit runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: set up python - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: "3.10" + - name: set up poetry + run: curl -sSL https://install.python-poetry.org | python3 - + - name: configure gitlab auth + run: poetry config http-basic.gitlab __token__ ${{ secrets.GITLAB_PYPI_TOKEN }} - name: install dependencies - run: pip install .[test] + run: poetry install --no-interaction - name: bandit - run: bandit -r -c pyproject.toml . + run: poetry run bandit -r -c pyproject.toml . --skip B105,B106,B101 + cognitive: name: cognitive runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: set up python - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: "3.10" + - name: set up poetry + run: curl -sSL https://install.python-poetry.org | python3 - + - name: configure gitlab auth + run: poetry config http-basic.gitlab __token__ ${{ secrets.GITLAB_PYPI_TOKEN }} - name: install dependencies - run: pip install .[test] + run: poetry install --no-interaction - name: cognitive - run: flake8 . --max-cognitive-complexity=5 - isort: - name: isort - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: set up python - uses: actions/setup-python@v1 - with: - python-version: "3.10" - - name: install dependencies - run: pip install .[test] - - name: isort - uses: isort/isort-action@v1.1.0 + run: poetry run flake8 . --max-cognitive-complexity=5 --ignore=E501 + pydocstyle: name: pydocstyle runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: set up python - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: "3.10" + - name: set up poetry + run: curl -sSL https://install.python-poetry.org | python3 - + - name: configure gitlab auth + run: poetry config http-basic.gitlab __token__ ${{ secrets.GITLAB_PYPI_TOKEN }} - name: install dependencies - run: pip install .[test] + run: poetry install --no-interaction - name: pydocstyle - run: pydocstyle . + run: poetry run pydocstyle . + test: name: test runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] - needs: [bandit, cognitive, isort, lint, pydocstyle] + needs: [bandit, cognitive, lint, pydocstyle] steps: - uses: actions/checkout@v3 - name: set up python uses: actions/setup-python@v4 with: - python-version: ${{ matrix.python-version }} + python-version: "3.10" + - name: set up poetry + run: curl -sSL https://install.python-poetry.org | python3 - + - name: configure gitlab auth + run: poetry config http-basic.gitlab __token__ ${{ secrets.GITLAB_PYPI_TOKEN }} - name: install dependencies - run: pip install .[test] + run: poetry install --no-interaction - name: test - run: python -m pytest + run: poetry run pytest + release: name: tag, changelog, release, publish runs-on: ubuntu-latest @@ -102,6 +110,12 @@ jobs: if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v3 + - name: set up poetry + run: curl -sSL https://install.python-poetry.org | python3 - + - name: configure gitlab auth + run: poetry config http-basic.gitlab __token__ ${{ secrets.GITLAB_PYPI_TOKEN }} + - name: install dependencies + run: poetry install --no-interaction - name: version uses: paulhatch/semantic-version@v5.0.0 id: version diff --git a/.gitignore b/.gitignore index 3c19803..534e783 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,9 @@ dmypy.json # Cython debug symbols cython_debug/ + +# Ignore config.yaml +config/config.yaml + +# Ignore .vscode +.vscode/ diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..ac196a0 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,10 @@ +"""Configuration package for the Datacosmos SDK. + +This package includes modules for loading and managing authentication +configurations. +""" + +# Expose Config class for easier imports +from .config import Config + +__all__ = ["Config"] diff --git a/config/config.py b/config/config.py new file mode 100644 index 0000000..35f7c81 --- /dev/null +++ b/config/config.py @@ -0,0 +1,166 @@ +"""Configuration module for the Datacosmos SDK. + +Handles configuration management using Pydantic and Pydantic Settings. +It loads default values, allows overrides via YAML configuration files, +and supports environment variable-based overrides. +""" + +import os +from typing import ClassVar, Optional + +import yaml +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from config.models.url import URL + + +class Config(BaseSettings): + """Centralized configuration for the Datacosmos SDK.""" + + model_config = SettingsConfigDict( + env_nested_delimiter="__", + nested_model_default_partial_update=True, + ) + + authentication: Optional[M2MAuthenticationConfig] = None + stac: Optional[URL] = None + + DEFAULT_AUTH_TYPE: ClassVar[str] = "m2m" + DEFAULT_AUTH_TOKEN_URL: ClassVar[str] = "https://login.open-cosmos.com/oauth/token" + DEFAULT_AUTH_AUDIENCE: ClassVar[str] = "https://beeapp.open-cosmos.com" + + @classmethod + def from_yaml(cls, file_path: str = "config/config.yaml") -> "Config": + """Load configuration from a YAML file and override defaults. + + Args: + file_path (str): The path to the YAML configuration file. + + Returns: + Config: An instance of the Config class with loaded settings. + """ + config_data: dict = {} + if os.path.exists(file_path): + with open(file_path, "r") as f: + yaml_data = yaml.safe_load(f) or {} + # Remove empty values from YAML to avoid overwriting with `None` + config_data = { + key: value + for key, value in yaml_data.items() + if value not in [None, ""] + } + + return cls(**config_data) + + @classmethod + def from_env(cls) -> "Config": + """Load configuration from environment variables. + + Returns: + Config: An instance of the Config class with settings loaded from environment variables. + """ + authentication_config = M2MAuthenticationConfig( + type=os.getenv("OC_AUTH_TYPE", cls.DEFAULT_AUTH_TYPE), + client_id=os.getenv("OC_AUTH_CLIENT_ID"), + client_secret=os.getenv("OC_AUTH_CLIENT_SECRET"), + token_url=os.getenv("OC_AUTH_TOKEN_URL", cls.DEFAULT_AUTH_TOKEN_URL), + audience=os.getenv("OC_AUTH_AUDIENCE", cls.DEFAULT_AUTH_AUDIENCE), + ) + + stac_config = URL( + protocol=os.getenv("OC_STAC_PROTOCOL", "https"), + host=os.getenv("OC_STAC_HOST", "app.open-cosmos.com"), + port=int(os.getenv("OC_STAC_PORT", "443")), + path=os.getenv("OC_STAC_PATH", "/api/data/v0/stac"), + ) + + return cls(authentication=authentication_config, stac=stac_config) + + @field_validator("authentication", mode="before") + @classmethod + def validate_authentication( + cls, auth_data: Optional[dict] + ) -> M2MAuthenticationConfig: + """Ensure authentication is provided and apply defaults. + + Args: + auth_data (Optional[dict]): The authentication config as a dictionary. + + Returns: + M2MAuthenticationConfig: The validated authentication configuration. + + Raises: + ValueError: If authentication is missing or required fields are not set. + """ + if not auth_data: + cls.raise_missing_auth_error() + + auth = cls.parse_auth_config(auth_data) + auth = cls.apply_auth_defaults(auth) + + cls.check_required_auth_fields(auth) + return auth + + @staticmethod + def raise_missing_auth_error(): + """Raise an error when authentication is missing.""" + raise ValueError( + "M2M authentication is required. Provide it via:\n" + "1. Explicit instantiation (Config(authentication=...))\n" + "2. A YAML config file (config.yaml)\n" + "3. Environment variables (OC_AUTH_CLIENT_ID, OC_AUTH_CLIENT_SECRET, etc.)" + ) + + @staticmethod + def parse_auth_config(auth_data: dict) -> M2MAuthenticationConfig: + """Convert dictionary input to M2MAuthenticationConfig object.""" + return ( + M2MAuthenticationConfig(**auth_data) + if isinstance(auth_data, dict) + else auth_data + ) + + @classmethod + def apply_auth_defaults( + cls, auth: M2MAuthenticationConfig + ) -> M2MAuthenticationConfig: + """Apply default authentication values if they are missing.""" + auth.type = auth.type or cls.DEFAULT_AUTH_TYPE + auth.token_url = auth.token_url or cls.DEFAULT_AUTH_TOKEN_URL + auth.audience = auth.audience or cls.DEFAULT_AUTH_AUDIENCE + return auth + + @staticmethod + def check_required_auth_fields(auth: M2MAuthenticationConfig): + """Ensure required fields (client_id, client_secret) are provided.""" + missing_fields = [ + field + for field in ("client_id", "client_secret") + if not getattr(auth, field) + ] + if missing_fields: + raise ValueError( + f"Missing required authentication fields: {', '.join(missing_fields)}" + ) + + @field_validator("stac", mode="before") + @classmethod + def validate_stac(cls, stac_config: Optional[URL]) -> URL: + """Ensure STAC configuration has a default if not explicitly set. + + Args: + stac_config (Optional[URL]): The STAC config to validate. + + Returns: + URL: The validated STAC configuration. + """ + if stac_config is None: + return URL( + protocol="https", + host="app.open-cosmos.com", + port=443, + path="/api/data/v0/stac", + ) + return stac_config diff --git a/config/models/m2m_authentication_config.py b/config/models/m2m_authentication_config.py new file mode 100644 index 0000000..45d92ca --- /dev/null +++ b/config/models/m2m_authentication_config.py @@ -0,0 +1,23 @@ +"""Module for configuring machine-to-machine (M2M) authentication. + +Used when running scripts in the cluster that require automated authentication +without user interaction. +""" + +from typing import Literal + +from pydantic import BaseModel + + +class M2MAuthenticationConfig(BaseModel): + """Configuration for machine-to-machine authentication. + + This is used when running scripts in the cluster that require authentication + with client credentials. + """ + + type: Literal["m2m"] + client_id: str + token_url: str + audience: str + client_secret: str diff --git a/config/models/url.py b/config/models/url.py new file mode 100644 index 0000000..7df9c83 --- /dev/null +++ b/config/models/url.py @@ -0,0 +1,34 @@ +"""Module defining a structured URL configuration model. + +Ensures that URLs contain required components such as protocol, host, +port, and path. +""" + +from common.domain.url import URL as DomainURL +from pydantic import BaseModel + + +class URL(BaseModel): + """Generic configuration model for a URL. + + This class provides attributes to store URL components and a method + to convert them into a `DomainURL` instance. + """ + + protocol: str + host: str + port: int + path: str + + def as_domain_url(self) -> DomainURL: + """Convert the URL instance to a `DomainURL` object. + + Returns: + DomainURL: A domain-specific URL object. + """ + return DomainURL( + protocol=self.protocol, + host=self.host, + port=self.port, + base=self.path, + ) diff --git a/datacosmos/client.py b/datacosmos/client.py new file mode 100644 index 0000000..d631b14 --- /dev/null +++ b/datacosmos/client.py @@ -0,0 +1,116 @@ +"""DatacosmosClient handles authenticated interactions with the Datacosmos API. + +Automatically manages token refreshing and provides HTTP convenience +methods. +""" + +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +import requests +from oauthlib.oauth2 import BackendApplicationClient +from requests.exceptions import ConnectionError, HTTPError, RequestException, Timeout +from requests_oauthlib import OAuth2Session + +from config.config import Config +from datacosmos.exceptions.datacosmos_exception import DatacosmosException + + +class DatacosmosClient: + """Client to interact with the Datacosmos API with authentication and request handling.""" + + def __init__(self, config: Optional[Config] = None): + """Initialize the DatacosmosClient. + + Args: + config (Optional[Config]): Configuration object. + """ + if config: + self.config = config + else: + try: + self.config = Config.from_yaml() + except ValueError: + self.config = Config.from_env() + + self.token = None + self.token_expiry = None + self._http_client = self._authenticate_and_initialize_client() + + def _authenticate_and_initialize_client(self) -> requests.Session: + """Authenticate and initialize the HTTP client with a valid token.""" + try: + client = BackendApplicationClient( + client_id=self.config.authentication.client_id + ) + oauth_session = OAuth2Session(client=client) + + token_response = oauth_session.fetch_token( + token_url=self.config.authentication.token_url, + client_id=self.config.authentication.client_id, + client_secret=self.config.authentication.client_secret, + audience=self.config.authentication.audience, + ) + + self.token = token_response["access_token"] + self.token_expiry = datetime.now(timezone.utc) + timedelta( + seconds=token_response.get("expires_in", 3600) + ) + + http_client = requests.Session() + http_client.headers.update({"Authorization": f"Bearer {self.token}"}) + return http_client + except (HTTPError, ConnectionError, Timeout) as e: + raise DatacosmosException(f"Authentication failed: {str(e)}") from e + except RequestException as e: + raise DatacosmosException( + f"Unexpected request failure during authentication: {str(e)}" + ) from e + + def _refresh_token_if_needed(self): + """Refresh the token if it has expired.""" + if not self.token or self.token_expiry <= datetime.now(timezone.utc): + self._http_client = self._authenticate_and_initialize_client() + + def request( + self, method: str, url: str, *args: Any, **kwargs: Any + ) -> requests.Response: + """Send an HTTP request using the authenticated session.""" + self._refresh_token_if_needed() + try: + response = self._http_client.request(method, url, *args, **kwargs) + response.raise_for_status() + return response + except HTTPError as e: + raise DatacosmosException( + f"HTTP error during {method.upper()} request to {url}", + response=e.response, + ) from e + except ConnectionError as e: + raise DatacosmosException( + f"Connection error during {method.upper()} request to {url}: {str(e)}" + ) from e + except Timeout as e: + raise DatacosmosException( + f"Request timeout during {method.upper()} request to {url}: {str(e)}" + ) from e + except RequestException as e: + raise DatacosmosException( + f"Unexpected request failure during {method.upper()} request to {url}: {str(e)}" + ) from e + + def get(self, url: str, *args: Any, **kwargs: Any) -> requests.Response: + """Send a GET request using the authenticated session.""" + return self.request("GET", url, *args, **kwargs) + + def post(self, url: str, *args: Any, **kwargs: Any) -> requests.Response: + """Send a POST request using the authenticated session.""" + return self.request("POST", url, *args, **kwargs) + + def put(self, url: str, *args: Any, **kwargs: Any) -> requests.Response: + """Send a PUT request using the authenticated session.""" + return self.request("PUT", url, *args, **kwargs) + + def delete(self, url: str, *args: Any, **kwargs: Any) -> requests.Response: + """Send a DELETE request using the authenticated session.""" + return self.request("DELETE", url, *args, **kwargs) diff --git a/datacosmos/exceptions/__init__.py b/datacosmos/exceptions/__init__.py new file mode 100644 index 0000000..17476aa --- /dev/null +++ b/datacosmos/exceptions/__init__.py @@ -0,0 +1 @@ +"""Exceptions for the datacosmos package.""" diff --git a/datacosmos/exceptions/datacosmos_exception.py b/datacosmos/exceptions/datacosmos_exception.py new file mode 100644 index 0000000..5639aca --- /dev/null +++ b/datacosmos/exceptions/datacosmos_exception.py @@ -0,0 +1,27 @@ +"""Base exception class for all Datacosmos SDK exceptions.""" + +from typing import Optional + +from requests import Response +from requests.exceptions import RequestException + + +class DatacosmosException(RequestException): + """Base exception class for all Datacosmos SDK exceptions.""" + + def __init__(self, message: str, response: Optional[Response] = None): + """Initialize DatacosmosException. + + Args: + message (str): The error message. + response (Optional[Response]): The HTTP response object, if available. + """ + self.response = response + self.status_code = response.status_code if response else None + self.details = response.text if response else None + full_message = ( + f"{message} (Status: {self.status_code}, Details: {self.details})" + if response + else message + ) + super().__init__(full_message) diff --git a/pyproject.toml b/pyproject.toml index b06bf42..bdb8c33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,34 +3,49 @@ requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [project] -name = "datacosmos-sdk" +name = "datacosmos" version = "0.0.1" authors = [ { name="Open Cosmos", email="support@open-cosmos.com" }, ] description = "A library for interacting with DataCosmos from Python code" -requires-python = ">=3.8" +requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", "Operating System :: OS Independent", ] -dependencies = [] - -[project.optional-dependencies] -test = [ - "black==22.12.0", - "flake8==6.0.0", +dependencies = [ + "python-common==0.13.1", + "black==22.3.0", + "flake8==4.0.1", "pytest==7.2.0", "bandit[toml]==1.7.4", "isort==5.11.4", "pydocstyle==6.1.1", "flake8-cognitive-complexity==0.1.0", + "requests==2.31.0", + "oauthlib==3.2.0", + "requests-oauthlib==1.3.1", + "pydantic==2.10.6", + "pydantic-settings==2.7.1", + "pystac==1.12.1" ] -[tool.setuptools.packages] -find = {} - [tool.bandit] [tool.pydocstyle] convention = "google" + +[tool.isort] +profile = "black" +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +line_length = 88 + +# Add GitLab private registry as a source +# useless comment +[[tool.poetry.source]] +name = "gitlab" +url = "https://git.o-c.space/api/v4/projects/689/packages/pypi/simple" \ No newline at end of file diff --git a/tests/test_pass.py b/tests/test_pass.py index 32c6ad8..228fe40 100644 --- a/tests/test_pass.py +++ b/tests/test_pass.py @@ -1,7 +1,9 @@ -"""An example test to check pytest setup.""" +"""Test suite for basic functionality and CI setup.""" class TestPass: + """A simple test class to validate the CI pipeline setup.""" + def test_pass(self): - """A passing test, to check the pytest CI setup.""" - pass + """A passing test to ensure the CI pipeline is functional.""" + assert True diff --git a/tests/unit/datacosmos/client/test_client_authentication.py b/tests/unit/datacosmos/client/test_client_authentication.py new file mode 100644 index 0000000..1e536f1 --- /dev/null +++ b/tests/unit/datacosmos/client/test_client_authentication.py @@ -0,0 +1,104 @@ +import os +from unittest.mock import patch + +import pytest +import yaml + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.client import DatacosmosClient + + +@pytest.mark.usefixtures("mock_fetch_token", "mock_auth_client") +class TestClientAuthentication: + """Test suite for DatacosmosClient authentication.""" + + @pytest.fixture + def mock_fetch_token(self): + """Fixture to mock OAuth2 token fetch.""" + with patch("datacosmos.client.OAuth2Session.fetch_token") as mock: + mock.return_value = { + "access_token": "mock-access-token", + "expires_in": 3600, + } + yield mock + + @pytest.fixture + def mock_auth_client(self, mock_fetch_token): + """Fixture to mock the authentication client initialization.""" + with patch( + "datacosmos.client.DatacosmosClient._authenticate_and_initialize_client", + autospec=True, + ) as mock: + + def mock_authenticate(self): + """Simulate authentication by setting token values.""" + token_response = mock_fetch_token.return_value + self.token = token_response["access_token"] + self.token_expiry = "mock-expiry" + + mock.side_effect = mock_authenticate + yield mock + + def test_authentication_with_explicit_config(self): + """Test authentication when explicitly providing Config.""" + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + + assert client.token == "mock-access-token" + assert client.token_expiry == "mock-expiry" + + @patch("config.config.Config.from_yaml") + def test_authentication_from_yaml(self, mock_from_yaml, tmp_path): + """Test authentication when loading Config from YAML file.""" + config_path = tmp_path / "config.yaml" + yaml_data = { + "authentication": { + "type": "m2m", + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "token_url": "https://mock.token.url/oauth/token", + "audience": "https://mock.audience", + } + } + + with open(config_path, "w") as f: + yaml.dump(yaml_data, f) + + mock_from_yaml.return_value = Config.from_yaml(str(config_path)) + + # Clear any previous calls before instantiating the client + mock_from_yaml.reset_mock() + + client = DatacosmosClient() + + assert client.token == "mock-access-token" + assert client.token_expiry == "mock-expiry" + + # Ensure it was called exactly once after reset + mock_from_yaml.assert_called_once() + + @patch.dict( + os.environ, + { + "OC_AUTH_CLIENT_ID": "test-client-id", + "OC_AUTH_TOKEN_URL": "https://mock.token.url/oauth/token", + "OC_AUTH_AUDIENCE": "https://mock.audience", + "OC_AUTH_CLIENT_SECRET": "test-client-secret", + }, + ) + def test_authentication_from_env(self): + """Test authentication when loading Config from environment variables.""" + client = DatacosmosClient() + + assert client.token == "mock-access-token" + assert client.token_expiry == "mock-expiry" diff --git a/tests/unit/datacosmos/client/test_client_delete_request.py b/tests/unit/datacosmos/client/test_client_delete_request.py new file mode 100644 index 0000000..6b61f0b --- /dev/null +++ b/tests/unit/datacosmos/client/test_client_delete_request.py @@ -0,0 +1,36 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.client import DatacosmosClient + + +@patch("datacosmos.client.DatacosmosClient._authenticate_and_initialize_client") +def test_delete_request(mock_auth_client): + """Test that the client performs a DELETE request correctly.""" + # Mock the HTTP client + mock_http_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 204 + mock_http_client.request.return_value = mock_response + mock_auth_client.return_value = mock_http_client + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + response = client.delete("https://mock.api/some-endpoint") + + # Assertions + assert response.status_code == 204 + mock_http_client.request.assert_called_once_with( + "DELETE", "https://mock.api/some-endpoint" + ) + mock_auth_client.call_count == 2 diff --git a/tests/unit/datacosmos/client/test_client_get_request.py b/tests/unit/datacosmos/client/test_client_get_request.py new file mode 100644 index 0000000..4963756 --- /dev/null +++ b/tests/unit/datacosmos/client/test_client_get_request.py @@ -0,0 +1,38 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.client import DatacosmosClient + + +@patch("datacosmos.client.DatacosmosClient._authenticate_and_initialize_client") +def test_client_get_request(mock_auth_client): + """Test that the client performs a GET request correctly.""" + # Mock the HTTP client + mock_http_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"message": "success"} + mock_http_client.request.return_value = mock_response + mock_auth_client.return_value = mock_http_client + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + response = client.get("https://mock.api/some-endpoint") + + # Assertions + assert response.status_code == 200 + assert response.json() == {"message": "success"} + mock_http_client.request.assert_called_once_with( + "GET", "https://mock.api/some-endpoint" + ) + mock_auth_client.call_count == 2 diff --git a/tests/unit/datacosmos/client/test_client_post_request.py b/tests/unit/datacosmos/client/test_client_post_request.py new file mode 100644 index 0000000..5023602 --- /dev/null +++ b/tests/unit/datacosmos/client/test_client_post_request.py @@ -0,0 +1,38 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.client import DatacosmosClient + + +@patch("datacosmos.client.DatacosmosClient._authenticate_and_initialize_client") +def test_post_request(mock_auth_client): + """Test that the client performs a POST request correctly.""" + # Mock the HTTP client + mock_http_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = {"message": "created"} + mock_http_client.request.return_value = mock_response + mock_auth_client.return_value = mock_http_client + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + response = client.post("https://mock.api/some-endpoint", json={"key": "value"}) + + # Assertions + assert response.status_code == 201 + assert response.json() == {"message": "created"} + mock_http_client.request.assert_called_once_with( + "POST", "https://mock.api/some-endpoint", json={"key": "value"} + ) + mock_auth_client.call_count == 2 diff --git a/tests/unit/datacosmos/client/test_client_put_request.py b/tests/unit/datacosmos/client/test_client_put_request.py new file mode 100644 index 0000000..c894fc0 --- /dev/null +++ b/tests/unit/datacosmos/client/test_client_put_request.py @@ -0,0 +1,40 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.client import DatacosmosClient + + +@patch("datacosmos.client.DatacosmosClient._authenticate_and_initialize_client") +def test_put_request(mock_auth_client): + """Test that the client performs a PUT request correctly.""" + # Mock the HTTP client + mock_http_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"message": "updated"} + mock_http_client.request.return_value = mock_response + mock_auth_client.return_value = mock_http_client + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + response = client.put( + "https://mock.api/some-endpoint", json={"key": "updated-value"} + ) + + # Assertions + assert response.status_code == 200 + assert response.json() == {"message": "updated"} + mock_http_client.request.assert_called_once_with( + "PUT", "https://mock.api/some-endpoint", json={"key": "updated-value"} + ) + mock_auth_client.call_count == 2 diff --git a/tests/unit/datacosmos/client/test_client_token_refreshing.py b/tests/unit/datacosmos/client/test_client_token_refreshing.py new file mode 100644 index 0000000..ad44784 --- /dev/null +++ b/tests/unit/datacosmos/client/test_client_token_refreshing.py @@ -0,0 +1,51 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.client import DatacosmosClient + + +@patch("datacosmos.client.DatacosmosClient._authenticate_and_initialize_client") +def test_client_token_refreshing(mock_auth_client): + """Test that the client refreshes the token when it expires.""" + # Mock the HTTP client returned by _authenticate_and_initialize_client + mock_http_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"message": "success"} + mock_http_client.request.return_value = mock_response + mock_auth_client.return_value = mock_http_client + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + # Initialize the client (first call to _authenticate_and_initialize_client) + client = DatacosmosClient(config=config) + + # Simulate expired token + client.token_expiry = datetime.now(timezone.utc) - timedelta(seconds=1) + + # Make a GET request (should trigger token refresh) + response = client.get("https://mock.api/some-endpoint") + + # Assertions + assert response.status_code == 200 + assert response.json() == {"message": "success"} + + # Verify _authenticate_and_initialize_client was called twice: + # 1. During initialization + # 2. During token refresh + assert mock_auth_client.call_count == 2 + + # Verify the request was made correctly + mock_http_client.request.assert_called_once_with( + "GET", "https://mock.api/some-endpoint" + )