Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add requests transport #66

Merged
merged 4 commits into from
Oct 31, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,8 @@
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {
'python': ('https://docs.python.org/3.5', None),
'urllib3': ('https://urllib3.readthedocs.io/en/latest', None),
'urllib3': ('https://urllib3.readthedocs.io/en/stable', None),
'requests': ('http://docs.python-requests.org/en/stable', None),

This comment was marked as spam.

}

# Autodoc config
Expand Down
7 changes: 7 additions & 0 deletions docs/reference/google.auth.transport.requests.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
google.auth.transport.requests module
=====================================

.. automodule:: google.auth.transport.requests
:members:
:inherited-members:
:show-inheritance:
1 change: 1 addition & 0 deletions docs/reference/google.auth.transport.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ Submodules

.. toctree::

google.auth.transport.requests
google.auth.transport.urllib3

194 changes: 194 additions & 0 deletions google/auth/transport/requests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# Copyright 2016 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Transport adapter for Requests."""

This comment was marked as spam.

This comment was marked as spam.


from __future__ import absolute_import

import logging


import requests
import requests.exceptions

from google.auth import exceptions
from google.auth import transport

_LOGGER = logging.getLogger(__name__)


class _Response(transport.Response):
"""Requests transport response adapter.

Args:
response (requests.Response): The raw Requests response.
"""
def __init__(self, response):
self._response = response

@property
def status(self):
return self._response.status_code

@property
def headers(self):
return self._response.headers

@property
def data(self):
return self._response.content


class Request(transport.Request):
"""Requests request adapter.

This class is used internally for making requests using various transports
in a consistent way. If you use :class:`AuthorizedSession` 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.requests
import requests

request = google.auth.transport.requests.Request()

credentials.refresh(request)

Args:
session (requests.Session): An instance :class:`requests.Session` used
to make HTTP requests. If not specified, a session will be created.

.. automethod:: __call__
"""
def __init__(self, session=None):
if not session:
session = requests.Session()

self.session = session

def __call__(self, url, method='GET', body=None, headers=None,
timeout=None, **kwargs):
"""Make an HTTP request using requests.

Args:
url (str): The URI to be requested.
method (str): The HTTP method to use for the request. Defaults
to 'GET'.
body (bytes): The payload / body in HTTP request.
headers (Mapping[str, str]): Request headers.
timeout (Optional[int]): The number of seconds to wait for a
response from the server. If not specified or if None, the
requests default timeout will be used.
kwargs: Additional arguments passed through to the underlying
requests :meth:`~requests.Session.request` method.

Returns:
google.auth.transport.Response: The HTTP response.

Raises:
google.auth.exceptions.TransportError: If any exception occurred.
"""
try:
_LOGGER.debug('Making request: %s %s', method, url)
response = self.session.request(
method, url, data=body, headers=headers, timeout=timeout,
**kwargs)
return _Response(response)
except requests.exceptions.RequestException as exc:
raise exceptions.TransportError(exc)


class AuthorizedSession(requests.Session):
"""A Requests Session class with credentials.

This class is used to perform requests to API endpoints that require
authorization::

from google.auth.transport.requests import AuthorizedSession

authed_session = AuthorizedSession(credentials)

response = authed_session.request(
'GET', 'https://www.googleapis.com/storage/v1/b')

The underlying :meth:`request` 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.
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.
kwargs: Additional arguments passed to the :class:`requests.Session`
constructor.
"""
def __init__(self, credentials,
refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES,
max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS,
**kwargs):
super(AuthorizedSession, self).__init__(**kwargs)
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).
# Do not pass `self` as the session here, as it can lead to infinite
# recursion.
self._auth_request = Request()

def request(self, method, url, data=None, headers=None, **kwargs):
"""Implementation of Requests' request."""

# Use a kwarg for this instead of an attribute to maintain
# thread-safety.
_credential_refresh_attempt = kwargs.pop(
'_credential_refresh_attempt', 0)

# 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() if headers is not None else {}

self.credentials.before_request(
self._auth_request, method, url, request_headers)

