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:
parent
6c265ce10a
commit
e6a71b9e6d
@ -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"""
|
||||
|
||||
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):
|
||||
"""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
|
||||
:raises: webob.HTTPNotAcceptable
|
||||
This metaclass automates the task of assembling a dictionary
|
||||
mapping action keys to method names.
|
||||
"""
|
||||
(self.major, self.minor) = Version.parse_headers(headers,
|
||||
default_version,
|
||||
latest_version)
|
||||
|
||||
def __repr__(self):
|
||||
return '%s.%s' % (self.major, self.minor)
|
||||
def __new__(mcs, name, bases, cls_dict):
|
||||
"""Adds version function dictionary to the class."""
|
||||
|
||||
versioned_methods = None
|
||||
|
||||
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)
|
||||
|
||||
if versioned_methods:
|
||||
cls_dict[VER_METHOD_ATTR] = versioned_methods
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
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):
|
||||
if counter > 1:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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,27 +38,13 @@ 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 = ver.Version({ver.Version.string: MIN_VER_STR},
|
||||
MIN_VER_STR, MAX_VER_STR)
|
||||
MAX_VER = controllers_base.Version(
|
||||
{controllers_base.Version.string: MAX_VER_STR},
|
||||
MAX_VER = ver.Version({ver.Version.string: MAX_VER_STR},
|
||||
MIN_VER_STR, MAX_VER_STR)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
@ -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__()
|
||||
|
@ -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 = {
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
138
magnum/api/controllers/versions.py
Normal file
138
magnum/api/controllers/versions.py
Normal 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
|
@ -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'
|
||||
|
35
magnum/api/versioned_method.py
Normal file
35
magnum/api/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))
|
@ -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):
|
||||
|
@ -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',
|
||||
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')
|
||||
|
Loading…
Reference in New Issue
Block a user