From: Bob Kukura Date: Mon, 16 Jul 2012 00:45:25 +0000 (-0400) Subject: Implements data-driven views and extended attributes. X-Git-Url: https://review.fuel-infra.org/gitweb?a=commitdiff_plain;h=a01ca27a88bfc7bdc6913580700315a7789e5a70;p=openstack-build%2Fneutron-build.git Implements data-driven views and extended attributes. The quantum/api/v2/views.py module is replaced by is_visible properties in the RESOURCE_ATTRIBUTE_MAP defined in quantum/api/v2/attributes.py. Extensions are given the ability to add extended attribute descriptions to this map during initialization, allowing extended attributes to be implemented similarly to core attributes in plugins. Resolves bug 1023111. Change-Id: Ic6e224d5d841b6a1d4d1c762d7306adaf91f7a2d --- diff --git a/quantum/api/v2/attributes.py b/quantum/api/v2/attributes.py index eeb36824b..799e13ec8 100644 --- a/quantum/api/v2/attributes.py +++ b/quantum/api/v2/attributes.py @@ -141,56 +141,87 @@ validators = {'type:boolean': _validate_boolean, RESOURCE_ATTRIBUTE_MAP = { 'networks': { 'id': {'allow_post': False, 'allow_put': False, - 'validate': {'type:regex': UUID_PATTERN}}, - 'name': {'allow_post': True, 'allow_put': True}, - 'subnets': {'allow_post': True, 'allow_put': True, 'default': []}, + 'validate': {'type:regex': UUID_PATTERN}, + 'is_visible': True}, + 'name': {'allow_post': True, 'allow_put': True, + 'is_visible': True}, + 'subnets': {'allow_post': True, 'allow_put': True, + 'default': [], + 'is_visible': True}, 'admin_state_up': {'allow_post': True, 'allow_put': True, - 'default': True, 'convert_to': convert_to_boolean, - 'validate': {'type:boolean': None}}, - 'status': {'allow_post': False, 'allow_put': False}, + 'default': True, + 'convert_to': convert_to_boolean, + 'validate': {'type:boolean': None}, + 'is_visible': True}, + 'status': {'allow_post': False, 'allow_put': False, + 'is_visible': True}, 'tenant_id': {'allow_post': True, 'allow_put': False, - 'required_by_policy': True}, + 'required_by_policy': True, + 'is_visible': True}, + 'mac_ranges': {'allow_post': False, 'allow_put': False, + 'is_visible': True}, }, 'ports': { 'id': {'allow_post': False, 'allow_put': False, - 'validate': {'type:regex': UUID_PATTERN}}, + 'validate': {'type:regex': UUID_PATTERN}, + 'is_visible': True}, 'network_id': {'allow_post': True, 'allow_put': False, - 'validate': {'type:regex': UUID_PATTERN}}, + 'validate': {'type:regex': UUID_PATTERN}, + 'is_visible': True}, 'admin_state_up': {'allow_post': True, 'allow_put': True, - 'default': True, 'convert_to': convert_to_boolean, - 'validate': {'type:boolean': None}}, + 'default': True, + 'convert_to': convert_to_boolean, + 'validate': {'type:boolean': None}, + 'is_visible': True}, 'mac_address': {'allow_post': True, 'allow_put': False, 'default': ATTR_NOT_SPECIFIED, - 'validate': {'type:mac_address': None}}, + 'validate': {'type:mac_address': None}, + 'is_visible': True}, 'fixed_ips': {'allow_post': True, 'allow_put': True, - 'default': ATTR_NOT_SPECIFIED}, + 'default': ATTR_NOT_SPECIFIED, + 'is_visible': True}, 'host_routes': {'allow_post': True, 'allow_put': True, - 'default': ATTR_NOT_SPECIFIED}, - 'device_id': {'allow_post': True, 'allow_put': True, 'default': ''}, + 'default': ATTR_NOT_SPECIFIED, + 'is_visible': False}, + 'device_id': {'allow_post': True, 'allow_put': True, + 'default': '', + 'is_visible': True}, 'tenant_id': {'allow_post': True, 'allow_put': False, - 'required_by_policy': True}, + 'required_by_policy': True, + 'is_visible': True}, + 'status': {'allow_post': False, 'allow_put': False, + 'is_visible': True}, }, 'subnets': { 'id': {'allow_post': False, 'allow_put': False, - 'validate': {'type:regex': UUID_PATTERN}}, + 'validate': {'type:regex': UUID_PATTERN}, + 'is_visible': True}, 'ip_version': {'allow_post': True, 'allow_put': False, 'convert_to': int, - 'validate': {'type:values': [4, 6]}}, + 'validate': {'type:values': [4, 6]}, + 'is_visible': True}, 'network_id': {'allow_post': True, 'allow_put': False, - 'validate': {'type:regex': UUID_PATTERN}}, + 'validate': {'type:regex': UUID_PATTERN}, + 'is_visible': True}, 'cidr': {'allow_post': True, 'allow_put': False, - 'validate': {'type:subnet': None}}, + 'validate': {'type:subnet': None}, + 'is_visible': True}, 'gateway_ip': {'allow_post': True, 'allow_put': True, 'default': ATTR_NOT_SPECIFIED, - 'validate': {'type:ip_address': None}}, + 'validate': {'type:ip_address': None}, + 'is_visible': True}, #TODO(salvatore-orlando): Enable PUT on allocation_pools 'allocation_pools': {'allow_post': True, 'allow_put': False, - 'default': ATTR_NOT_SPECIFIED}, + 'default': ATTR_NOT_SPECIFIED, + 'is_visible': True}, 'dns_namesevers': {'allow_post': True, 'allow_put': True, - 'default': ATTR_NOT_SPECIFIED}, + 'default': ATTR_NOT_SPECIFIED, + 'is_visible': False}, 'additional_host_routes': {'allow_post': True, 'allow_put': True, - 'default': ATTR_NOT_SPECIFIED}, + 'default': ATTR_NOT_SPECIFIED, + 'is_visible': False}, 'tenant_id': {'allow_post': True, 'allow_put': False, - 'required_by_policy': True}, + 'required_by_policy': True, + 'is_visible': True}, } } diff --git a/quantum/api/v2/base.py b/quantum/api/v2/base.py index f60887584..64786d342 100644 --- a/quantum/api/v2/base.py +++ b/quantum/api/v2/base.py @@ -18,7 +18,6 @@ import webob.exc from quantum.api.v2 import attributes from quantum.api.v2 import resource as wsgi_resource -from quantum.api.v2 import views from quantum.common import exceptions from quantum.common import utils from quantum import policy @@ -112,7 +111,18 @@ class Controller(object): self._policy_attrs = [name for (name, info) in self._attr_info.items() if 'required_by_policy' in info and info['required_by_policy']] - self._view = getattr(views, self._resource) + + def _is_visible(self, attr): + attr_val = self._attr_info.get(attr) + return attr_val and attr_val['is_visible'] + + def _view(self, data, fields_to_strip=None): + # make sure fields_to_strip is iterable + if not fields_to_strip: + fields_to_strip = [] + return dict(item for item in data.iteritems() + if self._is_visible(item[0]) + and not item[0] in fields_to_strip) def _do_field_list(self, original_fields): fields_to_add = None diff --git a/quantum/api/v2/router.py b/quantum/api/v2/router.py index a21589285..af07e3ec2 100644 --- a/quantum/api/v2/router.py +++ b/quantum/api/v2/router.py @@ -23,6 +23,7 @@ import webob.exc from quantum.api.v2 import attributes from quantum.api.v2 import base +from quantum.extensions import extensions from quantum import manager from quantum.openstack.common import cfg from quantum import wsgi @@ -69,6 +70,9 @@ class APIRouter(wsgi.Router): mapper = routes_mapper.Mapper() plugin = manager.QuantumManager.get_plugin() + ext_mgr = extensions.PluginAwareExtensionManager.get_instance() + ext_mgr.extend_resources("2.0", attributes.RESOURCE_ATTRIBUTE_MAP) + col_kwargs = dict(collection_actions=COLLECTION_ACTIONS, member_actions=MEMBER_ACTIONS) diff --git a/quantum/api/v2/views.py b/quantum/api/v2/views.py deleted file mode 100644 index 29e5ae600..000000000 --- a/quantum/api/v2/views.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright (c) 2012 OpenStack, LLC. -# -# 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. - - -def resource(data, keys, fields_to_strip=None): - """Formats the specified entity""" - # make sure fields_to_strip is iterable - if not fields_to_strip: - fields_to_strip = [] - return dict(item for item in data.iteritems() - if item[0] in keys and not item[0] in fields_to_strip) - - -def port(port_data, fields_to_strip=None): - """Represents a view for a port object""" - keys = ('id', 'network_id', 'mac_address', 'fixed_ips', - 'device_id', 'admin_state_up', 'tenant_id', 'status') - return resource(port_data, keys, fields_to_strip) - - -def network(network_data, fields_to_strip=None): - """Represents a view for a network object""" - keys = ('id', 'name', 'subnets', 'admin_state_up', 'status', - 'tenant_id', 'mac_ranges') - return resource(network_data, keys, fields_to_strip) - - -def subnet(subnet_data, fields_to_strip=None): - """Represents a view for a subnet object""" - keys = ('id', 'network_id', 'tenant_id', 'gateway_ip', 'ip_version', - 'cidr', 'allocation_pools') - return resource(subnet_data, keys, fields_to_strip) diff --git a/quantum/extensions/extensions.py b/quantum/extensions/extensions.py index 1f5dc5d4c..af7f91d41 100644 --- a/quantum/extensions/extensions.py +++ b/quantum/extensions/extensions.py @@ -132,6 +132,20 @@ class ExtensionDescriptor(object): request_exts = [] return request_exts + def get_extended_attributes(self, version): + """Map describing extended attributes for core resources. + + Extended attributes are implemented by a core plugin similarly + to the attributes defined in the core, and can appear in + request and response messages. Their names are scoped with the + extension's prefix. The core API version is passed to this + function, which must return a + map[][][] + specifying the extended resource attribute properties required + by that API version. + """ + return {} + def get_plugin_interface(self): """ Returns an abstract class which defines contract for the plugin. @@ -340,9 +354,7 @@ class ExtensionMiddleware(wsgi.Middleware): def plugin_aware_extension_middleware_factory(global_config, **local_config): """Paste factory.""" def _factory(app): - extensions_path = get_extensions_path() - ext_mgr = PluginAwareExtensionManager(extensions_path, - QuantumManager.get_plugin()) + ext_mgr = PluginAwareExtensionManager.get_instance() return ExtensionMiddleware(app, ext_mgr=ext_mgr) return _factory @@ -398,6 +410,18 @@ class ExtensionManager(object): pass return request_exts + def extend_resources(self, version, attr_map): + """Extend resources with additional attributes.""" + for ext in self.extensions.itervalues(): + try: + extended_attrs = ext.get_extended_attributes(version) + for resource, resource_attrs in extended_attrs.iteritems(): + attr_map[resource].update(resource_attrs) + except AttributeError: + # Extensions aren't required to have extended + # attributes + pass + def _check_extension(self, extension): """Checks for required methods in extension objects.""" try: @@ -467,6 +491,8 @@ class ExtensionManager(object): class PluginAwareExtensionManager(ExtensionManager): + _instance = None + def __init__(self, path, plugin): self.plugin = plugin super(PluginAwareExtensionManager, self).__init__(path) @@ -502,6 +528,13 @@ class PluginAwareExtensionManager(ExtensionManager): extension.get_alias())) return plugin_has_interface + @classmethod + def get_instance(cls): + if cls._instance is None: + cls._instance = cls(get_extensions_path(), + QuantumManager.get_plugin()) + return cls._instance + class RequestExtension(object): """Extend requests and responses of core Quantum OpenStack API controllers. diff --git a/quantum/tests/unit/extensions/v2attributes.py b/quantum/tests/unit/extensions/v2attributes.py new file mode 100644 index 000000000..1ec015c75 --- /dev/null +++ b/quantum/tests/unit/extensions/v2attributes.py @@ -0,0 +1,48 @@ +# Copyright (c) 2012 OpenStack, LLC. +# +# 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. + +EXTENDED_ATTRIBUTES_2_0 = { + 'networks': { + 'v2attrs:something': {'allow_post': False, + 'allow_put': False, + 'is_visible': True}, + 'v2attrs:something_else': {'allow_post': True, + 'allow_put': False, + 'is_visible': False}, + } +} + + +class V2attributes(object): + def get_name(self): + return "V2 Extended Attributes Example" + + def get_alias(self): + return "v2attrs" + + def get_description(self): + return "Demonstrates extended attributes on V2 core resources" + + def get_namespace(self): + return "http://docs.openstack.org/ext/examples/v2attributes/api/v1.0" + + def get_updated(self): + return "2012-07-18T10:00:00-00:00" + + def get_extended_attributes(self, version): + if version == "2.0": + return EXTENDED_ATTRIBUTES_2_0 + else: + return {} diff --git a/quantum/tests/unit/test_api_v2.py b/quantum/tests/unit/test_api_v2.py index a7972defe..1be9a5012 100644 --- a/quantum/tests/unit/test_api_v2.py +++ b/quantum/tests/unit/test_api_v2.py @@ -23,12 +23,13 @@ import webtest from webob import exc from quantum.api.v2 import attributes +from quantum.api.v2 import base from quantum.api.v2 import resource as wsgi_resource from quantum.api.v2 import router -from quantum.api.v2 import views from quantum.common import config from quantum.common import exceptions as q_exc from quantum import context +from quantum.extensions.extensions import PluginAwareExtensionManager from quantum.manager import QuantumManager from quantum.openstack.common import cfg @@ -41,6 +42,7 @@ def _uuid(): ROOTDIR = os.path.dirname(os.path.dirname(__file__)) ETCDIR = os.path.join(ROOTDIR, 'etc') +EXTDIR = os.path.join(ROOTDIR, 'unit/extensions') def etcdir(*p): @@ -133,6 +135,8 @@ class APIv2TestBase(unittest.TestCase): plugin = 'quantum.quantum_plugin_base_v2.QuantumPluginBaseV2' # Ensure 'stale' patched copies of the plugin are never returned QuantumManager._instance = None + # Ensure existing ExtensionManager is not used + PluginAwareExtensionManager._instance = None # Create the default configurations args = ['--config-file', etcdir('quantum.conf.test')] config.parse(args=args) @@ -602,6 +606,26 @@ class JSONV2TestCase(APIv2TestBase): self.assertEqual(port['network_id'], net_id) self.assertEqual(port['mac_address'], 'ca:fe:de:ad:be:ef') + def test_create_return_extra_attr(self): + net_id = _uuid() + data = {'network': {'name': 'net1', 'admin_state_up': True, + 'tenant_id': _uuid()}} + return_value = {'subnets': [], 'status': "ACTIVE", + 'id': net_id, 'v2attrs:something': "123"} + return_value.update(data['network'].copy()) + + instance = self.plugin.return_value + instance.create_network.return_value = return_value + + res = self.api.post_json(_get_path('networks'), data) + + self.assertEqual(res.status_int, exc.HTTPCreated.code) + self.assertTrue('network' in res.json) + net = res.json['network'] + self.assertEqual(net['id'], net_id) + self.assertEqual(net['status'], "ACTIVE") + self.assertFalse('v2attrs:something' in net) + def test_fields(self): return_value = {'name': 'net1', 'admin_state_up': True, 'subnets': []} @@ -704,33 +728,30 @@ class JSONV2TestCase(APIv2TestBase): class V2Views(unittest.TestCase): - def _view(self, keys, func): + def _view(self, keys, collection, resource): data = dict((key, 'value') for key in keys) data['fake'] = 'value' - res = func(data) + attr_info = attributes.RESOURCE_ATTRIBUTE_MAP[collection] + controller = base.Controller(None, collection, resource, attr_info) + res = controller._view(data) self.assertTrue('fake' not in res) for key in keys: self.assertTrue(key in res) - def test_resource(self): - res = views.resource({'one': 1, 'two': 2}, ['one']) - self.assertTrue('one' in res) - self.assertTrue('two' not in res) - def test_network(self): keys = ('id', 'name', 'subnets', 'admin_state_up', 'status', 'tenant_id', 'mac_ranges') - self._view(keys, views.network) + self._view(keys, 'networks', 'network') def test_port(self): keys = ('id', 'network_id', 'mac_address', 'fixed_ips', 'device_id', 'admin_state_up', 'tenant_id', 'status') - self._view(keys, views.port) + self._view(keys, 'ports', 'port') def test_subnet(self): keys = ('id', 'network_id', 'tenant_id', 'gateway_ip', 'ip_version', 'cidr') - self._view(keys, views.subnet) + self._view(keys, 'subnets', 'subnet') class QuotaTest(APIv2TestBase): @@ -770,3 +791,74 @@ class QuotaTest(APIv2TestBase): res = self.api.post_json( _get_path('networks'), initial_input) self.assertEqual(res.status_int, exc.HTTPCreated.code) + + +class ExtensionTestCase(unittest.TestCase): + # NOTE(jkoelker) This potentially leaks the mock object if the setUp + # raises without being caught. Using unittest2 + # or dropping 2.6 support so we can use addCleanup + # will get around this. + def setUp(self): + plugin = 'quantum.quantum_plugin_base_v2.QuantumPluginBaseV2' + + # Ensure 'stale' patched copies of the plugin are never returned + QuantumManager._instance = None + + # Ensure existing ExtensionManager is not used + PluginAwareExtensionManager._instance = None + + # Save the global RESOURCE_ATTRIBUTE_MAP + self.saved_attr_map = {} + for resource, attrs in attributes.RESOURCE_ATTRIBUTE_MAP.iteritems(): + self.saved_attr_map[resource] = attrs.copy() + + # Create the default configurations + args = ['--config-file', etcdir('quantum.conf.test')] + config.parse(args=args) + + # Update the plugin and extensions path + cfg.CONF.set_override('core_plugin', plugin) + cfg.CONF.set_override('api_extensions_path', EXTDIR) + + self._plugin_patcher = mock.patch(plugin, autospec=True) + self.plugin = self._plugin_patcher.start() + + # Instantiate mock plugin and enable the V2attributes extension + QuantumManager.get_plugin().supported_extension_aliases = ["v2attrs"] + + api = router.APIRouter() + self.api = webtest.TestApp(api) + + def tearDown(self): + self._plugin_patcher.stop() + self.api = None + self.plugin = None + cfg.CONF.reset() + + # Restore the global RESOURCE_ATTRIBUTE_MAP + attributes.RESOURCE_ATTRIBUTE_MAP = self.saved_attr_map + + def test_extended_create(self): + net_id = _uuid() + data = {'network': {'name': 'net1', 'admin_state_up': True, + 'tenant_id': _uuid(), 'subnets': [], + 'v2attrs:something_else': "abc"}} + return_value = {'subnets': [], 'status': "ACTIVE", + 'id': net_id, + 'v2attrs:something': "123"} + return_value.update(data['network'].copy()) + + instance = self.plugin.return_value + instance.create_network.return_value = return_value + + res = self.api.post_json(_get_path('networks'), data) + + instance.create_network.assert_called_with(mock.ANY, + network=data) + self.assertEqual(res.status_int, exc.HTTPCreated.code) + self.assertTrue('network' in res.json) + net = res.json['network'] + self.assertEqual(net['id'], net_id) + self.assertEqual(net['status'], "ACTIVE") + self.assertEqual(net['v2attrs:something'], "123") + self.assertFalse('v2attrs:something_else' in net)