From e6a71b9e6d58ad559c173f1c14823527dc2daf53 Mon Sep 17 00:00:00 2001 From: Jaycen Grant Date: Fri, 15 Jul 2016 13:19:58 -0700 Subject: [PATCH] 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 --- magnum/api/controllers/base.py | 218 +++++++++---- magnum/api/controllers/root.py | 5 +- magnum/api/controllers/v1/__init__.py | 47 +-- magnum/api/controllers/v1/bay.py | 3 +- magnum/api/controllers/v1/baymodel.py | 3 +- magnum/api/controllers/v1/certificate.py | 3 +- magnum/api/controllers/v1/magnum_services.py | 3 +- magnum/api/controllers/versions.py | 138 ++++++++ magnum/api/http_error.py | 2 +- magnum/api/versioned_method.py | 35 ++ magnum/common/exception.py | 5 + .../tests/unit/api/controllers/test_base.py | 307 ++++++++++++++++-- 12 files changed, 643 insertions(+), 126 deletions(-) create mode 100644 magnum/api/controllers/versions.py create mode 100644 magnum/api/versioned_method.py diff --git a/magnum/api/controllers/base.py b/magnum/api/controllers/base.py index bcc0b50109..c4a66640ff 100644 --- a/magnum/api/controllers/base.py +++ b/magnum/api/controllers/base.py @@ -13,12 +13,21 @@ # under the License. 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 import wsme 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): @@ -50,80 +59,171 @@ class APIBase(wtypes.Base): setattr(self, k, wsme.Unset) -class Version(object): - """API Version object.""" +class ControllerMetaclass(type): + """Controller metaclass. - string = 'OpenStack-API-Version' - """HTTP Header string carrying the requested version""" + This metaclass automates the task of assembling a dictionary + mapping action keys to method names. + """ - min_string = 'OpenStack-API-Minimum-Version' - """HTTP response header""" + def __new__(mcs, name, bases, cls_dict): + """Adds version function dictionary to the class.""" - max_string = 'OpenStack-API-Maximum-Version' - """HTTP response header""" + versioned_methods = None - 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): - """Create an API Version object from the supplied headers. + if versioned_methods: + cls_dict[VER_METHOD_ATTR] = versioned_methods - :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 - :raises: webob.HTTPNotAcceptable + return super(ControllerMetaclass, mcs).__new__(mcs, name, bases, + cls_dict) + + +@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): - return '%s.%s' % (self.major, self.minor) + def decorator(f): + 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 - def parse_headers(headers, default_version, latest_version): - """Determine the API version requested based on the headers supplied. + def check_for_versions_intersection(func_list): + """Determines whether function list intersections - :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 + General algorithm: + https://en.wikipedia.org/wiki/Intersection_algorithm + + :param func_list: list of VersionedMethod objects + :return: boolean """ - version_hdr = headers.get(Version.string, default_version) + pairs = [] + counter = 0 - try: - version_service, version_str = version_hdr.split() - except ValueError: - raise exc.HTTPNotAcceptable(_( - "Invalid service type for %s header") % Version.string) + for f in func_list: + pairs.append((f.start_version, 1)) + pairs.append((f.end_version, -1)) - if version_str.lower() == 'latest': - version_service, version_str = latest_version.split() + pairs.sort(key=operator.itemgetter(1), reverse=True) + pairs.sort(key=operator.itemgetter(0)) - 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 = () + for p in pairs: + counter += p[1] - if len(version) != 2: - raise exc.HTTPNotAcceptable(_( - "Invalid value for %s header") % Version.string) - return version + if counter > 1: + return True - 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 diff --git a/magnum/api/controllers/root.py b/magnum/api/controllers/root.py index 59166a0709..bf5fc718fd 100644 --- a/magnum/api/controllers/root.py +++ b/magnum/api/controllers/root.py @@ -19,6 +19,7 @@ from wsme import types as wtypes from magnum.api.controllers import base from magnum.api.controllers import link from magnum.api.controllers import v1 +from magnum.api.controllers import versions from magnum.api import expose @@ -69,7 +70,9 @@ class Root(base.APIBase): root.name = "OpenStack Magnum API" root.description = ("Magnum is an OpenStack project which aims to " "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 diff --git a/magnum/api/controllers/v1/__init__.py b/magnum/api/controllers/v1/__init__.py index 4af563e774..e45d3b3f15 100644 --- a/magnum/api/controllers/v1/__init__.py +++ b/magnum/api/controllers/v1/__init__.py @@ -20,7 +20,6 @@ NOTE: IN PROGRESS AND NOT FULLY IMPLEMENTED. from oslo_log import log as logging import pecan -from pecan import rest from wsme import types as wtypes 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 certificate 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 http_error from magnum.i18n import _ @@ -38,28 +38,14 @@ LOG = logging.getLogger(__name__) BASE_VERSION = 1 -# 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: -# +MIN_VER_STR = '%s %s' % (ver.Version.service_string, ver.BASE_VER) -# v1.1: API at the point in time when microversioning support was added -MIN_VER_STR = 'container-infra 1.1' +MAX_VER_STR = '%s %s' % (ver.Version.service_string, ver.CURRENT_MAX_VER) -# v1.1: Add API changelog here -MAX_VER_STR = 'container-infra 1.1' - - -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) +MIN_VER = ver.Version({ver.Version.string: MIN_VER_STR}, + MIN_VER_STR, MAX_VER_STR) +MAX_VER = ver.Version({ver.Version.string: MAX_VER_STR}, + MIN_VER_STR, MAX_VER_STR) class MediaType(controllers_base.APIBase): @@ -137,7 +123,7 @@ class V1(controllers_base.APIBase): return v1 -class Controller(rest.RestController): +class Controller(controllers_base.Controller): """Version 1 API controller root.""" bays = bay.BaysController() @@ -180,22 +166,18 @@ class Controller(rest.RestController): @pecan.expose() def _route(self, args): - version = controllers_base.Version( + version = ver.Version( pecan.request.headers, MIN_VER_STR, MAX_VER_STR) # Always set the basic version headers - pecan.response.headers[ - controllers_base.Version.min_string] = MIN_VER_STR - pecan.response.headers[ - controllers_base.Version.max_string] = MAX_VER_STR - pecan.response.headers[ - controllers_base.Version.string] = " ".join( - [controllers_base.Version.service_string, str(version)]) - pecan.response.headers["vary"] = controllers_base.Version.string + pecan.response.headers[ver.Version.min_string] = MIN_VER_STR + pecan.response.headers[ver.Version.max_string] = MAX_VER_STR + pecan.response.headers[ver.Version.string] = " ".join( + [ver.Version.service_string, str(version)]) + pecan.response.headers["vary"] = ver.Version.string # assert that requested version is supported self._check_version(version, pecan.response.headers) - pecan.response.headers[controllers_base.Version.string] = str(version) pecan.request.version = version if pecan.request.body: msg = ("Processing request: url: %(url)s, %(method)s, " @@ -207,4 +189,5 @@ class Controller(rest.RestController): return super(Controller, self)._route(args) + __all__ = (Controller) diff --git a/magnum/api/controllers/v1/bay.py b/magnum/api/controllers/v1/bay.py index 3e5fa64ff9..8b0d9e619e 100644 --- a/magnum/api/controllers/v1/bay.py +++ b/magnum/api/controllers/v1/bay.py @@ -16,7 +16,6 @@ from oslo_log import log as logging from oslo_utils import timeutils import pecan -from pecan import rest import wsme from wsme import types as wtypes @@ -199,7 +198,7 @@ class BayCollection(collection.Collection): return sample -class BaysController(rest.RestController): +class BaysController(base.Controller): """REST controller for Bays.""" def __init__(self): super(BaysController, self).__init__() diff --git a/magnum/api/controllers/v1/baymodel.py b/magnum/api/controllers/v1/baymodel.py index b924f634cf..0fa6cd5090 100644 --- a/magnum/api/controllers/v1/baymodel.py +++ b/magnum/api/controllers/v1/baymodel.py @@ -14,7 +14,6 @@ from oslo_utils import timeutils import pecan -from pecan import rest import wsme from wsme import types as wtypes @@ -226,7 +225,7 @@ class BayModelCollection(collection.Collection): return sample -class BayModelsController(rest.RestController): +class BayModelsController(base.Controller): """REST controller for BayModels.""" _custom_actions = { diff --git a/magnum/api/controllers/v1/certificate.py b/magnum/api/controllers/v1/certificate.py index adcb815ede..d3f5550e28 100644 --- a/magnum/api/controllers/v1/certificate.py +++ b/magnum/api/controllers/v1/certificate.py @@ -14,7 +14,6 @@ from oslo_utils import timeutils import pecan -from pecan import rest import wsme from wsme import types as wtypes @@ -114,7 +113,7 @@ class Certificate(base.APIBase): return cls._convert_with_links(sample, 'http://localhost:9511', expand) -class CertificateController(rest.RestController): +class CertificateController(base.Controller): """REST controller for Certificate.""" def __init__(self): diff --git a/magnum/api/controllers/v1/magnum_services.py b/magnum/api/controllers/v1/magnum_services.py index 80d24730e2..d2b9c39e08 100644 --- a/magnum/api/controllers/v1/magnum_services.py +++ b/magnum/api/controllers/v1/magnum_services.py @@ -11,7 +11,6 @@ # under the License. import pecan -from pecan import rest import wsme from wsme import types as wtypes @@ -79,7 +78,7 @@ class MagnumServiceCollection(collection.Collection): return collection -class MagnumServiceController(rest.RestController): +class MagnumServiceController(base.Controller): """REST controller for magnum-services.""" def __init__(self, **kwargs): diff --git a/magnum/api/controllers/versions.py b/magnum/api/controllers/versions.py new file mode 100644 index 0000000000..22c9d7a00e --- /dev/null +++ b/magnum/api/controllers/versions.py @@ -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 diff --git a/magnum/api/http_error.py b/magnum/api/http_error.py index 21e8aa0f9d..04167b75b0 100644 --- a/magnum/api/http_error.py +++ b/magnum/api/http_error.py @@ -29,7 +29,7 @@ class HTTPNotAcceptableAPIVersion(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 code = 406 title = 'Not Acceptable' diff --git a/magnum/api/versioned_method.py b/magnum/api/versioned_method.py new file mode 100644 index 0000000000..b7e30da839 --- /dev/null +++ b/magnum/api/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/magnum/common/exception.py b/magnum/common/exception.py index bfe6613bc2..441eb7e0a5 100644 --- a/magnum/common/exception.py +++ b/magnum/common/exception.py @@ -184,6 +184,11 @@ class Conflict(MagnumException): 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. # msg needs to be constructed when raised. class InvalidParameterValue(Invalid): diff --git a/magnum/tests/unit/api/controllers/test_base.py b/magnum/tests/unit/api/controllers/test_base.py index 4dc28cd058..9c565cb84d 100644 --- a/magnum/tests/unit/api/controllers/test_base.py +++ b/magnum/tests/unit/api/controllers/test_base.py @@ -14,19 +14,45 @@ import mock from webob import exc 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 class TestVersion(test_base.TestCase): - def setUp(self): super(TestVersion, self).setUp() - self.a = base.Version( - {base.Version.string: "container-infra 2.0"}, + self.a = versions.Version( + {versions.Version.string: "container-infra 2.0"}, "container-infra 2.0", "container-infra 2.1") - self.b = base.Version( - {base.Version.string: "container-infra 2.0"}, + self.b = versions.Version( + {versions.Version.string: "container-infra 2.0"}, "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): self.a.major = 2 @@ -80,73 +106,304 @@ class TestVersion(test_base.TestCase): self.assertEqual(self.a.major, self.b.major) 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): a = mock.Mock() b = mock.Mock() 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') self.assertEqual(a, v.major) 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): 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 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): 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 self.assertEqual('abc.def', result) def test_parse_headers_ok(self): - version = base.Version.parse_headers( - {base.Version.string: 'container-infra 123.456'}, + version = versions.Version.parse_headers( + {versions.Version.string: 'container-infra 123.456'}, mock.ANY, mock.ANY) self.assertEqual((123, 456), version) def test_parse_headers_latest(self): for s in ['magnum latest', 'magnum LATEST']: - version = base.Version.parse_headers( - {base.Version.string: s}, mock.ANY, 'container-infra 1.9') + version = versions.Version.parse_headers( + {versions.Version.string: s}, mock.ANY, 'container-infra 1.9') self.assertEqual((1, 9), version) def test_parse_headers_bad_length(self): self.assertRaises( exc.HTTPNotAcceptable, - base.Version.parse_headers, - {base.Version.string: 'container-infra 1'}, + versions.Version.parse_headers, + {versions.Version.string: 'container-infra 1'}, mock.ANY, mock.ANY) self.assertRaises( exc.HTTPNotAcceptable, - base.Version.parse_headers, - {base.Version.string: 'container-infra 1.2.3'}, + versions.Version.parse_headers, + {versions.Version.string: 'container-infra 1.2.3'}, mock.ANY, mock.ANY) def test_parse_no_header(self): # this asserts that the minimum version string is applied - version = base.Version.parse_headers({}, 'container-infra 1.1', - 'container-infra 1.5') + version = versions.Version.parse_headers({}, 'container-infra 1.1', + 'container-infra 1.5') self.assertEqual((1, 1), version) def test_parse_incorrect_service_type(self): self.assertRaises( exc.HTTPNotAcceptable, - base.Version.parse_headers, - {base.Version.string: '1.1'}, + versions.Version.parse_headers, + {versions.Version.string: '1.1'}, 'container-infra 1.1', 'container-infra 1.1') self.assertRaises( exc.HTTPNotAcceptable, - base.Version.parse_headers, - {base.Version.string: 'nova 1.1'}, + versions.Version.parse_headers, + {versions.Version.string: 'nova 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')