diff --git a/proliantutils/ilo/ris.py b/proliantutils/ilo/ris.py index 5a194016..101e0a0f 100755 --- a/proliantutils/ilo/ris.py +++ b/proliantutils/ilo/ris.py @@ -1,4 +1,4 @@ -# Copyright 2014 Hewlett-Packard Development Company, L.P. +# Copyright 2017 Hewlett Packard Enterprise Development Company, L.P. # # 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 @@ -12,25 +12,16 @@ # License for the specific language governing permissions and limitations # under the License. -__author__ = 'HP' +__author__ = 'HPE' -import base64 -import gzip import hashlib -import json - -import requests -from requests.packages import urllib3 -from requests.packages.urllib3 import exceptions as urllib3_exceptions -import retrying -import six -from six.moves.urllib import parse as urlparse from proliantutils import exception from proliantutils.ilo import common from proliantutils.ilo import firmware_controller from proliantutils.ilo import operations from proliantutils import log +from proliantutils import rest """ Currently this class supports only secure boot and firmware settings related API's . @@ -62,229 +53,16 @@ POWER_STATE = { CLASSCODE_FOR_GPU_DEVICES = [3] SUBCLASSCODE_FOR_GPU_DEVICES = [0, 1, 2, 128] -REDIRECTION_ATTEMPTS = 5 - LOG = log.get_logger(__name__) -class RISOperations(operations.IloOperations): +class RISOperations(rest.RestConnectorBase, operations.IloOperations): def __init__(self, host, login, password, bios_password=None, cacert=None): - self.host = host - self.login = login - self.password = password - self.bios_password = bios_password - # Message registry support - self.message_registries = {} - self.cacert = cacert - - # By default, requests logs following message if verify=False - # InsecureRequestWarning: Unverified HTTPS request is - # being made. Adding certificate verification is strongly advised. - # Just disable the warning if user intentionally did this. - if self.cacert is None: - urllib3.disable_warnings(urllib3_exceptions.InsecureRequestWarning) - - def _get_response_body_from_gzipped_content(self, url, response): - """Get the response body from gzipped content - - Try to decode as gzip (we should check the headers for - Content-Encoding=gzip) - - if response.headers['content-encoding'] == "gzip": - ... - - :param url: the url for which response was sent - :type url: str - :param response: response content object, probably gzipped - :type response: object - :returns: returns response body - :raises IloError: if the content is **not** gzipped - """ - try: - gzipper = gzip.GzipFile(fileobj=six.BytesIO(response.text)) - - LOG.debug(self._("Received compressed response for " - "url %(url)s."), {'url': url}) - uncompressed_string = (gzipper.read().decode('UTF-8')) - response_body = json.loads(uncompressed_string) - - except Exception as e: - LOG.debug( - self._("Error occurred while decompressing body. " - "Got invalid response '%(response)s' for " - "url %(url)s: %(error)s"), - {'url': url, 'response': response.text, 'error': e}) - raise exception.IloError(e) - - return response_body - - def _rest_op(self, operation, suburi, request_headers, request_body): - """Generic REST Operation handler.""" - - url = urlparse.urlparse('https://' + self.host + suburi) - # Used for logging on redirection error. - start_url = url.geturl() - - LOG.debug(self._("%(operation)s %(url)s"), - {'operation': operation, 'url': start_url}) - - if request_headers is None: - request_headers = {} - - # Use self.login/self.password and Basic Auth - if self.login is not None and self.password is not None: - auth_data = self.login + ":" + self.password - hr = "BASIC " + base64.b64encode( - auth_data.encode('ascii')).decode("utf-8") - request_headers['Authorization'] = hr - - """Helper methods to retry and keep retrying on redirection - START""" - - def retry_if_response_asks_for_redirection(response): - # NOTE:Do not assume every HTTP operation will return a JSON body. - # For example, ExtendedError structures are only required for - # HTTP 400 errors and are optional elsewhere as they are mostly - # redundant for many of the other HTTP status code. In particular, - # 200 OK responses should not have to return any body. - - # NOTE: this makes sure the headers names are all lower cases - # because HTTP says they are case insensitive - # Follow HTTP redirect - if response.status_code == 301 and 'location' in response.headers: - retry_if_response_asks_for_redirection.url = ( - urlparse.urlparse(response.headers['location'])) - LOG.debug(self._("Request redirected to %s."), - retry_if_response_asks_for_redirection.url.geturl()) - return True - return False - - @retrying.retry( - # Note(deray): Return True if we should retry, False otherwise. - # In our case, when the url response we receive asks for - # redirection then we retry. - retry_on_result=retry_if_response_asks_for_redirection, - # Note(deray): Return True if we should retry, False otherwise. - # In our case, when it's an IloConnectionError we don't retry. - # ``requests`` already takes care of issuing max number of - # retries if the URL service is unavailable. - retry_on_exception=( - lambda e: not isinstance(e, exception.IloConnectionError)), - stop_max_attempt_number=REDIRECTION_ATTEMPTS) - def _fetch_response(): - - url = retry_if_response_asks_for_redirection.url - - kwargs = {'headers': request_headers, - 'data': json.dumps(request_body)} - if self.cacert is not None: - kwargs['verify'] = self.cacert - else: - kwargs['verify'] = False - - LOG.debug(self._('\n\tHTTP REQUEST: %(restreq_method)s' - '\n\tPATH: %(restreq_path)s' - '\n\tBODY: %(restreq_body)s' - '\n'), - {'restreq_method': operation, - 'restreq_path': url.geturl(), - 'restreq_body': request_body}) - - request_method = getattr(requests, operation.lower()) - try: - response = request_method(url.geturl(), **kwargs) - except Exception as e: - LOG.debug(self._("Unable to connect to iLO. %s"), e) - raise exception.IloConnectionError(e) - - return response - - """Helper methods to retry and keep retrying on redirection - END""" - - try: - # Note(deray): This is a trick to use the function attributes - # to overwrite variable/s (in our case ``url``) and use the - # modified one in nested functions, i.e. :func:`_fetch_response` - # and :func:`retry_if_response_asks_for_redirection` - retry_if_response_asks_for_redirection.url = url - - response = _fetch_response() - except retrying.RetryError as e: - # Redirected for REDIRECTION_ATTEMPTS - th time. Throw error - msg = (self._("URL Redirected %(times)s times continuously. " - "URL used: %(start_url)s More info: %(error)s") % - {'start_url': start_url, 'times': REDIRECTION_ATTEMPTS, - 'error': str(e)}) - LOG.debug(msg) - raise exception.IloConnectionError(msg) - - response_body = {} - if response.text: - try: - response_body = json.loads(response.text) - except (TypeError, ValueError): - # Note(deray): If it doesn't decode as json, then - # resources may return gzipped content. - # ``json.loads`` on python3 raises TypeError when - # ``response.text`` is gzipped one. - response_body = ( - self._get_response_body_from_gzipped_content(url, - response)) - - LOG.debug(self._('\n\tHTTP RESPONSE for %(restreq_path)s:' - '\n\tCode: %(status_code)s' - '\n\tResponse Body: %(response_body)s' - '\n'), - {'restreq_path': url.geturl(), - 'status_code': response.status_code, - 'response_body': response_body}) - return response.status_code, response.headers, response_body - - def _rest_get(self, suburi, request_headers=None): - """REST GET operation. - - HTTP response codes could be 500, 404 etc. - """ - return self._rest_op('GET', suburi, request_headers, None) - - def _rest_patch(self, suburi, request_headers, request_body): - """REST PATCH operation. - - HTTP response codes could be 500, 404, 202 etc. - """ - if not isinstance(request_headers, dict): - request_headers = {} - request_headers['Content-Type'] = 'application/json' - return self._rest_op('PATCH', suburi, request_headers, request_body) - - def _rest_put(self, suburi, request_headers, request_body): - """REST PUT operation. - - HTTP response codes could be 500, 404, 202 etc. - """ - if not isinstance(request_headers, dict): - request_headers = {} - request_headers['Content-Type'] = 'application/json' - return self._rest_op('PUT', suburi, request_headers, request_body) - - def _rest_post(self, suburi, request_headers, request_body): - """REST POST operation. - - The response body after the operation could be the new resource, or - ExtendedError, or it could be empty. - """ - if not isinstance(request_headers, dict): - request_headers = {} - request_headers['Content-Type'] = 'application/json' - return self._rest_op('POST', suburi, request_headers, request_body) - - def _rest_delete(self, suburi, request_headers): - """REST DELETE operation. - - HTTP response codes could be 500, 404 etc. - """ - return self._rest_op('DELETE', suburi, request_headers, None) + super(RISOperations, self).__init__(host, login, password, + bios_password=bios_password, + cacert=cacert) def _get_collection(self, collection_uri, request_headers=None): """Generator function that returns collection members.""" diff --git a/proliantutils/rest/__init__.py b/proliantutils/rest/__init__.py new file mode 100644 index 00000000..7aaca90f --- /dev/null +++ b/proliantutils/rest/__init__.py @@ -0,0 +1,6 @@ +"""REST infrastructure to simplify communicating with REST based iLO interfaces + + Helper module to work with REST based APIs of BMCs. +""" + +from proliantutils.rest.v1 import RestConnectorBase # noqa diff --git a/proliantutils/rest/v1.py b/proliantutils/rest/v1.py new file mode 100755 index 00000000..be86445c --- /dev/null +++ b/proliantutils/rest/v1.py @@ -0,0 +1,260 @@ +# Copyright 2017 Hewlett Packard Enterprise Development Company, L.P. +# +# 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. + +"""Helper module to work with REST APIs""" + +__author__ = 'HPE' + +import base64 +import gzip +import json + +import requests +from requests.packages import urllib3 +from requests.packages.urllib3 import exceptions as urllib3_exceptions +import retrying +import six +from six.moves.urllib import parse as urlparse + +from proliantutils import exception +from proliantutils import log + + +REDIRECTION_ATTEMPTS = 5 + +LOG = log.get_logger(__name__) + + +class RestConnectorBase(object): + + def __init__(self, host, login, password, bios_password=None, + cacert=None): + self.host = host + self.login = login + self.password = password + self.bios_password = bios_password + # Message registry support + self.message_registries = {} + self.cacert = cacert + + # By default, requests logs following message if verify=False + # InsecureRequestWarning: Unverified HTTPS request is + # being made. Adding certificate verification is strongly advised. + # Just disable the warning if user intentionally did this. + if self.cacert is None: + urllib3.disable_warnings(urllib3_exceptions.InsecureRequestWarning) + + def _(self, msg): + """Prepends host information to msg and returns it.""" + return "[iLO %s] %s" % (self.host, msg) + + def _get_response_body_from_gzipped_content(self, url, response): + """Get the response body from gzipped content + + Try to decode as gzip (we should check the headers for + Content-Encoding=gzip) + + if response.headers['content-encoding'] == "gzip": + ... + + :param url: the url for which response was sent + :type url: str + :param response: response content object, probably gzipped + :type response: object + :returns: returns response body + :raises IloError: if the content is **not** gzipped + """ + try: + gzipper = gzip.GzipFile(fileobj=six.BytesIO(response.text)) + + LOG.debug(self._("Received compressed response for " + "url %(url)s."), {'url': url}) + uncompressed_string = (gzipper.read().decode('UTF-8')) + response_body = json.loads(uncompressed_string) + + except Exception as e: + LOG.debug( + self._("Error occurred while decompressing body. " + "Got invalid response '%(response)s' for " + "url %(url)s: %(error)s"), + {'url': url, 'response': response.text, 'error': e}) + raise exception.IloError(e) + + return response_body + + def _rest_op(self, operation, suburi, request_headers, request_body): + """Generic REST Operation handler.""" + + url = urlparse.urlparse('https://' + self.host + suburi) + # Used for logging on redirection error. + start_url = url.geturl() + + LOG.debug(self._("%(operation)s %(url)s"), + {'operation': operation, 'url': start_url}) + + if request_headers is None or not isinstance(request_headers, dict): + request_headers = {} + + # Use self.login/self.password and Basic Auth + if self.login is not None and self.password is not None: + auth_data = self.login + ":" + self.password + hr = "BASIC " + base64.b64encode( + auth_data.encode('ascii')).decode("utf-8") + request_headers['Authorization'] = hr + + if request_body is not None: + if (isinstance(request_body, dict) + or isinstance(request_body, list)): + request_headers['Content-Type'] = 'application/json' + else: + request_headers['Content-Type'] = ('application/' + 'x-www-form-urlencoded') + + """Helper methods to retry and keep retrying on redirection - START""" + + def retry_if_response_asks_for_redirection(response): + # NOTE:Do not assume every HTTP operation will return a JSON + # request_body. For example, ExtendedError structures are only + # required for HTTP 400 errors and are optional elsewhere as they + # are mostly redundant for many of the other HTTP status code. + # In particular, 200 OK responses should not have to return any + # request_body. + + # NOTE: this makes sure the headers names are all lower cases + # because HTTP says they are case insensitive + # Follow HTTP redirect + if response.status_code == 301 and 'location' in response.headers: + retry_if_response_asks_for_redirection.url = ( + urlparse.urlparse(response.headers['location'])) + LOG.debug(self._("Request redirected to %s."), + retry_if_response_asks_for_redirection.url.geturl()) + return True + return False + + @retrying.retry( + # Note(deray): Return True if we should retry, False otherwise. + # In our case, when the url response we receive asks for + # redirection then we retry. + retry_on_result=retry_if_response_asks_for_redirection, + # Note(deray): Return True if we should retry, False otherwise. + # In our case, when it's an IloConnectionError we don't retry. + # ``requests`` already takes care of issuing max number of + # retries if the URL service is unavailable. + retry_on_exception=( + lambda e: not isinstance(e, exception.IloConnectionError)), + stop_max_attempt_number=REDIRECTION_ATTEMPTS) + def _fetch_response(): + + url = retry_if_response_asks_for_redirection.url + + kwargs = {'headers': request_headers, + 'data': json.dumps(request_body)} + if self.cacert is not None: + kwargs['verify'] = self.cacert + else: + kwargs['verify'] = False + + LOG.debug(self._('\n\tHTTP REQUEST: %(restreq_method)s' + '\n\tPATH: %(restreq_path)s' + '\n\tBODY: %(restreq_body)s' + '\n'), + {'restreq_method': operation, + 'restreq_path': url.geturl(), + 'restreq_body': request_body}) + + request_method = getattr(requests, operation.lower()) + try: + response = request_method(url.geturl(), **kwargs) + except Exception as e: + LOG.debug(self._("Unable to connect to iLO. %s"), e) + raise exception.IloConnectionError(e) + + return response + + """Helper methods to retry and keep retrying on redirection - END""" + + try: + # Note(deray): This is a trick to use the function attributes + # to overwrite variable/s (in our case ``url``) and use the + # modified one in nested functions, i.e. :func:`_fetch_response` + # and :func:`retry_if_response_asks_for_redirection` + retry_if_response_asks_for_redirection.url = url + + response = _fetch_response() + except retrying.RetryError as e: + # Redirected for REDIRECTION_ATTEMPTS - th time. Throw error + msg = (self._("URL Redirected %(times)s times continuously. " + "URL used: %(start_url)s More info: %(error)s") % + {'start_url': start_url, 'times': REDIRECTION_ATTEMPTS, + 'error': str(e)}) + LOG.debug(msg) + raise exception.IloConnectionError(msg) + + response_body = {} + if response.text: + try: + response_body = json.loads(response.text) + except (TypeError, ValueError): + # Note(deray): If it doesn't decode as json, then + # resources may return gzipped content. + # ``json.loads`` on python3 raises TypeError when + # ``response.text`` is gzipped one. + response_body = ( + self._get_response_body_from_gzipped_content(url, + response)) + + LOG.debug(self._('\n\tHTTP RESPONSE for %(restreq_path)s:' + '\n\tCode: %(status_code)s' + '\n\tResponse Body: %(response_body)s' + '\n'), + {'restreq_path': url.geturl(), + 'status_code': response.status_code, + 'response_body': response_body}) + return response.status_code, response.headers, response_body + + def _rest_get(self, suburi, request_headers=None): + """REST GET operation. + + HTTP response codes could be 500, 404 etc. + """ + return self._rest_op('GET', suburi, request_headers, None) + + def _rest_patch(self, suburi, request_headers, request_body): + """REST PATCH operation. + + HTTP response codes could be 500, 404, 202 etc. + """ + return self._rest_op('PATCH', suburi, request_headers, request_body) + + def _rest_put(self, suburi, request_headers, request_body): + """REST PUT operation. + + HTTP response codes could be 500, 404, 202 etc. + """ + return self._rest_op('PUT', suburi, request_headers, request_body) + + def _rest_post(self, suburi, request_headers, request_body): + """REST POST operation. + + The response body after the operation could be the new resource, or + ExtendedError, or it could be empty. + """ + return self._rest_op('POST', suburi, request_headers, request_body) + + def _rest_delete(self, suburi, request_headers): + """REST DELETE operation. + + HTTP response codes could be 500, 404 etc. + """ + return self._rest_op('DELETE', suburi, request_headers, None) diff --git a/proliantutils/tests/ilo/ris_sample_outputs.py b/proliantutils/tests/ilo/ris_sample_outputs.py index 9f04748b..dae80ee9 100755 --- a/proliantutils/tests/ilo/ris_sample_outputs.py +++ b/proliantutils/tests/ilo/ris_sample_outputs.py @@ -219,10 +219,6 @@ RESPONSE_BODY_FOR_REST_OP = """ } """ -BASE64_GZIPPED_RESPONSE = """ -H4sIAHN9ClUC/+1YW2/iOBR+51dEeZqR2pAQCoWnhUALGm5qSndXoz6YYMAaEyPHoWVH/e9r50JJ7ARajVbzsH0p8rnZ5xx/53MqPysa/9M7QQDZI1jrbU3Xr5K1PUAYLDDseAwRP+Cy75FE/P08/op1IxVh/QC5p8TFUeyAHVggjBiCWTdqd+9uMSYvYgtPAIcFpkflqZ8Lm5HeEerB6Wp1VocfgAHKyvQmW1QmnoXBZkZeIO2GjPGsKDWf1Q70GSU7SNlhArbwmM/Hww7Kbt4yK8+V7HoSQO8iIhL3nmHdCSmFPsssRoInSANeRpdR5EetMLQb2t4y6qb2xbSqtdtqzbRuvuq5SG9pJEKyTqMVl4Qi83tIKVrCvi/KuRTOeyiIf1+VGbjhbkcoi0yyxdcnxIdSpy3zK4OltDQPFtISS9szJ+ghsJYWRU5dyMJdXjB7lXY0hyvkbiDGKsEjoGt+XSqKrlDkItFuS0c/8SVbfVS/JOODntHfLgzLqOUPcw99SJFnzN0uF1t58WToGHcYvo6mYyE2hrN9/QKdhlTenvGEKAsBNmo8SiXb/Gkj9mDgUbRLIckh213IINXcQ8DgVntC8CUFuQEJmEP4fcAgUT9pXyEcd5zOcklhIKOP3vDaXq1tNdt2q72C7Vszv928wq260iJOet9PqzScFYbORypKxdBfIg8wQkf9nnD/joD6GPjhCngspJAK0WB2lMAtoYdsLh4JAzhOYCy+73IFq5GJNiZLiIUvjmIjBHymdUf1hulpvD1aqff0pLmypOIp/5mtwk5GqtLZdG6oHGfVKUgXwPHZyVUe7FOT7GQWiNpfXajY8ZcDgpd6qfpzuTdp/IhZpp4+xxlw9Z/mpMr7o8pb4peeMg/D5ZNWnrgluTjbhHH3q2jT79GEDu+paLL/0oyX0Jr/G+v5HNXL0xHAOI4KwP7+rGAqEnwmRt6PcKeUxUMUsOgICv5X0KZ3YIvwIeGNRUof5pgl7VDIZKVDvHv+fTYvOJiDQTTda5USbc5n9siDnC97hHO0gxicGEYHU9S1M3Zz+jEB5OuKY+nulj92OpSCQ0Z/6HO0AVhlse+Er4oIPNVg6Hvp3lSGY4B8haULKf8pmElpFmacJbksKWg0uuXnXLwu7r8X8bkR2iLRHjemqVQMGZm+UwHpBZku9zg9jLY6Rj7ahlul2gNch1hQLcGCoowcfN5U3nnloJhyx5TIdYjPKFGWQx0lYXivymWUe5PmQSMuiIvWPhDskG8qn70IuQVn3KUsLh5j/VdmmIZlyi+AhLZzgFwhDOMW4+QT7WGB5nw+FIzVDzHOKWDk/ygCteHULUaDDYUrEbnK6RKr7q1qEG06qFrVhcDJi67tuD+ePvz9gSDuMUjCqy8KM3OG8VUJPhXqxPzScC4m7NPBYuOLQrnQ400lfSy4NNiJ+Zkx+VbwnSK6gLnHEO9LnquA0Py3EhJG88U6eZYddU9mhs8g/vLwVfsLEl/8d2ZzrX9zXWuYLW1va39oltEy7wf/nD7vBJiFcsb1AQSYbR4IxvNdtM1vRV9c3G9zodCsNc2add2tpbdO3GCO3pNwu4hP6t4P6vXWn5ORfdSQgyeBk5C5jcLMJ5vsLqLapJAw2xwC/uRMseoIFVmg4CjRMtI5qyd3XbdNu2nX7Oa1bdm163rzxr6u39r1a7tut26a9X7dsY8HkFFAdzZ8miKZ7ymAQmqwxLZq6QU9dPpgH5G1om4lTRsZVBS3QrzCwRouu4dP7Tq2phduO4B49ZFtS21Xeav8C14gvWoyFgAA -""" - HEADERS_FOR_REST_OP = [('content-length', '2729'), ('server', 'HP-iLO-Server/1.30'), ('etag', 'W/"B61EB245"'), diff --git a/proliantutils/tests/ilo/test_ris.py b/proliantutils/tests/ilo/test_ris.py index fd62a1b2..d9a34027 100755 --- a/proliantutils/tests/ilo/test_ris.py +++ b/proliantutils/tests/ilo/test_ris.py @@ -15,13 +15,9 @@ """Test class for RIS Module.""" -import base64 import json import mock -import requests -from requests.packages import urllib3 -from requests.packages.urllib3 import exceptions as urllib3_exceptions import testtools from proliantutils import exception @@ -30,34 +26,6 @@ from proliantutils.ilo import ris from proliantutils.tests.ilo import ris_sample_outputs as ris_outputs -class IloRisTestCaseInitTestCase(testtools.TestCase): - - @mock.patch.object(urllib3, 'disable_warnings') - def test_init(self, disable_warning_mock): - ris_client = ris.RISOperations( - "x.x.x.x", "admin", "Admin", bios_password='foo', - cacert='/somepath') - - self.assertEqual(ris_client.host, "x.x.x.x") - self.assertEqual(ris_client.login, "admin") - self.assertEqual(ris_client.password, "Admin") - self.assertEqual(ris_client.bios_password, "foo") - self.assertEqual({}, ris_client.message_registries) - self.assertEqual(ris_client.cacert, '/somepath') - - @mock.patch.object(urllib3, 'disable_warnings') - def test_init_without_cacert(self, disable_warning_mock): - ris_client = ris.RISOperations( - "x.x.x.x", "admin", "Admin", bios_password='foo') - - self.assertEqual(ris_client.host, "x.x.x.x") - self.assertEqual(ris_client.login, "admin") - self.assertEqual(ris_client.password, "Admin") - self.assertIsNone(ris_client.cacert) - disable_warning_mock.assert_called_once_with( - urllib3_exceptions.InsecureRequestWarning) - - class IloRisTestCase(testtools.TestCase): def setUp(self): @@ -1042,133 +1010,6 @@ class TestRISOperationsPrivateMethods(testtools.TestCase): result = self.client._is_boot_mode_uefi() self.assertFalse(result) - @mock.patch.object(requests, 'get') - def test__rest_op_okay(self, request_mock): - sample_headers = ris_outputs.HEADERS_FOR_REST_OP - exp_headers = dict((x.lower(), y) for x, y in sample_headers) - sample_response_body = ris_outputs.RESPONSE_BODY_FOR_REST_OP - response_mock_obj = mock.MagicMock( - status_code=200, text=sample_response_body, - headers=exp_headers) - request_mock.return_value = response_mock_obj - - status, headers, response = self.client._rest_op( - 'GET', '/v1/foo', None, None) - - self.assertEqual(200, status) - self.assertEqual(exp_headers, headers) - self.assertEqual(json.loads(sample_response_body), response) - request_mock.assert_called_once_with( - 'https://1.2.3.4/v1/foo', - headers={'Authorization': 'BASIC YWRtaW46QWRtaW4='}, - data="null", verify=False) - - @mock.patch.object(requests, 'get') - def test__rest_op_request_error(self, request_mock): - request_mock.side_effect = RuntimeError("boom") - - exc = self.assertRaises(exception.IloConnectionError, - self.client._rest_op, - 'GET', '/v1/foo', {}, None) - - request_mock.assert_called_once_with( - 'https://1.2.3.4/v1/foo', - headers={'Authorization': 'BASIC YWRtaW46QWRtaW4='}, - data="null", verify=False) - self.assertIn("boom", str(exc)) - - @mock.patch.object(requests, 'get') - def test__rest_op_continous_redirection(self, request_mock): - sample_response_body = ris_outputs.RESPONSE_BODY_FOR_REST_OP - sample_headers = ris_outputs.HEADERS_FOR_REST_OP - sample_headers.append(('location', 'https://foo')) - exp_headers = dict((x.lower(), y) for x, y in sample_headers) - response_mock_obj = mock.MagicMock( - status_code=301, text=sample_response_body, - headers=exp_headers) - request_mock.side_effect = [response_mock_obj, - response_mock_obj, - response_mock_obj, - response_mock_obj, - response_mock_obj] - - exc = self.assertRaises(exception.IloConnectionError, - self.client._rest_op, - 'GET', '/v1/foo', {}, None) - - self.assertEqual(5, request_mock.call_count) - self.assertIn('https://1.2.3.4/v1/foo', str(exc)) - - @mock.patch.object(requests, 'get') - def test__rest_op_one_redirection(self, request_mock): - sample_response_body = ris_outputs.RESPONSE_BODY_FOR_REST_OP - sample_headers1 = ris_outputs.HEADERS_FOR_REST_OP - sample_headers2 = ris_outputs.HEADERS_FOR_REST_OP - sample_headers1.append(('location', 'https://5.6.7.8/v1/foo')) - exp_headers1 = dict((x.lower(), y) for x, y in sample_headers1) - exp_headers2 = dict((x.lower(), y) for x, y in sample_headers2) - response_mock_obj1 = mock.MagicMock( - status_code=301, text=sample_response_body, - headers=exp_headers1) - response_mock_obj2 = mock.MagicMock( - status_code=200, text=sample_response_body, - headers=exp_headers2) - request_mock.side_effect = [response_mock_obj1, - response_mock_obj2] - - status, headers, response = self.client._rest_op( - 'GET', '/v1/foo', {}, None) - - exp_headers = dict((x.lower(), y) for x, y in sample_headers2) - self.assertEqual(200, status) - self.assertEqual(exp_headers, headers) - self.assertEqual(json.loads(sample_response_body), response) - request_mock.assert_has_calls([ - mock.call('https://1.2.3.4/v1/foo', - headers={'Authorization': 'BASIC YWRtaW46QWRtaW4='}, - data="null", verify=False), - mock.call('https://5.6.7.8/v1/foo', - headers={'Authorization': 'BASIC YWRtaW46QWRtaW4='}, - data="null", verify=False)]) - - @mock.patch.object(requests, 'get') - def test__rest_op_response_decode_error(self, request_mock): - sample_response_body = "{[wrong json" - sample_headers = ris_outputs.HEADERS_FOR_REST_OP - exp_headers = dict((x.lower(), y) for x, y in sample_headers) - response_mock_obj = mock.MagicMock( - status_code=200, text=sample_response_body, - headers=exp_headers) - request_mock.return_value = response_mock_obj - - self.assertRaises(exception.IloError, - self.client._rest_op, - 'GET', '/v1/foo', {}, None) - - request_mock.assert_called_once_with( - 'https://1.2.3.4/v1/foo', - headers={'Authorization': 'BASIC YWRtaW46QWRtaW4='}, - data="null", verify=False) - - @mock.patch.object(requests, 'get') - def test__rest_op_response_gzipped_response(self, request_mock): - sample_response_body = ris_outputs.RESPONSE_BODY_FOR_REST_OP - gzipped_response_body = base64.b64decode( - ris_outputs.BASE64_GZIPPED_RESPONSE) - sample_headers = ris_outputs.HEADERS_FOR_REST_OP - exp_headers = dict((x.lower(), y) for x, y in sample_headers) - response_mock_obj = mock.MagicMock( - status_code=200, text=gzipped_response_body, - headers=exp_headers) - request_mock.return_value = response_mock_obj - - status, headers, response = self.client._rest_op( - 'GET', '/v1/foo', {}, None) - - self.assertEqual(200, status) - self.assertEqual(exp_headers, headers) - self.assertEqual(json.loads(sample_response_body), response) - @mock.patch.object(ris.RISOperations, '_rest_patch') @mock.patch.object(ris.RISOperations, '_check_bios_resource') def test___change_bios_setting(self, check_bios_mock, patch_mock): diff --git a/proliantutils/tests/rest/__init__.py b/proliantutils/tests/rest/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/proliantutils/tests/rest/rest_sample_outputs.py b/proliantutils/tests/rest/rest_sample_outputs.py new file mode 100755 index 00000000..b995d321 --- /dev/null +++ b/proliantutils/tests/rest/rest_sample_outputs.py @@ -0,0 +1,226 @@ +# Copyright 2017 Hewlett Packard Enterprise Development Company, L.P. +# 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. + +RESPONSE_BODY_FOR_REST_OP = """ +{ + "AssetTag": "", + "AvailableActions": [ + { + "Action": "Reset", + "Capabilities": [ + { + "AllowableValues": [ + "On", + "ForceOff", + "ForceRestart", + "Nmi", + "PushPowerButton" + ], + "PropertyName": "ResetType" + } + ] + } + ], + "Bios": { + "Current": { + "VersionString": "I36 v1.40 (01/28/2015)" + } + }, + "Boot": { + "BootSourceOverrideEnabled": "Disabled", + "BootSourceOverrideSupported": [ + "None", + "Cd", + "Hdd", + "Usb", + "Utilities", + "Diags", + "BiosSetup", + "Pxe", + "UefiShell", + "UefiTarget" + ], + "BootSourceOverrideTarget": "None", + "UefiTargetBootSourceOverride": "None", + "UefiTargetBootSourceOverrideSupported": [ + "HD.Emb.1.2", + "Generic.USB.1.1", + "NIC.FlexLOM.1.1.IPv4", + "NIC.FlexLOM.1.1.IPv6", + "CD.Virtual.2.1" + ] + }, + "Description": "Computer System View", + "HostCorrelation": { + "HostMACAddress": [ + "6c:c2:17:39:fe:80", + "6c:c2:17:39:fe:88" + ], + "HostName": "", + "IPAddress": [ + "", + "" + ] + }, + "IndicatorLED": "Off", + "Manufacturer": "HP", + "Memory": { + "TotalSystemMemoryGB": 16 + }, + "Model": "ProLiant BL460c Gen9", + "Name": "Computer System", + "Oem": { + "Hp": { + "AvailableActions": [ + { + "Action": "PowerButton", + "Capabilities": [ + { + "AllowableValues": [ + "Press", + "PressAndHold" + ], + "PropertyName": "PushType" + }, + { + "AllowableValues": [ + "/Oem/Hp" + ], + "PropertyName": "Target" + } + ] + }, + { + "Action": "SystemReset", + "Capabilities": [ + { + "AllowableValues": [ + "ColdBoot" + ], + "PropertyName": "ResetType" + }, + { + "AllowableValues": [ + "/Oem/Hp" + ], + "PropertyName": "Target" + } + ] + } + ], + "Battery": [], + "Bios": { + "Backup": { + "Date": "v1.40 (01/28/2015)", + "Family": "I36", + "VersionString": "I36 v1.40 (01/28/2015)" + }, + "Current": { + "Date": "01/28/2015", + "Family": "I36", + "VersionString": "I36 v1.40 (01/28/2015)" + }, + "UefiClass": 2 + }, + "DeviceDiscoveryComplete": { + "AMSDeviceDiscovery": "NoAMS", + "SmartArrayDiscovery": "Initial", + "vAuxDeviceDiscovery": "DataIncomplete", + "vMainDeviceDiscovery": "ServerOff" + }, + "PostState": "PowerOff", + "PowerAllocationLimit": 500, + "PowerAutoOn": "PowerOn", + "PowerOnDelay": "Minimum", + "PowerRegulatorMode": "Dynamic", + "PowerRegulatorModesSupported": [ + "OSControl", + "Dynamic", + "Max", + "Min" + ], + "ServerSignature": 0, + "Type": "HpComputerSystemExt.0.10.1", + "VirtualProfile": "Inactive", + "VirtualUUID": null, + "links": { + "BIOS": { + "href": "/rest/v1/systems/1/bios" + }, + "MEMORY": { + "href": "/rest/v1/Systems/1/Memory" + }, + "PCIDevices": { + "href": "/rest/v1/Systems/1/PCIDevices" + }, + "PCISlots": { + "href": "/rest/v1/Systems/1/PCISlots" + }, + "SecureBoot": { + "href": "/rest/v1/Systems/1/SecureBoot" + } + } + } + }, + "Power": "Off", + "Processors": { + "Count": 1, + "ProcessorFamily": "Intel(R) Xeon(R) CPU E5-2609 v3 @ 1.90GHz", + "Status": { + "HealthRollUp": "OK" + } + }, + "SKU": "727021-B21", + "SerialNumber": "SGH449WNL3", + "Status": { + "Health": "OK", + "State": "Disabled" + }, + "SystemType": "Physical", + "Type": "ComputerSystem.0.9.6", + "UUID": "30373237-3132-4753-4834-3439574E4C33", + "links": { + "Chassis": [ + { + "href": "/rest/v1/Chassis/1" + } + ], + "Logs": { + "href": "/rest/v1/Systems/1/Logs" + }, + "ManagedBy": [ + { + "href": "/rest/v1/Managers/1" + } + ], + "self": { + "href": "/rest/v1/Systems/1" + } + } +} +""" + +BASE64_GZIPPED_RESPONSE = """ +H4sIAHN9ClUC/+1YW2/iOBR+51dEeZqR2pAQCoWnhUALGm5qSndXoz6YYMAaEyPHoWVH/e9r50JJ7ARajVbzsH0p8rnZ5xx/53MqPysa/9M7QQDZI1jrbU3Xr5K1PUAYLDDseAwRP+Cy75FE/P08/op1IxVh/QC5p8TFUeyAHVggjBiCWTdqd+9uMSYvYgtPAIcFpkflqZ8Lm5HeEerB6Wp1VocfgAHKyvQmW1QmnoXBZkZeIO2GjPGsKDWf1Q70GSU7SNlhArbwmM/Hww7Kbt4yK8+V7HoSQO8iIhL3nmHdCSmFPsssRoInSANeRpdR5EetMLQb2t4y6qb2xbSqtdtqzbRuvuq5SG9pJEKyTqMVl4Qi83tIKVrCvi/KuRTOeyiIf1+VGbjhbkcoi0yyxdcnxIdSpy3zK4OltDQPFtISS9szJ+ghsJYWRU5dyMJdXjB7lXY0hyvkbiDGKsEjoGt+XSqKrlDkItFuS0c/8SVbfVS/JOODntHfLgzLqOUPcw99SJFnzN0uF1t58WToGHcYvo6mYyE2hrN9/QKdhlTenvGEKAsBNmo8SiXb/Gkj9mDgUbRLIckh213IINXcQ8DgVntC8CUFuQEJmEP4fcAgUT9pXyEcd5zOcklhIKOP3vDaXq1tNdt2q72C7Vszv928wq260iJOet9PqzScFYbORypKxdBfIg8wQkf9nnD/joD6GPjhCngspJAK0WB2lMAtoYdsLh4JAzhOYCy+73IFq5GJNiZLiIUvjmIjBHymdUf1hulpvD1aqff0pLmypOIp/5mtwk5GqtLZdG6oHGfVKUgXwPHZyVUe7FOT7GQWiNpfXajY8ZcDgpd6qfpzuTdp/IhZpp4+xxlw9Z/mpMr7o8pb4peeMg/D5ZNWnrgluTjbhHH3q2jT79GEDu+paLL/0oyX0Jr/G+v5HNXL0xHAOI4KwP7+rGAqEnwmRt6PcKeUxUMUsOgICv5X0KZ3YIvwIeGNRUof5pgl7VDIZKVDvHv+fTYvOJiDQTTda5USbc5n9siDnC97hHO0gxicGEYHU9S1M3Zz+jEB5OuKY+nulj92OpSCQ0Z/6HO0AVhlse+Er4oIPNVg6Hvp3lSGY4B8haULKf8pmElpFmacJbksKWg0uuXnXLwu7r8X8bkR2iLRHjemqVQMGZm+UwHpBZku9zg9jLY6Rj7ahlul2gNch1hQLcGCoowcfN5U3nnloJhyx5TIdYjPKFGWQx0lYXivymWUe5PmQSMuiIvWPhDskG8qn70IuQVn3KUsLh5j/VdmmIZlyi+AhLZzgFwhDOMW4+QT7WGB5nw+FIzVDzHOKWDk/ygCteHULUaDDYUrEbnK6RKr7q1qEG06qFrVhcDJi67tuD+ePvz9gSDuMUjCqy8KM3OG8VUJPhXqxPzScC4m7NPBYuOLQrnQ400lfSy4NNiJ+Zkx+VbwnSK6gLnHEO9LnquA0Py3EhJG88U6eZYddU9mhs8g/vLwVfsLEl/8d2ZzrX9zXWuYLW1va39oltEy7wf/nD7vBJiFcsb1AQSYbR4IxvNdtM1vRV9c3G9zodCsNc2add2tpbdO3GCO3pNwu4hP6t4P6vXWn5ORfdSQgyeBk5C5jcLMJ5vsLqLapJAw2xwC/uRMseoIFVmg4CjRMtI5qyd3XbdNu2nX7Oa1bdm163rzxr6u39r1a7tut26a9X7dsY8HkFFAdzZ8miKZ7ymAQmqwxLZq6QU9dPpgH5G1om4lTRsZVBS3QrzCwRouu4dP7Tq2phduO4B49ZFtS21Xeav8C14gvWoyFgAA +""" + +HEADERS_FOR_REST_OP = [('content-length', '2729'), + ('server', 'HP-iLO-Server/1.30'), + ('etag', 'W/"B61EB245"'), + ('allow', 'GET, HEAD, POST, PATCH'), + ('cache-control', 'no-cache'), + ('date', 'Thu, 19 Mar 2015 06:55:59 GMT'), + ('x_hp-chrp-service-version', '1.0.3'), + ('content-type', 'application/json')] diff --git a/proliantutils/tests/rest/test_v1.py b/proliantutils/tests/rest/test_v1.py new file mode 100755 index 00000000..6000c909 --- /dev/null +++ b/proliantutils/tests/rest/test_v1.py @@ -0,0 +1,222 @@ +# Copyright 2017 Hewlett Packard Enterprise Development Company, L.P. +# +# 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 base64 +import json + +import mock +import requests +from requests.packages import urllib3 +from requests.packages.urllib3 import exceptions as urllib3_exceptions +import testtools + +from proliantutils import exception +from proliantutils.rest import v1 +from proliantutils.tests.rest import rest_sample_outputs as rest_outputs + + +class RestConnectorBaseInitAndLowdashTestCase(testtools.TestCase): + + @mock.patch.object(urllib3, 'disable_warnings') + def test_init(self, disable_warning_mock): + rest_client = v1.RestConnectorBase( + "x.x.x.x", "admin", "Admin", bios_password='foo', + cacert='/somepath') + + self.assertEqual(rest_client.host, "x.x.x.x") + self.assertEqual(rest_client.login, "admin") + self.assertEqual(rest_client.password, "Admin") + self.assertEqual(rest_client.bios_password, "foo") + self.assertEqual({}, rest_client.message_registries) + self.assertEqual(rest_client.cacert, '/somepath') + + @mock.patch.object(urllib3, 'disable_warnings') + def test_init_without_cacert(self, disable_warning_mock): + rest_client = v1.RestConnectorBase( + "x.x.x.x", "admin", "Admin", bios_password='foo') + + self.assertEqual(rest_client.host, "x.x.x.x") + self.assertEqual(rest_client.login, "admin") + self.assertEqual(rest_client.password, "Admin") + self.assertIsNone(rest_client.cacert) + disable_warning_mock.assert_called_once_with( + urllib3_exceptions.InsecureRequestWarning) + + def test__okay(self): + rest_client = v1.RestConnectorBase("1.2.3.4", "admin", "Admin") + self.assertEqual('[iLO 1.2.3.4] foo', rest_client._('foo')) + + +class RestConnectorBaseTestCase(testtools.TestCase): + + def setUp(self): + super(RestConnectorBaseTestCase, self).setUp() + self.client = v1.RestConnectorBase("1.2.3.4", "admin", "Admin") + + @mock.patch.object(requests, 'get') + def test__rest_op_okay(self, request_mock): + sample_headers = rest_outputs.HEADERS_FOR_REST_OP + exp_headers = dict((x.lower(), y) for x, y in sample_headers) + sample_response_body = rest_outputs.RESPONSE_BODY_FOR_REST_OP + response_mock_obj = mock.MagicMock( + status_code=200, text=sample_response_body, + headers=exp_headers) + request_mock.return_value = response_mock_obj + + status, headers, response = self.client._rest_op( + 'GET', '/v1/foo', None, None) + + self.assertEqual(200, status) + self.assertEqual(exp_headers, headers) + self.assertEqual(json.loads(sample_response_body), response) + request_mock.assert_called_once_with( + 'https://1.2.3.4/v1/foo', + headers={'Authorization': 'BASIC YWRtaW46QWRtaW4='}, + data="null", verify=False) + + @mock.patch.object(requests, 'get') + def test__rest_op_request_error(self, request_mock): + request_mock.side_effect = RuntimeError("boom") + + exc = self.assertRaises(exception.IloConnectionError, + self.client._rest_op, + 'GET', '/v1/foo', {}, None) + + request_mock.assert_called_once_with( + 'https://1.2.3.4/v1/foo', + headers={'Authorization': 'BASIC YWRtaW46QWRtaW4='}, + data="null", verify=False) + self.assertIn("boom", str(exc)) + + @mock.patch.object(requests, 'get') + def test__rest_op_continous_redirection(self, request_mock): + sample_response_body = rest_outputs.RESPONSE_BODY_FOR_REST_OP + sample_headers = rest_outputs.HEADERS_FOR_REST_OP + sample_headers.append(('location', 'https://foo')) + exp_headers = dict((x.lower(), y) for x, y in sample_headers) + response_mock_obj = mock.MagicMock( + status_code=301, text=sample_response_body, + headers=exp_headers) + request_mock.side_effect = [response_mock_obj, + response_mock_obj, + response_mock_obj, + response_mock_obj, + response_mock_obj] + + exc = self.assertRaises(exception.IloConnectionError, + self.client._rest_op, + 'GET', '/v1/foo', {}, None) + + self.assertEqual(5, request_mock.call_count) + self.assertIn('https://1.2.3.4/v1/foo', str(exc)) + + @mock.patch.object(requests, 'get') + def test__rest_op_one_redirection(self, request_mock): + sample_response_body = rest_outputs.RESPONSE_BODY_FOR_REST_OP + sample_headers1 = rest_outputs.HEADERS_FOR_REST_OP + sample_headers2 = rest_outputs.HEADERS_FOR_REST_OP + sample_headers1.append(('location', 'https://5.6.7.8/v1/foo')) + exp_headers1 = dict((x.lower(), y) for x, y in sample_headers1) + exp_headers2 = dict((x.lower(), y) for x, y in sample_headers2) + response_mock_obj1 = mock.MagicMock( + status_code=301, text=sample_response_body, + headers=exp_headers1) + response_mock_obj2 = mock.MagicMock( + status_code=200, text=sample_response_body, + headers=exp_headers2) + request_mock.side_effect = [response_mock_obj1, + response_mock_obj2] + + status, headers, response = self.client._rest_op( + 'GET', '/v1/foo', {}, None) + + exp_headers = dict((x.lower(), y) for x, y in sample_headers2) + self.assertEqual(200, status) + self.assertEqual(exp_headers, headers) + self.assertEqual(json.loads(sample_response_body), response) + request_mock.assert_has_calls([ + mock.call('https://1.2.3.4/v1/foo', + headers={'Authorization': 'BASIC YWRtaW46QWRtaW4='}, + data="null", verify=False), + mock.call('https://5.6.7.8/v1/foo', + headers={'Authorization': 'BASIC YWRtaW46QWRtaW4='}, + data="null", verify=False)]) + + @mock.patch.object(requests, 'get') + def test__rest_op_response_decode_error(self, request_mock): + sample_response_body = "{[wrong json" + sample_headers = rest_outputs.HEADERS_FOR_REST_OP + exp_headers = dict((x.lower(), y) for x, y in sample_headers) + response_mock_obj = mock.MagicMock( + status_code=200, text=sample_response_body, + headers=exp_headers) + request_mock.return_value = response_mock_obj + + self.assertRaises(exception.IloError, + self.client._rest_op, + 'GET', '/v1/foo', {}, None) + + request_mock.assert_called_once_with( + 'https://1.2.3.4/v1/foo', + headers={'Authorization': 'BASIC YWRtaW46QWRtaW4='}, + data="null", verify=False) + + @mock.patch.object(requests, 'get') + def test__rest_op_response_gzipped_response(self, request_mock): + sample_response_body = rest_outputs.RESPONSE_BODY_FOR_REST_OP + gzipped_response_body = base64.b64decode( + rest_outputs.BASE64_GZIPPED_RESPONSE) + sample_headers = rest_outputs.HEADERS_FOR_REST_OP + exp_headers = dict((x.lower(), y) for x, y in sample_headers) + response_mock_obj = mock.MagicMock( + status_code=200, text=gzipped_response_body, + headers=exp_headers) + request_mock.return_value = response_mock_obj + + status, headers, response = self.client._rest_op( + 'GET', '/v1/foo', {}, None) + + self.assertEqual(200, status) + self.assertEqual(exp_headers, headers) + self.assertEqual(json.loads(sample_response_body), response) + + @mock.patch.object(v1.RestConnectorBase, '_rest_op') + def test__rest_get(self, _rest_op_mock): + self.client._rest_get('/v1/foo', {}) + _rest_op_mock.assert_called_once_with( + 'GET', '/v1/foo', {}, None) + + @mock.patch.object(v1.RestConnectorBase, '_rest_op') + def test__rest_patch(self, _rest_op_mock): + self.client._rest_patch('/v1/foo', {}, {'data': 'Lorem ipsum'}) + _rest_op_mock.assert_called_once_with( + 'PATCH', '/v1/foo', {}, {'data': 'Lorem ipsum'}) + + @mock.patch.object(v1.RestConnectorBase, '_rest_op') + def test__rest_put(self, _rest_op_mock): + self.client._rest_put('/v1/foo', {}, {'data': 'Lorem ipsum'}) + _rest_op_mock.assert_called_once_with( + 'PUT', '/v1/foo', {}, {'data': 'Lorem ipsum'}) + + @mock.patch.object(v1.RestConnectorBase, '_rest_op') + def test__rest_post(self, _rest_op_mock): + self.client._rest_post('/v1/foo', {}, {'data': 'Lorem ipsum'}) + _rest_op_mock.assert_called_once_with( + 'POST', '/v1/foo', {}, {'data': 'Lorem ipsum'}) + + @mock.patch.object(v1.RestConnectorBase, '_rest_op') + def test__rest_delete(self, _rest_op_mock): + self.client._rest_delete('/v1/foo', None) + _rest_op_mock.assert_called_once_with( + 'DELETE', '/v1/foo', None, None)