response = super(AuthorizedSession, self).request(
method, url, data=data, 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.
if (response.status_code 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_code, _credential_refresh_attempt + 1,
self._max_refresh_attempts)

self.credentials.refresh(self._auth_request)

# Recurse. Pass in the original headers, not our modified set.
return self.request(
method, url, data=data, headers=headers,
_credential_refresh_attempt=_credential_refresh_attempt + 1,
**kwargs)

return response
15 changes: 11 additions & 4 deletions system_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,20 @@
import os

from google.auth import _helpers
import google.auth.transport.requests
import google.auth.transport.urllib3
import pytest
import requests
import urllib3


HERE = os.path.dirname(__file__)
DATA_DIR = os.path.join(HERE, 'data')
SERVICE_ACCOUNT_FILE = os.path.join(DATA_DIR, 'service_account.json')
AUTHORIZED_USER_FILE = os.path.join(DATA_DIR, 'authorized_user.json')
HTTP = urllib3.PoolManager(retries=False)
URLLIB3_HTTP = urllib3.PoolManager(retries=False)
REQUESTS_SESSION = requests.Session()
REQUESTS_SESSION.verify = False

This comment was marked as spam.

This comment was marked as spam.

TOKEN_INFO_URL = 'https://www.googleapis.com/oauth2/v3/tokeninfo'


Expand All @@ -41,10 +45,13 @@ def authorized_user_file():
yield AUTHORIZED_USER_FILE


@pytest.fixture
def http_request():
@pytest.fixture(params=['urllib3', 'requests'])
def http_request(request):

This comment was marked as spam.

This comment was marked as spam.

"""A transport.request object."""
yield google.auth.transport.urllib3.Request(HTTP)
if request.param == 'urllib3':
yield google.auth.transport.urllib3.Request(URLLIB3_HTTP)
elif request.param == 'requests':
yield google.auth.transport.requests.Request(REQUESTS_SESSION)


@pytest.fixture
Expand Down
118 changes: 118 additions & 0 deletions tests/transport/test_requests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Copyright 2016 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import mock
import requests
import requests.adapters
from six.moves import http_client

import google.auth.transport.requests
from tests.transport import compliance


class TestRequestResponse(compliance.RequestResponseTests):
def make_request(self):
return google.auth.transport.requests.Request()

def test_timeout(self):
http = mock.Mock()
request = google.auth.transport.requests.Request(http)
request(url='http://example.com', method='GET', timeout=5)

assert http.request.call_args[1]['timeout'] == 5


class MockCredentials(object):

This comment was marked as spam.

This comment was marked as spam.

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 MockAdapter(requests.adapters.BaseAdapter):
def __init__(self, responses, headers=None):
self.responses = responses
self.requests = []
self.headers = headers or {}

def send(self, request, **kwargs):
self.requests.append(request)
return self.responses.pop(0)


def make_response(status=http_client.OK, data=None):
response = requests.Response()
response.status_code = status
response._content = data
return response


class TestAuthorizedHttp(object):
TEST_URL = 'http://example.com/'

def test_constructor(self):
authed_session = google.auth.transport.requests.AuthorizedSession(
mock.sentinel.credentials)

assert authed_session.credentials == mock.sentinel.credentials

def test_request_no_refresh(self):
mock_credentials = mock.Mock(wraps=MockCredentials())
mock_response = make_response()
mock_adapter = MockAdapter([mock_response])

authed_session = google.auth.transport.requests.AuthorizedSession(
mock_credentials)
authed_session.mount(self.TEST_URL, mock_adapter)

response = authed_session.request('GET', self.TEST_URL)

assert response == mock_response
assert mock_credentials.before_request.called
assert not mock_credentials.refresh.called
assert len(mock_adapter.requests) == 1
assert mock_adapter.requests[0].url == self.TEST_URL
assert mock_adapter.requests[0].headers['authorization'] == 'token'

This comment was marked as spam.

This comment was marked as spam.


def test_request_refresh(self):
mock_credentials = mock.Mock(wraps=MockCredentials())
mock_final_response = make_response(status=http_client.OK)
# First request will 401, second request will succeed.
mock_adapter = MockAdapter([
make_response(status=http_client.UNAUTHORIZED),
mock_final_response])

authed_session = google.auth.transport.requests.AuthorizedSession(
mock_credentials)
authed_session.mount(self.TEST_URL, mock_adapter)

response = authed_session.request('GET', self.TEST_URL)

assert response == mock_final_response
assert mock_credentials.before_request.call_count == 2
assert mock_credentials.refresh.called
assert len(mock_adapter.requests) == 2

assert mock_adapter.requests[0].url == self.TEST_URL
assert mock_adapter.requests[0].headers['authorization'] == 'token'

assert mock_adapter.requests[1].url == self.TEST_URL
assert mock_adapter.requests[1].headers['authorization'] == 'token1'
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ deps =
pytest-localserver
urllib3
certifi
requests

This comment was marked as spam.

This comment was marked as spam.

commands =
py.test --cov=google.auth --cov=google.oauth2 --cov=tests {posargs:tests}

Expand Down