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
This commit is contained in:
Bob Kukura 2012-07-15 20:45:25 -04:00
parent b7f852d4c5
commit 4ff0723479
7 changed files with 259 additions and 85 deletions

View File

@ -141,56 +141,87 @@ validators = {'type:boolean': _validate_boolean,
RESOURCE_ATTRIBUTE_MAP = { RESOURCE_ATTRIBUTE_MAP = {
'networks': { 'networks': {
'id': {'allow_post': False, 'allow_put': False, 'id': {'allow_post': False, 'allow_put': False,
'validate': {'type:regex': UUID_PATTERN}}, 'validate': {'type:regex': UUID_PATTERN},
'name': {'allow_post': True, 'allow_put': True}, 'is_visible': True},
'subnets': {'allow_post': True, 'allow_put': True, 'default': []}, '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, 'admin_state_up': {'allow_post': True, 'allow_put': True,
'default': True, 'convert_to': convert_to_boolean, 'default': True,
'validate': {'type:boolean': None}}, 'convert_to': convert_to_boolean,
'status': {'allow_post': False, 'allow_put': False}, '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, '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': { 'ports': {
'id': {'allow_post': False, 'allow_put': False, '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, '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, 'admin_state_up': {'allow_post': True, 'allow_put': True,
'default': True, 'convert_to': convert_to_boolean, 'default': True,
'validate': {'type:boolean': None}}, 'convert_to': convert_to_boolean,
'validate': {'type:boolean': None},
'is_visible': True},
'mac_address': {'allow_post': True, 'allow_put': False, 'mac_address': {'allow_post': True, 'allow_put': False,
'default': ATTR_NOT_SPECIFIED, '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, '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, 'host_routes': {'allow_post': True, 'allow_put': True,
'default': ATTR_NOT_SPECIFIED}, 'default': ATTR_NOT_SPECIFIED,
'device_id': {'allow_post': True, 'allow_put': True, 'default': ''}, 'is_visible': False},
'device_id': {'allow_post': True, 'allow_put': True,
'default': '',
'is_visible': True},
'tenant_id': {'allow_post': True, 'allow_put': False, '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': { 'subnets': {
'id': {'allow_post': False, 'allow_put': False, '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, 'ip_version': {'allow_post': True, 'allow_put': False,
'convert_to': int, 'convert_to': int,
'validate': {'type:values': [4, 6]}}, 'validate': {'type:values': [4, 6]},
'is_visible': True},
'network_id': {'allow_post': True, 'allow_put': False, '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, '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, 'gateway_ip': {'allow_post': True, 'allow_put': True,
'default': ATTR_NOT_SPECIFIED, '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 #TODO(salvatore-orlando): Enable PUT on allocation_pools
'allocation_pools': {'allow_post': True, 'allow_put': False, '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, '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, '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, 'tenant_id': {'allow_post': True, 'allow_put': False,
'required_by_policy': True}, 'required_by_policy': True,
'is_visible': True},
} }
} }

View File

@ -18,7 +18,6 @@ import webob.exc
from quantum.api.v2 import attributes from quantum.api.v2 import attributes
from quantum.api.v2 import resource as wsgi_resource from quantum.api.v2 import resource as wsgi_resource
from quantum.api.v2 import views
from quantum.common import exceptions from quantum.common import exceptions
from quantum.common import utils from quantum.common import utils
from quantum import policy from quantum import policy
@ -112,7 +111,18 @@ class Controller(object):
self._policy_attrs = [name for (name, info) in self._attr_info.items() self._policy_attrs = [name for (name, info) in self._attr_info.items()
if 'required_by_policy' in info if 'required_by_policy' in info
and info['required_by_policy']] 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): def _do_field_list(self, original_fields):
fields_to_add = None fields_to_add = None

View File

@ -23,6 +23,7 @@ import webob.exc
from quantum.api.v2 import attributes from quantum.api.v2 import attributes
from quantum.api.v2 import base from quantum.api.v2 import base
from quantum.extensions import extensions
from quantum import manager from quantum import manager
from quantum.openstack.common import cfg from quantum.openstack.common import cfg
from quantum import wsgi from quantum import wsgi
@ -69,6 +70,9 @@ class APIRouter(wsgi.Router):
mapper = routes_mapper.Mapper() mapper = routes_mapper.Mapper()
plugin = manager.QuantumManager.get_plugin() 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, col_kwargs = dict(collection_actions=COLLECTION_ACTIONS,
member_actions=MEMBER_ACTIONS) member_actions=MEMBER_ACTIONS)

View File

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

View File

@ -132,6 +132,20 @@ class ExtensionDescriptor(object):
request_exts = [] request_exts = []
return 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): def get_plugin_interface(self):
""" """
Returns an abstract class which defines contract for the plugin. 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): def plugin_aware_extension_middleware_factory(global_config, **local_config):
"""Paste factory.""" """Paste factory."""
def _factory(app): def _factory(app):
extensions_path = get_extensions_path() ext_mgr = PluginAwareExtensionManager.get_instance()
ext_mgr = PluginAwareExtensionManager(extensions_path,
QuantumManager.get_plugin())
return ExtensionMiddleware(app, ext_mgr=ext_mgr) return ExtensionMiddleware(app, ext_mgr=ext_mgr)
return _factory return _factory
@ -398,6 +410,18 @@ class ExtensionManager(object):
pass pass
return request_exts 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): def _check_extension(self, extension):
"""Checks for required methods in extension objects.""" """Checks for required methods in extension objects."""
try: try:
@ -467,6 +491,8 @@ class ExtensionManager(object):
class PluginAwareExtensionManager(ExtensionManager): class PluginAwareExtensionManager(ExtensionManager):
_instance = None
def __init__(self, path, plugin): def __init__(self, path, plugin):
self.plugin = plugin self.plugin = plugin
super(PluginAwareExtensionManager, self).__init__(path) super(PluginAwareExtensionManager, self).__init__(path)
@ -502,6 +528,13 @@ class PluginAwareExtensionManager(ExtensionManager):
extension.get_alias())) extension.get_alias()))
return plugin_has_interface 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): class RequestExtension(object):
"""Extend requests and responses of core Quantum OpenStack API controllers. """Extend requests and responses of core Quantum OpenStack API controllers.

