From a672ded10c426bcd5f42aab47c7c8b4057726c97 Mon Sep 17 00:00:00 2001 From: andrey-canon Date: Wed, 2 Aug 2023 16:42:33 -0500 Subject: [PATCH] refactor: implementing abstract api class This move the general logic from the FuturexAPIClient to a new Abstract class. --- eox_nelp/api_clients/__init__.py | 129 ++++++++++++++ eox_nelp/api_clients/futurex.py | 105 +----------- eox_nelp/api_clients/tests/__init__.py | 179 +++++++++++++++++++ eox_nelp/api_clients/tests/tests_futurex.py | 180 ++------------------ 4 files changed, 324 insertions(+), 269 deletions(-) diff --git a/eox_nelp/api_clients/__init__.py b/eox_nelp/api_clients/__init__.py index e69de29b..637263f1 100644 --- a/eox_nelp/api_clients/__init__.py +++ b/eox_nelp/api_clients/__init__.py @@ -0,0 +1,129 @@ +"""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 + +import requests +from django.core.cache import cache +from oauthlib.oauth2 import BackendApplicationClient +from requests_oauthlib import OAuth2Session +from rest_framework import status + +LOGGER = logging.getLogger(__name__) + + +class AbstractApiClient(ABC): + """Abstract api client class, this implement a basic authentication method and defines methods POST and GET""" + + @property + @abstractmethod + def base_url(self): + """Abstract base_url property method.""" + raise NotImplementedError + + def __init__(self, client_id, client_secret): + """ + 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')}" + } + + 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, + ) + + 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.status_code == status.HTTP_200_OK: + return response.json() + + LOGGER.error( + "An error has occurred trying to make post request to %s with status code %s", + url, + response.status_code, + ) + + 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.status_code == status.HTTP_200_OK: + return response.json() + + LOGGER.error( + "An error has occurred trying to make a get request to %s with status code %s", + url, + response.status_code, + ) + + return { + "error": True, + "message": f"Invalid response with status {response.status_code}" + } diff --git a/eox_nelp/api_clients/futurex.py b/eox_nelp/api_clients/futurex.py index a0aac9fb..a1cb6a1c 100644 --- a/eox_nelp/api_clients/futurex.py +++ b/eox_nelp/api_clients/futurex.py @@ -4,19 +4,12 @@ FuturexApiClient: Base class to interact with Futurex services. FuturexMissingArguments: Exception used for indicate that some required arguments are missing. """ -import logging - -import requests from django.conf import settings -from django.core.cache import cache -from oauthlib.oauth2 import BackendApplicationClient -from requests_oauthlib import OAuth2Session -from rest_framework import status -LOGGER = logging.getLogger(__name__) +from eox_nelp.api_clients import AbstractApiClient -class FuturexApiClient: +class FuturexApiClient(AbstractApiClient): """Allow to perform multiple Futurex API operations based on an authenticated session. Attributes: @@ -25,100 +18,14 @@ class FuturexApiClient: """ def __init__(self): - """FuturexApiClient creator, this will set the session based on the authenticate result""" - self.base_url = getattr(settings, "FUTUREX_API_URL") client_id = getattr(settings, "FUTUREX_API_CLIENT_ID") client_secret = getattr(settings, "FUTUREX_API_CLIENT_SECRET") - 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')}" - } - - 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 identifier of futurex application. - client_secret: Confidential identifier used to authenticate against Futurex. - - 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, - ) - - def make_post(self, path, data): - """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: - 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.status_code == status.HTTP_200_OK: - return response.json() - - LOGGER.error( - "An error has occurred trying to make post request to %s with status code %s", - url, - response.status_code, - ) - - return {} - - 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.status_code == status.HTTP_200_OK: - return response.json() - - LOGGER.error( - "An error has occurred trying to make a get request to %s with status code %s", - url, - response.status_code, - ) + super().__init__(client_id, client_secret) - return {} + @property + def base_url(self): + return getattr(settings, "FUTUREX_API_URL") def enrollment_progress(self, enrollment_data): """Push the user progress across for a course. This data will affect the learner profile diff --git a/eox_nelp/api_clients/tests/__init__.py b/eox_nelp/api_clients/tests/__init__.py index e69de29b..5664360e 100644 --- a/eox_nelp/api_clients/tests/__init__.py +++ b/eox_nelp/api_clients/tests/__init__.py @@ -0,0 +1,179 @@ +"""General tests functions and classes for api_clients module. + +Classes: + BasicApiClientMixin: Basic tests that can be implement by AbstractApiClient children. +""" +from django.conf import settings +from django.core.cache import cache +from mock import Mock, patch +from oauthlib.oauth2 import MissingTokenError + +from eox_nelp import api_clients +from eox_nelp.api_clients import AbstractApiClient + + +class BasicApiClientMixin: + """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): + """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 = { + "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"} + 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.requests") + @patch.object(AbstractApiClient, "_authenticate") + def test_failed_post(self, auth_mock, 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. + """ + auth_mock.return_value = {} + response = Mock() + 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" + ) + 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.requests") + @patch.object(AbstractApiClient, "_authenticate") + def test_successful_get(self, auth_mock, 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 = { + "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"} + 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.requests") + @patch.object(AbstractApiClient, "_authenticate") + def test_failed_get(self, auth_mock, 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. + """ + auth_mock.return_value = {} + response = Mock() + 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" + ) + 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}" + ]) diff --git a/eox_nelp/api_clients/tests/tests_futurex.py b/eox_nelp/api_clients/tests/tests_futurex.py index 7d56c0ab..01276806 100644 --- a/eox_nelp/api_clients/tests/tests_futurex.py +++ b/eox_nelp/api_clients/tests/tests_futurex.py @@ -1,182 +1,22 @@ """This file contains all the test for futurex api client file. + Classes: - LikeDislikeUnitExperienceTestCase: Test LikeDislikeUnitExperienceView. + TestFuturexApiClient: Test for eox-nelp/api_clients/futurex.py. """ import unittest -from django.conf import settings -from django.core.cache import cache -from mock import Mock, patch -from oauthlib.oauth2 import MissingTokenError +from mock import patch -from eox_nelp.api_clients import futurex from eox_nelp.api_clients.futurex import FuturexApiClient, FuturexMissingArguments +from eox_nelp.api_clients.tests import BasicApiClientMixin -class TestFuturexApiclient(unittest.TestCase): +class TestFuturexApiClient(BasicApiClientMixin, unittest.TestCase): """Tests FuturexApiClient""" - def tearDown(self): - cache.clear() - - def test_failed_authentication(self): - """Test case for invalid credentials. - - Expected behavior: - - Raise MissingTokenError exception - """ - self.assertRaises(MissingTokenError, FuturexApiClient) - - @patch("eox_nelp.api_clients.futurex.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 = FuturexApiClient() - - 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.futurex.requests") - @patch.object(FuturexApiClient, "_authenticate") - def test_successful_post(self, auth_mock, 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 = { - "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"} - api_client = FuturexApiClient() - - 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.futurex.requests") - @patch.object(FuturexApiClient, "_authenticate") - def test_failed_post(self, auth_mock, 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. - """ - auth_mock.return_value = {} - response = Mock() - 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" - ) - api_client = FuturexApiClient() - - with self.assertLogs(futurex.__name__, level="ERROR") as logs: - response = api_client.make_post("fake/path", data) - - self.assertDictEqual(response, {}) - 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:{futurex.__name__}:{log_error}" - ]) - - @patch("eox_nelp.api_clients.futurex.requests") - @patch.object(FuturexApiClient, "_authenticate") - def test_successful_get(self, auth_mock, 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 = { - "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"} - api_client = FuturexApiClient() - - 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.futurex.requests") - @patch.object(FuturexApiClient, "_authenticate") - def test_failed_get(self, auth_mock, 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. - """ - auth_mock.return_value = {} - response = Mock() - 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" - ) - api_client = FuturexApiClient() - - with self.assertLogs(futurex.__name__, level="ERROR") as logs: - response = api_client.make_get("fake/path", params) - - self.assertDictEqual(response, {}) - 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:{futurex.__name__}:{log_error}" - ]) + def setUp(self): + """Setup common conditions for every test case""" + self.api_class = FuturexApiClient @patch.object(FuturexApiClient, "make_post") @patch.object(FuturexApiClient, "_authenticate") @@ -201,7 +41,7 @@ def test_enrollment_progress(self, auth_mock, post_mock): "enrolledAt": "2012-12-30", "isCompleted": False, } - api_client = FuturexApiClient() + api_client = self.api_class() response = api_client.enrollment_progress(data) @@ -221,6 +61,6 @@ def test_failed_enrollment_progress(self, auth_mock): "userId": 52, "approxTotalCourseHrs": 5, } - api_client = FuturexApiClient() + api_client = self.api_class() self.assertRaises(FuturexMissingArguments, api_client.enrollment_progress, data)