diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 8959471220..8b4f718706 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -25,7 +25,6 @@ from oslo_config import cfg from oslo_utils import uuidutils from pecan import rest from webob import static -import wsme from ironic import api from ironic.api.controllers.v1 import versions @@ -433,7 +432,7 @@ def vendor_passthru(ident, method, topic, data=None, driver_passthru=False): return_value = None response_params['return_type'] = None - return wsme.api.Response(return_value, **response_params) + return atypes.Response(return_value, **response_params) def check_for_invalid_fields(fields, object_fields): diff --git a/ironic/api/expose.py b/ironic/api/expose.py index 46d4649a6f..061be9ac62 100644 --- a/ironic/api/expose.py +++ b/ironic/api/expose.py @@ -14,11 +14,183 @@ # License for the specific language governing permissions and limitations # under the License. -import wsmeext.pecan as wsme_pecan +import datetime +import functools +from http import client as http_client +import inspect +import json +import sys +import traceback + +from oslo_config import cfg +from oslo_log import log +import pecan +import wsme +import wsme.rest.args + +from ironic.api import types as atypes + +LOG = log.getLogger(__name__) + + +class JSonRenderer(object): + @staticmethod + def __init__(path, extra_vars): + pass + + @staticmethod + def render(template_path, namespace): + if 'faultcode' in namespace: + return encode_error(None, namespace) + result = encode_result( + namespace['result'], + namespace['datatype'] + ) + return result + + +pecan.templating._builtin_renderers['wsmejson'] = JSonRenderer + +pecan_json_decorate = pecan.expose( + template='wsmejson:', + content_type='application/json', + generic=False) def expose(*args, **kwargs): - """Ensure that only JSON, and not XML, is supported.""" - if 'rest_content_types' not in kwargs: - kwargs['rest_content_types'] = ('json',) - return wsme_pecan.wsexpose(*args, **kwargs) + sig = wsme.signature(*args, **kwargs) + + def decorate(f): + sig(f) + funcdef = wsme.api.FunctionDefinition.get(f) + funcdef.resolve_types(atypes.registry) + + @functools.wraps(f) + def callfunction(self, *args, **kwargs): + return_type = funcdef.return_type + + try: + args, kwargs = wsme.rest.args.get_args( + funcdef, args, kwargs, pecan.request.params, None, + pecan.request.body, pecan.request.content_type + ) + result = f(self, *args, **kwargs) + + # NOTE: Support setting of status_code with default 201 + pecan.response.status = funcdef.status_code + if isinstance(result, atypes.Response): + pecan.response.status = result.status_code + + # NOTE(lucasagomes): If the return code is 204 + # (No Response) we have to make sure that we are not + # returning anything in the body response and the + # content-length is 0 + if result.status_code == 204: + return_type = None + elif not isinstance(result.return_type, + atypes.UnsetType): + return_type = result.return_type + + result = result.obj + + except Exception: + try: + exception_info = sys.exc_info() + orig_exception = exception_info[1] + orig_code = getattr(orig_exception, 'code', None) + data = format_exception( + exception_info, + cfg.CONF.debug_tracebacks_in_api + ) + finally: + del exception_info + + if orig_code and orig_code in http_client.responses: + pecan.response.status = orig_code + else: + pecan.response.status = 500 + + return data + + if return_type is None: + pecan.request.pecan['content_type'] = None + pecan.response.content_type = None + return '' + + return dict( + datatype=return_type, + result=result + ) + + pecan_json_decorate(callfunction) + pecan.util._cfg(callfunction)['argspec'] = inspect.getargspec(f) + callfunction._wsme_definition = funcdef + return callfunction + + return decorate + + +def tojson(datatype, value): + """A generic converter from python to jsonify-able datatypes. + + """ + if value is None: + return None + if isinstance(datatype, atypes.ArrayType): + return [tojson(datatype.item_type, item) for item in value] + if isinstance(datatype, atypes.DictType): + return dict(( + (tojson(datatype.key_type, item[0]), + tojson(datatype.value_type, item[1])) + for item in value.items() + )) + if isinstance(value, datetime.datetime): + return value.isoformat() + if atypes.iscomplex(datatype): + d = dict() + for attr in atypes.list_attributes(datatype): + attr_value = getattr(value, attr.key) + if attr_value is not atypes.Unset: + d[attr.name] = tojson(attr.datatype, attr_value) + return d + if isinstance(datatype, atypes.UserType): + return tojson(datatype.basetype, datatype.tobasetype(value)) + return value + + +def encode_result(value, datatype, **options): + jsondata = tojson(datatype, value) + return json.dumps(jsondata) + + +def encode_error(context, errordetail): + return json.dumps(errordetail) + + +def format_exception(excinfo, debug=False): + """Extract informations that can be sent to the client.""" + error = excinfo[1] + code = getattr(error, 'code', None) + if code and code in http_client.responses and (400 <= code < 500): + faultstring = (error.faultstring if hasattr(error, 'faultstring') + else str(error)) + faultcode = getattr(error, 'faultcode', 'Client') + r = dict(faultcode=faultcode, + faultstring=faultstring) + LOG.debug("Client-side error: %s", r['faultstring']) + r['debuginfo'] = None + return r + else: + faultstring = str(error) + debuginfo = "\n".join(traceback.format_exception(*excinfo)) + + LOG.error('Server-side error: "%s". Detail: \n%s', + faultstring, debuginfo) + + faultcode = getattr(error, 'faultcode', 'Server') + r = dict(faultcode=faultcode, faultstring=faultstring) + if debug: + r['debuginfo'] = debuginfo + else: + r['debuginfo'] = None + return r diff --git a/ironic/api/types.py b/ironic/api/types.py index 527abd7221..0da12360b3 100644 --- a/ironic/api/types.py +++ b/ironic/api/types.py @@ -21,9 +21,35 @@ from wsme.types import DictType # noqa from wsme.types import Enum # noqa from wsme.types import File # noqa from wsme.types import IntegerType # noqa +from wsme.types import iscomplex # noqa +from wsme.types import list_attributes # noqa +from wsme.types import registry # noqa from wsme.types import StringType # noqa from wsme.types import text # noqa from wsme.types import Unset # noqa +from wsme.types import UnsetType # noqa from wsme.types import UserType # noqa from wsme.types import wsattr # noqa from wsme.types import wsproperty # noqa + + +class Response(object): + """Object to hold the "response" from a view function""" + def __init__(self, obj, status_code=None, error=None, + return_type=Unset): + #: Store the result object from the view + self.obj = obj + + #: Store an optional status_code + self.status_code = status_code + + #: Return error details + #: Must be a dictionnary with the following keys: faultcode, + #: faultstring and an optional debuginfo + self.error = error + + #: Return type + #: Type of the value returned by the function + #: If the return type is wsme.types.Unset it will be ignored + #: and the default return type will prevail. + self.return_type = return_type diff --git a/ironic/tests/unit/api/controllers/v1/test_expose.py b/ironic/tests/unit/api/controllers/v1/test_expose.py index 0c9976dcb2..0cb41b22dd 100644 --- a/ironic/tests/unit/api/controllers/v1/test_expose.py +++ b/ironic/tests/unit/api/controllers/v1/test_expose.py @@ -12,15 +12,26 @@ # License for the specific language governing permissions and limitations # under the License. +import datetime +from http import client as http_client from importlib import machinery import inspect +import json import os import sys import mock from oslo_utils import uuidutils +import pecan.rest +import pecan.testing +from ironic.api.controllers import root +from ironic.api.controllers import v1 +from ironic.api import expose +from ironic.api import types as atypes +from ironic.common import exception from ironic.tests import base as test_base +from ironic.tests.unit.api import base as test_api_base class TestExposedAPIMethodsCheckPolicy(test_base.TestCase): @@ -85,3 +96,220 @@ class TestExposedAPIMethodsCheckPolicy(test_base.TestCase): def test_conductor_api_policy(self): self._test('ironic.api.controllers.v1.conductor') + + +class UnderscoreStr(atypes.UserType): + basetype = str + name = "custom string" + + def tobasetype(self, value): + return '__' + value + + +class Obj(atypes.Base): + id = int + name = str + unset_me = str + + +class NestedObj(atypes.Base): + o = Obj + + +class TestJsonRenderer(test_base.TestCase): + + def setUp(self): + super(TestJsonRenderer, self).setUp() + self.renderer = expose.JSonRenderer('/', None) + + def test_render_error(self): + error_dict = { + 'faultcode': 500, + 'faultstring': 'ouch' + } + self.assertEqual( + error_dict, + json.loads(self.renderer.render('/', error_dict)) + ) + + def test_render_exception(self): + error_dict = { + 'faultcode': 'Server', + 'faultstring': 'ouch', + 'debuginfo': None + } + try: + raise Exception('ouch') + except Exception: + excinfo = sys.exc_info() + self.assertEqual( + json.dumps(error_dict), + self.renderer.render('/', expose.format_exception(excinfo)) + ) + + def test_render_http_exception(self): + error_dict = { + 'faultcode': '403', + 'faultstring': 'Not authorized', + 'debuginfo': None + } + try: + e = exception.NotAuthorized() + e.code = 403 + except exception.IronicException: + excinfo = sys.exc_info() + self.assertEqual( + json.dumps(error_dict), + self.renderer.render('/', expose.format_exception(excinfo)) + ) + + def test_render_int(self): + self.assertEqual( + '42', + self.renderer.render('/', { + 'result': 42, + 'datatype': int + }) + ) + + def test_render_none(self): + self.assertEqual( + 'null', + self.renderer.render('/', { + 'result': None, + 'datatype': str + }) + ) + + def test_render_str(self): + self.assertEqual( + '"a string"', + self.renderer.render('/', { + 'result': 'a string', + 'datatype': str + }) + ) + + def test_render_datetime(self): + self.assertEqual( + '"2020-04-14T10:35:10.586431"', + self.renderer.render('/', { + 'result': datetime.datetime(2020, 4, 14, 10, 35, 10, 586431), + 'datatype': datetime.datetime + }) + ) + + def test_render_array(self): + self.assertEqual( + json.dumps(['one', 'two', 'three']), + self.renderer.render('/', { + 'result': ['one', 'two', 'three'], + 'datatype': atypes.ArrayType(str) + }) + ) + + def test_render_dict(self): + self.assertEqual( + json.dumps({'one': 'a', 'two': 'b', 'three': 'c'}), + self.renderer.render('/', { + 'result': {'one': 'a', 'two': 'b', 'three': 'c'}, + 'datatype': atypes.DictType(str, str) + }) + ) + + def test_complex_type(self): + o = Obj() + o.id = 1 + o.name = 'one' + o.unset_me = atypes.Unset + + n = NestedObj() + n.o = o + self.assertEqual( + json.dumps({'o': {'id': 1, 'name': 'one'}}), + self.renderer.render('/', { + 'result': n, + 'datatype': NestedObj + }) + ) + + def test_user_type(self): + self.assertEqual( + '"__foo"', + self.renderer.render('/', { + 'result': 'foo', + 'datatype': UnderscoreStr() + }) + ) + + +class MyThingController(pecan.rest.RestController): + + _custom_actions = { + 'no_content': ['GET'], + 'response_content': ['GET'], + 'ouch': ['GET'], + } + + @expose.expose(int, str, int) + def get(self, name, number): + return {name: number} + + @expose.expose(str) + def no_content(self): + return atypes.Response('nothing', status_code=204) + + @expose.expose(str) + def response_content(self): + return atypes.Response('nothing', status_code=200) + + @expose.expose(str) + def ouch(self): + raise Exception('ouch') + + +class MyV1Controller(v1.Controller): + + things = MyThingController() + + +class MyRootController(root.RootController): + + v1 = MyV1Controller() + + +class TestExpose(test_api_base.BaseApiTest): + + block_execute = False + + root_controller = '%s.%s' % (MyRootController.__module__, + MyRootController.__name__) + + def test_expose(self): + self.assertEqual( + {'foo': 1}, + self.get_json('/things/', name='foo', number=1) + ) + + def test_response_204(self): + response = self.get_json('/things/no_content', expect_errors=True) + self.assertEqual(http_client.NO_CONTENT, response.status_int) + self.assertIsNone(response.content_type) + self.assertEqual(b'', response.normal_body) + + def test_response_content(self): + response = self.get_json('/things/response_content', + expect_errors=True) + self.assertEqual(http_client.OK, response.status_int) + self.assertEqual(b'"nothing"', response.normal_body) + self.assertEqual('application/json', response.content_type) + + def test_exception(self): + response = self.get_json('/things/ouch', + expect_errors=True) + error_message = json.loads(response.json['error_message']) + self.assertEqual(http_client.INTERNAL_SERVER_ERROR, + response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertEqual('Server', error_message['faultcode']) + self.assertEqual('ouch', error_message['faultstring']) diff --git a/ironic/tests/unit/api/controllers/v1/test_utils.py b/ironic/tests/unit/api/controllers/v1/test_utils.py index 3defca326b..e33617d747 100644 --- a/ironic/tests/unit/api/controllers/v1/test_utils.py +++ b/ironic/tests/unit/api/controllers/v1/test_utils.py @@ -21,7 +21,6 @@ import os_traits from oslo_config import cfg from oslo_utils import uuidutils from webob import static -import wsme from ironic import api from ironic.api.controllers.v1 import node as api_node @@ -692,7 +691,7 @@ class TestVendorPassthru(base.TestCase): passthru_mock.assert_called_once_with( 'fake-context', 'fake-ident', 'squarepants', 'POST', 'fake-data', 'fake-topic') - self.assertIsInstance(response, wsme.api.Response) + self.assertIsInstance(response, atypes.Response) self.assertEqual('SpongeBob', response.obj) self.assertEqual(response.return_type, atypes.Unset) sc = http_client.ACCEPTED if async_call else http_client.OK @@ -731,7 +730,7 @@ class TestVendorPassthru(base.TestCase): self.assertEqual(expct_return_value, mock_response.app_iter.file.read()) # Assert response message is none - self.assertIsInstance(response, wsme.api.Response) + self.assertIsInstance(response, atypes.Response) self.assertIsNone(response.obj) self.assertIsNone(response.return_type) self.assertEqual(http_client.OK, response.status_code)