View File

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

View File

@ -23,12 +23,13 @@ import webtest
from webob import exc from webob import exc
from quantum.api.v2 import attributes 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 resource as wsgi_resource
from quantum.api.v2 import router from quantum.api.v2 import router
from quantum.api.v2 import views
from quantum.common import config from quantum.common import config
from quantum.common import exceptions as q_exc from quantum.common import exceptions as q_exc
from quantum import context from quantum import context
from quantum.extensions.extensions import PluginAwareExtensionManager
from quantum.manager import QuantumManager from quantum.manager import QuantumManager
from quantum.openstack.common import cfg from quantum.openstack.common import cfg
@ -41,6 +42,7 @@ def _uuid():
ROOTDIR = os.path.dirname(os.path.dirname(__file__)) ROOTDIR = os.path.dirname(os.path.dirname(__file__))
ETCDIR = os.path.join(ROOTDIR, 'etc') ETCDIR = os.path.join(ROOTDIR, 'etc')
EXTDIR = os.path.join(ROOTDIR, 'unit/extensions')
def etcdir(*p): def etcdir(*p):
@ -133,6 +135,8 @@ class APIv2TestBase(unittest.TestCase):
plugin = 'quantum.quantum_plugin_base_v2.QuantumPluginBaseV2' plugin = 'quantum.quantum_plugin_base_v2.QuantumPluginBaseV2'
# Ensure 'stale' patched copies of the plugin are never returned # Ensure 'stale' patched copies of the plugin are never returned
QuantumManager._instance = None QuantumManager._instance = None
# Ensure existing ExtensionManager is not used
PluginAwareExtensionManager._instance = None
# Create the default configurations # Create the default configurations
args = ['--config-file', etcdir('quantum.conf.test')] args = ['--config-file', etcdir('quantum.conf.test')]
config.parse(args=args) config.parse(args=args)
@ -602,6 +606,26 @@ class JSONV2TestCase(APIv2TestBase):
self.assertEqual(port['network_id'], net_id) self.assertEqual(port['network_id'], net_id)
self.assertEqual(port['mac_address'], 'ca:fe:de:ad:be:ef') 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): def test_fields(self):
return_value = {'name': 'net1', 'admin_state_up': True, return_value = {'name': 'net1', 'admin_state_up': True,
'subnets': []} 'subnets': []}
@ -704,33 +728,30 @@ class JSONV2TestCase(APIv2TestBase):
class V2Views(unittest.TestCase): class V2Views(unittest.TestCase):
def _view(self, keys, func): def _view(self, keys, collection, resource):
data = dict((key, 'value') for key in keys) data = dict((key, 'value') for key in keys)
data['fake'] = 'value' 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) self.assertTrue('fake' not in res)
for key in keys: for key in keys:
self.assertTrue(key in res) 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): def test_network(self):
keys = ('id', 'name', 'subnets', 'admin_state_up', 'status', keys = ('id', 'name', 'subnets', 'admin_state_up', 'status',
'tenant_id', 'mac_ranges') 'tenant_id', 'mac_ranges')
self._view(keys, views.network) self._view(keys, 'networks', 'network')
def test_port(self): def test_port(self):
keys = ('id', 'network_id', 'mac_address', 'fixed_ips', keys = ('id', 'network_id', 'mac_address', 'fixed_ips',
'device_id', 'admin_state_up', 'tenant_id', 'status') 'device_id', 'admin_state_up', 'tenant_id', 'status')
self._view(keys, views.port) self._view(keys, 'ports', 'port')
def test_subnet(self): def test_subnet(self):
keys = ('id', 'network_id', 'tenant_id', 'gateway_ip', keys = ('id', 'network_id', 'tenant_id', 'gateway_ip',
'ip_version', 'cidr') 'ip_version', 'cidr')
self._view(keys, views.subnet) self._view(keys, 'subnets', 'subnet')
class QuotaTest(APIv2TestBase): class QuotaTest(APIv2TestBase):
@ -770,3 +791,74 @@ class QuotaTest(APIv2TestBase):
res = self.api.post_json( res = self.api.post_json(
_get_path('networks'), initial_input) _get_path('networks'), initial_input)
self.assertEqual(res.status_int, exc.HTTPCreated.code) 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)