Skip to content

Commit fa44c68

Browse files
authored
Merge pull request #223 from ianunruh/adapter_pattern
Breakout Requests Functionality to New Adapter Module
2 parents 9725caf + 4828420 commit fa44c68

File tree

12 files changed

+630
-323
lines changed

12 files changed

+630
-323
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22

33
## 0.6.2 (UNRELEASED)
44

5+
BACKWARDS COMPATIBILITY NOTICE:
6+
7+
* With the newly added `hvac.adapters.Request` class, request kwargs can no longer be directly modified via the `_kwargs` attribute on the `Client` class. If runtime modifications to this dictionary are required, callers either need to explicitly pass in a new `adapter` instance with the desired settings via the `adapter` propery on the `Client` class *or* access the `_kwargs` property via the `adapter` property on the `Client` class.
8+
59
IMPROVEMENTS:
610

711
* sphinx documentation and [readthedocs.io project](https://hvac.readthedocs.io/en/latest/) added. [GH-222]
812
* README.md included in setuptools metadata. [GH-222]
913
* All `tune_secret_backend()` parameters now accepted. [GH-215]
1014
* Add `read_lease()` method [GH-218]
15+
* Added adapter module with `Request` class to abstract HTTP requests away from the `Client` class. [GH-223]
1116

1217
Thanks to @bbayszczak, @jvanbrunschot-coolblue for their lovely contributions.
1318

CONTRIBUTING.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,10 @@ the latest `vault` binary is available in your `PATH`.
1616
### Examples
1717

1818
Example code or general guides for methods in this module can be added under [docs/examples](docs/examples).
19+
20+
## Backwards Compatibility Breaking Changes
21+
22+
Due to the close connection between this module and HashiCorp Vault versions, breaking changes are sometimes required. This can also occur as part of code refactoring to enable improvements in the module generally. In these cases:
23+
24+
* A deprecation notice should be displayed to callers of the module until the minor revision +2. E.g., a notice added in version 0.6.2 could see the marked method / functionality removed in version 0.8.0.
25+
* Breaking changes should be called out in the [CHANGELOG.md](CHANGELOG.md) for the affected version.

docs/advanced_usage.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Advanced Usage
2+
==============
3+
4+
Custom Requests / HTTP Adapter
5+
------------------------------
6+
7+
.. versionadded:: 0.6.3
8+
9+
Calls to the `requests module`_. (which provides the methods hvac utilizes to send HTTP/HTTPS request to Vault instances) were extracted from the :class:`Client <hvac.v1.Client>` class and moved to a newly added :meth:`hvac.adapters` module. The :class:`Client <hvac.v1.Client>` class itself defaults to an instance of the :class:`Request <hvac.adapters.Request>` class for its :attr:`_adapter <hvac.v1.Client._adapter>` private attribute attribute if no adapter argument is provided to its :meth:`constructor <hvac.v1.Client.__init__>`. This attribute provides an avenue for modifying the manner in which hvac completes request. To enable this type of customization, implement a class of type :meth:`hvac.adapters.Adapter`, override its abstract methods, and pass an instance of this custom class to the adapter argument of the :meth:`Client constructor <hvac.v1.Client.__init__>`
10+
11+
.. _requests module: http://requests.readthedocs.io/en/master/

docs/conf.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,17 @@
6767
# A list of files that should not be packed into the epub file.
6868
epub_exclude_files = ['search.html']
6969

70-
# -- Extension configuration -------------------------------------------------
70+
# -- Autodoc configuration -------------------------------------------------
71+
72+
73+
def skip(app, what, name, obj, skip, options):
74+
"""Method to override default autodoc skip call. Ensures class constructor (e.g., __init__()) methods are included
75+
regardless of if private methods are included in the documentation generally.
76+
"""
77+
if name == "__init__":
78+
return False
79+
return skip
80+
81+
82+
def setup(app):
83+
app.connect("autodoc-skip-member", skip)

docs/examples/system_backend.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ View and Manage Leases
109109

110110
Read a lease:
111111

112+
.. versionadded:: 0.6.3
113+
112114
.. code-block:: python
113115
114116
>>> client.read_lease(lease_id='pki/issue/my-role/d05138a2-edeb-889d-db98-2057ecd5138f')

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Source code repository hosted at `github.com/ianunruh/hvac`_.
1111

1212
Readme <readme>
1313
Examples <examples/examples>
14+
advanced_usage
1415
source/hvac
1516
contributing
1617
changelog

docs/source/hvac.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ hvac.v1.Client
99
:undoc-members:
1010
:show-inheritance:
1111

12+
hvac.utils
13+
----------------------
14+
15+
.. automodule:: hvac.utils
16+
:members:
17+
:undoc-members:
18+
:show-inheritance:
19+
1220
hvac.aws\_utils
1321
----------------------
1422

@@ -17,6 +25,14 @@ hvac.aws\_utils
1725
:undoc-members:
1826
:show-inheritance:
1927

28+
hvac.adapters
29+
----------------------
30+
31+
.. automodule:: hvac.adapters
32+
:members:
33+
:undoc-members:
34+
:show-inheritance:
35+
2036
hvac.exceptions
2137
----------------------
2238

hvac/adapters.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
# coding=utf-8
2+
"""
3+
HTTP Client Library Adapters
4+
5+
"""
6+
import logging
7+
8+
import requests
9+
import requests.exceptions
10+
11+
from hvac import utils
12+
from abc import ABCMeta, abstractmethod
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class Adapter(object):
18+
"""Abstract base class used when constructing adapters for use with the Client class."""
19+
__metaclass__ = ABCMeta
20+
21+
@staticmethod
22+
def urljoin(*args):
23+
"""Joins given arguments into a url. Trailing and leading slashes are stripped for each argument.
24+
25+
:param args: Multiple parts of a URL to be combined into one string.
26+
:type args: str
27+
:return: Full URL combining all provided arguments
28+
:rtype: str
29+
"""
30+
31+
return '/'.join(map(lambda x: str(x).strip('/'), args))
32+
33+
@abstractmethod
34+
def close(self):
35+
raise NotImplemented
36+
37+
@abstractmethod
38+
def get(self, url, **kwargs):
39+
raise NotImplemented
40+
41+
@abstractmethod
42+
def post(self, url, **kwargs):
43+
raise NotImplemented
44+
45+
@abstractmethod
46+
def put(self, url, **kwargs):
47+
raise NotImplemented
48+
49+
@abstractmethod
50+
def delete(self, url, **kwargs):
51+
raise NotImplemented
52+
53+
@abstractmethod
54+
def request(self, method, url, headers=None, **kwargs):
55+
raise NotImplemented
56+
57+
58+
class Request(Adapter):
59+
"""The Request adapter class"""
60+
61+
def __init__(self, base_uri='http://localhost:8200', token=None, cert=None, verify=True, timeout=30, proxies=None,
62+
allow_redirects=True, session=None):
63+
"""Create a new request adapter instance.
64+
65+
:param base_uri: Base URL for the Vault instance being addressed.
66+
:type base_uri: str
67+
:param token: Authentication token to include in requests sent to Vault.
68+
:type token: str
69+
:param cert: Certificates for use in requests sent to the Vault instance. This should be a tuple with the
70+
certificate and then key.
71+
:type cert: tuple
72+
:param verify: Flag to indicate whether TLS verification should be performed when sending requests to Vault.
73+
:type verify: bool
74+
:param timeout: The timeout value for requests sent to Vault.
75+
:type timeout: int
76+
:param proxies: Proxies to use when preforming requests.
77+
See: http://docs.python-requests.org/en/master/user/advanced/#proxies
78+
:type proxies: dict
79+
:param allow_redirects: Whether to follow redirects when sending requests to Vault.
80+
:type allow_redirects: bool
81+
:param session: Optional session object to use when performing request.
82+
:type session: request.Session
83+
"""
84+
if not session:
85+
session = requests.Session()
86+
87+
self.base_uri = base_uri
88+
self.token = token
89+
self.session = session
90+
self.allow_redirects = allow_redirects
91+
92+
self._kwargs = {
93+
'cert': cert,
94+
'verify': verify,
95+
'timeout': timeout,
96+
'proxies': proxies,
97+
}
98+
99+
def close(self):
100+
"""Close the underlying Requests session.
101+
"""
102+
self.session.close()
103+
104+
def get(self, url, **kwargs):
105+
"""Performs a GET request.
106+
107+
:param url: Partial URL path to send the request to. This will be joined to the end of the instance's base_uri
108+
attribute.
109+
:type url: str
110+
:param kwargs: Additional keyword arguments to include in the requests call.
111+
:type kwargs: dict
112+
:return: The response of the request.
113+
:rtype: requests.Response
114+
"""
115+
return self.request('get', url, **kwargs)
116+
117+
def post(self, url, **kwargs):
118+
"""Performs a POST request.
119+
120+
:param url: Partial URL path to send the request to. This will be joined to the end of the instance's base_uri
121+
attribute.
122+
:type url: str
123+
:param kwargs: Additional keyword arguments to include in the requests call.
124+
:type kwargs: dict
125+
:return: The response of the request.
126+
:rtype: requests.Response
127+
"""
128+
return self.request('post', url, **kwargs)
129+
130+
def put(self, url, **kwargs):
131+
"""Performs a PUT request.
132+
133+
:param url: Partial URL path to send the request to. This will be joined to the end of the instance's base_uri
134+
attribute.
135+
:type url: str
136+
:param kwargs: Additional keyword arguments to include in the requests call.
137+
:type kwargs: dict
138+
:return: The response of the request.
139+
:rtype: requests.Response
140+
"""
141+
return self.request('put', url, **kwargs)
142+
143+
def delete(self, url, **kwargs):
144+
"""Performs a DELETE request.
145+
146+
:param url: Partial URL path to send the request to. This will be joined to the end of the instance's base_uri
147+
attribute.
148+
:type url: str
149+
:param kwargs: Additional keyword arguments to include in the requests call.
150+
:type kwargs: dict
151+
:return: The response of the request.
152+
:rtype: requests.Response
153+
"""
154+
return self.request('delete', url, **kwargs)
155+
156+
def request(self, method, url, headers=None, **kwargs):
157+
"""
158+
159+
:param method: HTTP method to use with the request. E.g., GET, POST, etc.
160+
:type method: str
161+
:param url: Partial URL path to send the request to. This will be joined to the end of the instance's base_uri
162+
attribute.
163+
:type url: str
164+
:param headers: Additional headers to include with the request.
165+
:type headers: dict
166+
:param kwargs: Additional keyword arguments to include in the requests call.
167+
:type kwargs: dict
168+
:return: The response of the request.
169+
:rtype: requests.Response
170+
"""
171+
url = self.urljoin(self.base_uri, url)
172+
173+
if not headers:
174+
headers = {}
175+
176+
if self.token:
177+
headers['X-Vault-Token'] = self.token
178+
179+
wrap_ttl = kwargs.pop('wrap_ttl', None)
180+
if wrap_ttl:
181+
headers['X-Vault-Wrap-TTL'] = str(wrap_ttl)
182+
183+
_kwargs = self._kwargs.copy()
184+
_kwargs.update(kwargs)
185+
186+
response = self.session.request(method, url, headers=headers,
187+
allow_redirects=False, **_kwargs)
188+
189+
# NOTE(ianunruh): workaround for https://github.com/ianunruh/hvac/issues/51
190+
while response.is_redirect and self.allow_redirects:
191+
url = self.urljoin(self.base_uri, response.headers['Location'])
192+
response = self.session.request(method, url, headers=headers,
193+
allow_redirects=False, **_kwargs)
194+
195+
if response.status_code >= 400 and response.status_code < 600:
196+
text = errors = None
197+
if response.headers.get('Content-Type') == 'application/json':
198+
errors = response.json().get('errors')
199+
if errors is None:
200+
text = response.text
201+
utils.raise_for_error(response.status_code, text, errors=errors)
202+
203+
return response

hvac/aws_utils.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import hmac
22
from datetime import datetime
33
from hashlib import sha256
4+
import requests
45

56

67
class SigV4Auth(object):
@@ -40,3 +41,28 @@ def add_auth(self, request):
4041
authorization = '{0} Credential={1}/{2}, SignedHeaders={3}, Signature={4}'.format(
4142
algorithm, self.access_key, credential_scope, signed_headers, signature)
4243
request.headers['Authorization'] = authorization
44+
45+
46+
def generate_sigv4_auth_request(header_value=None):
47+
"""Helper function to prepare a AWS API request to subsequently generate a "AWS Signature Version 4" header.
48+
49+
:param header_value: Vault allows you to require an additional header, X-Vault-AWS-IAM-Server-ID, to be present
50+
to mitigate against different types of replay attacks. Depending on the configuration of the AWS auth
51+
backend, providing a argument to this optional parameter may be required.
52+
:type header_value: str
53+
:return: A PreparedRequest instance, optionally containing the provided header value under a
54+
'X-Vault-AWS-IAM-Server-ID' header name pointed to AWS's simple token service with action "GetCallerIdentity"
55+
:rtype: requests.PreparedRequest
56+
"""
57+
request = requests.Request(
58+
method='POST',
59+
url='https://sts.amazonaws.com/',
60+
headers={'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', 'Host': 'sts.amazonaws.com'},
61+
data='Action=GetCallerIdentity&Version=2011-06-15',
62+
)
63+
64+
if header_value:
65+
request.headers['X-Vault-AWS-IAM-Server-ID'] = header_value
66+
67+
prepared_request = request.prepare()
68+
return prepared_request

hvac/tests/test_client.py renamed to hvac/tests/test_adapters.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import requests_mock
44
from parameterized import parameterized
55

6-
from hvac import Client
6+
from hvac import adapters
77

88

9-
class TestClient(TestCase):
9+
class TestRequest(TestCase):
1010
"""Unit tests providing coverage for requests-related methods in the hvac Client class."""
1111

1212
@parameterized.expand([
@@ -22,8 +22,8 @@ def test___request(self, test_label, test_url, requests_mocker):
2222
method='GET',
2323
url=mock_url,
2424
)
25-
client = Client(url=test_url)
26-
response = client._get(
25+
adapter = adapters.Request(base_uri=test_url)
26+
response = adapter.get(
2727
url='v1/sys/health',
2828
)
2929
self.assertEquals(

0 commit comments

Comments
 (0)