Repository: box/box-python-sdk
Pull request #246 (2017-10-03 20:49:39)

Add network_response to BoxOAuthException

boxcla: Verified that @anthonywee has signed the CLA. Thanks for the pull request!
anthonywee: This is a PR to compare 2 options for allowing for retries of a failed downscope_token() request. The other PR is #247 
jmoldow: Can you also add `headers`, and include `headers` in the string representation?
jmoldow: Please update the `HISTORY` file.
anthonywee: For `headers`, do you mean the response headers or the request headers? For the response headers, that should be accessible from the `network_response`. Would we expose that as a convenience?
jmoldow: I guess it doesn't need to be a separate param or a separate property. But can you put it into the message?

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index c75fe80..0f36f58 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -102,11 +102,12 @@ Release History
   These classes manage the logic of constructing requests to an endpoint and storing the results,
   then provide ``__next__`` to easily iterate over the results. The option to return results one
   by one or as a ``Page`` of results is also provided.
-- Added a ``downscope_token()`` method to the ``OAuth2`` class. This generates a token that
+- Added a ``downscope_token()`` method to the ``Client`` class. This generates a token that
   has its permissions reduced to the provided scopes and for the optionally provided 
   ``File`` or ``Folder``.
 - Added methods for configuring ``JWTAuth`` from config file: ``JWTAuth.from_settings_file`` and
   ``JWTAuth.from_settings_dictionary``.
+- Added ``network_response`` property to ``BoxOAuthException``.
 
 **Other**
 
diff --git a/boxsdk/auth/oauth2.py b/boxsdk/auth/oauth2.py
index 8cac23a..e3b2895 100644
--- a/boxsdk/auth/oauth2.py
+++ b/boxsdk/auth/oauth2.py
@@ -328,14 +328,14 @@ def _execute_token_request(self, data, access_token, expect_refresh_token=True):
             access_token=access_token,
         )
         if not network_response.ok:
-            raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
+            raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST', network_response)
         try:
             token_response = TokenResponse(network_response.json())
         except ValueError:
-            raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
+            raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST', network_response)
 
         if ('access_token' not in token_response) or (expect_refresh_token and 'refresh_token' not in token_response):
-            raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
+            raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST', network_response)
 
         return token_response
 
@@ -383,48 +383,9 @@ def revoke(self):
                 access_token=access_token,
             )
             if not network_response.ok:
-                raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
+                raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST', network_response)
             self._store_tokens(None, None)
 
-    def downscope_token(self, scopes, item=None, additional_data=None):
-        """
-        Generate a downscoped token for the provided file or folder with the provided scopes.
-
-        :param scope:
-            The scope(s) to apply to the resulting token.
-        :type scopes:
-            `Iterable` of :class:`TokenScope`
-        :param item:
-            (Optional) The file or folder to get a downscoped token for. If None, the resulting token will
-            not be scoped down to just a single item.
-        :type item:
-            :class:`Item`
-        :param additional_data:
-            (Optional) Key value pairs which can be used to add/update the default data values in the request.
-        :type additional_data:
-            `dict`
-        :return:
-            The response for the downscope token request.
-        :rtype:
-            :class:`TokenResponse`
-        """
-        self._check_closed()
-        with self._refresh_lock:
-            self._check_closed()
-            access_token, _ = self._get_and_update_current_tokens()
-            data = {
-                'subject_token': access_token,
-                'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
-                'scope': ' '.join(scopes),
-                'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
-            }
-            if item:
-                data['resource'] = item.get_url()
-            if additional_data:
-                data.update(additional_data)
-
-            return self._execute_token_request(data, access_token, expect_refresh_token=False)
-
     def close(self, revoke=True):
         """Close the auth object.
 
diff --git a/boxsdk/client/client.py b/boxsdk/client/client.py
index 8a7c825..8feee40 100644
--- a/boxsdk/client/client.py
+++ b/boxsdk/client/client.py
@@ -3,6 +3,7 @@
 from __future__ import unicode_literals, absolute_import
 import json
 
+from ..auth.oauth2 import TokenResponse
 from ..config import API
 from ..session.box_session import BoxSession
 from ..network.default_network import DefaultNetwork
@@ -438,6 +439,43 @@ def create_user(self, name, login=None, **user_attributes):
             response_object=response,
         )
 
+    def downscope_token(self, scopes, item=None, additional_data=None):
+        """
+        Generate a downscoped token for the provided file or folder with the provided scopes.
+
+        :param scope:
+            The scope(s) to apply to the resulting token.
+        :type scopes:
+            `Iterable` of :class:`TokenScope`
+        :param item:
+            (Optional) The file or folder to get a downscoped token for. If None, the resulting token will
+            not be scoped down to just a single item.
+        :type item:
+            :class:`Item`
+        :param additional_data:
+            (Optional) Key value pairs which can be used to add/update the default data values in the request.
+        :type additional_data:
+            `dict`
+        :return:
+            The response for the downscope token request.
+        :rtype:
+            :class:`TokenResponse`
+        """
+        url = '{base_auth_url}/token'.format(base_auth_url=API.OAUTH2_API_URL)
+        data = {
+            'subject_token': self.auth.access_token,
+            'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
+            'scope': ' '.join(scopes),
+            'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
+        }
+        if item:
+            data['resource'] = item.get_url()
+        if additional_data:
+            data.update(additional_data)
+
+        box_response = self._session.post(url, data=data)
+        return TokenResponse(box_response.json())
+
     def clone(self, session=None):
         """Base class override."""
         return self.__class__(oauth=self._oauth, session=(session or self._session))
diff --git a/boxsdk/exception.py b/boxsdk/exception.py
index c3118f3..ae0d324 100644
--- a/boxsdk/exception.py
+++ b/boxsdk/exception.py
@@ -175,7 +175,7 @@ class BoxOAuthException(BoxException):
     """
     Exception raised during auth.
     """
-    def __init__(self, status, message=None, url=None, method=None):
+    def __init__(self, status, message=None, url=None, method=None, network_response=None):
         """
         :param status:
             HTTP status code of the auth response
