Commit 62403eea by Renzo Lucioni

Extend edX API utility to support retrieval of specific resources

This will be used to retrieve data for the program detail page. Part of ECOM-4415.
parent b3462e5b
......@@ -11,23 +11,25 @@ from openedx.core.lib.token_utils import get_id_token
log = logging.getLogger(__name__)
def get_edx_api_data(api_config, user, resource, querystring=None, cache_key=None):
"""Fetch data from an API using provided API configuration and resource
name.
def get_edx_api_data(api_config, user, resource, resource_id=None, querystring=None, cache_key=None):
"""GET data from an edX REST API.
DRY utility for handling caching and pagination.
Arguments:
api_config (ConfigurationModel): The configuration model governing
interaction with the API.
api_config (ConfigurationModel): The configuration model governing interaction with the API.
user (User): The user to authenticate as when requesting data.
resource(str): Name of the API resource for which data is being
requested.
querystring(dict): Querystring parameters that might be required to
request data.
cache_key(str): Where to cache retrieved data. Omitting this will cause the
cache to be bypassed.
resource (str): Name of the API resource being requested.
Keyword Arguments:
resource_id (int or str): Identifies a specific resource to be retrieved.
querystring (dict): Optional query string parameters.
cache_key (str): Where to cache retrieved data. The cache will be ignored if this is omitted
(neither inspected nor updated).
Returns:
list of dict, representing data returned by the API.
Data returned by the API. When hitting a list endpoint, extracts "results" (list of dict)
returned by DRF-powered APIs.
"""
no_data = []
......@@ -36,30 +38,29 @@ def get_edx_api_data(api_config, user, resource, querystring=None, cache_key=Non
return no_data
if cache_key:
cache_key = '{}.{}'.format(cache_key, resource_id) if resource_id else cache_key
cached = cache.get(cache_key)
if cached is not None:
if cached:
return cached
try:
jwt = get_id_token(user, api_config.OAUTH2_CLIENT_NAME)
api = EdxRestApiClient(api_config.internal_api_url, jwt=jwt)
except Exception: # pylint: disable=broad-except
except: # pylint: disable=bare-except
log.exception('Failed to initialize the %s API client.', api_config.API_NAME)
return no_data
try:
querystring = {} if not querystring else querystring
response = getattr(api, resource).get(**querystring)
results = response.get('results', no_data)
page = 1
next_page = response.get('next', None)
while next_page:
page += 1
querystring['page'] = page
response = getattr(api, resource).get(**querystring)
results += response.get('results', no_data)
next_page = response.get('next', None)
except Exception: # pylint: disable=broad-except
endpoint = getattr(api, resource)
querystring = querystring if querystring else {}
response = endpoint(resource_id).get(**querystring)
if resource_id:
results = response
else:
results = _traverse_pagination(response, endpoint, querystring, no_data)
except: # pylint: disable=bare-except
log.exception('Failed to retrieve data from the %s API.', api_config.API_NAME)
return no_data
......@@ -67,3 +68,22 @@ def get_edx_api_data(api_config, user, resource, querystring=None, cache_key=Non
cache.set(cache_key, results, api_config.cache_ttl)
return results
def _traverse_pagination(response, endpoint, querystring, no_data):
"""Traverse a paginated API response.
Extracts and concatenates "results" (list of dict) returned by DRF-powered APIs.
"""
results = response.get('results', no_data)
page = 1
next_page = response.get('next')
while next_page:
page += 1
querystring['page'] = page
response = endpoint.get(**querystring)
results += response.get('results', no_data)
next_page = response.get('next')
return results
"""Tests covering Api utils."""
"""Tests covering edX API utilities."""
import json
import unittest
from django.conf import settings
from django.core.cache import cache
from django.test import TestCase
import httpretty
import mock
from nose.plugins.attrib import attr
from edx_oauth2_provider.tests.factories import ClientFactory
from provider.constants import CONFIDENTIAL
from testfixtures import LogCapture
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin, CredentialsDataMixin
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin, ProgramsDataMixin
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
from openedx.core.lib.edx_api_utils import get_edx_api_data
from student.tests.factories import UserFactory
LOGGER_NAME = 'openedx.core.lib.edx_api_utils'
UTILITY_MODULE = 'openedx.core.lib.edx_api_utils'
@attr('shard_2')
class TestApiDataRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin, ProgramsApiConfigMixin, ProgramsDataMixin,
CacheIsolationTestCase):
"""Test utility for API data retrieval."""
@httpretty.activate
class TestGetEdxApiData(ProgramsApiConfigMixin, CacheIsolationTestCase):
"""Tests for edX API data retrieval utility."""
ENABLED_CACHES = ['default']
def setUp(self):
super(TestApiDataRetrieval, self).setUp()
ClientFactory(name=CredentialsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
super(TestGetEdxApiData, self).setUp()
self.user = UserFactory()
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
cache.clear()
@httpretty.activate
def test_get_edx_api_data_programs(self):
"""Verify programs data can be retrieved using get_edx_api_data."""
def _mock_programs_api(self, responses, url=None):
"""Helper for mocking out Programs API URLs."""
self.assertTrue(httpretty.is_enabled(), msg='httpretty must be enabled to mock Programs API calls.')
url = url if url else ProgramsApiConfig.current().internal_api_url.strip('/') + '/programs/'
httpretty.register_uri(httpretty.GET, url, responses=responses)
def _assert_num_requests(self, count):
"""DRY helper for verifying request counts."""
self.assertEqual(len(httpretty.httpretty.latest_requests), count)
def test_get_unpaginated_data(self):
"""Verify that unpaginated data can be retrieved."""
program_config = self.create_programs_config()
self.mock_programs_api()
actual = get_edx_api_data(program_config, self.user, 'programs')
self.assertEqual(
actual,
self.PROGRAMS_API_RESPONSE['results']
expected_collection = ['some', 'test', 'data']
data = {
'next': None,
'results': expected_collection,
}
self._mock_programs_api(
[httpretty.Response(body=json.dumps(data), content_type='application/json')]
)
# Verify the API was actually hit (not the cache).
self.assertEqual(len(httpretty.httpretty.latest_requests), 1)
actual_collection = get_edx_api_data(program_config, self.user, 'programs')
self.assertEqual(actual_collection, expected_collection)
def test_get_edx_api_data_disable_config(self):
"""Verify no data is retrieved if configuration is disabled."""
program_config = self.create_programs_config(enabled=False)
# Verify the API was actually hit (not the cache)
self._assert_num_requests(1)
actual = get_edx_api_data(program_config, self.user, 'programs')
self.assertEqual(actual, [])
def test_get_paginated_data(self):
"""Verify that paginated data can be retrieved."""
program_config = self.create_programs_config()
expected_collection = ['some', 'test', 'data']
url = ProgramsApiConfig.current().internal_api_url.strip('/') + '/programs/?page={}'
responses = []
for page, record in enumerate(expected_collection, start=1):
data = {
'next': url.format(page + 1) if page < len(expected_collection) else None,
'results': [record],
}
@httpretty.activate
def test_get_edx_api_data_cache(self):
body = json.dumps(data)
responses.append(
httpretty.Response(body=body, content_type='application/json')
)
self._mock_programs_api(responses)
actual_collection = get_edx_api_data(program_config, self.user, 'programs')
self.assertEqual(actual_collection, expected_collection)
self._assert_num_requests(len(expected_collection))
def test_get_specific_resource(self):
"""Verify that a specific resource can be retrieved."""
program_config = self.create_programs_config()
resource_id = 1
url = '{api_root}/programs/{resource_id}/'.format(
api_root=ProgramsApiConfig.current().internal_api_url.strip('/'),
resource_id=resource_id,
)
expected_resource = {'key': 'value'}
self._mock_programs_api(
[httpretty.Response(body=json.dumps(expected_resource), content_type='application/json')],
url=url
)
actual_resource = get_edx_api_data(program_config, self.user, 'programs', resource_id=resource_id)
self.assertEqual(actual_resource, expected_resource)
self._assert_num_requests(1)
def test_cache_utilization(self):
"""Verify that when enabled, the cache is used."""
program_config = self.create_programs_config(cache_ttl=1)
self.mock_programs_api()
program_config = self.create_programs_config(cache_ttl=5)
expected_collection = ['some', 'test', 'data']
data = {
'next': None,
'results': expected_collection,
}
self._mock_programs_api(
[httpretty.Response(body=json.dumps(data), content_type='application/json')],
)
resource_id = 1
url = '{api_root}/programs/{resource_id}/'.format(
api_root=ProgramsApiConfig.current().internal_api_url.strip('/'),
resource_id=resource_id,
)
expected_resource = {'key': 'value'}
self._mock_programs_api(
[httpretty.Response(body=json.dumps(expected_resource), content_type='application/json')],
url=url
)
cache_key = ProgramsApiConfig.current().CACHE_KEY
# Warm up the cache.
get_edx_api_data(program_config, self.user, 'programs', cache_key='test.key')
get_edx_api_data(program_config, self.user, 'programs', cache_key=cache_key)
get_edx_api_data(program_config, self.user, 'programs', resource_id=resource_id, cache_key=cache_key)
# Hit the cache.
get_edx_api_data(program_config, self.user, 'programs', cache_key='test.key')
actual_collection = get_edx_api_data(program_config, self.user, 'programs', cache_key=cache_key)
self.assertEqual(actual_collection, expected_collection)
actual_resource = get_edx_api_data(
program_config, self.user, 'programs', resource_id=resource_id, cache_key=cache_key
)
self.assertEqual(actual_resource, expected_resource)
# Verify that only two requests were made, not four.
self._assert_num_requests(2)
@mock.patch(UTILITY_MODULE + '.log.warning')
def test_api_config_disabled(self, mock_warning):
"""Verify that no data is retrieved if the provided config model is disabled."""
program_config = self.create_programs_config(enabled=False)
actual = get_edx_api_data(program_config, self.user, 'programs')
# Verify only one request was made.
self.assertEqual(len(httpretty.httpretty.latest_requests), 1)
self.assertTrue(mock_warning.called)
self.assertEqual(actual, [])
@mock.patch('edx_rest_api_client.client.EdxRestApiClient.__init__')
def test_get_edx_api_data_client_initialization_failure(self, mock_init):
"""Verify no data is retrieved and exception logged when API client
fails to initialize.
"""
program_config = self.create_programs_config()
@mock.patch(UTILITY_MODULE + '.log.exception')
def test_client_initialization_failure(self, mock_exception, mock_init):
"""Verify that an exception is logged when the API client fails to initialize."""
mock_init.side_effect = Exception
with LogCapture(LOGGER_NAME) as logger:
actual = get_edx_api_data(program_config, self.user, 'programs')
logger.check(
(LOGGER_NAME, 'ERROR', u'Failed to initialize the programs API client.')
)
self.assertEqual(actual, [])
self.assertTrue(mock_init.called)
program_config = self.create_programs_config()
actual = get_edx_api_data(program_config, self.user, 'programs')
self.assertTrue(mock_exception.called)
self.assertEqual(actual, [])
@httpretty.activate
def test_get_edx_api_data_retrieval_failure(self):
"""Verify exception is logged when data can't be retrieved from API."""
@mock.patch(UTILITY_MODULE + '.log.exception')
def test_data_retrieval_failure(self, mock_exception):
"""Verify that an exception is logged when data can't be retrieved."""
program_config = self.create_programs_config()
self.mock_programs_api(status_code=500)
with LogCapture(LOGGER_NAME) as logger:
actual = get_edx_api_data(program_config, self.user, 'programs')
logger.check(
(LOGGER_NAME, 'ERROR', u'Failed to retrieve data from the programs API.')
)
self.assertEqual(actual, [])
# this test is skipped under cms because the credentials app is only installed under LMS.
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@httpretty.activate
def test_get_edx_api_data_multiple_page(self):
"""Verify that all data is retrieve for multiple page response."""
credentials_config = self.create_credentials_config()
self.mock_credentials_api(self.user, is_next_page=True)
querystring = {'username': self.user.username}
actual = get_edx_api_data(credentials_config, self.user, 'user_credentials', querystring=querystring)
expected_data = self.CREDENTIALS_NEXT_API_RESPONSE['results'] + self.CREDENTIALS_API_RESPONSE['results']
self.assertEqual(actual, expected_data)
self._mock_programs_api(
[httpretty.Response(body='clunk', content_type='application/json', status_code=500)]
)
actual = get_edx_api_data(program_config, self.user, 'programs')
self.assertTrue(mock_exception.called)
self.assertEqual(actual, [])
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment