Merge "Do all serialization in the expose decorator"

This commit is contained in:
Zuul 2020-05-18 17:10:13 +00:00 committed by Gerrit Code Review
commit 8d0ed2c1fb
5 changed files with 434 additions and 10 deletions

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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'])

View File

@ -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)