]> review.fuel-infra Code Review - openstack-build/neutron-build.git/commitdiff
Implements data-driven views and extended attributes.
authorBob Kukura <rkukura@redhat.com>
Mon, 16 Jul 2012 00:45:25 +0000 (20:45 -0400)
committerBob Kukura <rkukura@redhat.com>
Mon, 23 Jul 2012 03:46:46 +0000 (23:46 -0400)
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

quantum/api/v2/attributes.py
quantum/api/v2/base.py
quantum/api/v2/router.py
quantum/api/v2/views.py [deleted file]
quantum/extensions/extensions.py
quantum/tests/unit/extensions/v2attributes.py [new file with mode: 0644]
quantum/tests/unit/test_api_v2.py

index eeb36824bbe1acc1592d07f28dd2ef01e99926dd..799e13ec8f51bd83c54823f4fcb4d755d45b0c35 100644 (file)
@@ -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},
     }
 }
index f608875843529f03f614bebf4f82ae481096f8e3..64786d34221ad5cedb3ad0a316836a0624535904 100644 (file)
@@ -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
index a21589285f3c529e513280b8bd0cbf597890a47a..af07e3ec2493c7693cc83d41f49aee8bfb0704cf 100644 (file)
@@ -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 (file)
index 29e5ae6..0000000
+++ /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)
index 1f5dc5d4ca042617ae4844167f3a2040540054cb..af7f91d41346cce90042ba138c3df7a96d16cd8d 100644 (file)
@@ -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[<resource_name>][<attribute_name>][<attribute_property>]
+        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 (file)
index 0000000..1ec015c
--- /dev/null
@@ -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 {}
index a7972defebaf0007d0a0f67dd875563bb6d20a41..1be9a50122b4a6fe6641f330819a65edc9cd360c 100644 (file)
@@ -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)