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
This commit is contained in:
parent
0ba66aa539
commit
39a5a736d0
35
nova/api/openstack/versioned_method.py
Normal file
35
nova/api/openstack/versioned_method.py
Normal file
@ -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))
|
@ -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):
|
||||
|
@ -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):
|
||||
|
97
nova/tests/unit/api/openstack/compute/test_microversions.py
Normal file
97
nova/tests/unit/api/openstack/compute/test_microversions.py
Normal file
@ -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)
|
@ -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 []
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user