Merge "Do all serialization in the expose decorator"
This commit is contained in:
commit
8d0ed2c1fb
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'])
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue