diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72364f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,89 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..56e63db --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.6.5-stretch + +ENV PYTHONDONTWRITEBYTECODE 1 + +RUN mkdir /rest3client + +COPY . /rest3client/ + +WORKDIR /rest3client + +RUN apt-get update +RUN apt-get install -y git gcc libssl-dev +RUN pip install pybuilder==0.11.17 +RUN pyb install_dependencies +RUN pyb install + +WORKDIR /rest3client +CMD echo 'DONE' diff --git a/README.md b/README.md index 5a96d17..cc8290c 100644 --- a/README.md +++ b/README.md @@ -1 +1,85 @@ -# rest2api \ No newline at end of file +# rest3client # + +rest3client is a requests-based library providing simple methods to enable consumption of HTTP REST APIs. + +The library further abstracts the underlying requests calls providing HTTP verb equivalent methods for GET, POST, PATCH, PUT and DELETE. The library includes a RESTclient class that implements a consistent approach for processing request responses, extracting error messages from responses, and providing standard headers to request calls. Enabling the consumer to focus on their business logic and less on the complexites of setting up and processing the requests repsonses. +A subclass inheriting RESTclient can override the base methods providing further customization and flexibility. The library supports most popular authentication schemes; including no-auth, basic auth, api-key and token-based. + +### Installation ### +```bash +pip install git+https://gitlab.com/soda480/rest3client.git +``` + +### Example Usage ### +Examples below show how RESTclient can be used to consume the GitHub REST API. However RESTclient can be used to consume just about any REST API. + +```python +>>> from rest3client import RESTclient + +# instantiate RESTclient - no auth +>>> client = RESTclient('api.github.com') + +# GET request - return json response +>>> client.get('/rate_limit')['resources']['core'] +{'limit': 60, 'remaining': 37, 'reset': 1588898701} + +# GET request - return raw resonse +>>> client.get('/rate_limit', raw_response=True) + + +# instantiate RESTclient using bearer token +>>> client = RESTclient('api.github.com', bearer_token='****************') + +# POST request +>>> client.post('/user/repos', json={'name': 'test-repo1'})['full_name'] +'soda480/test-repo1' + +# POST request +>>> client.post('/repos/soda480/test-repo1/labels', json={'name': 'label1', 'color': '#006b75'})['url'] +'https://api.github.com/repos/soda480/test-repo1/labels/label1' + +# PATCH request +>>> client.patch('/repos/soda480/test-repo1/labels/label1', json={'description': 'my label'})['url'] +'https://api.github.com/repos/soda480/test-repo1/labels/label1' + +# DELETE request +>>> client.delete('/repos/soda480/test-repo1') +``` + +### Development ### + +Ensure the latest version of Docker is installed on your development server. + +Clone the repository: +```bash +cd +git clone https://github.com/soda480/rest3client.git +cd rest3client +``` + +Build the Docker image: +```sh +docker image build \ +--build-arg http_proxy \ +--build-arg https_proxy \ +-t rest3client:latest . +``` + +Run the Docker container: +```sh +docker container run \ +--rm \ +-it \ +-e http_proxy \ +-e https_proxy \ +-v $PWD:/rest3client \ +rest3client:latest \ +/bin/sh +``` + +Execute the build: +```sh +pyb -X +``` + +NOTE: commands above assume working behind a proxy, if not then the proxy arguments to both the docker build and run commands can be removed. diff --git a/build.py b/build.py new file mode 100644 index 0000000..cf36c61 --- /dev/null +++ b/build.py @@ -0,0 +1,101 @@ + +# Copyright (c) 2020 Emilio Reyes (soda480@gmail.com) + +# 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. + +""" +rest3client is a requests-based library providing simple methods to enable consumption of HTTP REST APIs. + +The library further abstracts the underlying requests calls providing HTTP verb equivalent methods for GET, POST, PATCH, PUT and DELETE. The library includes a RESTclient class that implements a consistent approach for processing request responses, extracting error messages from responses, and providing standard headers to request calls. Enabling the consumer to focus on their business logic and less on the complexites of setting up and processing the requests repsonses. +A subclass inheriting RESTclient can override the base methods providing further customization and flexibility. The library supports most popular authentication schemes; including no-auth, basic auth, api-key and token-based. +""" + +from pybuilder.core import use_plugin +from pybuilder.core import init +from pybuilder.core import Author +from pybuilder.core import task +from pybuilder.pluginhelper.external_command import ExternalCommandBuilder + +use_plugin('python.core') +use_plugin('python.unittest') +use_plugin('python.install_dependencies') +use_plugin('python.flake8') +use_plugin('python.coverage') +use_plugin('python.distutils') +use_plugin('filter_resources') + +name = 'rest3client' +authors = [ + Author('Emilio Reyes', 'soda480@gmail.com') +] +summary = 'A Python class providing primitive methods for enabling consumption of REST APIs' +url = 'https://github.com/soda480/rest3client' +version = '0.0.1' +default_task = [ + 'clean', + 'analyze', + 'cyclomatic_complexity', + 'package' +] +license = 'Apache License, Version 2.0' +description = __doc__ + + +@init +def set_properties(project): + project.set_property('unittest_module_glob', 'test_*.py') + project.set_property('coverage_break_build', False) + project.set_property('flake8_max_line_length', 120) + project.set_property('flake8_verbose_output', True) + project.set_property('flake8_break_build', True) + project.set_property('flake8_include_scripts', True) + project.set_property('flake8_include_test_sources', True) + project.set_property('flake8_ignore', 'E501, W503, F401') + project.get_property('filter_resources_glob').extend(['**/rest3client/*']) + project.build_depends_on_requirements('requirements-build.txt') + project.depends_on_requirements('requirements.txt') + project.set_property('distutils_classifiers', [ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Environment :: Other Environment', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3.6', + 'Topic :: Software Development :: Libraries', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: System :: Networking', + 'Topic :: System :: Systems Administration' + ]) + + +@task('cyclomatic_complexity', description='calculates and publishes cyclomatic complexity') +def cyclomatic_complexity(project, logger): + try: + command = ExternalCommandBuilder('radon', project) + command.use_argument('cc') + command.use_argument('-a') + result = command.run_on_production_source_files(logger) + if len(result.error_report_lines) > 0: + logger.error('Errors while running radon, see {0}'.format(result.error_report_file)) + for line in result.report_lines[:-1]: + logger.debug(line.strip()) + if not result.report_lines: + return + average_complexity_line = result.report_lines[-1].strip() + logger.info(average_complexity_line) + + except Exception as exception: + print('Unable to execute cyclomatic complexity due to ERROR: {}'.format(str(exception))) diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..60f7494 --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,7 @@ +coverage +flake8 +pypandoc +unittest-xml-reporting +mccabe +mock +radon diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f229360 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..495b355 --- /dev/null +++ b/setup.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python +# + +# -*- coding: utf-8 -*- +# +# This file is part of PyBuilder +# +# Copyright 2011-2015 PyBuilder Team +# +# 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. + +# +# This script allows to support installation via: +# pip install git+git://@ +# +# This script is designed to be used in combination with `pip install` ONLY +# +# DO NOT RUN MANUALLY +# + +import os +import subprocess +import sys +import glob +import shutil + +from sys import version_info +py3 = version_info[0] == 3 +py2 = not py3 +if py2: + FileNotFoundError = OSError + +script_dir = os.path.dirname(os.path.realpath(__file__)) +exit_code = 0 +try: + subprocess.check_call(["pyb", "--version"]) +except FileNotFoundError as e: + if py3 or py2 and e.errno == 2: + try: + subprocess.check_call([sys.executable, "-m", "pip.__main__", "install", "pybuilder"]) + except subprocess.CalledProcessError as e: + sys.exit(e.returncode) + else: + raise +except subprocess.CalledProcessError as e: + sys.exit(e.returncode) + +try: + subprocess.check_call(["pyb", "clean", "install_build_dependencies", "package", "-o"]) + dist_dir = glob.glob(os.path.join(script_dir, "target", "dist", "*"))[0] + for src_file in glob.glob(os.path.join(dist_dir, "*")): + file_name = os.path.basename(src_file) + target_file_name = os.path.join(script_dir, file_name) + if os.path.exists(target_file_name): + if os.path.isdir(target_file_name): + shutil.rmtree(target_file_name) + else: + os.remove(target_file_name) + shutil.move(src_file, script_dir) + setup_args = sys.argv[1:] + subprocess.check_call([sys.executable, "setup.py"] + setup_args, cwd=script_dir) +except subprocess.CalledProcessError as e: + exit_code = e.returncode +sys.exit(exit_code) diff --git a/src/main/python/rest3client/__init__.py b/src/main/python/rest3client/__init__.py new file mode 100644 index 0000000..1f4ad31 --- /dev/null +++ b/src/main/python/rest3client/__init__.py @@ -0,0 +1,17 @@ + +# Copyright (c) 2020 Emilio Reyes (soda480@gmail.com) + +# 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. + + +from .restclient import RESTclient diff --git a/src/main/python/rest3client/restclient.py b/src/main/python/rest3client/restclient.py new file mode 100644 index 0000000..424a2b8 --- /dev/null +++ b/src/main/python/rest3client/restclient.py @@ -0,0 +1,216 @@ + +# Copyright (c) 2020 Emilio Reyes (soda480@gmail.com) + +# 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 os +import json +import base64 +import requests +from requests.packages.urllib3.exceptions import InsecurePlatformWarning +from requests.packages.urllib3.exceptions import InsecureRequestWarning + +import copy +import time +import logging +logger = logging.getLogger(__name__) + +logging.getLogger('requests.packages.urllib3.connectionpool').setLevel(logging.CRITICAL) + +requests.packages.urllib3.disable_warnings(InsecurePlatformWarning) +requests.packages.urllib3.disable_warnings(InsecureRequestWarning) + + +def redact(kwargs): + """ return redacted copy of dictionary + """ + scrubbed = copy.deepcopy(kwargs) + if 'headers' in scrubbed: + if 'Authorization' in scrubbed['headers']: + scrubbed['headers']['Authorization'] = '[REDACTED]' + + if 'Auth' in scrubbed['headers']: + scrubbed['headers']['Auth'] = '[REDACTED]' + + if 'x-api-key' in scrubbed['headers']: + scrubbed['headers']['x-api-key'] = '[REDACTED]' + + if 'address' in scrubbed: + del scrubbed['address'] + + if 'json' in scrubbed: + if 'password' in scrubbed['json']: + scrubbed['json']['password'] = '[REDACTED]' + if 'Password' in scrubbed['json']: + scrubbed['json']['Password'] = '[REDACTED]' + + return scrubbed + + +class RESTclient(object): + + cabundle = '/etc/ssl/certs/ca-certificates.crt' + + def __init__(self, hostname, username=None, password=None, api_key=None, bearer_token=None, cabundle=None): + """ class constructor + """ + logger.debug('executing RESTclient constructor') + self.hostname = hostname + + if not cabundle: + cabundle = RESTclient.cabundle + self.cabundle = cabundle if os.access(cabundle, os.R_OK) else False + + if username: + self.username = username + + if password: + self.password = password + + if api_key: + self.api_key = api_key + + if bearer_token: + self.bearer_token = bearer_token + + def get_headers(self, **kwargs): + """ return headers to pass to requests method + """ + headers = kwargs.get('headers', {}) + + if 'Content-Type' not in headers: + headers['Content-Type'] = 'application/json' + + if hasattr(self, 'username') and hasattr(self, 'password'): + basic = base64.b64encode(('{}:{}'.format(self.username, self.password)).encode()) + headers['Authorization'] = 'Basic {}'.format(basic).replace('b\'', '').replace('\'', '') + + if hasattr(self, 'api_key'): + headers['x-api-key'] = self.api_key + + if hasattr(self, 'bearer_token'): + headers['Authorization'] = 'Bearer {}'.format(self.bearer_token) + + return headers + + def request_handler(function): + """ decorator to process arguments and response for request method + """ + def _request_handler(self, *args, **kwargs): + """ decorator method to prepare and handle requests and responses + """ + noop = kwargs.pop('noop', False) + key_word_arguments = self.process_arguments(args, kwargs) + + redacted_key_word_arguments = redact(key_word_arguments) + try: + redacted = json.dumps(redact(redacted_key_word_arguments), indent=2, sort_keys=True) + except TypeError: + redacted = redacted_key_word_arguments + + logger.debug('\n{}: {} NOOP: {}\n{}'.format( + function.__name__.upper(), key_word_arguments['address'], noop, redacted)) + if noop: + return + response = function(self, *args, **key_word_arguments) + return self.process_response(response, **kwargs) + + return _request_handler + + def process_arguments(self, args, kwargs): + """ return key word arguments to pass to requests method + """ + arguments = copy.deepcopy(kwargs) + + headers = self.get_headers(**kwargs) + if 'headers' not in arguments: + arguments['headers'] = headers + else: + arguments['headers'].update(headers) + + if 'verify' not in arguments or arguments.get('verify') is None: + arguments['verify'] = self.cabundle + + arguments['address'] = 'https://{}{}'.format(self.hostname, args[0]) + arguments.pop('raw_response', None) + return arguments + + def get_error_message(self, response): + """ return error message from response + """ + logger.debug('getting error message from response') + try: + response_json = response.json() + logger.debug('returning error from response json') + return response_json + + except ValueError: + logger.debug('returning error from response text') + return response.text + + def process_response(self, response, **kwargs): + """ process request response + """ + logger.debug('processing response') + raw_response = kwargs.get('raw_response', False) + if raw_response: + logger.debug('returning raw response') + return response + + if not response.ok: + logger.debug('response was not OK') + error_message = self.get_error_message(response) + logger.error('{}: {}'.format(error_message, response.status_code)) + response.raise_for_status() + + logger.debug('response was OK') + try: + response_json = response.json() + logger.debug('returning response json') + return response_json + + except ValueError: + logger.debug('returning response text') + return response.text + + @request_handler + def post(self, endpoint, **kwargs): + """ helper method to submit post requests + """ + return requests.post(kwargs.pop('address'), **kwargs) + + @request_handler + def put(self, endpoint, **kwargs): + """ helper method to submit post requests + """ + return requests.put(kwargs.pop('address'), **kwargs) + + @request_handler + def get(self, endpoint, **kwargs): + """ helper method to submit get requests + """ + return requests.get(kwargs.pop('address'), **kwargs) + + @request_handler + def delete(self, endpoint, **kwargs): + """ helper method to submit delete requests + """ + return requests.delete(kwargs.pop('address'), **kwargs) + + @request_handler + def patch(self, endpoint, **kwargs): + """ helper method to submit delete requests + """ + return requests.patch(kwargs.pop('address'), **kwargs) + + request_handler = staticmethod(request_handler) diff --git a/src/unittest/python/test_RESTclient.py b/src/unittest/python/test_RESTclient.py new file mode 100644 index 0000000..f788ed5 --- /dev/null +++ b/src/unittest/python/test_RESTclient.py @@ -0,0 +1,491 @@ + +# Copyright (c) 2020 Emilio Reyes (soda480@gmail.com) + +# 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 unittest +from mock import patch +from mock import mock_open +from mock import call +from mock import Mock + +from rest3client import RESTclient +from rest3client.restclient import redact + +import sys +import logging +logger = logging.getLogger(__name__) + +consoleHandler = logging.StreamHandler(sys.stdout) +logFormatter = logging.Formatter( + "%(asctime)s %(threadName)s %(name)s [%(funcName)s] %(levelname)s %(message)s") +consoleHandler.setFormatter(logFormatter) +rootLogger = logging.getLogger() +rootLogger.addHandler(consoleHandler) +rootLogger.setLevel(logging.DEBUG) + + +class TestRESTclient(unittest.TestCase): + + def setUp(self): + """ + """ + pass + + def tearDown(self): + """ + """ + pass + + @patch('rest3client.restclient.os.access', return_value=True) + def test__init__Should_SetAttributes_When_CabundleExists(self, *patches): + hostname = 'hostname1.company.com' + cabundle = 'cabundle' + client = RESTclient(hostname, cabundle=cabundle) + self.assertEqual(client.hostname, hostname) + self.assertEqual(client.cabundle, cabundle) + + @patch('rest3client.restclient.os.access', return_value=False) + def test__init__Should_SetAttributes_When_CabundleDoesNotExist(self, *patches): + hostname = 'hostname1.company.com' + cabundle = 'cabundle' + client = RESTclient(hostname, cabundle=cabundle) + self.assertEqual(client.hostname, hostname) + self.assertFalse(client.cabundle) + + @patch('rest3client.restclient.os.access', return_value=False) + def test__init__Should_SetAttributes_When_ApiKey(self, *patches): + hostname = 'hostname1.company.com' + cabundle = 'cabundle' + api_key = 'some-api-key' + client = RESTclient(hostname, api_key=api_key, cabundle=cabundle) + self.assertEqual(client.api_key, api_key) + + @patch('rest3client.restclient.os.access', return_value=False) + def test__init__Should_SetAttributes_When_BearerToken(self, *patches): + hostname = 'hostname1.company.com' + cabundle = 'cabundle' + bearer_token = 'token' + client = RESTclient(hostname, bearer_token=bearer_token, cabundle=cabundle) + self.assertEqual(client.bearer_token, bearer_token) + + @patch('rest3client.restclient.os.access') + def test__get_headers_Should_ReturnHeaders_When_Called(self, *patches): + client = RESTclient('hostname1.company.com') + result = client.get_headers() + expected_result = { + 'Content-Type': 'application/json', + } + self.assertEqual(result, expected_result) + + @patch('rest3client.restclient.os.access') + def test__get_headers_Should_ReturnHeaders_When_ApiKey(self, *patches): + client = RESTclient('hostname1.company.com', api_key='some-api-key') + result = client.get_headers() + expected_result = { + 'Content-Type': 'application/json', + 'x-api-key': 'some-api-key' + } + self.assertEqual(result, expected_result) + + @patch('rest3client.restclient.os.access') + def test__get_headers_Should_ReturnHeaders_When_BearerToken(self, *patches): + client = RESTclient('hostname1.company.com', bearer_token='token') + result = client.get_headers() + expected_result = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer token' + } + self.assertEqual(result, expected_result) + + @patch('rest3client.restclient.os.access') + def test__request_handler_Should_CallFunctionWithArgs_When_Args(self, *patches): + mock_function = Mock(__name__='mocked method') + client = RESTclient('hostname1.company.com') + decorated_function = RESTclient.request_handler(mock_function) + decorated_function(client, '/rest/endpoint', 'arg1', 'arg2') + expected_args = (client, '/rest/endpoint', 'arg1', 'arg2') + args, _ = mock_function.call_args_list[0] + self.assertEqual(args, expected_args) + + @patch('rest3client.restclient.os.access') + @patch('rest3client.RESTclient.get_headers') + def test__request_handler_Should_CallFunctionWithKwargs_When_Kwargs(self, get_headers, *patches): + get_headers.return_value = {'h1': 'v1'} + mock_function = Mock(__name__='mocked method') + client = RESTclient('hostname1.company.com') + decorated_function = RESTclient.request_handler(mock_function) + object1 = b'' + decorated_function(client, '/rest/endpoint', kwarg1='kwarg1', kwarg2='kwarg2', kwarg3=object1, verify=False) + expected_kwargs = { + 'headers': { + 'h1': 'v1' + }, + 'verify': False, + 'address': 'https://hostname1.company.com/rest/endpoint', + 'kwarg1': 'kwarg1', + 'kwarg2': 'kwarg2', + 'kwarg3': object1 + } + _, kwargs = mock_function.call_args_list[0] + self.assertEqual(kwargs, expected_kwargs) + + @patch('rest3client.restclient.os.access') + @patch('rest3client.RESTclient.process_response', return_value='result') + def test__request_handler_Should_CallFunctionAndReturnResult_When_FunctionDoesNotSetNoop(self, *patches): + mock_function = Mock(__name__='mocked method') + client = RESTclient('hostname1.company.com') + decorated_function = RESTclient.request_handler(mock_function) + result = decorated_function(client, '/rest/endpoint') + self.assertTrue(mock_function.called) + self.assertEqual(result, 'result') + + @patch('rest3client.restclient.os.access') + @patch('rest3client.RESTclient.process_response') + def test__request_handler_Should_NotCallFunctionAndReturnNone_When_FunctionSetsNoop(self, *patches): + mock_function = Mock(__name__='mocked method') + client = RESTclient('hostname1.company.com') + decorated_function = RESTclient.request_handler(mock_function) + result = decorated_function(client, '/rest/endpoint', noop=True) + self.assertIsNone(result) + self.assertFalse(mock_function.called) + + @patch('rest3client.restclient.os.access') + @patch('rest3client.RESTclient.get_headers', return_value={'h1': 'v1'}) + def test__get_standard_kwargs_Should_SetHeaders_When_NoHeadersSpecified(self, *patches): + client = RESTclient('hostname1.company.com') + args = ['/endpoint'] + kwargs = {} + result = client.process_arguments(args, kwargs) + expected_result = { + 'h1': 'v1' + } + self.assertEqual(result['headers'], expected_result) + + @patch('rest3client.restclient.os.access') + @patch('rest3client.RESTclient.get_headers', return_value={'h1': 'v1'}) + def test__get_standard_kwargs_Should_UpdatedHeaders_When_HeadersSpecified(self, *patches): + client = RESTclient('hostname1.company.com') + args = ['/endpoint'] + kwargs = { + 'headers': { + 'h2': 'v2' + } + } + result = client.process_arguments(args, kwargs) + expected_result = { + 'h1': 'v1', + 'h2': 'v2' + } + self.assertEqual(result['headers'], expected_result) + + @patch('rest3client.restclient.os.access') + @patch('rest3client.RESTclient.get_headers', return_value={'h1': 'v1'}) + def test__get_standard_kwargs_Should_SetVerifyToCabundle_When_VerifyNotSpecified(self, *patches): + client = RESTclient('hostname1.company.com') + args = ['/endpoint'] + kwargs = {} + result = client.process_arguments(args, kwargs) + self.assertEqual(result['verify'], client.cabundle) + + @patch('rest3client.restclient.os.access') + @patch('rest3client.RESTclient.get_headers', return_value={'h1': 'v1'}) + def test__get_standard_kwargs_Should_SetVerifyToCabundle_When_VerifyIsNone(self, *patches): + client = RESTclient('hostname1.company.com') + args = ['/endpoint'] + kwargs = { + 'verify': None + } + result = client.process_arguments(args, kwargs) + self.assertEqual(result['verify'], client.cabundle) + + @patch('rest3client.restclient.os.access') + @patch('rest3client.RESTclient.get_headers', return_value={'h1': 'v1'}) + def test__get_standard_kwargs_Should_NotSetVerify_When_VerifyIsSet(self, *patches): + client = RESTclient('hostname1.company.com') + args = ['/endpoint'] + kwargs = { + 'verify': False + } + result = client.process_arguments(args, kwargs) + self.assertFalse(result['verify']) + + @patch('rest3client.restclient.os.access') + @patch('rest3client.RESTclient.get_headers', return_value={'h1': 'v1'}) + def test__get_standard_kwargs_Should_SetAddress_When_Called(self, *patches): + client = RESTclient('hostname1.company.com') + args = ['/endpoint'] + kwargs = {} + result = client.process_arguments(args, kwargs) + expected_result = 'https://hostname1.company.com/endpoint' + self.assertEqual(result['address'], expected_result) + + @patch('rest3client.restclient.os.access') + def test__process_response_Should_ReturnResponseJson_When_ResponseOk(self, *patches): + mock_response = Mock(ok=True) + mock_response.json.return_value = { + 'result': 'result' + } + client = RESTclient('hostname1.company.com') + result = client.process_response(mock_response) + self.assertEqual(result, mock_response.json.return_value) + + @patch('rest3client.restclient.os.access') + def test__process_response_Should_CallResponseRaiseForStatus_When_ResponseNotOk(self, *patches): + mock_response = Mock(ok=False) + mock_response.json.return_value = { + 'message': 'error message', + 'details': 'error details'} + mock_response.raise_for_status.side_effect = [ + Exception('exception occurred') + ] + + client = RESTclient('hostname1.company.com') + with self.assertRaises(Exception): + client.process_response(mock_response) + + @patch('rest3client.restclient.os.access') + def test__process_response_Should_ReturnRawResponse_When_RawResponse(self, *patches): + mock_response = Mock() + client = RESTclient('hostname1.company.com') + result = client.process_response(mock_response, raw_response=True) + self.assertEqual(result, mock_response) + + @patch('rest3client.restclient.os.access') + @patch('rest3client.restclient.requests') + def test__get_Should_CallRequestsGet_When_Called(self, requests, *patches): + client = RESTclient('hostname1.company.com') + client.get('/rest/endpoint') + requests_get_call = call( + 'https://hostname1.company.com/rest/endpoint', + headers={ + 'Content-Type': 'application/json'}, + verify=client.cabundle) + self.assertTrue(requests_get_call in requests.get.mock_calls) + + @patch('rest3client.restclient.os.access') + @patch('rest3client.restclient.requests') + def test__post_Should_CallRequestsPost_When_Called(self, requests, *patches): + client = RESTclient('hostname1.company.com') + requests_data = { + 'arg1': 'val1', + 'arg2': 'val2'} + client.post('/rest/endpoint', json=requests_data) + requests_post_call = call( + 'https://hostname1.company.com/rest/endpoint', + headers={ + 'Content-Type': 'application/json'}, + json={ + 'arg1': 'val1', + 'arg2': 'val2'}, + verify=client.cabundle) + self.assertTrue(requests_post_call in requests.post.mock_calls) + + @patch('rest3client.restclient.os.access') + @patch('rest3client.restclient.requests') + def test__put_Should_CallRequestsPut_When_Called(self, requests, *patches): + client = RESTclient('hostname1.company.com') + requests_data = { + 'arg1': 'val1', + 'arg2': 'val2'} + client.put('/rest/endpoint', json=requests_data) + requests_put_call = call( + 'https://hostname1.company.com/rest/endpoint', + headers={ + 'Content-Type': 'application/json'}, + json={ + 'arg1': 'val1', + 'arg2': 'val2'}, + verify=client.cabundle) + self.assertTrue(requests_put_call in requests.put.mock_calls) + + @patch('rest3client.restclient.os.access') + @patch('rest3client.restclient.requests') + def test__patch_Should_CallRequestsPatch_When_Called(self, requests, *patches): + client = RESTclient('hostname1.company.com') + requests_data = { + 'arg1': 'val1', + 'arg2': 'val2'} + client.patch('/rest/endpoint', json=requests_data) + requests_patch_call = call( + 'https://hostname1.company.com/rest/endpoint', + headers={ + 'Content-Type': 'application/json'}, + json={ + 'arg1': 'val1', + 'arg2': 'val2'}, + verify=client.cabundle) + self.assertTrue(requests_patch_call in requests.patch.mock_calls) + + @patch('rest3client.restclient.os.access') + @patch('rest3client.restclient.requests') + def test__delete_Should_CallRequestsDelete_When_Called(self, requests, *patches): + client = RESTclient(hostname='hostname1.company.com') + client.delete('/rest/endpoint') + requests_delete_call = call( + 'https://hostname1.company.com/rest/endpoint', + headers={ + 'Content-Type': 'application/json'}, + verify=client.cabundle) + self.assertEqual(requests.delete.mock_calls[0], requests_delete_call) + + @patch('rest3client.restclient.os.access', return_value=False) + def test__init__Should_SetUsernamePasswordAttributes_When_CalledWithUsernamePassword(self, *patches): + client = RESTclient('hostname', username='value1', password='value2') + self.assertEqual(client.username, 'value1') + self.assertEqual(client.password, 'value2') + + @patch('rest3client.restclient.os.access', return_value=False) + def test__get_headers_Should_SetAuthorizationHeader_When_UsernamePasswordAttributesExist(self, *patches): + client = RESTclient('hostname', username='value1', password='value2') + results = client.get_headers() + self.assertTrue('Authorization' in results) + self.assertTrue('Basic' in results['Authorization']) + + @patch('rest3client.restclient.os.access') + def test__process_response_Should_ReturnResponseText_When_ResponseJsonRaisesValueError(self, *patches): + mock_response = Mock(ok=True, text='response text') + mock_response.json.side_effect = [ + ValueError('No JSON') + ] + client = RESTclient('hostname1.company.com') + result = client.process_response(mock_response) + self.assertEqual(result, 'response text') + + def test__redact_Should_Redact_When_AuthorizationInHeaders(self, *patches): + headers = { + 'headers': { + 'Content-Type': 'application/json', + 'Authorization': 'Basic abcdefghijklmnopqrstuvwxyz' + }, + 'address': 'Address', + 'verify': 'verify' + } + result = redact(headers) + expected_result = { + 'headers': { + 'Content-Type': 'application/json', + 'Authorization': '[REDACTED]' + }, + 'verify': 'verify' + } + self.assertEqual(result, expected_result) + + def test__redact_Should_Redact_When_AuthInHeaders(self, *patches): + headers = { + 'headers': { + 'Content-Type': 'application/json', + 'Auth': 'SessionToken' + }, + 'address': 'Address', + 'verify': 'verify' + } + result = redact(headers) + expected_result = { + 'headers': { + 'Content-Type': 'application/json', + 'Auth': '[REDACTED]' + }, + 'verify': 'verify' + } + self.assertEqual(result, expected_result) + + def test__redact_Should_Redact_When_XApiKeyInHeaders(self, *patches): + headers = { + 'headers': { + 'Content-Type': 'application/json', + 'x-api-key': 'some-api-key' + }, + 'address': 'Address', + 'verify': 'verify' + } + result = redact(headers) + expected_result = { + 'headers': { + 'Content-Type': 'application/json', + 'x-api-key': '[REDACTED]' + }, + 'verify': 'verify' + } + self.assertEqual(result, expected_result) + + def test__redact_Should_Redact_When_JsonPassword(self, *patches): + headers = { + 'headers': { + 'Content-Type': 'application/json', + 'Auth': 'SessionToken' + }, + 'address': 'Address', + 'verify': 'verify', + 'json': { + 'userName': 'some user', + 'password': 'some password' + } + } + result = redact(headers) + expected_result = { + 'headers': { + 'Content-Type': 'application/json', + 'Auth': '[REDACTED]' + }, + 'verify': 'verify', + 'json': { + 'userName': 'some user', + 'password': '[REDACTED]' + } + } + self.assertEqual(result, expected_result) + + def test__redact_Should_Redact_When_JsonPasswordCap(self, *patches): + headers = { + 'headers': { + 'Content-Type': 'application/json', + 'Auth': 'SessionToken' + }, + 'address': 'Address', + 'verify': 'verify', + 'json': { + 'userName': 'some user', + 'Password': 'some password' + } + } + result = redact(headers) + expected_result = { + 'headers': { + 'Content-Type': 'application/json', + 'Auth': '[REDACTED]' + }, + 'verify': 'verify', + 'json': { + 'userName': 'some user', + 'Password': '[REDACTED]' + } + } + self.assertEqual(result, expected_result) + + @patch('rest3client.restclient.os.access') + def test__get_error_message_Should_ReturnExpected_When_ResponseJson(self, *patches): + client = RESTclient(hostname='hostname1.company.com') + response_mock = Mock() + response_mock.json.return_value = 'json value' + result = client.get_error_message(response_mock) + self.assertEqual(result, response_mock.json.return_value) + + @patch('rest3client.restclient.os.access') + def test__get_error_message_ShouldReturnExpected_When_ResponseJsonValueError(self, *patches): + client = RESTclient(hostname='hostname1.company.com') + response_mock = Mock() + response_mock.json.side_effect = ValueError() + response_mock.text = 'text error' + result = client.get_error_message(response_mock) + self.assertEqual(result, response_mock.text)