@@ -193,17 +193,32 @@ def __init__(self, status, message=None, url=None, method=None):
             The HTTP verb used to make the request.
         :type method:
             `unicode`
+        :param network_response:
+            The network response for the request.
+        :type network_response:
+            :class:`NetworkResponse`
         """
         super(BoxOAuthException, self).__init__()
         self._status = status
         self._message = message
         self._url = url
         self._method = method
+        self._network_response = network_response
+
+    @property
+    def network_response(self):
+        """
+        The response returned from the network.
+
+        :rtype: :class:`NetworkResponse`
+        """
+        return self._network_response
 
     def __unicode__(self):
-        return '\nMessage: {0}\nStatus: {1}\nURL: {2}\nMethod: {3}'.format(
+        return '\nMessage: {0}\nStatus: {1}\nURL: {2}\nMethod: {3}\nHeaders: {4}'.format(
             self._message,
             self._status,
             self._url,
             self._method,
+            self.network_response.headers if self.network_response else 'N/A',
         )
diff --git a/boxsdk/version.py b/boxsdk/version.py
index 9095d34..5ff1bc6 100644
--- a/boxsdk/version.py
+++ b/boxsdk/version.py
@@ -3,4 +3,4 @@
 from __future__ import unicode_literals, absolute_import
 
 
-__version__ = '2.0.0a9'
+__version__ = '2.0.0a10'


Test patch:
diff --git a/test/unit/auth/test_oauth2.py b/test/unit/auth/test_oauth2.py
index 7859659..798af37 100644
--- a/test/unit/auth/test_oauth2.py
+++ b/test/unit/auth/test_oauth2.py
@@ -14,10 +14,8 @@
 
 from boxsdk.exception import BoxOAuthException
 from boxsdk.network.default_network import DefaultNetworkResponse
-from boxsdk.auth.oauth2 import OAuth2, TokenScope
+from boxsdk.auth.oauth2 import OAuth2
 from boxsdk.config import API
-from boxsdk.object.file import File
-from boxsdk.object.folder import Folder
 
 
 class MyError(Exception):
@@ -200,20 +198,17 @@ def refresh_tokens_and_verify_the_response():
 
 
 @pytest.fixture()
-def token_method(request, mock_box_session, mock_object_id):
+def token_method(request):
     """ Fixture that returns a partial method based on the method provided in request.param"""
     if request.param == OAuth2.refresh:
         return partial(OAuth2.refresh, access_token_to_refresh='fake_access_token')
     elif request.param == OAuth2.authenticate:
         return partial(OAuth2.authenticate, auth_code='fake_code')
-    elif request.param == OAuth2.downscope_token:
-        item = File(mock_box_session, mock_object_id)
-        return partial(OAuth2.downscope_token, scopes=[TokenScope.ITEM_READ], item=item)
 
 
 @pytest.mark.parametrize(
     'token_method',
-    [OAuth2.refresh, OAuth2.authenticate, OAuth2.downscope_token],
+    [OAuth2.refresh, OAuth2.authenticate],
     indirect=True,
 )
 def test_token_request_raises_box_oauth_exception_when_getting_bad_network_response(
@@ -234,7 +229,7 @@ def test_token_request_raises_box_oauth_exception_when_getting_bad_network_respo
 
 @pytest.mark.parametrize(
     'token_method',
-    [OAuth2.refresh, OAuth2.authenticate, OAuth2.downscope_token],
+    [OAuth2.refresh, OAuth2.authenticate],
     indirect=True,
 )
 def test_token_request_raises_box_oauth_exception_when_no_json_object_can_be_decoded(
@@ -346,85 +341,6 @@ def test_revoke_sends_revoke_request(
     assert oauth.access_token is None
 
 
-@pytest.fixture
-def check_downscope_token_request(
-        oauth,
-        mock_network_layer,
-        mock_box_session,
-        mock_object_id,
-        make_mock_box_request,
-):
-    def do_check(access_token, item_class, scopes, additional_data, expected_data):
-        dummy_downscoped_token = 'dummy_downscoped_token'
-        dummy_expires_in = 1234
-        mock_network_response, _ = make_mock_box_request(
-            response={'access_token': dummy_downscoped_token, 'expires_in': dummy_expires_in},
-        )
-        mock_network_layer.request.return_value = mock_network_response
-
-        item = item_class(mock_box_session, mock_object_id) if item_class else None
-
-        if additional_data:
-            downscoped_token_response = oauth.downscope_token(scopes, item, additional_data)
-        else:
-            downscoped_token_response = oauth.downscope_token(scopes, item)
-
-        assert downscoped_token_response.access_token == dummy_downscoped_token
-        assert downscoped_token_response.expires_in == dummy_expires_in
-
-        if item:
-            expected_data['resource'] = item.get_url()
-        mock_network_layer.request.assert_called_once_with(
-            'POST',
-            '{0}/token'.format(API.OAUTH2_API_URL),
-            data=expected_data,
-            headers={'content-type': 'application/x-www-form-urlencoded'},
-            access_token=access_token,
-        )
-
-    return do_check
-
-
-@pytest.mark.parametrize(
-    'item_class,scopes,expected_scopes',
-    [
-        (File, [TokenScope.ITEM_READWRITE], 'item_readwrite'),
-        (Folder, [TokenScope.ITEM_PREVIEW, TokenScope.ITEM_SHARE], 'item_preview item_share'),
-        (File, [TokenScope.ITEM_READ, TokenScope.ITEM_SHARE, TokenScope.ITEM_DELETE], 'item_read item_share item_delete'),
-        (None, [TokenScope.ITEM_DOWNLOAD], 'item_download'),
-    ],
-)
-def test_downscope_token_sends_downscope_request(
-        access_token,
-        check_downscope_token_request,
-        item_class,
-        scopes,
-        expected_scopes,
-):
-    expected_data = {
-        'subject_token': access_token,
-        'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
-        'scope': expected_scopes,
-        'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
-    }
-    check_downscope_token_request(access_token, item_class, scopes, {}, expected_data)
-
-
-def test_downscope_token_sends_downscope_request_with_additional_data(
-        access_token,
-        check_downscope_token_request,
-):
-    additional_data = {'grant_type': 'new_grant_type', 'extra_data_key': 'extra_data_value'}
-    expected_data = {
-        'subject_token': access_token,
-        'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
-        'scope': 'item_readwrite',
-        'grant_type': 'new_grant_type',
-        'extra_data_key': 'extra_data_value',
-    }
-    check_downscope_token_request(access_token, File, [TokenScope.ITEM_READWRITE], additional_data, expected_data)
-
-
 def test_tokens_get_updated_after_noop_refresh(client_id, client_secret, access_token, new_access_token, refresh_token, mock_network_layer):
     """`OAuth2` object should update its state with new tokens, after no-op refresh.
 
