diff --git a/requirements.txt b/requirements.txt index 6d6298a..88c9084 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pbr>=2.0.0 # Apache-2.0 oslo.utils>=3.20.0 # Apache-2.0 oslo.i18n>=2.1.0 # Apache-2.0 +oslo.serialization>=1.10.0 # Apache-2.0 diff --git a/test-requirements.txt b/test-requirements.txt index c64a437..1848573 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -14,6 +14,7 @@ testscenarios>=0.4 # Apache-2.0/BSD testtools>=1.4.0 # MIT oslo.utils>=3.20.0 # Apache-2.0 oslo.i18n>=2.1.0 # Apache-2.0 +oslo.serialization>=1.10.0 # Apache-2.0 # releasenotes reno>=1.8.0 # Apache-2.0 diff --git a/valenceclient/common/apiclient/exceptions.py b/valenceclient/common/apiclient/exceptions.py index b8988dd..2e875a0 100644 --- a/valenceclient/common/apiclient/exceptions.py +++ b/valenceclient/common/apiclient/exceptions.py @@ -32,6 +32,11 @@ class ClientException(Exception): message = _("ClientException") +class ValidationError(ClientException): + """Error in validation on API client side.""" + pass + + class HttpError(ClientException): """The base exception class of all HTTP exceptions""" @@ -85,6 +90,16 @@ class HttpServerError(HttpError): message = _("HTTP Server Error") +class InternalServerError(HttpServerError): + """HTTP 500 - Internal Server Error. + + A generic error message, given when no more specific message is suitable. + """ + + http_status = http_client.INTERNAL_SERVER_ERROR + message = _("Internal Server Error") + + # _code_map cotains all the classes that have http_status attribute _code_map = dict((getattr(obj, 'http_status', None), obj) for name, obj in vars(sys.modules[__name__]).items() diff --git a/valenceclient/common/http.py b/valenceclient/common/http.py new file mode 100644 index 0000000..5283505 --- /dev/null +++ b/valenceclient/common/http.py @@ -0,0 +1,187 @@ +# Copyright 2017 99cloud, Inc. +# All Rights Reserved. +# +# 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 copy +import functools +import logging +import requests +import time + +import six +from six.moves import http_client +import six.moves.urllib.parse as urlparse + + +from oslo_serialization import jsonutils +from oslo_utils import strutils + +from valenceclient.common.i18n import _ +from valenceclient.common.i18n import _LE +from valenceclient import exc + +LOG = logging.getLogger(__name__) +USER_AGENT = 'python-valenceclient' + +API_VERSION = '/v1' +DEFAULT_VERSION = 1 + +DEFAULT_MAX_RETRIES = 5 +DEFAULT_RETRY_INTERVAL = 2 + + +def _trim_endpoint_api_version(url): + """Trim API version and trailing slash from endpoint.""" + + return url.rstrip('/').rstip(API_VERSION) + + +def _extract_error_json(body): + error_json = {} + try: + body_json = jsonutils.loads(body) + if 'error_message' in body_json: + raw_msg = body_json['error_message'] + error_json = jsonutils.loads(raw_msg) + except ValueError: + pass + + return error_json + + +def with_retries(func): + """Wrapper for _http_request adding support for retries.""" + + @functools.wraps(func) + def wrapper(self, url, method, **kwargs): + if self.conflict_max_retries is None: + self.conflict_max_retries = DEFAULT_MAX_RETRIES + if self.conflict_retry_interval is None: + self.conflict_retry_interval = DEFAULT_RETRY_INTERVAL + + num_attempts = self.conflict_max_retries + 1 + for attempt in range(1, num_attempts + 1): + try: + return func(url, method, **kwargs) + except Exception as error: + msg = (_LE("Error contacting Valence server: %(error)s." + "Attempt %(attempt)d of %(total)d") % + {'attempt': attempt, + 'total': num_attempts, + 'error': error}) + if attempt == num_attempts: + LOG.error(msg) + else: + LOG.debug(msg) + time.sleep(self.conflict_retry_interval) + return wrapper + + +class HTTPClient(object): + + def __init__(self, **kwargs): + self.valence_api_version = kwargs.get('valence_api_version', + DEFAULT_VERSION) + self.valence_url = kwargs.get('valence_url') + self.session = requests.Session() + + def log_curl_request(self, method, url, kwargs): + curl = ['curl -i -X %s' % method] + + for (key, value) in kwargs['headers'].items(): + header = "-H '%s: %s'" % (key, value) + curl.append(header) + + if 'body' in kwargs: + body = strutils.mask_password(kwargs['body']) + curl.append("-d '%s'" % body) + + curl.append(urlparse.urljoin('v1', self.valence_url)) + LOG.debug(" ".join(curl)) + + @staticmethod + def log_http_response(resp, body=None): + status = (resp.raw.version / 10.0, resp.status_code, resp.reason) + dump = ['\nHTTP/%.1f %s %s' % status] + dump.extend(['%s: %s' % (k, v) for k, v in resp.headers.items()]) + dump.append('') + if body: + body = strutils.mask_password(body) + dump.extend([body, '']) + LOG.debug('\n'.join(dump)) + + def _make_connection_url(self, url): + return urlparse.urljoin(self.valence_url, url) + + def _http_request(self, url, method, **kwargs): + """Send an http request with the specified characteristics + + Wrapper around request.Session.request to handle tasks such as + setting headers and error handling. + """ + + kwargs['headers'] = copy.deepcopy(kwargs.get('headers', {})) + kwargs['headers'].setdefault('User-agent', USER_AGENT) + + self.log_curl_request(method, url, kwargs) + body = kwargs.pop('body', None) + if body: + kwargs['data'] = body + conn_url = self._make_connection_url(url) + try: + resp = self.session.request(method, conn_url, **kwargs) + except requests.exceptions.RequestException as e: + msg = (_("Error has occured while handling request for " + "%(url)s: %(e)s") % dict(url=conn_url, e=e)) + if isinstance(e, ValueError): + raise exc.ValidationError(msg) + raise exc.ConnectionRefuse(msg) + + self.log_http_response(resp, resp.text) + body_iter = six.StringIO(resp.text) + + if resp.status_code >= http_client.BAD_REQUEST: + error_json = _extract_error_json(resp.text) + raise exc.from_response(resp, error_json.get('faultstring'), + error_json.get('debugfino'), method, url) + elif resp.status_code in (http_client.FOUND, + http_client.USE_PROXY): + return self._http_request(resp['location'], method, **kwargs) + + return resp, body_iter + + def json_request(self, method, url, **kwargs): + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', 'application/json') + kwargs['headers'].setdefault('Accept', 'application/json') + if 'body' in kwargs: + kwargs['body'] = jsonutils.dump_as_bytes(kwargs['body']) + + resp, body_iter = self._http_request(url, method, **kwargs) + content_type = resp.headers.get('Content-Type') + if(resp.status_code in (http_client.NO_CONTENT, + http_client.RESET_CONTENT) + or content_type is None): + return resp, list() + + if 'application/json' in content_type: + body = ''.join([chunk for chunk in body_iter]) + try: + body = jsonutils.loads(body) + except ValueError: + LOG.error(_LE('Could not decode response body as JSON')) + else: + body = None + return resp, body diff --git a/valenceclient/exc.py b/valenceclient/exc.py index c3ea5ad..489f467 100644 --- a/valenceclient/exc.py +++ b/valenceclient/exc.py @@ -14,7 +14,15 @@ # under the License. from valenceclient.common.apiclient import exceptions +from valenceclient.common.apiclient.exceptions import BadRequest from valenceclient.common.apiclient.exceptions import ClientException +from valenceclient.common.apiclient.exceptions import InternalServerError +from valenceclient.common.apiclient.exceptions import ValidationError + +BadRequest = BadRequest +ClientException = ClientException +InternalServerError = InternalServerError +ValidationError = ValidationError class InvalidValenceUrl(ClientException): diff --git a/valenceclient/tests/unit/common/test_http.py b/valenceclient/tests/unit/common/test_http.py new file mode 100644 index 0000000..6ecbc30 --- /dev/null +++ b/valenceclient/tests/unit/common/test_http.py @@ -0,0 +1,155 @@ +# Copyright 2017 99cloud, Inc. +# All Rights Reserved. +# +# 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 +from oslo_serialization import jsonutils + +from six.moves import http_client +from valenceclient.common import http +from valenceclient import exc +from valenceclient.tests.unit import utils + +DEFAULT_TIMEOUT = 600 +DEFAULT_HOST = 'localhost' +DEFAULT_POST = '1234' + + +def _get_error_body(faultstring=None, debuginfo=None, description=None): + if description: + error_body = {'description': description} + else: + error_body = { + 'faultstring': faultstring, + 'debuginfo': debuginfo + } + raw_error_body = jsonutils.dump_as_bytes(error_body) + body = {'error_message': raw_error_body} + return jsonutils.dumps(body) + + +class HttpClientTest(utils.BaseTestCase): + + def test_url_generation_trailing_slash_in_base(self): + kwargs = {"valence_url": "http://localhost/"} + client = http.HTTPClient(**kwargs) + url = client._make_connection_url('/redfish/v1') + self.assertEqual('http://localhost/redfish/v1', url) + + def test_url_generation_without_trailing_slash_in_base(self): + kwargs = {"valence_url": "http://localhost"} + client = http.HTTPClient(**kwargs) + url = client._make_connection_url('/redfish/v1') + self.assertEqual('http://localhost/redfish/v1', url) + + def test_url_generation_without_prefix_slash_in_path(self): + kwargs = {"valence_url": "http://localhost"} + client = http.HTTPClient(**kwargs) + url = client._make_connection_url('redfish/v1') + self.assertEqual('http://localhost/redfish/v1', url) + + def test_server_exception_empty_body(self): + error_body = _get_error_body() + kwargs = {"valence_url": "http://localhost"} + client = http.HTTPClient(**kwargs) + client.session = utils.mockSession( + {'Content-Type': 'application/json'}, + error_body, + version=1, + status_code=http_client.INTERNAL_SERVER_ERROR) + + self.assertRaises(exc.InternalServerError, + client.json_request, + 'GET', 'redfish/v1') + + def test_server_exception_msg_only(self): + error_body = "test error msg" + kwargs = {"valence_url": "http://localhost"} + client = http.HTTPClient(**kwargs) + client.session = utils.mockSession( + {'Content-Type': 'application/json'}, + error_body, + version=1, + status_code=http_client.INTERNAL_SERVER_ERROR) + + self.assertRaises(exc.InternalServerError, + client.json_request, + 'GET', 'redfish/v1') + + def test_server_exception_description_only(self): + error_msg = "test error msg" + error_body = _get_error_body(description=error_msg) + kwargs = {"valence_url": "http://localhost/"} + client = http.HTTPClient(**kwargs) + client.session = utils.mockSession( + {'Content-Type': 'application/json'}, + error_body, + version=1, + status_code=http_client.BAD_REQUEST) + + self.assertRaises(exc.BadRequest, + client.json_request, + 'GET', 'redfish/v1') + + def test_server_https_request_ok(self): + kwargs = {"valence_url": "http://localhost/"} + client = http.HTTPClient(**kwargs) + client.session = utils.mockSession( + {'Content-Type': 'application/json'}, + 'Body', + version=1, + status_code=http_client.OK) + + client.json_request('GET', 'redfish/v1') + + def test_server_http_not_valide_request(self): + kwargs = {"valence_url": "http://localhost/"} + client = http.HTTPClient(**kwargs) + client.session.request = mock.Mock( + side_effect=http.requests.exceptions.InvalidSchema) + self.assertRaises(exc.ValidationError, client._http_request, 'GET', + 'http://localhost/') + + @mock.patch.object(http.LOG, 'debug', autospec=True) + def test_log_curl_request_with_body_and_header(self, mock_log): + kwargs = {"valence_url": "http://localhost"} + client = http.HTTPClient(**kwargs) + headers = {'header1': 'value1'} + body = 'example body' + + client.log_curl_request('GET', '/redfish/v1/Nodes', + {'headers': headers, 'body': body}) + self.assertTrue(mock_log.called) + self.assertTrue(mock_log.call_args[0]) + self.assertEqual("curl -i -X GET -H 'header1: value1'" + " -d 'example body' http://localhost", + mock_log.call_args[0][0]) + + def test_http_request_client_success(self): + kwargs = {"valence_url": "http://localhost/"} + client = http.HTTPClient(**kwargs) + resp = utils.mockSessionResponse( + {'content-type': 'test/plain'}, + 'Body', + version=1, + status_code=http_client.OK) + + with mock.patch.object(client, 'session', + autospec=True) as mock_session: + mock_session.request.side_effect = iter([resp]) + response, body_iter = client._http_request('/redfish/v1/Nodes', + 'GET') + + self.assertEqual(http_client.OK, response.status_code)