diff --git a/nova/api/openstack/versioned_method.py b/nova/api/openstack/versioned_method.py new file mode 100644 index 000000000000..b7e30da839b2 --- /dev/null +++ b/nova/api/openstack/versioned_method.py @@ -0,0 +1,35 @@ +# Copyright 2014 IBM Corp. +# +# 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. + + +class VersionedMethod(object): + + def __init__(self, name, start_version, end_version, func): + """Versioning information for a single method + + @name: Name of the method + @start_version: Minimum acceptable version + @end_version: Maximum acceptable_version + @func: Method to call + + Minimum and maximums are inclusive + """ + self.name = name + self.start_version = start_version + self.end_version = end_version + self.func = func + + def __str__(self): + return ("Version Method %s: min: %s, max: %s" + % (self.name, self.start_version, self.end_version)) diff --git a/nova/api/openstack/wsgi.py b/nova/api/openstack/wsgi.py index daf914590948..4e6c709ce416 100644 --- a/nova/api/openstack/wsgi.py +++ b/nova/api/openstack/wsgi.py @@ -14,6 +14,7 @@ # License for the specific language governing permissions and limitations # under the License. +import functools import inspect import math import time @@ -26,6 +27,7 @@ import six import webob from nova.api.openstack import api_version_request as api_version +from nova.api.openstack import versioned_method from nova.api.openstack import xmlutil from nova import exception from nova import i18n @@ -84,6 +86,10 @@ _METHODS_WITH_BODY = [ # support is fully merged. It does not affect the V2 API. DEFAULT_API_VERSION = "2.1" +# name of attribute to keep version method information +VER_METHOD_ATTR = 'versioned_methods' + + # TODO(dims): Temporary, we already deprecated the v2 XML API in # Juno, we should remove this before Kilo DISABLE_XML_V2_API = True @@ -1076,7 +1082,13 @@ class Resource(wsgi.Application): def dispatch(self, method, request, action_args): """Dispatch a call to the action-specific method.""" - return method(req=request, **action_args) + try: + return method(req=request, **action_args) + except exception.VersionNotFoundForAPIMethod: + # We deliberately don't return any message information + # about the exception to the user so it looks as if + # the method is simply not implemented. + return Fault(webob.exc.HTTPNotFound()) def action(name): @@ -1136,9 +1148,22 @@ class ControllerMetaclass(type): # Find all actions actions = {} extensions = [] + versioned_methods = None # start with wsgi actions from base classes for base in bases: actions.update(getattr(base, 'wsgi_actions', {})) + + if base.__name__ == "Controller": + # NOTE(cyeoh): This resets the VER_METHOD_ATTR attribute + # between API controller class creations. This allows us + # to use a class decorator on the API methods that doesn't + # require naming explicitly what method is being versioned as + # it can be implicit based on the method decorated. It is a bit + # ugly. + if VER_METHOD_ATTR in base.__dict__: + versioned_methods = getattr(base, VER_METHOD_ATTR) + delattr(base, VER_METHOD_ATTR) + for key, value in cls_dict.items(): if not callable(value): continue @@ -1150,6 +1175,8 @@ class ControllerMetaclass(type): # Add the actions and extensions to the class dict cls_dict['wsgi_actions'] = actions cls_dict['wsgi_extensions'] = extensions + if versioned_methods: + cls_dict[VER_METHOD_ATTR] = versioned_methods return super(ControllerMetaclass, mcs).__new__(mcs, name, bases, cls_dict) @@ -1170,6 +1197,97 @@ class Controller(object): else: self._view_builder = None + def __getattribute__(self, key): + + def version_select(*args, **kwargs): + """Look for the method which matches the name supplied and version + constraints and calls it with the supplied arguments. + + @return: Returns the result of the method called + @raises: VersionNotFoundForAPIMethod if there is no method which + matches the name and version constraints + """ + + # The first arg to all versioned methods is always the request + # object. The version for the request is attached to the + # request object + if len(args) == 0: + ver = kwargs['req'].api_version_request + else: + ver = args[0].api_version_request + + func_list = self.versioned_methods[key] + for func in func_list: + if ver.matches(func.start_version, func.end_version): + # Update the version_select wrapper function so + # other decorator attributes like wsgi.response + # are still respected. + functools.update_wrapper(version_select, func.func) + return func.func(self, *args, **kwargs) + + # No version match + raise exception.VersionNotFoundForAPIMethod(version=ver) + + try: + version_meth_dict = object.__getattribute__(self, VER_METHOD_ATTR) + except AttributeError: + # No versioning on this class + return object.__getattribute__(self, key) + + if version_meth_dict and \ + key in object.__getattribute__(self, VER_METHOD_ATTR): + return version_select + + return object.__getattribute__(self, key) + + # NOTE(cyeoh): This decorator MUST appear first (the outermost + # decorator) on an API method for it to work correctly + # TODO(cyeoh): Would be much better if this was not a requirement + # and if so then checked with probably a hacking check + @classmethod + def api_version(cls, min_ver, max_ver=None): + """Decorator for versioning api methods. + + Add the decorator to any method which takes a request object + as the first parameter and belongs to a class which inherits from + wsgi.Controller. + + @min_ver: string representing minimum version + @max_ver: optional string representing maximum version + """ + + def decorator(f): + obj_min_ver = api_version.APIVersionRequest(min_ver) + if max_ver: + obj_max_ver = api_version.APIVersionRequest(max_ver) + else: + obj_max_ver = api_version.APIVersionRequest() + + # Add to list of versioned methods registered + func_name = f.__name__ + new_func = versioned_method.VersionedMethod( + func_name, obj_min_ver, obj_max_ver, f) + + func_dict = getattr(cls, VER_METHOD_ATTR, {}) + if not func_dict: + setattr(cls, VER_METHOD_ATTR, func_dict) + + func_list = func_dict.get(func_name, []) + if not func_list: + func_dict[func_name] = func_list + func_list.append(new_func) + # Ensure the list is sorted by minimum version (reversed) + # so later when we work through the list in order we find + # the method which has the latest version which supports + # the version requested. + # TODO(cyeoh): Add check to ensure that there are no overlapping + # ranges of valid versions as that is amibiguous + func_list.sort(key=lambda f: f.start_version, reverse=True) + + return f + + return decorator + @staticmethod def is_valid_body(body, entity_name): if not (body and entity_name in body): diff --git a/nova/exception.py b/nova/exception.py index a4e3a1a22a5b..e3a91daae053 100644 --- a/nova/exception.py +++ b/nova/exception.py @@ -326,6 +326,10 @@ class InvalidAPIVersionString(Invalid): "be of format MajorNum.MinorNum.") +class VersionNotFoundForAPIMethod(Invalid): + msg_fmt = _("API version %(version)s is not supported on this method.") + + # Cannot be templated as the error syntax varies. # msg needs to be constructed when raised. class InvalidParameterValue(Invalid): diff --git a/nova/tests/unit/api/openstack/compute/test_microversions.py b/nova/tests/unit/api/openstack/compute/test_microversions.py new file mode 100644 index 000000000000..21b0e56f9628 --- /dev/null +++ b/nova/tests/unit/api/openstack/compute/test_microversions.py @@ -0,0 +1,97 @@ +# Copyright 2014 IBM Corp. +# +# 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 mock +from oslo.config import cfg +from oslo.serialization import jsonutils + +from nova import test +from nova.tests.unit.api.openstack import fakes + +CONF = cfg.CONF + + +class MicroversionsTest(test.NoDBTestCase): + + @mock.patch("nova.api.openstack.APIRouterV21.api_extension_namespace", + return_value='nova.api.v3.test_extensions') + def test_microversions_no_header(self, mock_namespace): + app = fakes.wsgi_app_v21(init_only='test-microversions') + req = fakes.HTTPRequest.blank('/v2/fake/microversions') + res = req.get_response(app) + self.assertEqual(200, res.status_int) + resp_json = jsonutils.loads(res.body) + self.assertEqual('val', resp_json['param']) + + @mock.patch("nova.api.openstack.APIRouterV21.api_extension_namespace", + return_value='nova.api.v3.test_extensions') + def test_microversions_with_header(self, mock_namespace): + app = fakes.wsgi_app_v21(init_only='test-microversions') + req = fakes.HTTPRequest.blank('/v2/fake/microversions') + req.headers = {'X-OpenStack-Compute-API-Version': '2.3'} + res = req.get_response(app) + self.assertEqual(200, res.status_int) + resp_json = jsonutils.loads(res.body) + self.assertEqual('val2', resp_json['param']) + + @mock.patch("nova.api.openstack.APIRouterV21.api_extension_namespace", + return_value='nova.api.v3.test_extensions') + def test_microversions_with_header_exact_match(self, mock_namespace): + app = fakes.wsgi_app_v21(init_only='test-microversions') + req = fakes.HTTPRequest.blank('/v2/fake/microversions') + req.headers = {'X-OpenStack-Compute-API-Version': '2.2'} + res = req.get_response(app) + self.assertEqual(200, res.status_int) + resp_json = jsonutils.loads(res.body) + self.assertEqual('val2', resp_json['param']) + + @mock.patch("nova.api.openstack.APIRouterV21.api_extension_namespace", + return_value='nova.api.v3.test_extensions') + def test_microversions2_no_2_1_version(self, mock_namespace): + app = fakes.wsgi_app_v21(init_only='test-microversions') + req = fakes.HTTPRequest.blank('/v2/fake/microversions2') + req.headers = {'X-OpenStack-Compute-API-Version': '2.3'} + res = req.get_response(app) + self.assertEqual(200, res.status_int) + resp_json = jsonutils.loads(res.body) + self.assertEqual('controller2_val1', resp_json['param']) + + @mock.patch("nova.api.openstack.APIRouterV21.api_extension_namespace", + return_value='nova.api.v3.test_extensions') + def test_microversions2_later_version(self, mock_namespace): + app = fakes.wsgi_app_v21(init_only='test-microversions') + req = fakes.HTTPRequest.blank('/v2/fake/microversions2') + req.headers = {'X-OpenStack-Compute-API-Version': '3.0'} + res = req.get_response(app) + self.assertEqual(202, res.status_int) + resp_json = jsonutils.loads(res.body) + self.assertEqual('controller2_val2', resp_json['param']) + + @mock.patch("nova.api.openstack.APIRouterV21.api_extension_namespace", + return_value='nova.api.v3.test_extensions') + def test_microversions2_version_too_high(self, mock_namespace): + app = fakes.wsgi_app_v21(init_only='test-microversions') + req = fakes.HTTPRequest.blank('/v2/fake/microversions2') + req.headers = {'X-OpenStack-Compute-API-Version': '3.2'} + res = req.get_response(app) + self.assertEqual(404, res.status_int) + + @mock.patch("nova.api.openstack.APIRouterV21.api_extension_namespace", + return_value='nova.api.v3.test_extensions') + def test_microversions2_version_too_low(self, mock_namespace): + app = fakes.wsgi_app_v21(init_only='test-microversions') + req = fakes.HTTPRequest.blank('/v2/fake/microversions2') + req.headers = {'X-OpenStack-Compute-API-Version': '2.1'} + res = req.get_response(app) + self.assertEqual(404, res.status_int) diff --git a/nova/tests/unit/api/openstack/compute/test_plugins/microversions.py b/nova/tests/unit/api/openstack/compute/test_plugins/microversions.py new file mode 100644 index 000000000000..240323180534 --- /dev/null +++ b/nova/tests/unit/api/openstack/compute/test_plugins/microversions.py @@ -0,0 +1,69 @@ +# Copyright 2014 IBM Corp. +# +# 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. + +"""Microversions Test Extension""" + +from nova.api.openstack import extensions +from nova.api.openstack import wsgi + + +ALIAS = 'test-microversions' + + +class MicroversionsController(wsgi.Controller): + + @wsgi.Controller.api_version("2.1") + def index(self, req): + data = {'param': 'val'} + return data + + @wsgi.Controller.api_version("2.2") # noqa + def index(self, req): + data = {'param': 'val2'} + return data + + +# We have a second example controller here to help check +# for accidental dependencies between API controllers +# due to base class changes +class MicroversionsController2(wsgi.Controller): + + @wsgi.Controller.api_version("2.2", "2.5") + def index(self, req): + data = {'param': 'controller2_val1'} + return data + + @wsgi.Controller.api_version("2.5", "3.1") # noqa + @wsgi.response(202) + def index(self, req): + data = {'param': 'controller2_val2'} + return data + + +class Microversions(extensions.V3APIExtensionBase): + """Basic Microversions Extension.""" + + name = "Microversions" + alias = ALIAS + version = 1 + + def get_resources(self): + res1 = extensions.ResourceExtension('microversions', + MicroversionsController()) + res2 = extensions.ResourceExtension('microversions2', + MicroversionsController2()) + return [res1, res2] + + def get_controller_extensions(self): + return [] diff --git a/nova/tests/unit/api/openstack/fakes.py b/nova/tests/unit/api/openstack/fakes.py index e11205bcf262..692ce8b36664 100644 --- a/nova/tests/unit/api/openstack/fakes.py +++ b/nova/tests/unit/api/openstack/fakes.py @@ -26,6 +26,7 @@ import webob.request from nova.api import auth as api_auth from nova.api import openstack as openstack_api +from nova.api.openstack import api_version_request as api_version from nova.api.openstack import auth from nova.api.openstack import compute from nova.api.openstack.compute import limits @@ -265,6 +266,7 @@ class HTTPRequest(os_wsgi.Request): out = os_wsgi.Request.blank(*args, **kwargs) out.environ['nova.context'] = FakeRequestContext('fake_user', 'fake', is_admin=use_admin_context) + out.api_version_request = api_version.APIVersionRequest("2.1") return out diff --git a/setup.cfg b/setup.cfg index c6be5d1b5304..0efe4bc52ac5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -165,6 +165,7 @@ nova.api.v3.extensions.server.resize = nova.api.v3.test_extensions = basic = nova.tests.unit.api.openstack.compute.test_plugins.basic:Basic + microversions = nova.tests.unit.api.openstack.compute.test_plugins.microversions:Microversions # These are for backwards compat with Havana notification_driver configuration values