Add urllib3 AuthorizedHttp (#19)
diff --git a/docs/conf.py b/docs/conf.py
index ccd864a..b70918f 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -363,7 +363,10 @@
# Example configuration for intersphinx: refer to the Python standard library.
-intersphinx_mapping = {'https://docs.python.org/3.5': None}
+intersphinx_mapping = {
+ 'python': ('https://docs.python.org/3.5', None),
+ 'urllib3': ('https://urllib3.readthedocs.io/en/latest', None),
+}
# Autodoc config
autoclass_content = 'both'
diff --git a/google/auth/transport/__init__.py b/google/auth/transport/__init__.py
index 50b1c43..d73c63c 100644
--- a/google/auth/transport/__init__.py
+++ b/google/auth/transport/__init__.py
@@ -27,6 +27,15 @@
import abc
import six
+from six.moves import http_client
+
+DEFAULT_REFRESH_STATUS_CODES = (http_client.UNAUTHORIZED,)
+"""Sequence[int]: Which HTTP status code indicate that credentials should be
+refreshed and a request should be retried.
+"""
+
+DEFAULT_MAX_REFRESH_ATTEMPTS = 2
+"""int: How many times to refresh the credentials and retry a request."""
@six.add_metaclass(abc.ABCMeta)
diff --git a/google/auth/transport/urllib3.py b/google/auth/transport/urllib3.py
index be6e55b..9d417b5 100644
--- a/google/auth/transport/urllib3.py
+++ b/google/auth/transport/urllib3.py
@@ -18,6 +18,18 @@
import logging
+
+# Certifi is Mozilla's certificate bundle. Urllib3 needs a certificate bundle
+# to verify HTTPS requests, and certifi is the recommended and most reliable
+# way to get a root certificate bundle. See
+# http://urllib3.readthedocs.io/en/latest/user-guide.html\
+# #certificate-verification
+# For more details.
+try:
+ import certifi
+except ImportError: # pragma: NO COVER
+ certifi = None
+
import urllib3
import urllib3.exceptions
@@ -27,7 +39,7 @@
_LOGGER = logging.getLogger(__name__)
-class Response(transport.Response):
+class _Response(transport.Response):
"""urllib3 transport response adapter.
Args:
@@ -50,7 +62,22 @@
class Request(transport.Request):
- """urllib3 request adapter
+ """urllib3 request adapter.
+
+ This class is used internally for making requests using various transports
+ in a consistent way. If you use :class:`AuthorizedHttp` you do not need
+ to construct or use this class directly.
+
+ This class can be useful if you want to manually refresh a
+ :class:`~google.auth.credentials.Credentials` instance::
+
+ import google.auth.transport.urllib3
+ import urllib3
+
+ http = urllib3.PoolManager()
+ request = google.auth.transport.urllib3.Request(http)
+
+ credentials.refresh(request)
Args:
http (urllib3.request.RequestMethods): An instance of any urllib3
@@ -79,7 +106,7 @@
urllib3 :meth:`urlopen` method.
Returns:
- Response: The HTTP response.
+ google.auth.transport.Response: The HTTP response.
Raises:
google.auth.exceptions.TransportError: If any exception occurred.
@@ -93,6 +120,129 @@
_LOGGER.debug('Making request: %s %s', method, url)
response = self.http.request(
method, url, body=body, headers=headers, **kwargs)
- return Response(response)
+ return _Response(response)
except urllib3.exceptions.HTTPError as exc:
raise exceptions.TransportError(exc)
+
+
+def _make_default_http():
+ if certifi is not None:
+ return urllib3.PoolManager(
+ cert_reqs='CERT_REQUIRED',
+ ca_certs=certifi.where())
+ else:
+ return urllib3.PoolManager()
+
+
+class AuthorizedHttp(urllib3.request.RequestMethods):
+ """A urllib3 HTTP class with credentials.
+
+ This class is used to perform requests to API endpoints that require
+ authorization::
+
+ from google.auth.transport.urllib3 import AuthorizedHttp
+
+ authed_http = AuthorizedHttp(credentials)
+
+ response = authed_http.request(
+ 'GET', 'https://www.googleapis.com/storage/v1/b')
+
+ This class implements :class:`urllib3.request.RequestMethods` and can be
+ used just like any other :class:`urllib3.PoolManager`.
+
+ The underlying :meth:`urlopen` implementation handles adding the
+ credentials' headers to the request and refreshing credentials as needed.
+
+ Args:
+ credentials (google.auth.credentials.Credentials): The credentials to
+ add to the request.
+ http (urllib3.PoolManager): The underlying HTTP object to
+ use to make requests. If not specified, a
+ :class:`urllib3.PoolManager` instance will be constructed with
+ sane defaults.
+ refresh_status_codes (Sequence[int]): Which HTTP status codes indicate
+ that credentials should be refreshed and the request should be
+ retried.
+ max_refresh_attempts (int): The maximum number of times to attempt to
+ refresh the credentials and retry the request.
+ """
+ def __init__(self, credentials, http=None,
+ refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES,
+ max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS):
+
+ if http is None:
+ http = _make_default_http()
+
+ self.http = http
+ self.credentials = credentials
+ self._refresh_status_codes = refresh_status_codes
+ self._max_refresh_attempts = max_refresh_attempts
+ # Request instance used by internal methods (for example,
+ # credentials.refresh).
+ self._request = Request(self.http)
+
+ def urlopen(self, method, url, body=None, headers=None, **kwargs):
+ """Implementation of urllib3's urlopen."""
+
+ # Use a kwarg for this instead of an attribute to maintain
+ # thread-safety.
+ _credential_refresh_attempt = kwargs.pop(
+ '_credential_refresh_attempt', 0)
+
+ if headers is None:
+ headers = self.headers
+
+ # Make a copy of the headers. They will be modified by the credentials
+ # and we want to pass the original headers if we recurse.
+ request_headers = headers.copy()
+
+ self.credentials.before_request(
+ self._request, method, url, request_headers)
+
+ response = self.http.urlopen(
+ method, url, body=body, headers=request_headers, **kwargs)
+
+ # If the response indicated that the credentials needed to be
+ # refreshed, then refresh the credentials and re-attempt the
+ # request.
+ # A stored token may expire between the time it is retrieved and
+ # the time the request is made, so we may need to try twice.
+ # The reason urllib3's retries aren't used is because they
+ # don't allow you to modify the request headers. :/
+ if (response.status in self._refresh_status_codes
+ and _credential_refresh_attempt < self._max_refresh_attempts):
+
+ _LOGGER.info(
+ 'Refreshing credentials due to a %s response. Attempt %s/%s.',
+ response.status, _credential_refresh_attempt + 1,
+ self._max_refresh_attempts)
+
+ self.credentials.refresh(self._request)
+
+ # Recurse. Pass in the original headers, not our modified set.
+ return self.urlopen(
+ method, url, body=body, headers=headers,
+ _credential_refresh_attempt=_credential_refresh_attempt + 1,
+ **kwargs)
+
+ return response
+
+ # Proxy methods for compliance with the urllib3.PoolManager interface
+
+ def __enter__(self):
+ """Proxy to ``self.http``."""
+ return self.http.__enter__()
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ """Proxy to ``self.http``."""
+ return self.http.__exit__(exc_type, exc_val, exc_tb)
+
+ @property
+ def headers(self):
+ """Proxy to ``self.http``."""
+ return self.http.headers
+
+ @headers.setter
+ def headers(self, value):
+ """Proxy to ``self.http``."""
+ self.http.headers = value
diff --git a/tests/transport/test_urllib3.py b/tests/transport/test_urllib3.py
index bdd5ac9..d35a759 100644
--- a/tests/transport/test_urllib3.py
+++ b/tests/transport/test_urllib3.py
@@ -13,6 +13,7 @@
# limitations under the License.
import mock
+from six.moves import http_client
import urllib3
import google.auth.transport.urllib3
@@ -24,10 +25,113 @@
http = urllib3.PoolManager()
return google.auth.transport.urllib3.Request(http)
+ def test_timeout(self):
+ http = mock.Mock()
+ request = google.auth.transport.urllib3.Request(http)
+ request(url='http://example.com', method='GET', timeout=5)
-def test_timeout():
- http = mock.Mock()
- request = google.auth.transport.urllib3.Request(http)
- request(url='http://example.com', method='GET', timeout=5)
+ assert http.request.call_args[1]['timeout'] == 5
- assert http.request.call_args[1]['timeout'] == 5
+
+def test__make_default_http_with_certfi():
+ http = google.auth.transport.urllib3._make_default_http()
+ assert 'cert_reqs' in http.connection_pool_kw
+
+
+@mock.patch.object(google.auth.transport.urllib3, 'certifi', new=None)
+def test__make_default_http_without_certfi():
+ http = google.auth.transport.urllib3._make_default_http()
+ assert 'cert_reqs' not in http.connection_pool_kw
+
+
+class MockCredentials(object):
+ def __init__(self, token='token'):
+ self.token = token
+
+ def apply(self, headers):
+ headers['authorization'] = self.token
+
+ def before_request(self, request, method, url, headers):
+ self.apply(headers)
+
+ def refresh(self, request):
+ self.token += '1'
+
+
+class MockHttp(object):
+ def __init__(self, responses, headers=None):
+ self.responses = responses
+ self.requests = []
+ self.headers = headers or {}
+
+ def urlopen(self, method, url, body=None, headers=None, **kwargs):
+ self.requests.append((method, url, body, headers, kwargs))
+ return self.responses.pop(0)
+
+
+class MockResponse(object):
+ def __init__(self, status=http_client.OK, data=None):
+ self.status = status
+ self.data = data
+
+
+class TestAuthorizedHttp(object):
+ TEST_URL = 'http://example.com'
+
+ def test_authed_http_defaults(self):
+ authed_http = google.auth.transport.urllib3.AuthorizedHttp(
+ mock.sentinel.credentials)
+
+ assert authed_http.credentials == mock.sentinel.credentials
+ assert isinstance(authed_http.http, urllib3.PoolManager)
+
+ def test_urlopen_no_refresh(self):
+ mock_credentials = mock.Mock(wraps=MockCredentials())
+ mock_response = MockResponse()
+ mock_http = MockHttp([mock_response])
+
+ authed_http = google.auth.transport.urllib3.AuthorizedHttp(
+ mock_credentials, http=mock_http)
+
+ response = authed_http.urlopen('GET', self.TEST_URL)
+
+ assert response == mock_response
+ assert mock_credentials.before_request.called
+ assert not mock_credentials.refresh.called
+ assert mock_http.requests == [
+ ('GET', self.TEST_URL, None, {'authorization': 'token'}, {})]
+
+ def test_urlopen_refresh(self):
+ mock_credentials = mock.Mock(wraps=MockCredentials())
+ mock_final_response = MockResponse(status=http_client.OK)
+ # First request will 401, second request will succeed.
+ mock_http = MockHttp([
+ MockResponse(status=http_client.UNAUTHORIZED),
+ mock_final_response])
+
+ authed_http = google.auth.transport.urllib3.AuthorizedHttp(
+ mock_credentials, http=mock_http)
+
+ response = authed_http.urlopen('GET', 'http://example.com')
+
+ assert response == mock_final_response
+ assert mock_credentials.before_request.call_count == 2
+ assert mock_credentials.refresh.called
+ assert mock_http.requests == [
+ ('GET', self.TEST_URL, None, {'authorization': 'token'}, {}),
+ ('GET', self.TEST_URL, None, {'authorization': 'token1'}, {})]
+
+ def test_proxies(self):
+ mock_http = mock.MagicMock()
+
+ authed_http = google.auth.transport.urllib3.AuthorizedHttp(
+ None, http=mock_http)
+
+ with authed_http:
+ pass
+
+ assert mock_http.__enter__.called
+ assert mock_http.__exit__.called
+
+ authed_http.headers = mock.sentinel.headers
+ assert authed_http.headers == mock_http.headers
diff --git a/tox.ini b/tox.ini
index 5f2df1c..ffaef94 100644
--- a/tox.ini
+++ b/tox.ini
@@ -9,6 +9,7 @@
pytest-cov
pytest-localserver
urllib3
+ certifi
commands =
py.test --cov=google.auth --cov=google.oauth2 --cov=tests {posargs:tests}