diff --git a/test/unit/client/test_client.py b/test/unit/client/test_client.py
index a522d49..7dfcc9f 100644
--- a/test/unit/client/test_client.py
+++ b/test/unit/client/test_client.py
@@ -13,7 +13,7 @@
 # pylint:enable=redefined-builtin
 # pylint:enable=import-error
 
-from boxsdk.auth.oauth2 import OAuth2
+from boxsdk.auth.oauth2 import OAuth2, TokenScope
 from boxsdk.client import Client, DeveloperTokenClient, DevelopmentClient, LoggingClient
 from boxsdk.config import API
 from boxsdk.network.default_network import DefaultNetworkResponse
@@ -368,3 +368,78 @@ def test_create_enterprise_user_returns_the_correct_user_object(mock_client, moc
     assert isinstance(new_user, User)
     assert new_user.object_id == 1234
     assert new_user.name == test_user_name
+
+
+@pytest.fixture
+def check_downscope_token_request(
+        mock_client,
+        mock_box_session,
+        mock_object_id,
+        make_mock_box_request,
+):
+    def do_check(item_class, scopes, additional_data, expected_data):
+        dummy_downscoped_token = 'dummy_downscoped_token'
+        dummy_expires_in = 1234
+        mock_box_response, _ = make_mock_box_request(
+            response={'access_token': dummy_downscoped_token, 'expires_in': dummy_expires_in},
+        )
+        mock_box_session.post.return_value = mock_box_response
+
+        item = item_class(mock_box_session, mock_object_id) if item_class else None
+
+        if additional_data:
+            downscoped_token_response = mock_client.downscope_token(scopes, item, additional_data)
+        else:
+            downscoped_token_response = mock_client.downscope_token(scopes, item)
+
+        assert downscoped_token_response.access_token == dummy_downscoped_token
+        assert downscoped_token_response.expires_in == dummy_expires_in
+
+        if item:
+            expected_data['resource'] = item.get_url()
+        mock_box_session.post.assert_called_once_with(
+            '{0}/token'.format(API.OAUTH2_API_URL),
+            data=expected_data,
+        )
+
+    return do_check
+
+
+@pytest.mark.parametrize(
+    'item_class,scopes,expected_scopes',
+    [
+        (File, [TokenScope.ITEM_READWRITE], 'item_readwrite'),
+        (Folder, [TokenScope.ITEM_PREVIEW, TokenScope.ITEM_SHARE], 'item_preview item_share'),
+        (File, [TokenScope.ITEM_READ, TokenScope.ITEM_SHARE, TokenScope.ITEM_DELETE], 'item_read item_share item_delete'),
+        (None, [TokenScope.ITEM_DOWNLOAD], 'item_download'),
+    ],
+)
+def test_downscope_token_sends_downscope_request(
+        mock_client,
+        check_downscope_token_request,
+        item_class,
+        scopes,
+        expected_scopes,
+):
+    expected_data = {
+        'subject_token': mock_client.auth.access_token,
+        'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
+        'scope': expected_scopes,
+        'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
+    }
+    check_downscope_token_request(item_class, scopes, {}, expected_data)
+
+
+def test_downscope_token_sends_downscope_request_with_additional_data(
+        mock_client,
+        check_downscope_token_request,
+):
+    additional_data = {'grant_type': 'new_grant_type', 'extra_data_key': 'extra_data_value'}
+    expected_data = {
+        'subject_token': mock_client.auth.access_token,
+        'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
+        'scope': 'item_readwrite',
+        'grant_type': 'new_grant_type',
+        'extra_data_key': 'extra_data_value',
+    }
+    check_downscope_token_request(File, [TokenScope.ITEM_READWRITE], additional_data, expected_data)
diff --git a/test/unit/test_exception.py b/test/unit/test_exception.py
index e0eb730..c332d95 100644
--- a/test/unit/test_exception.py
+++ b/test/unit/test_exception.py
@@ -1,7 +1,12 @@
 # coding: utf-8
 
 from __future__ import unicode_literals
+
+from mock import Mock
+import pytest
+
 from boxsdk.exception import BoxAPIException, BoxOAuthException
+from boxsdk.network.default_network import DefaultNetworkResponse
 
 
 def test_box_api_exception():
@@ -42,19 +47,25 @@ def test_box_api_exception():
 Context info: {7}'''.format(message, status, code, request_id, headers, url, method, context_info)
 
 
-def test_box_oauth_exception():
+@pytest.mark.parametrize('has_network_response', [True, False])
+def test_box_oauth_exception(has_network_response):
     status = 'status'
     message = 'message'
     url = 'https://example.com'
     method = 'GET'
+    headers = {'header': 'value'}
+    network_response = Mock(DefaultNetworkResponse, headers=headers) if has_network_response else None
     box_exception = BoxOAuthException(
         status,
         message=message,
         url=url,
         method=method,
+        network_response=network_response,
     )
     assert str(box_exception) == '''
 Message: {0}
 Status: {1}
 URL: {2}
-Method: {3}'''.format(message, status, url, method)
+Method: {3}
+Headers: {4}'''.format(message, status, url, method, headers if has_network_response else 'N/A')
+    assert box_exception.network_response is network_response