Skip to content

Commit

Permalink
feat: add ability to load certificate from private key (#38)
Browse files Browse the repository at this point in the history
* fix: include Py 3.11 and 3.12 drop 3.7
* feat: add ability to load certificate from private key
* doc: document CABundle override options
---------
Signed-off-by: Emilio Reyes <soda480@gmail.com>
  • Loading branch information
soda480 authored Nov 22, 2023
1 parent 4dd69b5 commit 215394c
Show file tree
Hide file tree
Showing 8 changed files with 51 additions and 13 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
build-images:
strategy:
matrix:
version: ['3.7', '3.8', '3.9', '3.10']
version: ['3.8', '3.9', '3.10', '3.11', '3.12']
name: Build Python Docker images
runs-on: ubuntu-20.04
steps:
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![Code Coverage](https://codecov.io/gh/soda480/rest3client/branch/master/graph/badge.svg)](https://codecov.io/gh/soda480/rest3client)
[![vulnerabilities](https://img.shields.io/badge/vulnerabilities-None-brightgreen)](https://pypi.org/project/bandit/)
[![PyPI version](https://badge.fury.io/py/rest3client.svg)](https://badge.fury.io/py/rest3client)
[![python](https://img.shields.io/badge/python-3.7%20%7C%203.8%20%7C%203.9%20%7C%203.10-teal)](https://www.python.org/downloads/)
[![python](https://img.shields.io/badge/python-3.8%20%7C%203.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-teal)](https://www.python.org/downloads/)

rest3client is an abstraction of the HTTP requests library (https://pypi.org/project/requests/) providing a simpler API for consuming HTTP REST APIs.

Expand Down Expand Up @@ -48,9 +48,12 @@ The examples below show how RESTclient can be used to consume the GitHub REST AP
# token authentication
>>> client = RESTclient('codecov.io', token='--my-token--')

# certificate-based authentication
# certificate-based authentication using certificate and password
>>> client = RESTclient('my-api.my-company.com', certfile='/path/to/my-certificate.pem', certpass='--my-certificate-password--')

# certificate-based authentication using certificate and private key
>>> client = RESTclient('my-api.my-company.com', certfile='/path/to/my-certificate.pem', certkey='/path/to/my-certificate-private.key')

# jwt authentication
>>> client = RESTclient('my-api.my-company.com', jwt='--my-jwt--')
```
Expand Down Expand Up @@ -154,6 +157,16 @@ export RETRY_CONNECTION_ERROR_WAIT_RANDOM_MIN = 5000
export RETRY_CONNECTION_ERROR_WAIT_RANDOM_MAX = 15000
```

#### Certificate Authority (CA) Bundle

The `rest3client` module's default location for the CA Bundle is `/etc/ssl/certs/ca-certificates.crt`. This location can be overridden in two different ways:

* setting the `REQUESTS_CA_BUNDLE` environment variable to the desired location
* specifying the `cabundle` parameter to the RESTclient constructor:
```Python
client = RESTclient(bearer_token="--token--", cabundle='/location/to/your/cabundle')
```

#### Real Eamples
See [GitHub3API](https://github.com/soda480/github3api) for an example of how RESTclient can be subclassed to provide further custom functionality for a specific REST API (including retry on exceptions).

Expand Down
5 changes: 3 additions & 2 deletions build.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
authors = [Author('Emilio Reyes', 'emilio.reyes@intel.com')]
summary = 'An abstraction of the requests library providing a simpler API for consuming HTTP REST APIs'
url = 'https://github.com/soda480/rest3client'
version = '0.5.1'
version = '0.6.0'
default_task = [
'clean',
'analyze',
Expand Down Expand Up @@ -67,10 +67,11 @@ def set_properties(project):
'License :: OSI Approved :: Apache Software License',
'Operating System :: POSIX :: Linux',
'Programming Language :: Python',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Topic :: Software Development :: Libraries',
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: System :: Networking',
Expand Down
2 changes: 1 addition & 1 deletion build.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
versions=( '3.7' '3.8' '3.9' '3.10' )
versions=( '3.8' '3.9' '3.10' '3.11' '3.12' )
for version in "${versions[@]}";
do
docker image build --target build-image --build-arg PYTHON_VERSION=$version -t rest3client:$version .
Expand Down
5 changes: 3 additions & 2 deletions src/main/python/rest3client/restclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,10 @@ def __init__(self, hostname, **kwargs):
self.jwt = kwargs.get('jwt')

self.certfile = kwargs.get('certfile')
self.certkey = kwargs.get('certkey')
self.certpass = kwargs.get('certpass')
if self.certfile and self.certpass:
ssl_adapter = SSLAdapter(certfile=self.certfile, certpass=self.certpass)
if self.certfile and (self.certkey or self.certpass):
ssl_adapter = SSLAdapter(certfile=self.certfile, certkey=self.certkey, certpass=self.certpass)
self.session.mount(f'https://{self.hostname}', ssl_adapter)

self.retries = kwargs.get('retries', [])
Expand Down
6 changes: 4 additions & 2 deletions src/main/python/rest3client/ssladapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ class SSLAdapter(requests.adapters.HTTPAdapter):

def __init__(self, *args, **kwargs):
certfile = kwargs.pop('certfile')
certpass = str(kwargs.pop('certpass', ''))
certkey = kwargs.pop('certkey', None)
certpass = kwargs.pop('certpass', None)
ssl_context = ssl.SSLContext()
ssl_context.load_cert_chain(certfile, password=certpass)
ssl_context.load_cert_chain(certfile, keyfile=certkey, password=certpass)
self.ssl_context = ssl_context
logger.debug(f'loaded ssl context using certificate: {certfile}')
super(SSLAdapter, self).__init__(*args, **kwargs)

def init_poolmanager(self, *args, **kwargs):
Expand Down
15 changes: 14 additions & 1 deletion src/unittest/python/test_RESTclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,20 @@ def test__init_Should_InstantiateSslAdapterAndMountSslAdapterToSession_When_Cert
certfile = '--certfile--'
certpass = '--certpass--'
client = RESTclient('api.name.com', certfile=certfile, certpass=certpass)
ssl_adapter_patch.assert_called_once_with(certfile=certfile, certpass=certpass)
ssl_adapter_patch.assert_called_once_with(certfile=certfile, certkey=None, certpass=certpass)
client.session.mount.assert_called_once_with(f'https://{hostname}', ssl_adapter_patch.return_value)

@patch('rest3client.restclient.os.access')
@patch('rest3client.restclient.SSLAdapter')
@patch('rest3client.restclient.requests.Session')
def test__init_Should_InstantiateSslAdapterAndMountSslAdapterToSession_When_CertfileCertkey(self, session_patch, ssl_adapter_patch, *patches):
session_mock = Mock()
session_patch.return_value = session_mock
hostname = 'api.name.com'
certfile = '--certfile--'
certkey = '--certkey--'
client = RESTclient('api.name.com', certfile=certfile, certkey=certkey)
ssl_adapter_patch.assert_called_once_with(certfile=certfile, certkey=certkey, certpass=None)
client.session.mount.assert_called_once_with(f'https://{hostname}', ssl_adapter_patch.return_value)

@patch('rest3client.restclient.os.access', return_value=False)
Expand Down
12 changes: 10 additions & 2 deletions src/unittest/python/test_SSLAadapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,19 @@ def tearDown(self):
pass

@patch('rest3client.ssladapter.ssl.SSLContext')
def test__init__Should_SetAttributes_When_Called(self, ssl_context_patch, *patches):
def test__init__Should_SetAttributes_When_Certpass(self, ssl_context_patch, *patches):
ssl_context_mock = Mock()
ssl_context_patch.return_value = ssl_context_mock
adapter = SSLAdapter(certfile='-certfile-', certpass='-certpass-')
ssl_context_mock.load_cert_chain.assert_called_once_with('-certfile-', password='-certpass-')
ssl_context_mock.load_cert_chain.assert_called_once_with('-certfile-', keyfile=None, password='-certpass-')
self.assertEqual(adapter.ssl_context, ssl_context_mock)

@patch('rest3client.ssladapter.ssl.SSLContext')
def test__init__Should_SetAttributes_When_Certkey(self, ssl_context_patch, *patches):
ssl_context_mock = Mock()
ssl_context_patch.return_value = ssl_context_mock
adapter = SSLAdapter(certfile='-certfile-', certkey='-certkey-')
ssl_context_mock.load_cert_chain.assert_called_once_with('-certfile-', keyfile='-certkey-', password=None)
self.assertEqual(adapter.ssl_context, ssl_context_mock)

@patch('rest3client.ssladapter.requests.adapters.HTTPAdapter.init_poolmanager')
Expand Down

0 comments on commit 215394c

Please sign in to comment.