From 7620cf193067016436e7cd3ac57e2751a96fc48c Mon Sep 17 00:00:00 2001 From: Hongbin Lu Date: Sat, 4 Jun 2016 16:24:12 -0500 Subject: [PATCH] Implement root controller This patch remove the usage of WSME because it is deprecated: http://lists.openstack.org/pipermail/openstack-dev/2016-March/ 088658.html . We will manually validate the inputs instead. Change-Id: I976d4c9e67581a12e42b6f46e45b32a84b95d32f --- zun/api/controllers/base.py | 31 ++++--- zun/api/controllers/link.py | 28 +++--- zun/api/controllers/root.py | 98 ++++++++++++++++---- zun/api/controllers/types.py | 61 ++++++++++++ zun/common/exception.py | 4 + zun/tests/unit/api/__init__.py | 0 zun/tests/unit/api/controllers/__init__.py | 0 zun/tests/unit/api/controllers/test_base.py | 85 +++++++++++++++++ zun/tests/unit/api/controllers/test_link.py | 33 +++++++ zun/tests/unit/api/controllers/test_types.py | 69 ++++++++++++++ 10 files changed, 366 insertions(+), 43 deletions(-) create mode 100644 zun/api/controllers/types.py create mode 100644 zun/tests/unit/api/__init__.py create mode 100644 zun/tests/unit/api/controllers/__init__.py create mode 100644 zun/tests/unit/api/controllers/test_base.py create mode 100644 zun/tests/unit/api/controllers/test_link.py create mode 100644 zun/tests/unit/api/controllers/test_types.py diff --git a/zun/api/controllers/base.py b/zun/api/controllers/base.py index 4a87a4706..a4674382e 100644 --- a/zun/api/controllers/base.py +++ b/zun/api/controllers/base.py @@ -12,23 +12,26 @@ # License for the specific language governing permissions and limitations # under the License. -import datetime -import wsme -from wsme import types as wtypes +class APIBase(object): + def __init__(self, **kwargs): + for field in self.fields: + if field in kwargs: + value = kwargs[field] + setattr(self, field, value) -class APIBase(wtypes.Base): - - created_at = wsme.wsattr(datetime.datetime, readonly=True) - """The time in UTC at which the object is created""" - - updated_at = wsme.wsattr(datetime.datetime, readonly=True) - """The time in UTC at which the object is updated""" + def __setattr__(self, field, value): + if field in self.fields: + validator = self.fields[field]['validate'] + value = validator(value) + super(APIBase, self).__setattr__(field, value) def as_dict(self): """Render this object as a dict of its fields.""" - return {k: getattr(self, k) - for k in self.fields - if hasattr(self, k) and - getattr(self, k) != wsme.Unset} + return {f: getattr(self, f) + for f in self.fields + if hasattr(self, f)} + + def __json__(self): + return self.as_dict() diff --git a/zun/api/controllers/link.py b/zun/api/controllers/link.py index 64a734d3f..b7fc8d72c 100644 --- a/zun/api/controllers/link.py +++ b/zun/api/controllers/link.py @@ -14,9 +14,9 @@ # under the License. import pecan -from wsme import types as wtypes from zun.api.controllers import base +from zun.api.controllers import types def build_url(resource, resource_args, bookmark=False, base_url=None): @@ -34,21 +34,27 @@ def build_url(resource, resource_args, bookmark=False, base_url=None): class Link(base.APIBase): """A link representation.""" - href = wtypes.text - """The url of a link.""" - - rel = wtypes.text - """The name of a link.""" - - type = wtypes.text - """Indicates the type of document/link.""" + fields = { + 'href': { + 'validate': types.Text.validate + }, + 'rel': { + 'validate': types.Text.validate + }, + 'type': { + 'validate': types.Text.validate + }, + } @staticmethod def make_link(rel_name, url, resource, resource_args, - bookmark=False, type=wtypes.Unset): + bookmark=False, type=None): href = build_url(resource, resource_args, bookmark=bookmark, base_url=url) - return Link(href=href, rel=rel_name, type=type) + if type is None: + return Link(href=href, rel=rel_name) + else: + return Link(href=href, rel=rel_name, type=type) @classmethod def sample(cls): diff --git a/zun/api/controllers/root.py b/zun/api/controllers/root.py index 079ca3b4b..348ee3bd4 100644 --- a/zun/api/controllers/root.py +++ b/zun/api/controllers/root.py @@ -10,26 +10,88 @@ # See the License for the specific language governing permissions and # limitations under the License. -from pecan import expose -from pecan import redirect -from webob.exc import status_map +import pecan +from pecan import rest + +from zun.api.controllers import base +from zun.api.controllers import link +from zun.api.controllers import types -class RootController(object): +class Version(base.APIBase): + """An API version representation.""" - @expose(generic=True, template='index.html') - def index(self): - return dict() + fields = { + 'id': { + 'validate': types.Text.validate + }, + 'links': { + 'validate': types.List(types.Custom(link.Link)).validate + }, + } - @index.when(method='POST') - def index_post(self, q): - redirect('http://pecan.readthedocs.org/en/latest/search.html?q=%s' % q) + @staticmethod + def convert(id): + version = Version() + version.id = id + version.links = [link.Link.make_link('self', pecan.request.host_url, + id, '', bookmark=True)] + return version - @expose('error.html') - def error(self, status): - try: - status = int(status) - except ValueError: # pragma: no cover - status = 500 - message = getattr(status_map.get(status), 'explanation', '') - return dict(status=status, message=message) + +class Root(base.APIBase): + + fields = { + 'id': { + 'validate': types.Text.validate + }, + 'description': { + 'validate': types.Text.validate + }, + 'versions': { + 'validate': types.List(types.Custom(Version)).validate + }, + 'default_version': { + 'validate': types.Custom(Version).validate + }, + } + + @staticmethod + def convert(): + root = Root() + root.name = "OpenStack Zun API" + root.description = ("Zun is an OpenStack project which aims to " + "provide container management.") + root.versions = [Version.convert('v1')] + root.default_version = Version.convert('v1') + return root + + +class RootController(rest.RestController): + + _versions = ['v1'] + """All supported API versions""" + + _default_version = 'v1' + """The default API version""" + + # v1 = v1.Controller() + + @pecan.expose('json') + def get(self): + # NOTE: The reason why convert() it's being called for every + # request is because we need to get the host url from + # the request object to make the links. + return Root.convert() + + @pecan.expose() + def _route(self, args): + """Overrides the default routing behavior. + + It redirects the request to the default version of the zun API + if the version number is not specified in the url. + """ + + if args[0] and args[0] not in self._versions: + args = [self._default_version] + args + return super(RootController, self)._route(args) diff --git a/zun/api/controllers/types.py b/zun/api/controllers/types.py new file mode 100644 index 000000000..8b88edcfe --- /dev/null +++ b/zun/api/controllers/types.py @@ -0,0 +1,61 @@ +# 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 logging + +from zun.common import exception +from zun.common.i18n import _LE + + +LOG = logging.getLogger(__name__) + + +class Text(object): + type_name = 'Text' + + @classmethod + def validate(cls, value): + return value + + +class Custom(object): + def __init__(self, user_class): + super(Custom, self).__init__() + self.user_class = user_class + self.type_name = self.user_class.__name__ + + def validate(self, value): + if not isinstance(value, self.user_class): + try: + value = self.user_class(**value) + except Exception: + LOG.exception(_LE('Failed to validate received value')) + raise exception.InvalidValue(value=value, type=self.type_name) + + return value + + +class List(object): + def __init__(self, type): + super(List, self).__init__() + self.type = type + self.type_name = 'List(%s)' % self.type.type_name + + def validate(self, value): + if not isinstance(value, list): + raise exception.InvalidValue(value=value, type=self.type_name) + + try: + return [self.type.validate(v) for v in value] + except Exception: + LOG.exception(_LE('Failed to validate received value')) + raise exception.InvalidValue(value=value, type=self.type_name) diff --git a/zun/common/exception.py b/zun/common/exception.py index 0b677c63c..2ae939f21 100644 --- a/zun/common/exception.py +++ b/zun/common/exception.py @@ -268,6 +268,10 @@ class Invalid(ZunException): code = 400 +class InvalidValue(Invalid): + message = _("Received value '%(value)s' is invalid for type %(type)s.") + + class InvalidUUID(Invalid): message = _("Expected a uuid but received %(uuid)s.") diff --git a/zun/tests/unit/api/__init__.py b/zun/tests/unit/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/zun/tests/unit/api/controllers/__init__.py b/zun/tests/unit/api/controllers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/zun/tests/unit/api/controllers/test_base.py b/zun/tests/unit/api/controllers/test_base.py new file mode 100644 index 000000000..639d38d28 --- /dev/null +++ b/zun/tests/unit/api/controllers/test_base.py @@ -0,0 +1,85 @@ +# 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 collections +import six + +from zun.api.controllers import base +from zun.tests import base as test_base + + +class TestAPIBase(test_base.BaseTestCase): + + def setUp(self): + super(TestAPIBase, self).setUp() + + class TestAPI(base.APIBase): + fields = { + 'test': { + 'validate': lambda v: v + }, + } + + self.test_api_cls = TestAPI + + def test_assign_field(self): + test_api = self.test_api_cls() + test_api.test = 'test_value' + + expected_value = { + 'test': 'test_value', + } + self.assertEqual(test_api.__json__(), expected_value) + + def test_no_field_assigned(self): + test_api = self.test_api_cls() + expected_value = {} + self.assertEqual(test_api.__json__(), expected_value) + + def test_assign_field_in_constructor(self): + test_api = self.test_api_cls(test='test_value') + expected_value = { + 'test': 'test_value', + } + self.assertEqual(test_api.__json__(), expected_value) + + def test_assign_nonexist_field(self): + test_api = self.test_api_cls() + test_api.nonexist = 'test_value' + + expected_value = {} + self.assertEqual(test_api.__json__(), expected_value) + + def test_assign_multiple_fields(self): + class TestAPI(base.APIBase): + fields = { + 'test': { + 'validate': lambda v: v + }, + 'test2': { + 'validate': lambda v: v + }, + } + + test_api = TestAPI() + test_api.test = 'test_value' + test_api.test2 = 'test_value2' + test_api.test3 = 'test_value3' + + expected_value = collections.OrderedDict([ + ('test', 'test_value'), + ('test2', 'test_value2'), + ]) + actual_value = collections.OrderedDict( + sorted(test_api.as_dict().items())) + self.assertEqual(six.text_type(actual_value), + six.text_type(expected_value)) diff --git a/zun/tests/unit/api/controllers/test_link.py b/zun/tests/unit/api/controllers/test_link.py new file mode 100644 index 000000000..3cb5af448 --- /dev/null +++ b/zun/tests/unit/api/controllers/test_link.py @@ -0,0 +1,33 @@ +# 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 collections +import six + +from zun.api.controllers import link as link_module +from zun.tests import base as test_base + + +class TestLink(test_base.BaseTestCase): + + def test_make_link(self): + link = link_module.Link.make_link( + 'self', 'http://localhost:8080', 'v1', '', + bookmark=True) + + ordered_link = collections.OrderedDict(sorted(link.as_dict().items())) + expected_value = collections.OrderedDict([ + ('href', 'http://localhost:8080/v1/'), + ('rel', 'self') + ]) + self.assertEqual(six.text_type(ordered_link), + six.text_type(expected_value)) diff --git a/zun/tests/unit/api/controllers/test_types.py b/zun/tests/unit/api/controllers/test_types.py new file mode 100644 index 000000000..1b626b692 --- /dev/null +++ b/zun/tests/unit/api/controllers/test_types.py @@ -0,0 +1,69 @@ +# 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. + +from zun.api.controllers import base +from zun.api.controllers import types +from zun.common import exception +from zun.tests import base as test_base + + +class TestTypes(test_base.BaseTestCase): + + def test_text(self): + self.assertEqual('test_value', types.Text.validate('test_value')) + + def test_custom(self): + class TestAPI(base.APIBase): + fields = { + 'test': { + 'validate': lambda v: v + }, + } + + test_type = types.Custom(TestAPI) + value = TestAPI(test='test_value') + value = test_type.validate(value) + self.assertIsInstance(value, TestAPI) + self.assertEqual(value.as_dict(), {'test': 'test_value'}) + + test_type = types.Custom(TestAPI) + value = test_type.validate({'test': 'test_value'}) + self.assertIsInstance(value, TestAPI) + self.assertEqual(value.as_dict(), {'test': 'test_value'}) + + self.assertRaises( + exception.InvalidValue, + test_type.validate, 'invalid_value') + + def test_list_with_text_type(self): + list_type = types.List(types.Text) + value = list_type.validate(['test1', 'test2']) + self.assertEqual(value, ['test1', 'test2']) + + self.assertRaises( + exception.InvalidValue, + list_type.validate, 'invalid_value') + + def test_list_with_custom_type(self): + class TestAPI(base.APIBase): + fields = { + 'test': { + 'validate': lambda v: v + }, + } + + list_type = types.List(types.Custom(TestAPI)) + value = [{'test': 'test_value'}] + value = list_type.validate(value) + self.assertIsInstance(value, list) + self.assertIsInstance(value[0], TestAPI) + self.assertEqual(value[0].as_dict(), {'test': 'test_value'})