Commit 9fa2dc03 by Renzo Lucioni

Merge pull request #12565 from edx/renzo/program-by-id

Extend edX API utility to support retrieval of specific resources
parents 3c9a328b 62403eea
...@@ -11,23 +11,25 @@ from openedx.core.lib.token_utils import get_id_token ...@@ -11,23 +11,25 @@ from openedx.core.lib.token_utils import get_id_token
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def get_edx_api_data(api_config, user, resource, querystring=None, cache_key=None): def get_edx_api_data(api_config, user, resource, resource_id=None, querystring=None, cache_key=None):
"""Fetch data from an API using provided API configuration and resource """GET data from an edX REST API.
name.
DRY utility for handling caching and pagination.
Arguments: Arguments:
api_config (ConfigurationModel): The configuration model governing api_config (ConfigurationModel): The configuration model governing interaction with the API.
interaction with the API.
user (User): The user to authenticate as when requesting data. user (User): The user to authenticate as when requesting data.
resource(str): Name of the API resource for which data is being resource (str): Name of the API resource being requested.
requested.
querystring(dict): Querystring parameters that might be required to Keyword Arguments:
request data. resource_id (int or str): Identifies a specific resource to be retrieved.
cache_key(str): Where to cache retrieved data. Omitting this will cause the querystring (dict): Optional query string parameters.
cache to be bypassed. cache_key (str): Where to cache retrieved data. The cache will be ignored if this is omitted
(neither inspected nor updated).
Returns: 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 = [] no_data = []
...@@ -36,34 +38,52 @@ def get_edx_api_data(api_config, user, resource, querystring=None, cache_key=Non ...@@ -36,34 +38,52 @@ def get_edx_api_data(api_config, user, resource, querystring=None, cache_key=Non
return no_data return no_data
if cache_key: if cache_key:
cache_key = '{}.{}'.format(cache_key, resource_id) if resource_id else cache_key
cached = cache.get(cache_key) cached = cache.get(cache_key)
if cached is not None: if cached:
return cached return cached
try: try:
jwt = get_id_token(user, api_config.OAUTH2_CLIENT_NAME) jwt = get_id_token(user, api_config.OAUTH2_CLIENT_NAME)
api = EdxRestApiClient(api_config.internal_api_url, jwt=jwt) 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) log.exception('Failed to initialize the %s API client.', api_config.API_NAME)
return no_data return no_data
try: try:
querystring = {} if not querystring else querystring endpoint = getattr(api, resource)
response = getattr(api, resource).get(**querystring) 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
if cache_key:
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) results = response.get('results', no_data)
page = 1 page = 1
next_page = response.get('next', None) next_page = response.get('next')
while next_page: while next_page:
page += 1 page += 1
querystring['page'] = page querystring['page'] = page
response = getattr(api, resource).get(**querystring) response = endpoint.get(**querystring)
results += response.get('results', no_data) results += response.get('results', no_data)
next_page = response.get('next', None) next_page = response.get('next')
except Exception: # pylint: disable=broad-except
log.exception('Failed to retrieve data from the %s API.', api_config.API_NAME)
return no_data
if cache_key:
cache.set(cache_key, results, api_config.cache_ttl)
return results return results
"""Tests covering Api utils.""" """Tests covering edX API utilities."""
import json
import unittest import unittest
from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.test import TestCase
import httpretty import httpretty
import mock import mock
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from edx_oauth2_provider.tests.factories import ClientFactory from edx_oauth2_provider.tests.factories import ClientFactory
from provider.constants import CONFIDENTIAL 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.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.djangolib.testing.utils import CacheIsolationTestCase
from openedx.core.lib.edx_api_utils import get_edx_api_data from openedx.core.lib.edx_api_utils import get_edx_api_data
from student.tests.factories import UserFactory 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') @attr('shard_2')
class TestApiDataRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin, ProgramsApiConfigMixin, ProgramsDataMixin, @httpretty.activate
CacheIsolationTestCase): class TestGetEdxApiData(ProgramsApiConfigMixin, CacheIsolationTestCase):
"""Test utility for API data retrieval.""" """Tests for edX API data retrieval utility."""
ENABLED_CACHES = ['default'] ENABLED_CACHES = ['default']
def setUp(self): def setUp(self):
super(TestApiDataRetrieval, self).setUp() super(TestGetEdxApiData, self).setUp()
ClientFactory(name=CredentialsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
self.user = UserFactory() self.user = UserFactory()
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
cache.clear() cache.clear()
@httpretty.activate def _mock_programs_api(self, responses, url=None):
def test_get_edx_api_data_programs(self): """Helper for mocking out Programs API URLs."""
"""Verify programs data can be retrieved using get_edx_api_data.""" 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() program_config = self.create_programs_config()
self.mock_programs_api()
actual = get_edx_api_data(program_config, self.user, 'programs') expected_collection = ['some', 'test', 'data']
self.assertEqual( data = {
actual, 'next': None,
self.PROGRAMS_API_RESPONSE['results'] '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). actual_collection = get_edx_api_data(program_config, self.user, 'programs')
self.assertEqual(len(httpretty.httpretty.latest_requests), 1) self.assertEqual(actual_collection, expected_collection)
def test_get_edx_api_data_disable_config(self): # Verify the API was actually hit (not the cache)
"""Verify no data is retrieved if configuration is disabled.""" self._assert_num_requests(1)
program_config = self.create_programs_config(enabled=False)
actual = get_edx_api_data(program_config, self.user, 'programs') def test_get_paginated_data(self):
self.assertEqual(actual, []) """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],
}
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)
@httpretty.activate def test_cache_utilization(self):
def test_get_edx_api_data_cache(self):
"""Verify that when enabled, the cache is used.""" """Verify that when enabled, the cache is used."""
program_config = self.create_programs_config(cache_ttl=1) program_config = self.create_programs_config(cache_ttl=5)
self.mock_programs_api()
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. # 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. # 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)
# Verify only one request was made. actual = get_edx_api_data(program_config, self.user, 'programs')
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__') @mock.patch('edx_rest_api_client.client.EdxRestApiClient.__init__')
def test_get_edx_api_data_client_initialization_failure(self, mock_init): @mock.patch(UTILITY_MODULE + '.log.exception')
"""Verify no data is retrieved and exception logged when API client def test_client_initialization_failure(self, mock_exception, mock_init):
fails to initialize. """Verify that an exception is logged when the API client fails to initialize."""
"""
program_config = self.create_programs_config()
mock_init.side_effect = Exception mock_init.side_effect = Exception
with LogCapture(LOGGER_NAME) as logger: program_config = self.create_programs_config()
actual = get_edx_api_data(program_config, self.user, 'programs') actual = get_edx_api_data(program_config, self.user, 'programs')
logger.check(
(LOGGER_NAME, 'ERROR', u'Failed to initialize the programs API client.') self.assertTrue(mock_exception.called)
)
self.assertEqual(actual, []) self.assertEqual(actual, [])
self.assertTrue(mock_init.called)
@httpretty.activate @mock.patch(UTILITY_MODULE + '.log.exception')
def test_get_edx_api_data_retrieval_failure(self): def test_data_retrieval_failure(self, mock_exception):
"""Verify exception is logged when data can't be retrieved from API.""" """Verify that an exception is logged when data can't be retrieved."""
program_config = self.create_programs_config() program_config = self.create_programs_config()
self.mock_programs_api(status_code=500)
with LogCapture(LOGGER_NAME) as logger: self._mock_programs_api(
actual = get_edx_api_data(program_config, self.user, 'programs') [httpretty.Response(body='clunk', content_type='application/json', status_code=500)]
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. actual = get_edx_api_data(program_config, self.user, 'programs')
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@httpretty.activate self.assertTrue(mock_exception.called)
def test_get_edx_api_data_multiple_page(self): self.assertEqual(actual, [])
"""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)
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