Add microversioning support for methods

Adds the functionality to allow versioning of api methods
based by adding a decorator api_version("min_version",
"max_version"). This is similar to how nova implemented api
versioning but updated to work with pecan.

Change-Id: Ie18d92531487f7c107b5132b3d35f38bd0a37aa0
Implements: blueprint api-versioning
This commit is contained in:
Jaycen Grant 2016-07-15 13:19:58 -07:00
parent 6c265ce10a
commit e6a71b9e6d
12 changed files with 643 additions and 126 deletions

View File

@ -13,12 +13,21 @@
# under the License. # under the License.
import datetime import datetime
import operator
import six
from magnum.api.controllers import versions
from magnum.api import versioned_method
from magnum.common import exception
from magnum.i18n import _
from pecan import rest
from webob import exc from webob import exc
import wsme import wsme
from wsme import types as wtypes from wsme import types as wtypes
from magnum.i18n import _
# name of attribute to keep version method information
VER_METHOD_ATTR = 'versioned_methods'
class APIBase(wtypes.Base): class APIBase(wtypes.Base):
@ -50,80 +59,171 @@ class APIBase(wtypes.Base):
setattr(self, k, wsme.Unset) setattr(self, k, wsme.Unset)
class Version(object): class ControllerMetaclass(type):
"""API Version object.""" """Controller metaclass.
string = 'OpenStack-API-Version' This metaclass automates the task of assembling a dictionary
"""HTTP Header string carrying the requested version""" mapping action keys to method names.
"""
min_string = 'OpenStack-API-Minimum-Version' def __new__(mcs, name, bases, cls_dict):
"""HTTP response header""" """Adds version function dictionary to the class."""
max_string = 'OpenStack-API-Maximum-Version' versioned_methods = None
"""HTTP response header"""
service_string = 'container-infra' for base in bases:
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)
def __init__(self, headers, default_version, latest_version): if versioned_methods:
"""Create an API Version object from the supplied headers. cls_dict[VER_METHOD_ATTR] = versioned_methods
:param headers: webob headers return super(ControllerMetaclass, mcs).__new__(mcs, name, bases,
:param default_version: version to use if not specified in headers cls_dict)
:param latest_version: version to use if latest is requested
:raises: webob.HTTPNotAcceptable
@six.add_metaclass(ControllerMetaclass)
class Controller(rest.RestController):
"""Base Rest Controller"""
def __getattribute__(self, key):
def version_select():
"""Select the correct method based on version
@return: Returns the correct versioned method
@raises: HTTPNotAcceptable if there is no method which
matches the name and version constraints
"""
from pecan import request
ver = request.version
func_list = self.versioned_methods[key]
for func in func_list:
if ver.matches(func.start_version, func.end_version):
return func.func
raise exc.HTTPNotAcceptable(_(
"Version %(ver)s was requested but the requested API %(api)s "
"is not supported for this version.") % {'ver': ver,
'api': key})
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 version_meth_dict:
return version_select().__get__(self, self.__class__)
return object.__getattribute__(self, key)
# NOTE: This decorator MUST appear first (the outermost
# decorator) on an API method for it to work correctly
@classmethod
def api_version(cls, min_ver, max_ver=None):
"""Decorator for versioning api methods.
Add the decorator to any pecan method that has been exposed.
This decorator will store the method, min version, and max
version in a list for each api. It will check that there is no
overlap between versions and methods. When the api is called the
controller will use the list for each api to determine which
method to call.
Example:
@base.Controller.api_version("1.1", "1.2")
@expose.expose(Bay, types.uuid_or_name)
def get_one(self, bay_ident):
{...code for versions 1.1 to 1.2...}
@base.Controller.api_version("1.3")
@expose.expose(Bay, types.uuid_or_name)
def get_one(self, bay_ident):
{...code for versions 1.3 to latest}
@min_ver: string representing minimum version
@max_ver: optional string representing maximum version
@raises: ApiVersionsIntersect if an version overlap is found between
method versions.
""" """
(self.major, self.minor) = Version.parse_headers(headers,
default_version,
latest_version)
def __repr__(self): def decorator(f):
return '%s.%s' % (self.major, self.minor) obj_min_ver = versions.Version('', '', '', min_ver)
if max_ver:
obj_max_ver = versions.Version('', '', '', max_ver)
else:
obj_max_ver = versions.Version('', '', '',
versions.CURRENT_MAX_VER)
# 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)
is_intersect = Controller.check_for_versions_intersection(
func_list)
if is_intersect:
raise exception.ApiVersionsIntersect(
name=new_func.name,
min_ver=new_func.start_version,
max_ver=new_func.end_version
)
# 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.
func_list.sort(key=lambda f: f.start_version, reverse=True)
return f
return decorator
@staticmethod @staticmethod
def parse_headers(headers, default_version, latest_version): def check_for_versions_intersection(func_list):
"""Determine the API version requested based on the headers supplied. """Determines whether function list intersections
:param headers: webob headers General algorithm:
:param default_version: version to use if not specified in headers https://en.wikipedia.org/wiki/Intersection_algorithm
:param latest_version: version to use if latest is requested
:returns: a tuple of (major, minor) version numbers :param func_list: list of VersionedMethod objects
:raises: webob.HTTPNotAcceptable :return: boolean
""" """
version_hdr = headers.get(Version.string, default_version) pairs = []
counter = 0
try: for f in func_list:
version_service, version_str = version_hdr.split() pairs.append((f.start_version, 1))
except ValueError: pairs.append((f.end_version, -1))
raise exc.HTTPNotAcceptable(_(
"Invalid service type for %s header") % Version.string)
if version_str.lower() == 'latest': pairs.sort(key=operator.itemgetter(1), reverse=True)
version_service, version_str = latest_version.split() pairs.sort(key=operator.itemgetter(0))
if version_service != Version.service_string: for p in pairs:
raise exc.HTTPNotAcceptable(_( counter += p[1]
"Invalid service type for %s header") % Version.string)
try:
version = tuple(int(i) for i in version_str.split('.'))
except ValueError:
version = ()
if len(version) != 2: if counter > 1:
raise exc.HTTPNotAcceptable(_( return True
"Invalid value for %s header") % Version.string)
return version
def __lt__(a, b):
if (a.major < b.major):
return True
if (a.major == b.major and a.minor < b.minor):
return True
return False
def __gt__(a, b):
if (a.major > b.major):
return True
if (a.major == b.major and a.minor > b.minor):
return True
return False return False

