diff --git a/eox_nelp/api_clients/__init__.py b/eox_nelp/api_clients/__init__.py deleted file mode 100644 index 7f872f64..00000000 --- a/eox_nelp/api_clients/__init__.py +++ /dev/null @@ -1,154 +0,0 @@ -"""This file contains the common functions and classes for the api_clients module. - -Classes: - AbstractApiClient: Base API class. -""" -import logging -from abc import ABC, abstractmethod - -from django.conf import settings - -from eox_nelp.api_clients.authenticators import UnAuthenticatedAuthenticator - -LOGGER = logging.getLogger(__name__) - - -class AbstractApiClient(ABC): - """Abstract api client class, this defines common API client methods.""" - - authentication_class = UnAuthenticatedAuthenticator - extra_headers_key = None - - @property - @abstractmethod - def base_url(self): - """Abstract base_url property method.""" - raise NotImplementedError - - def __init__(self): - """ - Abstract ApiClient creator, this will set the session based on the authenticate result. - """ - self.session = self._authenticate() - self.session.headers.update(self._get_extra_headers()) - - def _authenticate(self): - """Calls the authenticator's authenticate method""" - authenticator = self.authentication_class() - - return authenticator.authenticate(api_client=self) - - def _get_extra_headers(self): - """This verify the extra_headers_key attribute and returns its value from the django settings. - - Returns - Dict: The extra_headers_key must be set a dictionary. - """ - if self.extra_headers_key: - return getattr(settings, self.extra_headers_key, {}) - - return {} - - -class AbstractAPIRestClient(AbstractApiClient): - """This abstract class is an extension of AbstractApiClient that includes common http methods (POST and GET) - based on the REST API standard. - """ - - def make_post(self, path, data): - """This method uses the session attribute to perform a POST request based on the - base_url attribute and the given path, if the response has a status code 200 - this will return the json from that response otherwise this will return an empty dictionary. - - Args: - path: makes reference to the url path. - data: request body as dictionary. - - Return: - Dictionary: Empty dictionary or json response. - """ - url = f"{self.base_url}/{path}" - - response = self.session.post(url=url, json=data) - - if response.ok: - return response.json() - - LOGGER.error( - "An error has occurred trying to make post request to %s with status code %s and message %s", - url, - response.status_code, - response.json(), - ) - - return { - "error": True, - "message": f"Invalid response with status {response.status_code}" - } - - def make_get(self, path, payload): - """This method uses the session attribute to perform a GET request based on the - base_url attribute and the given path, if the response has a status code 200 - this will return the json from that response otherwise this will return an empty dictionary. - - Args: - path: makes reference to the url path. - payload: queryparams as dictionary. - - Return: - Dictionary: Empty dictionary or json response. - """ - url = f"{self.base_url}/{path}" - - response = self.session.get(url=url, params=payload) - - if response.ok: - return response.json() - - LOGGER.error( - "An error has occurred trying to make a get request to %s with status code %s and message %s", - url, - response.status_code, - response.json(), - ) - - return { - "error": True, - "message": f"Invalid response with status {response.status_code}" - } - - -class AbstractSOAPClient(AbstractApiClient): - """This abstract class is an extension of AbstractApiClient that includes - a common POST method whose expected result is a xml response. - """ - - def make_post(self, path, data): - """This method uses the session attribute to perform a POST request based on the - base_url attribute and the given path, if the status code is different from 200 this - will log the error and finally this will return the xml response in any case. - - Arguments: - path : makes reference to the url path. - data : request body as xml string. - - Return: - : xml response. - """ - if not isinstance(data, str): - raise TypeError("Invalid data type, the data argument must be a string") - - url = f"{self.base_url}/{path}" - - response = self.session.post(url=url, data=data.encode("utf-8")) - content = response.text - - if not response.ok: - LOGGER.error( - "An error has occurred trying to make post request to %s with status code %s and message %s", - url, - response.status_code, - content, - ) - - return content diff --git a/eox_nelp/api_clients/authenticators.py b/eox_nelp/api_clients/authenticators.py deleted file mode 100644 index 58e042e3..00000000 --- a/eox_nelp/api_clients/authenticators.py +++ /dev/null @@ -1,163 +0,0 @@ -""" -This module contains classes for authenticating users using various authentication methods. - -Classes: - BasicAuthenticator: - A class for authenticating users using Basic Authentication with a username and password. - - Oauth2Authenticator: - A class for authenticating users using OAuth 2.0 with client ID and client secret. - - UnAuthenticatedAuthenticator: - A class for unauthenticated request. - - PKCS12Authenticator: - A class for authenticating users using PFX certificate. -""" -from abc import ABC, abstractmethod - -import requests -from django.core.cache import cache -from oauthlib.oauth2 import BackendApplicationClient -from requests.auth import HTTPBasicAuth -from requests_oauthlib import OAuth2Session -from requests_pkcs12 import Pkcs12Adapter - - -class AbstractAuthenticator(ABC): - """This class define required methods that an autheticator class must have.""" - - @abstractmethod - def authenticate(self, api_client): - """Abstract method that should return a requests Session.""" - raise NotImplementedError - - -class Oauth2Authenticator(AbstractAuthenticator): - """ Oauth2Authenticator is a class for authenticating users using - the OAuth 2.0 standard with client ID and client secret. - """ - - def authenticate(self, api_client): - """Authenticate the session with OAuth 2.0 credentials. - - This method uses OAuth 2.0 client credentials (client ID and client secret) - to obtain an access token from the OAuth token endpoint. The access token - is then used to create and configure a requests session. - - The access token is cached to minimize token requests to the OAuth server. - - Returns: - requests.Session: Session authenticated with OAuth 2.0 credentials. - """ - # pylint: disable=no-member - key = f"{api_client.client_id}-{api_client.client_secret}" - headers = cache.get(key) - - if not headers: - client = BackendApplicationClient(client_id=api_client.client_id) - oauth = OAuth2Session(client_id=api_client.client_id, client=client) - authenticate_url = f"{api_client.base_url}/{api_client.authentication_path}" - response = oauth.fetch_token( - token_url=authenticate_url, - client_secret=api_client.client_secret, - include_client_id=True, - ) - headers = { - "Authorization": f"{response.get('token_type')} {response.get('access_token')}" - } - - cache.set(key, headers, response.get("expires_in", 300)) - - session = requests.Session() - session.headers.update(headers) - - return session - - -class BasicAuthAuthenticator(AbstractAuthenticator): - """ BasicAuthenticator is a class for authenticating users - using Basic Authentication with a username and password. - """ - - def authenticate(self, api_client): - """Authenticate the session with the user and password. - - Creates and configures a requests session with basic authentication - provided by the user and password. - - Returns: - requests.Session: Session authenticated. - """ - # pylint: disable=no-member - session = requests.Session() - session.auth = HTTPBasicAuth(api_client.user, api_client.password) - - return session - - -class UnAuthenticatedAuthenticator(AbstractAuthenticator): - """This authenticator class doesn't implement any authentication method.""" - - def authenticate(self, api_client): - """Creates and configures a requests session without authentication. - - Returns: - requests.Session: Basic Session. - """ - # pylint: disable=no-member - return requests.Session() - - -class PKCS12Authenticator(AbstractAuthenticator): - """PKCS12Authenticator is a class for authenticating users - using a PFX certificate and its passphrase. - """ - - def authenticate(self, api_client): - """Creates and configures a requests session with a specific certificate. - - Returns: - requests.Session: Basic Session. - """ - session = requests.Session() - session.mount( - api_client.base_url, - Pkcs12Adapter(pkcs12_filename=api_client.cert, pkcs12_password=api_client.passphrase), - ) - - return session - - -class Oauth2BasicAuthenticator(BasicAuthAuthenticator): - """Authenticator for custom use using basic auth to get a Oauth2 Token (Bearer or JWT). - Token_type on depends of the response used after the oauth2 token request. - Then the token is used for the next requests. - """ - - def authenticate(self, api_client): - """Authenticate the session with basic auth in order to get token(Bearer or JWT). - Then the token is added to a new session Headers. - Is needed the user, password and token_path class atrributes to the get oauth2 token, - based on the client configuration. - """ - auth_session = super().authenticate(api_client) - key = f"oauth2-basic-{api_client.user}-{api_client.password}" - headers = cache.get(key) - - if not headers: - authenticate_url = f"{api_client.base_url}/{api_client.token_path}" - response = auth_session.post( - url=authenticate_url, - data={"grant_type": "client_credentials", "scope": "notification"} - ).json() - headers = { - "Authorization": f"{response.get('token_type')} {response.get('access_token')}" - } - - cache.set(key, headers, int(response.get("expires_in", 300))) - - session = requests.Session() - session.headers.update(headers) - - return session diff --git a/eox_nelp/api_clients/tests/__init__.py b/eox_nelp/api_clients/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/eox_nelp/api_clients/tests/mixins.py b/eox_nelp/api_clients/tests/mixins.py deleted file mode 100644 index a10adbd4..00000000 --- a/eox_nelp/api_clients/tests/mixins.py +++ /dev/null @@ -1,355 +0,0 @@ -"""Mixins for multiple test implementations. - -Classes: - TestRestApiClientMixin: Basic tests that can be implemented by AbstractAPIRestClient children. - TestOauth2AuthenticatorMixin: Basic tests that can be implemented by Oauth2Authenticator children. - TestBasicAuthAuthenticatorMixin: Basic tests that can be implemented by BasicAuthAuthenticator children. - TestSOAPClientMixin: Basic tests that can be implemented by AbstractSOAPClient children. - TestPKCS12AuthenticatorMixin: Basic tests that can be implemented by PKCS12Authenticator children. -""" -from django.core.cache import cache -from mock import Mock, patch -from oauthlib.oauth2 import MissingTokenError -from requests.auth import HTTPBasicAuth - -from eox_nelp import api_clients - - -class TestRestApiClientMixin: - """Basic API client tests.""" - - def tearDown(self): # pylint: disable=invalid-name - """Clear cache after each test case""" - cache.clear() - - @patch("eox_nelp.api_clients.authenticators.requests") - def test_successful_post(self, requests_mock): - """Test case when a POST request success. - - Expected behavior: - - Response is the expected value - - POST was called with the given data and right url. - """ - response = Mock() - response.ok = True - response.status_code = 200 - expected_value = { - "status": {"success": True, "message": "successful", "code": 1} - } - response.json.return_value = expected_value - requests_mock.Session.return_value.post.return_value = response - data = {"testing": True, "application": "futurex"} - - with patch.object(self.api_class, "_authenticate") as auth_mock: - auth_mock.return_value = requests_mock.Session() - api_client = self.api_class() - - response = api_client.make_post("fake/path", data) - - self.assertDictEqual(response, expected_value) - requests_mock.Session.return_value.post.assert_called_with( - url=f"{api_client.base_url}/fake/path", - json=data, - ) - - @patch("eox_nelp.api_clients.authenticators.requests") - def test_failed_post(self, requests_mock): - """Test case when a POST request fails. - - Expected behavior: - - Response is an empty dict - - POST was called with the given data and right url. - - Error was logged. - """ - response = Mock() - response.ok = False - response.status_code = 400 - response.json.return_value = {"test": True} - requests_mock.Session.return_value.post.return_value = response - data = {"testing": True, "application": "futurex"} - log_error = ( - "An error has occurred trying to make post request to https://testing.com/fake/path with status code 400 " - f"and message {response.json()}" - ) - with patch.object(self.api_class, "_authenticate") as auth_mock: - auth_mock.return_value = requests_mock.Session() - api_client = self.api_class() - - with self.assertLogs(api_clients.__name__, level="ERROR") as logs: - response = api_client.make_post("fake/path", data) - - self.assertDictEqual(response, {'error': True, 'message': 'Invalid response with status 400'}) - requests_mock.Session.return_value.post.assert_called_with( - url=f"{api_client.base_url}/fake/path", - json=data, - ) - self.assertEqual(logs.output, [ - f"ERROR:{api_clients.__name__}:{log_error}" - ]) - - @patch("eox_nelp.api_clients.authenticators.requests") - def test_successful_get(self, requests_mock): - """Test case when a GET request success. - - Expected behavior: - - Response is the expected value - - GET was called with the given data and right url. - """ - response = Mock() - response.ok = True - response.status_code = 200 - expected_value = { - "status": {"success": True, "message": "successful", "code": 1} - } - response.json.return_value = expected_value - requests_mock.Session.return_value.get.return_value = response - params = {"format": "json"} - with patch.object(self.api_class, "_authenticate") as auth_mock: - auth_mock.return_value = requests_mock.Session() - api_client = self.api_class() - - response = api_client.make_get("field-options/vocabulary/language", params) - - self.assertDictEqual(response, expected_value) - requests_mock.Session.return_value.get.assert_called_with( - url=f"{api_client.base_url}/field-options/vocabulary/language", - params=params, - ) - - @patch("eox_nelp.api_clients.authenticators.requests") - def test_failed_get(self, requests_mock): - """Test case when a GET request fails. - - Expected behavior: - - Response is an empty dict - - GET was called with the given data and right url. - - Error was logged. - """ - response = Mock() - response.ok = False - response.status_code = 404 - response.json.return_value = {"test": True} - requests_mock.Session.return_value.get.return_value = response - params = {"format": "json"} - log_error = ( - "An error has occurred trying to make a get request to https://testing.com/fake/path with status code 404 " - f"and message {response.json()}" - ) - with patch.object(self.api_class, "_authenticate") as auth_mock: - auth_mock.return_value = requests_mock.Session() - api_client = self.api_class() - - with self.assertLogs(api_clients.__name__, level="ERROR") as logs: - response = api_client.make_get("fake/path", params) - - self.assertDictEqual(response, {'error': True, 'message': 'Invalid response with status 404'}) - requests_mock.Session.return_value.get.assert_called_with( - url=f"{api_client.base_url}/fake/path", - params=params, - ) - self.assertEqual(logs.output, [ - f"ERROR:{api_clients.__name__}:{log_error}" - ]) - - -class TestSOAPClientMixin: - """Basic API client tests.""" - - @patch("eox_nelp.api_clients.authenticators.requests") - def test_successful_post(self, requests_mock): - """Test case when a POST request success. - - Expected behavior: - - Response is the expected value - - POST was called with the given data and right url. - """ - response = Mock() - response.ok = True - response.text = " xml response string from API " - expected_value = response.text - requests_mock.Session.return_value.post.return_value = response - data = """ - - - - - - - """ - - with patch.object(self.api_class, "_authenticate") as auth_mock: - auth_mock.return_value = requests_mock.Session() - api_client = self.api_class() - - response = api_client.make_post("fake/path", data) - - self.assertEqual(response, expected_value) - requests_mock.Session.return_value.post.assert_called_with( - url=f"{api_client.base_url}/fake/path", - data=data.encode("utf-8"), - ) - - @patch("eox_nelp.api_clients.authenticators.requests") - def test_failed_post(self, requests_mock): - """Test case when a POST request fails. - - Expected behavior: - - Response is the expected value. - - POST was called with the given data and right url. - - Error was logged. - """ - response = Mock() - response.ok = False - response.status_code = 400 - response.text = " xml response string from API " - expected_value = response.text - requests_mock.Session.return_value.post.return_value = response - data = """ - - - - - - - """ - - log_error = ( - "An error has occurred trying to make post request to https://testing.com/fake/path with status code 400 " - f"and message {response.text}" - ) - with patch.object(self.api_class, "_authenticate") as auth_mock: - auth_mock.return_value = requests_mock.Session() - api_client = self.api_class() - - with self.assertLogs(api_clients.__name__, level="ERROR") as logs: - response = api_client.make_post("fake/path", data) - - self.assertEqual(response, expected_value) - requests_mock.Session.return_value.post.assert_called_with( - url=f"{api_client.base_url}/fake/path", - data=data.encode("utf-8"), - ) - self.assertEqual(logs.output, [ - f"ERROR:{api_clients.__name__}:{log_error}" - ]) - - def test_invalid_data(self): - """Test that a TypeError exception is raised when the data is not and string. - - Expected behavior: - - Exception was raised - """ - data = {"testing": True, "application": "futurex"} - - with patch.object(self.api_class, "_authenticate"): - api_client = self.api_class() - - with self.assertRaises(TypeError): - api_client.make_post("fake/path", data) - - -class TestOauth2AuthenticatorMixin: - """ - This test class contains test cases for the `AbstractOauth2ApiClient` class - to ensure that the authentication process using OAuth2 is working correctly. - """ - - def test_failed_authentication(self): - """Test case for invalid credentials. - - Expected behavior: - - Raise MissingTokenError exception - """ - self.assertRaises(MissingTokenError, self.api_class) - - @patch("eox_nelp.api_clients.authenticators.OAuth2Session") - def test_successful_authentication(self, oauth2_session_mock): - """Test case when the authentication response is valid. - - Expected behavior: - - Session is set - - Session headers contains Authorization key. - - fetch_token was called with the right values. - """ - fetch_token_mock = Mock() - fetch_token_mock.return_value = { - "token_type": "Bearer", - "access_token": "12345678abc", - "expires_in": 200, - } - oauth2_session_mock.return_value.fetch_token = fetch_token_mock - - api_client = self.api_class() - - self.assertTrue(hasattr(api_client, "session")) - self.assertTrue("Authorization" in api_client.session.headers) - fetch_token_mock.assert_called_with( - token_url=f"{api_client.base_url}/{api_client.authentication_path}", - client_secret=api_client.client_secret, - include_client_id=True, - ) - - -class TestBasicAuthAuthenticatorMixin: - """ - This test class contains test cases for the `AbstractBasicAuthApiClient` class - to ensure that the authentication process using Basic Auth is working correctly. - """ - - @patch("eox_nelp.api_clients.authenticators.requests.Session") - def test_authentication_call(self, session_mock): - """ - Test the authentication call for the API client. - - This test case ensures that the `_authenticate` method of the `AbstractBasicAuthApiClient` - class sets the expected HTTP Basic authentication credentials (user and password) - in the session object. - - Expected behavior: - - Session mock is called once. - - api client has the attribute session - - Session has the right auth value. - """ - expected_auth = HTTPBasicAuth(self.user, self.password) - session_mock.return_value.auth = expected_auth - - api_client = self.api_class() - - session_mock.assert_called_once() - self.assertEqual(api_client.session, session_mock.return_value) - self.assertEqual(api_client.session.auth, expected_auth) - - -class TestPKCS12AuthenticatorMixin: - """ - This test class contains test cases for the `PKCS12Authenticator` class - to ensure that the authentication process using PFX certificate is working correctly. - """ - - @patch("eox_nelp.api_clients.authenticators.Pkcs12Adapter") - @patch("eox_nelp.api_clients.authenticators.requests.Session") - def test_authentication_call(self, session_mock, adapter_mock): - """ - Test the authentication call for the API client. - - This test case ensures that the `_authenticate` method of the `PKCS12Authenticator` - class sets the session object with the right adapter. - - Expected behavior: - - Session mock is called once. - - Session mount method is called once with the right parameters. - - Adapter is called once with the right parameters. - - api client has the attribute session - """ - api_client = self.api_class() - - session_mock.assert_called_once() - session_mock.return_value.mount.assert_called_once_with( - api_client.base_url, - adapter_mock.return_value, - ) - adapter_mock.assert_called_once_with( - pkcs12_filename=api_client.cert, - pkcs12_password=api_client.passphrase, - ) - self.assertEqual(api_client.session, session_mock.return_value)