From 3efc7316a8a0b66a08ec5b73264c0d0ba0070401 Mon Sep 17 00:00:00 2001 From: andrey-canon Date: Fri, 4 Aug 2023 17:29:40 -0500 Subject: [PATCH] feat: add api client based on basic authentication This is part of https://edunext.atlassian.net/browse/FUTUREX-478 --- eox_nelp/api_clients/__init__.py | 142 +++++++++++----- eox_nelp/api_clients/certificates.py | 10 +- eox_nelp/api_clients/futurex.py | 11 +- eox_nelp/api_clients/tests/__init__.py | 151 +++++++++++------- .../api_clients/tests/tests_certificates.py | 16 +- eox_nelp/api_clients/tests/tests_futurex.py | 4 +- eox_nelp/settings/test.py | 4 +- 7 files changed, 217 insertions(+), 121 deletions(-) diff --git a/eox_nelp/api_clients/__init__.py b/eox_nelp/api_clients/__init__.py index 637263f1..3ebe4eaf 100644 --- a/eox_nelp/api_clients/__init__.py +++ b/eox_nelp/api_clients/__init__.py @@ -7,8 +7,10 @@ from abc import ABC, abstractmethod import requests +from django.conf import settings from django.core.cache import cache from oauthlib.oauth2 import BackendApplicationClient +from requests.auth import HTTPBasicAuth from requests_oauthlib import OAuth2Session from rest_framework import status @@ -24,49 +26,16 @@ def base_url(self): """Abstract base_url property method.""" raise NotImplementedError - def __init__(self, client_id, client_secret): + def __init__(self): """ Abstract ApiClient creator, this will set the session based on the authenticate result. - - Args: - client_id: Public application identifier. - client_secret: Confidential identifier. """ - key = f"{client_id}-{client_secret}" - headers = cache.get(key) - - if not headers: - response = self._authenticate(client_id, client_secret) - headers = { - "Authorization": f"{response.get('token_type')} {response.get('access_token')}" - } + self.session = self._authenticate() - cache.set(key, headers, response.get("expires_in", 300)) - - self.session = requests.Session() - self.session.headers.update(headers) - - def _authenticate(self, client_id, client_secret): - """This method uses the session attribute to perform a POS 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: - client_id: Public application identifier. - client_secret: Confidential identifier. - - Return: - Dictionary: Response from authentication request. - """ - client = BackendApplicationClient(client_id=client_id) - oauth = OAuth2Session(client_id=client_id, client=client) - authenticate_url = f"{self.base_url}/oauth/token" - - return oauth.fetch_token( - token_url=authenticate_url, - client_secret=client_secret, - include_client_id=True, - ) + @abstractmethod + def _authenticate(self): + """Abstract method that should return a requests Session instance in its implementation.""" + raise NotImplementedError def make_post(self, path, data): """This method uses the session attribute to perform a POST request based on the @@ -127,3 +96,98 @@ def make_get(self, path, payload): "error": True, "message": f"Invalid response with status {response.status_code}" } + + +class AbstractOauth2ApiClient(AbstractApiClient): + """Abstract class for an OAuth 2.0 authentication API client. + + This class provides basic functionality for an API client that requires + OAuth 2.0 authentication using the client ID and client secret. + + Attributes: + client_id (str): Client ID for OAuth 2.0 authentication. + client_secret (str): Client secret for OAuth 2.0 authentication. + """ + + def __init__(self): + """Initialize an instance of AbstractOauth2ApiClient. + + Gets the client ID and client secret from Django settings and then + calls the constructor of the parent class (AbstractApiClient). + """ + self.client_id = getattr(settings, "FUTUREX_API_CLIENT_ID") + self.client_secret = getattr(settings, "FUTUREX_API_CLIENT_SECRET") + + super().__init__() + + def _authenticate(self): + """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. + """ + key = f"{self.client_id}-{self.client_secret}" + headers = cache.get(key) + + if not headers: + client = BackendApplicationClient(client_id=self.client_id) + oauth = OAuth2Session(client_id=self.client_id, client=client) + authenticate_url = f"{self.base_url}/oauth/token" + response = oauth.fetch_token( + token_url=authenticate_url, + client_secret=self.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 AbstractBasicAuthApiClient(AbstractApiClient): + """Abstract class for a basic authentication API client. + + This class provides basic functionality for an API client that requires + basic authentication using a usern and password. + + Attributes: + user (str): Username for basic authentication. + password (str): Password for basic authentication. + """ + + def __init__(self): + """Initialize an instance of AbstractBasicAuthApiClient. + + Gets the user and password from Django settings and then calls + the constructor of the parent class (AbstractApiClient). + """ + self.user = getattr(settings, "EXTERNAL_CERTIFICATES_USER") + self.password = getattr(settings, "EXTERNAL_CERTIFICATES_PASSWORD") + + super().__init__() + + def _authenticate(self): + """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. + """ + session = requests.Session() + session.auth = HTTPBasicAuth(self.user, self.password) + + return session diff --git a/eox_nelp/api_clients/certificates.py b/eox_nelp/api_clients/certificates.py index 41c70337..e65f1c63 100644 --- a/eox_nelp/api_clients/certificates.py +++ b/eox_nelp/api_clients/certificates.py @@ -5,17 +5,17 @@ """ from django.conf import settings -from eox_nelp.api_clients import AbstractApiClient +from eox_nelp.api_clients import AbstractBasicAuthApiClient -class ExternalCertificatesApiClient(AbstractApiClient): +class ExternalCertificatesApiClient(AbstractBasicAuthApiClient): """Allow to perform multiple external certificates operations.""" def __init__(self): - client_id = getattr(settings, "EXTERNAL_CERTIFICATES_API_CLIENT_ID") - client_secret = getattr(settings, "EXTERNAL_CERTIFICATES_API_CLIENT_SECRET") + self.user = getattr(settings, "EXTERNAL_CERTIFICATES_USER") + self.password = getattr(settings, "EXTERNAL_CERTIFICATES_PASSWORD") - super().__init__(client_id, client_secret) + super().__init__() @property def base_url(self): diff --git a/eox_nelp/api_clients/futurex.py b/eox_nelp/api_clients/futurex.py index a1cb6a1c..79b4b616 100644 --- a/eox_nelp/api_clients/futurex.py +++ b/eox_nelp/api_clients/futurex.py @@ -6,23 +6,16 @@ """ from django.conf import settings -from eox_nelp.api_clients import AbstractApiClient +from eox_nelp.api_clients import AbstractOauth2ApiClient -class FuturexApiClient(AbstractApiClient): +class FuturexApiClient(AbstractOauth2ApiClient): """Allow to perform multiple Futurex API operations based on an authenticated session. Attributes: base_url: Futurex domain. session: persist certain parameters across requests. """ - - def __init__(self): - client_id = getattr(settings, "FUTUREX_API_CLIENT_ID") - client_secret = getattr(settings, "FUTUREX_API_CLIENT_SECRET") - - super().__init__(client_id, client_secret) - @property def base_url(self): return getattr(settings, "FUTUREX_API_URL") diff --git a/eox_nelp/api_clients/tests/__init__.py b/eox_nelp/api_clients/tests/__init__.py index 5664360e..fb0aea6a 100644 --- a/eox_nelp/api_clients/tests/__init__.py +++ b/eox_nelp/api_clients/tests/__init__.py @@ -7,65 +7,26 @@ 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 -from eox_nelp.api_clients import AbstractApiClient -class BasicApiClientMixin: +class TestApiClientMixin: """Basic API client tests.""" def tearDown(self): # pylint: disable=invalid-name """Clear cache after each test case""" cache.clear() - 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.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 - authentication_url = f"{settings.FUTUREX_API_URL}/oauth/token" - client_secret = settings.FUTUREX_API_CLIENT_SECRET - - 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=authentication_url, - client_secret=client_secret, - include_client_id=True, - ) - @patch("eox_nelp.api_clients.requests") - @patch.object(AbstractApiClient, "_authenticate") - def test_successful_post(self, auth_mock, requests_mock): + 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. """ - auth_mock.return_value = {} response = Mock() response.status_code = 200 expected_value = { @@ -74,7 +35,10 @@ def test_successful_post(self, auth_mock, requests_mock): response.json.return_value = expected_value requests_mock.Session.return_value.post.return_value = response data = {"testing": True, "application": "futurex"} - api_client = self.api_class() + + 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) @@ -85,8 +49,7 @@ def test_successful_post(self, auth_mock, requests_mock): ) @patch("eox_nelp.api_clients.requests") - @patch.object(AbstractApiClient, "_authenticate") - def test_failed_post(self, auth_mock, requests_mock): + def test_failed_post(self, requests_mock): """Test case when a POST request fails. Expected behavior: @@ -94,7 +57,6 @@ def test_failed_post(self, auth_mock, requests_mock): - POST was called with the given data and right url. - Error was logged. """ - auth_mock.return_value = {} response = Mock() response.status_code = 400 response.json.return_value = {"test": True} @@ -103,7 +65,9 @@ def test_failed_post(self, auth_mock, requests_mock): log_error = ( "An error has occurred trying to make post request to https://testing.com/fake/path with status code 400" ) - api_client = self.api_class() + 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) @@ -118,15 +82,13 @@ def test_failed_post(self, auth_mock, requests_mock): ]) @patch("eox_nelp.api_clients.requests") - @patch.object(AbstractApiClient, "_authenticate") - def test_successful_get(self, auth_mock, requests_mock): + 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. """ - auth_mock.return_value = {} response = Mock() response.status_code = 200 expected_value = { @@ -135,7 +97,9 @@ def test_successful_get(self, auth_mock, requests_mock): response.json.return_value = expected_value requests_mock.Session.return_value.get.return_value = response params = {"format": "json"} - api_client = self.api_class() + 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) @@ -146,8 +110,7 @@ def test_successful_get(self, auth_mock, requests_mock): ) @patch("eox_nelp.api_clients.requests") - @patch.object(AbstractApiClient, "_authenticate") - def test_failed_get(self, auth_mock, requests_mock): + def test_failed_get(self, requests_mock): """Test case when a GET request fails. Expected behavior: @@ -155,7 +118,6 @@ def test_failed_get(self, auth_mock, requests_mock): - GET was called with the given data and right url. - Error was logged. """ - auth_mock.return_value = {} response = Mock() response.status_code = 404 response.json.return_value = {"test": True} @@ -164,7 +126,9 @@ def test_failed_get(self, auth_mock, requests_mock): log_error = ( "An error has occurred trying to make a get request to https://testing.com/fake/path with status code 404" ) - api_client = self.api_class() + 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) @@ -177,3 +141,80 @@ def test_failed_get(self, auth_mock, requests_mock): self.assertEqual(logs.output, [ f"ERROR:{api_clients.__name__}:{log_error}" ]) + + +class TestOauth2ApiClientMixin(TestApiClientMixin): + """ + 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.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 + authentication_url = f"{settings.FUTUREX_API_URL}/oauth/token" + client_secret = settings.FUTUREX_API_CLIENT_SECRET + + 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=authentication_url, + client_secret=client_secret, + include_client_id=True, + ) + + +class TestBasicAuthApiClientMixin(TestApiClientMixin): + """ + 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.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( + settings.EXTERNAL_CERTIFICATES_USER, + settings.EXTERNAL_CERTIFICATES_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) diff --git a/eox_nelp/api_clients/tests/tests_certificates.py b/eox_nelp/api_clients/tests/tests_certificates.py index 79d8e90f..f20e4d7c 100644 --- a/eox_nelp/api_clients/tests/tests_certificates.py +++ b/eox_nelp/api_clients/tests/tests_certificates.py @@ -6,13 +6,13 @@ import unittest from django.utils import timezone -from mock import patch +from mock import Mock, patch from eox_nelp.api_clients.certificates import ExternalCertificatesApiClient -from eox_nelp.api_clients.tests import BasicApiClientMixin +from eox_nelp.api_clients.tests import TestBasicAuthApiClientMixin -class TestExternalCertificatesApiClient(BasicApiClientMixin, unittest.TestCase): +class TestExternalCertificatesApiClient(TestBasicAuthApiClientMixin, unittest.TestCase): """Tests ExternalCertificatesApiClient""" def setUp(self): @@ -20,14 +20,13 @@ def setUp(self): self.api_class = ExternalCertificatesApiClient @patch.object(ExternalCertificatesApiClient, "make_post") - @patch.object(ExternalCertificatesApiClient, "_authenticate") - def test_create_certificate(self, auth_mock, post_mock): + @patch.object(ExternalCertificatesApiClient, "_authenticate", Mock) + def test_create_certificate(self, post_mock): """Test successful post request. Expected behavior: - Response is the expected value """ - auth_mock.return_value = {} expected_value = { "status": {"success": True, "message": "successful", "code": 1} } @@ -51,14 +50,13 @@ def test_create_certificate(self, auth_mock, post_mock): self.assertDictEqual(response, expected_value) - @patch.object(ExternalCertificatesApiClient, "_authenticate") - def test_failed_create_certificate(self, auth_mock): + @patch.object(ExternalCertificatesApiClient, "_authenticate", Mock) + def test_failed_create_certificate(self): """Test when the mandatory fields has not been sent. Expected behavior: - Raise KeyError exception. """ - auth_mock.return_value = {} data = {} api_client = ExternalCertificatesApiClient() diff --git a/eox_nelp/api_clients/tests/tests_futurex.py b/eox_nelp/api_clients/tests/tests_futurex.py index 01276806..0013b847 100644 --- a/eox_nelp/api_clients/tests/tests_futurex.py +++ b/eox_nelp/api_clients/tests/tests_futurex.py @@ -8,10 +8,10 @@ from mock import patch from eox_nelp.api_clients.futurex import FuturexApiClient, FuturexMissingArguments -from eox_nelp.api_clients.tests import BasicApiClientMixin +from eox_nelp.api_clients.tests import TestOauth2ApiClientMixin -class TestFuturexApiClient(BasicApiClientMixin, unittest.TestCase): +class TestFuturexApiClient(TestOauth2ApiClientMixin, unittest.TestCase): """Tests FuturexApiClient""" def setUp(self): diff --git a/eox_nelp/settings/test.py b/eox_nelp/settings/test.py index 0caf89d7..836533a6 100644 --- a/eox_nelp/settings/test.py +++ b/eox_nelp/settings/test.py @@ -37,8 +37,8 @@ def plugin_settings(settings): # pylint: disable=function-redefined settings.FUTUREX_API_CLIENT_SECRET = 'my-test-client-secret' settings.FUTUREX_NOTIFY_SUBSECTION_SUBJECT_MESSAGE = DEFAULT_FUTUREX_NOTIFY_SUBSECTION_SUBJECT_MESSAGE # noqa: F405 settings.EXTERNAL_CERTIFICATES_API_URL = 'https://testing.com' - settings.EXTERNAL_CERTIFICATES_API_CLIENT_ID = 'my-test-client-id' - settings.EXTERNAL_CERTIFICATES_API_CLIENT_SECRET = 'my-test-client-secret' + settings.EXTERNAL_CERTIFICATES_USER = 'test-user' + settings.EXTERNAL_CERTIFICATES_PASSWORD = 'test-password' SETTINGS = SettingsClass()