View File

@ -19,6 +19,7 @@ from wsme import types as wtypes
from magnum.api.controllers import base from magnum.api.controllers import base
from magnum.api.controllers import link from magnum.api.controllers import link
from magnum.api.controllers import v1 from magnum.api.controllers import v1
from magnum.api.controllers import versions
from magnum.api import expose from magnum.api import expose
@ -69,7 +70,9 @@ class Root(base.APIBase):
root.name = "OpenStack Magnum API" root.name = "OpenStack Magnum API"
root.description = ("Magnum is an OpenStack project which aims to " root.description = ("Magnum is an OpenStack project which aims to "
"provide container management.") "provide container management.")
root.versions = [Version.convert('v1', "CURRENT", "1.1", "1.1")] root.versions = [Version.convert('v1', "CURRENT",
versions.CURRENT_MAX_VER,
versions.BASE_VER)]
return root return root

View File

@ -20,7 +20,6 @@ NOTE: IN PROGRESS AND NOT FULLY IMPLEMENTED.
from oslo_log import log as logging from oslo_log import log as logging
import pecan import pecan
from pecan import rest
from wsme import types as wtypes from wsme import types as wtypes
from magnum.api.controllers import base as controllers_base from magnum.api.controllers import base as controllers_base
@ -29,6 +28,7 @@ from magnum.api.controllers.v1 import bay
from magnum.api.controllers.v1 import baymodel from magnum.api.controllers.v1 import baymodel
from magnum.api.controllers.v1 import certificate from magnum.api.controllers.v1 import certificate
from magnum.api.controllers.v1 import magnum_services from magnum.api.controllers.v1 import magnum_services
from magnum.api.controllers import versions as ver
from magnum.api import expose from magnum.api import expose
from magnum.api import http_error from magnum.api import http_error
from magnum.i18n import _ from magnum.i18n import _
@ -38,28 +38,14 @@ LOG = logging.getLogger(__name__)
BASE_VERSION = 1 BASE_VERSION = 1
# NOTE(yuntong): v1.0 is reserved to indicate Kilo's API, but is not presently MIN_VER_STR = '%s %s' % (ver.Version.service_string, ver.BASE_VER)
# supported by the API service. All changes between Kilo and the
# point where we added microversioning are considered backwards-
# compatible, but are not specifically discoverable at this time.
#
# The v1.1 version indicates this "initial" version as being
# different from Kilo (v1.0), and includes the following changes:
#
# v1.1: API at the point in time when microversioning support was added MAX_VER_STR = '%s %s' % (ver.Version.service_string, ver.CURRENT_MAX_VER)
MIN_VER_STR = 'container-infra 1.1'
# v1.1: Add API changelog here MIN_VER = ver.Version({ver.Version.string: MIN_VER_STR},
MAX_VER_STR = 'container-infra 1.1' MIN_VER_STR, MAX_VER_STR)
MAX_VER = ver.Version({ver.Version.string: MAX_VER_STR},
MIN_VER_STR, MAX_VER_STR)
MIN_VER = controllers_base.Version(
{controllers_base.Version.string: MIN_VER_STR},
MIN_VER_STR, MAX_VER_STR)
MAX_VER = controllers_base.Version(
{controllers_base.Version.string: MAX_VER_STR},
MIN_VER_STR, MAX_VER_STR)
class MediaType(controllers_base.APIBase): class MediaType(controllers_base.APIBase):
@ -137,7 +123,7 @@ class V1(controllers_base.APIBase):
return v1 return v1
class Controller(rest.RestController): class Controller(controllers_base.Controller):
"""Version 1 API controller root.""" """Version 1 API controller root."""
bays = bay.BaysController() bays = bay.BaysController()
@ -180,22 +166,18 @@ class Controller(rest.RestController):
@pecan.expose() @pecan.expose()
def _route(self, args): def _route(self, args):
version = controllers_base.Version( version = ver.Version(
pecan.request.headers, MIN_VER_STR, MAX_VER_STR) pecan.request.headers, MIN_VER_STR, MAX_VER_STR)
# Always set the basic version headers # Always set the basic version headers
pecan.response.headers[ pecan.response.headers[ver.Version.min_string] = MIN_VER_STR
controllers_base.Version.min_string] = MIN_VER_STR pecan.response.headers[ver.Version.max_string] = MAX_VER_STR
pecan.response.headers[ pecan.response.headers[ver.Version.string] = " ".join(
controllers_base.Version.max_string] = MAX_VER_STR [ver.Version.service_string, str(version)])
pecan.response.headers[ pecan.response.headers["vary"] = ver.Version.string
controllers_base.Version.string] = " ".join(
[controllers_base.Version.service_string, str(version)])
pecan.response.headers["vary"] = controllers_base.Version.string
# assert that requested version is supported # assert that requested version is supported
self._check_version(version, pecan.response.headers) self._check_version(version, pecan.response.headers)
pecan.response.headers[controllers_base.Version.string] = str(version)
pecan.request.version = version pecan.request.version = version
if pecan.request.body: if pecan.request.body:
msg = ("Processing request: url: %(url)s, %(method)s, " msg = ("Processing request: url: %(url)s, %(method)s, "
@ -207,4 +189,5 @@ class Controller(rest.RestController):
return super(Controller, self)._route(args) return super(Controller, self)._route(args)
__all__ = (Controller) __all__ = (Controller)

View File

@ -16,7 +16,6 @@
from oslo_log import log as logging from oslo_log import log as logging
from oslo_utils import timeutils from oslo_utils import timeutils
import pecan import pecan
from pecan import rest
import wsme import wsme
from wsme import types as wtypes from wsme import types as wtypes
@ -199,7 +198,7 @@ class BayCollection(collection.Collection):
return sample return sample
class BaysController(rest.RestController): class BaysController(base.Controller):
"""REST controller for Bays.""" """REST controller for Bays."""
def __init__(self): def __init__(self):
super(BaysController, self).__init__() super(BaysController, self).__init__()

View File

@ -14,7 +14,6 @@
from oslo_utils import timeutils from oslo_utils import timeutils
import pecan import pecan
from pecan import rest
import wsme import wsme
from wsme import types as wtypes from wsme import types as wtypes
@ -226,7 +225,7 @@ class BayModelCollection(collection.Collection):
return sample return sample
class BayModelsController(rest.RestController): class BayModelsController(base.Controller):
"""REST controller for BayModels.""" """REST controller for BayModels."""
_custom_actions = { _custom_actions = {

View File

@ -14,7 +14,6 @@
from oslo_utils import timeutils from oslo_utils import timeutils
import pecan import pecan
from pecan import rest
import wsme import wsme
from wsme import types as wtypes from wsme import types as wtypes
@ -114,7 +113,7 @@ class Certificate(base.APIBase):
return cls._convert_with_links(sample, 'http://localhost:9511', expand) return cls._convert_with_links(sample, 'http://localhost:9511', expand)
class CertificateController(rest.RestController): class CertificateController(base.Controller):
"""REST controller for Certificate.""" """REST controller for Certificate."""
def __init__(self): def __init__(self):

View File

@ -11,7 +11,6 @@
# under the License. # under the License.
import pecan import pecan
from pecan import rest
import wsme import wsme
from wsme import types as wtypes from wsme import types as wtypes
@ -79,7 +78,7 @@ class MagnumServiceCollection(collection.Collection):
return collection return collection
class MagnumServiceController(rest.RestController): class MagnumServiceController(base.Controller):
"""REST controller for magnum-services.""" """REST controller for magnum-services."""
def __init__(self, **kwargs): def __init__(self, **kwargs):

View File

@ -0,0 +1,138 @@
# All Rights Reserved.
#
# 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.
from webob import exc
from magnum.i18n import _
# NOTE(yuntong): v1.0 is reserved to indicate Kilo's API, but is not presently
# supported by the API service. All changes between Kilo and the
# point where we added microversioning are considered backwards-
# compatible, but are not specifically discoverable at this time.
#
# The v1.1 version indicates this "initial" version as being
# different from Kilo (v1.0), and includes the following changes:
#
# Add details of new api versions here:
BASE_VER = '1.1'
CURRENT_MAX_VER = '1.1'
class Version(object):
"""API Version object."""
string = 'OpenStack-API-Version'
"""HTTP Header string carrying the requested version"""
min_string = 'OpenStack-API-Minimum-Version'
"""HTTP response header"""
max_string = 'OpenStack-API-Maximum-Version'
"""HTTP response header"""
service_string = 'container-infra'
def __init__(self, headers, default_version, latest_version,
from_string=None):
"""Create an API Version object from the supplied headers.
:param headers: webob headers
:param default_version: version to use if not specified in headers
:param latest_version: version to use if latest is requested
:param from_string: create the version from string not headers
:raises: webob.HTTPNotAcceptable
"""
if from_string:
(self.major, self.minor) = tuple(int(i)
for i in from_string.split('.'))
else:
(self.major, self.minor) = Version.parse_headers(headers,
default_version,
latest_version)
def __repr__(self):
return '%s.%s' % (self.major, self.minor)
@staticmethod
def parse_headers(headers, default_version, latest_version):
"""Determine the API version requested based on the headers supplied.
:param headers: webob headers
:param default_version: version to use if not specified in headers
:param latest_version: version to use if latest is requested
:returns: a tuple of (major, minor) version numbers
:raises: webob.HTTPNotAcceptable
"""
version_hdr = headers.get(Version.string, default_version)
try:
version_service, version_str = version_hdr.split()
except ValueError:
raise exc.HTTPNotAcceptable(_(
"Invalid service type for %s header") % Version.string)
if version_str.lower() == 'latest':
version_service, version_str = latest_version.split()
if version_service != Version.service_string:
raise exc.HTTPNotAcceptable(_(
"Invalid service type for %s header") % Version.string)
try:
version = tuple(int(i) for i in version_str.split('.'))
except ValueError:
version = ()
if len(version) != 2:
raise exc.HTTPNotAcceptable(_(
"Invalid value for %s header") % Version.string)
return version
def is_null(self):
return self.major == 0 and self.minor == 0
def matches(self, start_version, end_version):
if self.is_null():
raise ValueError
return start_version <= self <= end_version
def __lt__(self, other):
if self.major < other.major:
return True
if self.major == other.major and self.minor < other.minor:
return True
return False
def __gt__(self, other):
if self.major > other.major:
return True
if self.major == other.major and self.minor > other.minor:
return True
return False
def __eq__(self, other):
return self.major == other.major and self.minor == other.minor
def __le__(self, other):
return self < other or self == other
def __ne__(self, other):
return not self.__eq__(other)
def __ge__(self, other):
return self > other or self == other

View File

@ -29,7 +29,7 @@ class HTTPNotAcceptableAPIVersion(exc.HTTPNotAcceptable):
# #
# differences from webob.exc.HTTPNotAcceptable: # differences from webob.exc.HTTPNotAcceptable:
# #
# - additional max and min version paramters # - additional max and min version parameters
# - additional error info for code, title, and links # - additional error info for code, title, and links
code = 406 code = 406
title = 'Not Acceptable' title = 'Not Acceptable'

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

View File

@ -184,6 +184,11 @@ class Conflict(MagnumException):
code = 409 code = 409
class ApiVersionsIntersect(MagnumException):
message = _("Version of %(name)s %(min_ver)s %(max_ver)s intersects "
"with another versions.")
# Cannot be templated as the error syntax varies. # Cannot be templated as the error syntax varies.
# msg needs to be constructed when raised. # msg needs to be constructed when raised.
class InvalidParameterValue(Invalid): class InvalidParameterValue(Invalid):

View File

@ -14,19 +14,45 @@ import mock
from webob import exc from webob import exc
from magnum.api.controllers import base from magnum.api.controllers import base
from magnum.api.controllers import versions
from magnum.api import versioned_method
from magnum.tests import base as test_base from magnum.tests import base as test_base
class TestVersion(test_base.TestCase): class TestVersion(test_base.TestCase):
def setUp(self): def setUp(self):
super(TestVersion, self).setUp() super(TestVersion, self).setUp()
self.a = base.Version( self.a = versions.Version(
{base.Version.string: "container-infra 2.0"}, {versions.Version.string: "container-infra 2.0"},
"container-infra 2.0", "container-infra 2.1") "container-infra 2.0", "container-infra 2.1")
self.b = base.Version( self.b = versions.Version(
{base.Version.string: "container-infra 2.0"}, {versions.Version.string: "container-infra 2.0"},
"container-infra 2.0", "container-infra 2.1") "container-infra 2.0", "container-infra 2.1")
self.c = versions.Version(
{versions.Version.string: "container-infra 2.2"},
"container-infra 2.0", "container-infra 2.2")
def test_is_null_true(self):
self.a.major = 0
self.a.minor = 0
self.assertEqual(0 == 0, self.a.is_null())
def test_is_null_false(self):
self.assertEqual(2 == 0, self.a.is_null())
def test__eq__with_equal(self):
self.assertEqual(2 == 2, self.a == self.b)
def test__eq__with_unequal(self):
self.a.major = 1
self.assertEqual(1 == 2, self.a == self.b)
def test__ne__with_equal(self):
self.assertEqual(2 != 2, self.a != self.b)
def test__ne__with_unequal(self):
self.a.major = 1
self.assertEqual(1 != 2, self.a != self.b)
def test__lt__with_higher_major_version(self): def test__lt__with_higher_major_version(self):
self.a.major = 2 self.a.major = 2
@ -80,73 +106,304 @@ class TestVersion(test_base.TestCase):
self.assertEqual(self.a.major, self.b.major) self.assertEqual(self.a.major, self.b.major)
self.assertEqual(1 > 2, self.a > self.b) self.assertEqual(1 > 2, self.a > self.b)
@mock.patch('magnum.api.controllers.base.Version.parse_headers') def test__le__with_equal(self):
self.assertEqual(2 == 2, self.a <= self.b)
def test__le__with_higher_version(self):
self.a.major = 3
self.assertEqual(3 <= 2, self.a <= self.b)
def test__le__with_lower_version(self):
self.a.major = 1
self.assertEqual(1 <= 2, self.a <= self.b)
def test__ge__with_equal(self):
self.assertEqual(2 >= 2, self.a >= self.b)
def test__ge__with_higher_version(self):
self.a.major = 3
self.assertEqual(3 >= 2, self.a >= self.b)
def test__ge__with_lower_version(self):
self.a.major = 1
self.assertEqual(1 >= 2, self.a >= self.b)
def test_matches_start_version(self):
self.assertEqual(0 >= 0, self.a.matches(self.b, self.c))
def test_matches_end_version(self):
self.a.minor = 2
self.assertEqual(2 <= 2, self.a.matches(self.b, self.c))
def test_matches_valid_version(self):
self.a.minor = 1
self.assertEqual(0 <= 1 <= 2, self.a.matches(self.b, self.c))
def test_matches_version_too_high(self):
self.a.minor = 3
self.assertEqual(0 <= 3 <= 2, self.a.matches(self.b, self.c))
def test_matches_version_too_low(self):
self.a.major = 1
self.assertEqual(2 <= 1 <= 2, self.a.matches(self.b, self.c))
def test_matches_null_version(self):
self.a.major = 0
self.a.minor = 0
self.assertRaises(ValueError, self.a.matches, self.b, self.c)
@mock.patch('magnum.api.controllers.versions.Version.parse_headers')
def test_init(self, mock_parse): def test_init(self, mock_parse):
a = mock.Mock() a = mock.Mock()
b = mock.Mock() b = mock.Mock()
mock_parse.return_value = (a, b) mock_parse.return_value = (a, b)
v = base.Version('test', 'foo', 'bar') v = versions.Version('test', 'foo', 'bar')
mock_parse.assert_called_with('test', 'foo', 'bar') mock_parse.assert_called_with('test', 'foo', 'bar')
self.assertEqual(a, v.major) self.assertEqual(a, v.major)
self.assertEqual(b, v.minor) self.assertEqual(b, v.minor)
@mock.patch('magnum.api.controllers.base.Version.parse_headers') @mock.patch('magnum.api.controllers.versions.Version.parse_headers')
def test_repr(self, mock_parse): def test_repr(self, mock_parse):
mock_parse.return_value = (123, 456) mock_parse.return_value = (123, 456)
v = base.Version('test', mock.ANY, mock.ANY) v = versions.Version('test', mock.ANY, mock.ANY)
result = "%s" % v result = "%s" % v
self.assertEqual('123.456', result) self.assertEqual('123.456', result)
@mock.patch('magnum.api.controllers.base.Version.parse_headers') @mock.patch('magnum.api.controllers.versions.Version.parse_headers')
def test_repr_with_strings(self, mock_parse): def test_repr_with_strings(self, mock_parse):
mock_parse.return_value = ('abc', 'def') mock_parse.return_value = ('abc', 'def')
v = base.Version('test', mock.ANY, mock.ANY) v = versions.Version('test', mock.ANY, mock.ANY)
result = "%s" % v result = "%s" % v
self.assertEqual('abc.def', result) self.assertEqual('abc.def', result)
def test_parse_headers_ok(self): def test_parse_headers_ok(self):
version = base.Version.parse_headers( version = versions.Version.parse_headers(
{base.Version.string: 'container-infra 123.456'}, {versions.Version.string: 'container-infra 123.456'},
mock.ANY, mock.ANY) mock.ANY, mock.ANY)
self.assertEqual((123, 456), version) self.assertEqual((123, 456), version)
def test_parse_headers_latest(self): def test_parse_headers_latest(self):
for s in ['magnum latest', 'magnum LATEST']: for s in ['magnum latest', 'magnum LATEST']:
version = base.Version.parse_headers( version = versions.Version.parse_headers(
{base.Version.string: s}, mock.ANY, 'container-infra 1.9') {versions.Version.string: s}, mock.ANY, 'container-infra 1.9')
self.assertEqual((1, 9), version) self.assertEqual((1, 9), version)
def test_parse_headers_bad_length(self): def test_parse_headers_bad_length(self):
self.assertRaises( self.assertRaises(
exc.HTTPNotAcceptable, exc.HTTPNotAcceptable,
base.Version.parse_headers, versions.Version.parse_headers,
{base.Version.string: 'container-infra 1'}, {versions.Version.string: 'container-infra 1'},
mock.ANY, mock.ANY,
mock.ANY) mock.ANY)
self.assertRaises( self.assertRaises(
exc.HTTPNotAcceptable, exc.HTTPNotAcceptable,
base.Version.parse_headers, versions.Version.parse_headers,
{base.Version.string: 'container-infra 1.2.3'}, {versions.Version.string: 'container-infra 1.2.3'},
mock.ANY, mock.ANY,
mock.ANY) mock.ANY)
def test_parse_no_header(self): def test_parse_no_header(self):
# this asserts that the minimum version string is applied # this asserts that the minimum version string is applied
version = base.Version.parse_headers({}, 'container-infra 1.1', version = versions.Version.parse_headers({}, 'container-infra 1.1',
'container-infra 1.5') 'container-infra 1.5')
self.assertEqual((1, 1), version) self.assertEqual((1, 1), version)
def test_parse_incorrect_service_type(self): def test_parse_incorrect_service_type(self):
self.assertRaises( self.assertRaises(
exc.HTTPNotAcceptable, exc.HTTPNotAcceptable,
base.Version.parse_headers, versions.Version.parse_headers,
{base.Version.string: '1.1'}, {versions.Version.string: '1.1'},
'container-infra 1.1', 'container-infra 1.1',
'container-infra 1.1') 'container-infra 1.1')
self.assertRaises( self.assertRaises(
exc.HTTPNotAcceptable, exc.HTTPNotAcceptable,
base.Version.parse_headers, versions.Version.parse_headers,
{base.Version.string: 'nova 1.1'}, {versions.Version.string: 'nova 1.1'},
'container-infra 1.1', 'container-infra 1.1',
'container-infra 1.1') 'container-infra 1.1')
class TestController(test_base.TestCase):
def test_check_for_versions_intersection_negative(self):
func_list = \
[versioned_method.VersionedMethod('foo',
versions.Version('', '', '',
'2.1'),
versions.Version('', '', '',
'2.4'),
None),
versioned_method.VersionedMethod('foo',
versions.Version('', '', '',
'2.11'),
versions.Version('', '', '',
'3.1'),
None),
versioned_method.VersionedMethod('foo',
versions.Version('', '', '',
'2.8'),
versions.Version('', '', '',
'2.9'),
None),
]
result = base.Controller.check_for_versions_intersection(
func_list=func_list)
self.assertFalse(result)
func_list = \
[versioned_method.VersionedMethod('foo',
versions.Version('', '', '',
'2.12'),
versions.Version('', '', '',
'2.14'),
None),
versioned_method.VersionedMethod('foo',
versions.Version('', '', '',
'3.0'),
versions.Version('', '', '',
'3.4'),
None)
]
result = base.Controller.check_for_versions_intersection(
func_list=func_list)
self.assertFalse(result)
def test_check_for_versions_intersection_positive(self):
func_list = \
[versioned_method.VersionedMethod('foo',
versions.Version('', '', '',
'2.1'),
versions.Version('', '', '',
'2.4'),
None),
versioned_method.VersionedMethod('foo',
versions.Version('', '', '',
'2.3'),
versions.Version('', '', '',
'3.1'),
None),
versioned_method.VersionedMethod('foo',
versions.Version('', '', '',
'2.9'),
versions.Version('', '', '',
'3.4'),
None)
]
result = base.Controller.check_for_versions_intersection(
func_list=func_list)
self.assertTrue(result)
def test_check_for_versions_intersection_shared_start_end(self):
func_list = \
[versioned_method.VersionedMethod('foo',
versions.Version('', '', '',
'1.1'),
versions.Version('', '', '',
'1.1'),
None),
versioned_method.VersionedMethod('foo',
versions.Version('', '', '',
'1.1'),
versions.Version('', '', '',
'1.2'),
None)
]
result = base.Controller.check_for_versions_intersection(
func_list=func_list)
self.assertTrue(result)
def test_api_version_decorator(self):
class MyController(base.Controller):
@base.Controller.api_version('1.0', '1.1')
def testapi1(self):
return 'API1_1.0_1.1'
@base.Controller.api_version('1.2', '1.3') # noqa
def testapi1(self):
return 'API1_1.2_1.3'
@base.Controller.api_version('2.1', '2.2')
def testapi2(self):
return 'API2_2.1_2.2'
@base.Controller.api_version('1.0', '2.0') # noqa
def testapi2(self):
return 'API2_1.0_2.0'
controller = MyController()
# verify list was added to controller
self.assertIsNotNone(controller.versioned_methods)
api1_list = controller.versioned_methods['testapi1']
api2_list = controller.versioned_methods['testapi2']
# verify versioned_methods reordered correctly
self.assertEqual('1.2', str(api1_list[0].start_version))
self.assertEqual('1.3', str(api1_list[0].end_version))
self.assertEqual('1.0', str(api1_list[1].start_version))
self.assertEqual('1.1', str(api1_list[1].end_version))
# verify stored methods can be called
result = api1_list[0].func(controller)
self.assertEqual('API1_1.2_1.3', result)
result = api1_list[1].func(controller)
self.assertEqual('API1_1.0_1.1', result)
# verify versioned_methods reordered correctly
self.assertEqual('2.1', str(api2_list[0].start_version))
self.assertEqual('2.2', str(api2_list[0].end_version))
self.assertEqual('1.0', str(api2_list[1].start_version))
self.assertEqual('2.0', str(api2_list[1].end_version))
# Verify stored methods can be called
result = api2_list[0].func(controller)
self.assertEqual('API2_2.1_2.2', result)
result = api2_list[1].func(controller)
self.assertEqual('API2_1.0_2.0', result)
@mock.patch('pecan.request')
def test_controller_get_attribute(self, mock_pecan_request):
class MyController(base.Controller):
@base.Controller.api_version('1.0', '1.1')
def testapi1(self):
return 'API1_1.0_1.1'
@base.Controller.api_version('1.2', '1.3') # noqa
def testapi1(self):
return 'API1_1.2_1.3'
controller = MyController()
mock_pecan_request.version = versions.Version("", "",
"", "1.2")
controller.request = mock_pecan_request
method = controller.__getattribute__('testapi1')
result = method()
self.assertEqual('API1_1.2_1.3', result)
@mock.patch('pecan.request')
def test_controller_get_attr_version_not_found(self,
mock_pecan_request):
class MyController(base.Controller):
@base.Controller.api_version('1.0', '1.1')
def testapi1(self):
return 'API1_1.0_1.1'
@base.Controller.api_version('1.3', '1.4') # noqa
def testapi1(self):
return 'API1_1.3_1.4'
controller = MyController()
mock_pecan_request.version = versions.Version("", "",
"", "1.2")
controller.request = mock_pecan_request
self.assertRaises(exc.HTTPNotAcceptable,
controller.__getattribute__, 'testapi1')