From 39a5a736d0f464630dd640636b958b6860aa48cd Mon Sep 17 00:00:00 2001 From: Chris Yeoh Date: Tue, 25 Nov 2014 00:21:38 +1030 Subject: [PATCH] Implement microversion support on api methods Adds support for specifying api versioning information on api methods and the infrastructure to route requests to the correct method based on the request information. The api_version decorator allows us to retain the same method name for different implementations (versions) of the API method (GET/PUT/POST, etc). Note that currently the @api_version decorator must be the first (outermost) decorator on an API method. We should in future have at least a hacking rule to enforce this but better would be to remove this restriction. Partially Implements Blueprint api-microversions Change-Id: Ifb6698c582d37284c42b9b81100a651fd8d1dd1a --- nova/api/openstack/versioned_method.py | 35 +++++ nova/api/openstack/wsgi.py | 120 +++++++++++++++++- nova/exception.py | 4 + .../openstack/compute/test_microversions.py | 97 ++++++++++++++ .../compute/test_plugins/microversions.py | 69 ++++++++++ nova/tests/unit/api/openstack/fakes.py | 2 + setup.cfg | 1 + 7 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 nova/api/openstack/versioned_method.py create mode 100644 nova/tests/unit/api/openstack/compute/test_microversions.py create mode 100644 nova/tests/unit/api/openstack/compute/test_plugins/microversions.py 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