Skip to content

Commit e3d6069

Browse files
author
Jon Wayne Parrott
authored
Add requests transport (#66)
* Add requests transport * Parametrize http_request so that both urllib3 and requests are exercised in system tests.
1 parent 7afda43 commit e3d6069

File tree

7 files changed

+334
-5
lines changed

7 files changed

+334
-5
lines changed

packages/google-auth/docs/conf.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,8 @@
366366
# Example configuration for intersphinx: refer to the Python standard library.
367367
intersphinx_mapping = {
368368
'python': ('https://docs.python.org/3.5', None),
369-
'urllib3': ('https://urllib3.readthedocs.io/en/latest', None),
369+
'urllib3': ('https://urllib3.readthedocs.io/en/stable', None),
370+
'requests': ('http://docs.python-requests.org/en/stable', None),
370371
}
371372

372373
# Autodoc config
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
google.auth.transport.requests module
2+
=====================================
3+
4+
.. automodule:: google.auth.transport.requests
5+
:members:
6+
:inherited-members:
7+
:show-inheritance:

packages/google-auth/docs/reference/google.auth.transport.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ Submodules
1111

1212
.. toctree::
1313

14+
google.auth.transport.requests
1415
google.auth.transport.urllib3
1516

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# Copyright 2016 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Transport adapter for Requests."""
16+
17+
from __future__ import absolute_import
18+
19+
import logging
20+
21+
22+
import requests
23+
import requests.exceptions
24+
25+
from google.auth import exceptions
26+
from google.auth import transport
27+
28+
_LOGGER = logging.getLogger(__name__)
29+
30+
31+
class _Response(transport.Response):
32+
"""Requests transport response adapter.
33+
34+
Args:
35+
response (requests.Response): The raw Requests response.
36+
"""
37+
def __init__(self, response):
38+
self._response = response
39+
40+
@property
41+
def status(self):
42+
return self._response.status_code
43+
44+
@property
45+
def headers(self):
46+
return self._response.headers
47+
48+
@property
49+
def data(self):
50+
return self._response.content
51+
52+
53+
class Request(transport.Request):
54+
"""Requests request adapter.
55+
56+
This class is used internally for making requests using various transports
57+
in a consistent way. If you use :class:`AuthorizedSession` you do not need
58+
to construct or use this class directly.
59+
60+
This class can be useful if you want to manually refresh a
61+
:class:`~google.auth.credentials.Credentials` instance::
62+
63+
import google.auth.transport.requests
64+
import requests
65+
66+
request = google.auth.transport.requests.Request()
67+
68+
credentials.refresh(request)
69+
70+
Args:
71+
session (requests.Session): An instance :class:`requests.Session` used
72+
to make HTTP requests. If not specified, a session will be created.
73+
74+
.. automethod:: __call__
75+
"""
76+
def __init__(self, session=None):
77+
if not session:
78+
session = requests.Session()
79+
80+
self.session = session
81+
82+
def __call__(self, url, method='GET', body=None, headers=None,
83+
timeout=None, **kwargs):
84+
"""Make an HTTP request using requests.
85+
86+
Args:
87+
url (str): The URI to be requested.
88+
method (str): The HTTP method to use for the request. Defaults
89+
to 'GET'.
90+
body (bytes): The payload / body in HTTP request.
91+
headers (Mapping[str, str]): Request headers.
92+
timeout (Optional[int]): The number of seconds to wait for a
93+
response from the server. If not specified or if None, the
94+
requests default timeout will be used.
95+
kwargs: Additional arguments passed through to the underlying
96+
requests :meth:`~requests.Session.request` method.
97+
98+
Returns:
99+
google.auth.transport.Response: The HTTP response.
100+
101+
Raises:
102+
google.auth.exceptions.TransportError: If any exception occurred.
103+
"""
104+
try:
105+
_LOGGER.debug('Making request: %s %s', method, url)
106+
response = self.session.request(
107+
method, url, data=body, headers=headers, timeout=timeout,
108+
**kwargs)
109+
return _Response(response)
110+
except requests.exceptions.RequestException as exc:
111+
raise exceptions.TransportError(exc)
112+
113+
114+
class AuthorizedSession(requests.Session):
115+
"""A Requests Session class with credentials.
116+
117+
This class is used to perform requests to API endpoints that require
118+
authorization::
119+
120+
from google.auth.transport.requests import AuthorizedSession
121+
122+
authed_session = AuthorizedSession(credentials)
123+
124+
response = authed_session.request(
125+
'GET', 'https://www.googleapis.com/storage/v1/b')
126+
127+
The underlying :meth:`request` implementation handles adding the
128+
credentials' headers to the request and refreshing credentials as needed.
129+
130+
Args:
131+
credentials (google.auth.credentials.Credentials): The credentials to
132+
add to the request.
133+
refresh_status_codes (Sequence[int]): Which HTTP status codes indicate
134+
that credentials should be refreshed and the request should be
135+
retried.
136+
max_refresh_attempts (int): The maximum number of times to attempt to
137+
refresh the credentials and retry the request.
138+
kwargs: Additional arguments passed to the :class:`requests.Session`
139+
constructor.
140+
"""
141+
def __init__(self, credentials,
142+
refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES,
143+
max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS,
144+
**kwargs):
145+
super(AuthorizedSession, self).__init__(**kwargs)
146+
self.credentials = credentials
147+
self._refresh_status_codes = refresh_status_codes
148+
self._max_refresh_attempts = max_refresh_attempts
149+
# Request instance used by internal methods (for example,
150+
# credentials.refresh).
151+
# Do not pass `self` as the session here, as it can lead to infinite
152+
# recursion.
153+
self._auth_request = Request()
154+
155+
def request(self, method, url, data=None, headers=None, **kwargs):
156+
"""Implementation of Requests' request."""
157+
158+
# Use a kwarg for this instead of an attribute to maintain
159+
# thread-safety.
160+
_credential_refresh_attempt = kwargs.pop(
161+
'_credential_refresh_attempt', 0)
162+
163+
# Make a copy of the headers. They will be modified by the credentials
164+
# and we want to pass the original headers if we recurse.
165+
request_headers = headers.copy() if headers is not None else {}
166+
167+
self.credentials.before_request(
168+
self._auth_request, method, url, request_headers)
169+
170+
response = super(AuthorizedSession, self).request(
171+
method, url, data=data, headers=request_headers, **kwargs)
172+
173+
# If the response indicated that the credentials needed to be
174+
# refreshed, then refresh the credentials and re-attempt the
175+
# request.
176+
# A stored token may expire between the time it is retrieved and
177+
# the time the request is made, so we may need to try twice.
178+
if (response.status_code in self._refresh_status_codes
179+
and _credential_refresh_attempt < self._max_refresh_attempts):
180+
181+
_LOGGER.info(
182+
'Refreshing credentials due to a %s response. Attempt %s/%s.',
183+
response.status_code, _credential_refresh_attempt + 1,
184+
self._max_refresh_attempts)
185+
186+
self.credentials.refresh(self._auth_request)
187+
188+
# Recurse. Pass in the original headers, not our modified set.
189+
return self.request(
190+
method, url, data=data, headers=headers,
191+
_credential_refresh_attempt=_credential_refresh_attempt + 1,
192+
**kwargs)
193+
194+
return response

packages/google-auth/system_tests/conftest.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,20 @@
1616
import os
1717

1818
from google.auth import _helpers
19+
import google.auth.transport.requests
1920
import google.auth.transport.urllib3
2021
import pytest
22+
import requests
2123
import urllib3
2224

2325

2426
HERE = os.path.dirname(__file__)
2527
DATA_DIR = os.path.join(HERE, 'data')
2628
SERVICE_ACCOUNT_FILE = os.path.join(DATA_DIR, 'service_account.json')
2729
AUTHORIZED_USER_FILE = os.path.join(DATA_DIR, 'authorized_user.json')
28-
HTTP = urllib3.PoolManager(retries=False)
30+
URLLIB3_HTTP = urllib3.PoolManager(retries=False)
31+
REQUESTS_SESSION = requests.Session()
32+
REQUESTS_SESSION.verify = False
2933
TOKEN_INFO_URL = 'https://www.googleapis.com/oauth2/v3/tokeninfo'
3034

3135

@@ -41,10 +45,13 @@ def authorized_user_file():
4145
yield AUTHORIZED_USER_FILE
4246

4347

44-
@pytest.fixture
45-
def http_request():
48+
@pytest.fixture(params=['urllib3', 'requests'])
49+
def http_request(request):
4650
"""A transport.request object."""
47-
yield google.auth.transport.urllib3.Request(HTTP)
51+
if request.param == 'urllib3':
52+
yield google.auth.transport.urllib3.Request(URLLIB3_HTTP)
53+
elif request.param == 'requests':
54+
yield google.auth.transport.requests.Request(REQUESTS_SESSION)
4855

4956

5057
@pytest.fixture
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Copyright 2016 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import mock
16+
import requests
17+
import requests.adapters
18+
from six.moves import http_client
19+
20+
import google.auth.transport.requests
21+
from tests.transport import compliance
22+
23+
24+
class TestRequestResponse(compliance.RequestResponseTests):
25+
def make_request(self):
26+
return google.auth.transport.requests.Request()
27+
28+
def test_timeout(self):
29+
http = mock.Mock()
30+
request = google.auth.transport.requests.Request(http)
31+
request(url='http://example.com', method='GET', timeout=5)
32+
33+
assert http.request.call_args[1]['timeout'] == 5
34+
35+
36+
class MockCredentials(object):
37+
def __init__(self, token='token'):
38+
self.token = token
39+
40+
def apply(self, headers):
41+
headers['authorization'] = self.token
42+
43+
def before_request(self, request, method, url, headers):
44+
self.apply(headers)
45+
46+
def refresh(self, request):
47+
self.token += '1'
48+
49+
50+
class MockAdapter(requests.adapters.BaseAdapter):
51+
def __init__(self, responses, headers=None):
52+
self.responses = responses
53+
self.requests = []
54+
self.headers = headers or {}
55+
56+
def send(self, request, **kwargs):
57+
self.requests.append(request)
58+
return self.responses.pop(0)
59+
60+
61+
def make_response(status=http_client.OK, data=None):
62+
response = requests.Response()
63+
response.status_code = status
64+
response._content = data
65+
return response
66+
67+
68+
class TestAuthorizedHttp(object):
69+
TEST_URL = 'http://example.com/'
70+
71+
def test_constructor(self):
72+
authed_session = google.auth.transport.requests.AuthorizedSession(
73+
mock.sentinel.credentials)
74+
75+
assert authed_session.credentials == mock.sentinel.credentials
76+
77+
def test_request_no_refresh(self):
78+
mock_credentials = mock.Mock(wraps=MockCredentials())
79+
mock_response = make_response()
80+
mock_adapter = MockAdapter([mock_response])
81+
82+
authed_session = google.auth.transport.requests.AuthorizedSession(
83+
mock_credentials)
84+
authed_session.mount(self.TEST_URL, mock_adapter)
85+
86+
response = authed_session.request('GET', self.TEST_URL)
87+
88+
assert response == mock_response
89+
assert mock_credentials.before_request.called
90+
assert not mock_credentials.refresh.called
91+
assert len(mock_adapter.requests) == 1
92+
assert mock_adapter.requests[0].url == self.TEST_URL
93+
assert mock_adapter.requests[0].headers['authorization'] == 'token'
94+
95+
def test_request_refresh(self):
96+
mock_credentials = mock.Mock(wraps=MockCredentials())
97+
mock_final_response = make_response(status=http_client.OK)
98+
# First request will 401, second request will succeed.
99+
mock_adapter = MockAdapter([
100+
make_response(status=http_client.UNAUTHORIZED),
101+
mock_final_response])
102+
103+
authed_session = google.auth.transport.requests.AuthorizedSession(
104+
mock_credentials)
105+
authed_session.mount(self.TEST_URL, mock_adapter)
106+
107+
response = authed_session.request('GET', self.TEST_URL)
108+
109+
assert response == mock_final_response
110+
assert mock_credentials.before_request.call_count == 2
111+
assert mock_credentials.refresh.called
112+
assert len(mock_adapter.requests) == 2
113+
114+
assert mock_adapter.requests[0].url == self.TEST_URL
115+
assert mock_adapter.requests[0].headers['authorization'] == 'token'
116+
117+
assert mock_adapter.requests[1].url == self.TEST_URL
118+
assert mock_adapter.requests[1].headers['authorization'] == 'token1'

packages/google-auth/tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ deps =
1010
pytest-localserver
1111
urllib3
1212
certifi
13+
requests
1314
commands =
1415
py.test --cov=google.auth --cov=google.oauth2 --cov=tests {posargs:tests}
1516

0 commit comments

Comments
 (0)