77a8e5d582
Provide a decorator that allows Placement API handlers to express a microversion window within which that method should be used. This makes it possible to define N different methods (with the same name) to present different behaviors dependent on what microversion is requested by the user-agent. If there is no match between a valid requested microversion and available versioned handlers a 404 response is returned. The mechanism for managing the versioned handlers is borrowed from the compute api but adjusted to allow for the fact that placement handlers are module level functions. Some code is borrowed from twisted to get fully qualified names of methods for the VERSION_METHODS dictionary. Tests are added which validate that versioned handlers do not intersect. In the rest of nova this is done at runtime, but in the placement context this isn't necessary. Doing it at testime is sufficient. Since there are currently no versioned handlers in addition to the faked tests of the intersection handling I also manually forced some tests with real handlers, just to be sure. Change-Id: I713c139ee12bb7f5301edd85951f8960fda84ac3
243 lines
8.0 KiB
Python
243 lines
8.0 KiB
Python
# 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.
|
|
"""Microversion handling."""
|
|
|
|
# NOTE(cdent): This code is taken from enamel:
|
|
# https://github.com/jaypipes/enamel and was the original source of
|
|
# the code now used in microversion_parse library.
|
|
|
|
import collections
|
|
import inspect
|
|
|
|
import microversion_parse
|
|
import webob
|
|
|
|
# NOTE(cdent): avoid cyclical import conflict between util and
|
|
# microversion
|
|
import nova.api.openstack.placement.util
|
|
from nova.i18n import _
|
|
|
|
|
|
SERVICE_TYPE = 'placement'
|
|
MICROVERSION_ENVIRON = '%s.microversion' % SERVICE_TYPE
|
|
VERSIONED_METHODS = collections.defaultdict(list)
|
|
|
|
# The Canonical Version List
|
|
VERSIONS = [
|
|
'1.0',
|
|
]
|
|
|
|
|
|
def max_version_string():
|
|
return VERSIONS[-1]
|
|
|
|
|
|
def min_version_string():
|
|
return VERSIONS[0]
|
|
|
|
|
|
def parse_version_string(version_string):
|
|
"""Turn a version string into a Version
|
|
|
|
:param version_string: A string of two numerals, X.Y, or 'latest'
|
|
:returns: a Version
|
|
:raises: TypeError
|
|
"""
|
|
if version_string == 'latest':
|
|
version_string = max_version_string()
|
|
try:
|
|
# The combination of int and a limited split with the
|
|
# named tuple means that this incantation will raise
|
|
# ValueError or TypeError when the incoming data is
|
|
# poorly formed but will, however, naturally adapt to
|
|
# extraneous whitespace.
|
|
return Version(*(int(value) for value
|
|
in version_string.split('.', 1)))
|
|
except (ValueError, TypeError) as exc:
|
|
raise TypeError('invalid version string: %s; %s' % (
|
|
version_string, exc))
|
|
|
|
|
|
class MicroversionMiddleware(object):
|
|
"""WSGI middleware for getting microversion info."""
|
|
|
|
def __init__(self, application):
|
|
self.application = application
|
|
|
|
@webob.dec.wsgify
|
|
def __call__(self, req):
|
|
util = nova.api.openstack.placement.util
|
|
try:
|
|
microversion = extract_version(req.headers)
|
|
except ValueError as exc:
|
|
raise webob.exc.HTTPNotAcceptable(
|
|
_('Invalid microversion: %(error)s') % {'error': exc},
|
|
json_formatter=util.json_error_formatter)
|
|
except TypeError as exc:
|
|
raise webob.exc.HTTPBadRequest(
|
|
_('Invalid microversion: %(error)s') % {'error': exc},
|
|
json_formatter=util.json_error_formatter)
|
|
|
|
req.environ[MICROVERSION_ENVIRON] = microversion
|
|
microversion_header = '%s %s' % (SERVICE_TYPE, microversion)
|
|
|
|
try:
|
|
response = req.get_response(self.application)
|
|
except webob.exc.HTTPError as exc:
|
|
# If there was an error in the application we still need
|
|
# to send the microversion header, so add the header and
|
|
# re-raise the exception.
|
|
exc.headers.add(Version.HEADER, microversion_header)
|
|
raise exc
|
|
|
|
response.headers.add(Version.HEADER, microversion_header)
|
|
response.headers.add('vary', Version.HEADER)
|
|
return response
|
|
|
|
|
|
class Version(collections.namedtuple('Version', 'major minor')):
|
|
"""A namedtuple containing major and minor values.
|
|
|
|
Since it is a tuple is automatically comparable.
|
|
"""
|
|
|
|
HEADER = 'OpenStack-API-Version'
|
|
|
|
MIN_VERSION = None
|
|
MAX_VERSION = None
|
|
|
|
def __str__(self):
|
|
return '%s.%s' % (self.major, self.minor)
|
|
|
|
def __float__(self):
|
|
return float(self.__str__())
|
|
|
|
@property
|
|
def max_version(self):
|
|
if not self.MAX_VERSION:
|
|
self.MAX_VERSION = parse_version_string(max_version_string())
|
|
return self.MAX_VERSION
|
|
|
|
@property
|
|
def min_version(self):
|
|
if not self.MIN_VERSION:
|
|
self.MIN_VERSION = parse_version_string(min_version_string())
|
|
return self.MIN_VERSION
|
|
|
|
def matches(self, min_version=None, max_version=None):
|
|
if min_version is None:
|
|
min_version = self.min_version
|
|
if max_version is None:
|
|
max_version = self.max_version
|
|
return min_version <= self <= max_version
|
|
|
|
|
|
def extract_version(headers):
|
|
"""Extract the microversion from Version.HEADER
|
|
|
|
There may be multiple headers and some which don't match our
|
|
service.
|
|
"""
|
|
found_version = microversion_parse.get_version(headers,
|
|
service_type=SERVICE_TYPE)
|
|
|
|
version_string = found_version or min_version_string()
|
|
request_version = parse_version_string(version_string)
|
|
# We need a version that is in VERSION and within MIX and MAX.
|
|
# This gives us the option to administratively disable a
|
|
# version if we really need to.
|
|
if (str(request_version) in VERSIONS and request_version.matches()):
|
|
return request_version
|
|
raise ValueError('Unacceptable version header: %s' % version_string)
|
|
|
|
|
|
# From twisted
|
|
# https://github.com/twisted/twisted/blob/trunk/twisted/python/deprecate.py
|
|
def _fully_qualified_name(obj):
|
|
"""Return the fully qualified name of a module, class, method or function.
|
|
|
|
Classes and functions need to be module level ones to be correctly
|
|
qualified.
|
|
"""
|
|
try:
|
|
name = obj.__qualname__
|
|
except AttributeError:
|
|
name = obj.__name__
|
|
|
|
if inspect.isclass(obj) or inspect.isfunction(obj):
|
|
moduleName = obj.__module__
|
|
return "%s.%s" % (moduleName, name)
|
|
elif inspect.ismethod(obj):
|
|
try:
|
|
cls = obj.im_class
|
|
except AttributeError:
|
|
# Python 3 eliminates im_class, substitutes __module__ and
|
|
# __qualname__ to provide similar information.
|
|
return "%s.%s" % (obj.__module__, obj.__qualname__)
|
|
else:
|
|
className = _fully_qualified_name(cls)
|
|
return "%s.%s" % (className, name)
|
|
return name
|
|
|
|
|
|
def _find_method(f, version_float):
|
|
"""Look in VERSIONED_METHODS for method with right name matching version.
|
|
|
|
If no match is found raise a 404.
|
|
"""
|
|
qualified_name = _fully_qualified_name(f)
|
|
# A KeyError shouldn't be possible here, but let's be robust
|
|
# just in case.
|
|
method_list = VERSIONED_METHODS.get(qualified_name, [])
|
|
for min_version, max_version, func in method_list:
|
|
if min_version <= version_float <= max_version:
|
|
return func
|
|
|
|
raise webob.exc.HTTPNotFound()
|
|
|
|
|
|
def version_handler(min_ver, max_ver=None):
|
|
"""Decorator for versioning API methods.
|
|
|
|
Add as a decorator to a placement API handler to constrain
|
|
the microversions at which it will run. Add after the
|
|
``wsgify`` decorator.
|
|
|
|
This does not check for version intersections. That's the
|
|
domain of tests.
|
|
|
|
:param min_ver: A string of two numerals, X.Y indicating the
|
|
minimum version allowed for the decorated method.
|
|
:param min_ver: A string of two numerals, X.Y, indicating the
|
|
maximum version allowed for the decorated method.
|
|
"""
|
|
def decorator(f):
|
|
min_version_float = float(min_ver)
|
|
if max_ver:
|
|
max_version_float = float(max_ver)
|
|
else:
|
|
max_version_float = float(max_version_string())
|
|
qualified_name = _fully_qualified_name(f)
|
|
VERSIONED_METHODS[qualified_name].append(
|
|
(min_version_float, max_version_float, f))
|
|
|
|
def decorated_func(req, *args, **kwargs):
|
|
version_float = float(req.environ[MICROVERSION_ENVIRON])
|
|
return _find_method(f, version_float)(req, *args, **kwargs)
|
|
|
|
# Sort highest min version to beginning of list.
|
|
VERSIONED_METHODS[qualified_name].sort(key=lambda x: x[0],
|
|
reverse=True)
|
|
return decorated_func
|
|
return decorator
|