cinder-api-microversions code

Many changes to the Cinder REST API require changes to the consumers of the API.
For example, If we need to add a required parameter to a method that is called
 by Nova, we'd need both the Nova calling code and the cinderclient that
Nova uses to change. But newer Cinder versions with the change must work with
older Nova versions, and there is no mechanism for this at the moment. Adding
microversions will solve this problem.
With microversions, the highest supported version will be negotiated by a field
in the HTTP header that is sent to the Cinder API. In the case where the field
'versions' is not sent (i.e. clients and scripts that pre-date this change),
then the lowest supported version would be used. In order to ensure that the
API consumer is explicitly asking for a microversioned API, a new endpoint v3
is added, which is identical to API version v2. This means that our new
Cinder API v3 would be the default, and consumers of the API that wished to
use a newer version could do so by using that endpoint and a microversion in
the HTTP header.
New tests for microversioned API features on endpoint /v3 should be added to
cinder/tests/unit/api/v3/ directory. Existing functionality will be tested via
the .../v2/ unit tests.

DocImpact
APIImpact
Implements: https://blueprints.launchpad.net/cinder/+spec/cinder-api-microversions
Change-Id: I48cdbbc900c2805e59ee9aebc3b1c64aed3212ae
This commit is contained in:
scottda 2015-09-16 12:57:35 +00:00
parent 5e821cdb33
commit 6b11d276d1
31 changed files with 1687 additions and 556 deletions

View File

@ -28,11 +28,7 @@ LOG = logging.getLogger(__name__)
def root_app_factory(loader, global_conf, **local_conf): def root_app_factory(loader, global_conf, **local_conf):
if CONF.enable_v1_api: if CONF.enable_v1_api:
LOG.warning(_LW('The v1 api is deprecated and will be removed in the ' LOG.warning(_LW('The v1 api is deprecated and is not under active '
'Liberty release. You should set enable_v1_api=false ' 'development. You should set enable_v1_api=false '
'and enable_v2_api=true in your cinder.conf file.')) 'and enable_v3_api=true in your cinder.conf file.'))
else:
del local_conf['/v1']
if not CONF.enable_v2_api:
del local_conf['/v2']
return paste.urlmap.urlmap_factory(loader, global_conf, **local_conf) return paste.urlmap.urlmap_factory(loader, global_conf, **local_conf)

View File

@ -38,6 +38,16 @@ api_common_opts = [
help='Base URL that will be presented to users in links ' help='Base URL that will be presented to users in links '
'to the OpenStack Volume API', 'to the OpenStack Volume API',
deprecated_name='osapi_compute_link_prefix'), deprecated_name='osapi_compute_link_prefix'),
cfg.ListOpt('query_volume_filters',
default=['name', 'status', 'metadata',
'availability_zone',
'bootable'],
help="Volume filter options which "
"non-admin user could use to "
"query volumes. Default values "
"are: ['name', 'status', "
"'metadata', 'availability_zone' ,"
"'bootable']")
] ]
CONF = cfg.CONF CONF = cfg.CONF

View File

@ -0,0 +1,164 @@
# Copyright 2014 IBM Corp.
# Copyright 2015 Clinton Knight
# 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.
import re
from cinder.api.openstack import versioned_method
from cinder import exception
from cinder.i18n import _
from cinder import utils
# Define the minimum and maximum version of the API across all of the
# REST API. The format of the version is:
# X.Y where:
#
# - X will only be changed if a significant backwards incompatible API
# change is made which affects the API as whole. That is, something
# that is only very very rarely incremented.
#
# - Y when you make any change to the API. Note that this includes
# semantic changes which may not affect the input or output formats or
# even originate in the API code layer. We are not distinguishing
# between backwards compatible and backwards incompatible changes in
# the versioning system. It must be made clear in the documentation as
# to what is a backwards compatible change and what is a backwards
# incompatible one.
#
# You must update the API version history string below with a one or
# two line description as well as update rest_api_version_history.rst
REST_API_VERSION_HISTORY = """
REST API Version History:
* 3.0 - Includes all V2 APIs and extensions. V1 API is still supported.
* 3.0 - Versions API updated to reflect beginning of microversions epoch.
"""
# The minimum and maximum versions of the API supported
# The default api version request is defined to be the
# the minimum version of the API supported.
# Explicitly using /v1 or /v2 enpoints will still work
_MIN_API_VERSION = "3.0"
_MAX_API_VERSION = "3.0"
_LEGACY_API_VERSION1 = "1.0"
_LEGACY_API_VERSION2 = "2.0"
# NOTE(cyeoh): min and max versions declared as functions so we can
# mock them for unittests. Do not use the constants directly anywhere
# else.
def min_api_version():
return APIVersionRequest(_MIN_API_VERSION)
def max_api_version():
return APIVersionRequest(_MAX_API_VERSION)
def legacy_api_version1():
return APIVersionRequest(_LEGACY_API_VERSION1)
def legacy_api_version2():
return APIVersionRequest(_LEGACY_API_VERSION2)
class APIVersionRequest(utils.ComparableMixin):
"""This class represents an API Version Request.
This class includes convenience methods for manipulation
and comparison of version numbers as needed to implement
API microversions.
"""
def __init__(self, version_string=None, experimental=False):
"""Create an API version request object."""
self._ver_major = None
self._ver_minor = None
if version_string is not None:
match = re.match(r"^([1-9]\d*)\.([1-9]\d*|0)$",
version_string)
if match:
self._ver_major = int(match.group(1))
self._ver_minor = int(match.group(2))
else:
raise exception.InvalidAPIVersionString(version=version_string)
def __str__(self):
"""Debug/Logging representation of object."""
return ("API Version Request Major: %(major)s, Minor: %(minor)s"
% {'major': self._ver_major, 'minor': self._ver_minor})
def is_null(self):
return self._ver_major is None and self._ver_minor is None
def _cmpkey(self):
"""Return the value used by ComparableMixin for rich comparisons."""
return self._ver_major, self._ver_minor
def matches_versioned_method(self, method):
"""Compares this version to that of a versioned method."""
if type(method) != versioned_method.VersionedMethod:
msg = _('An API version request must be compared '
'to a VersionedMethod object.')
raise exception.InvalidParameterValue(err=msg)
return self.matches(method.start_version,
method.end_version,
method.experimental)
def matches(self, min_version, max_version, experimental=False):
"""Compares this version to the specified min/max range.
Returns whether the version object represents a version
greater than or equal to the minimum version and less than
or equal to the maximum version.
If min_version is null then there is no minimum limit.
If max_version is null then there is no maximum limit.
If self is null then raise ValueError.
:param min_version: Minimum acceptable version.
:param max_version: Maximum acceptable version.
:param experimental: Whether to match experimental APIs.
:returns: boolean
"""
if self.is_null():
raise ValueError
if max_version.is_null() and min_version.is_null():
return True
elif max_version.is_null():
return min_version <= self
elif min_version.is_null():
return self <= max_version
else:
return min_version <= self <= max_version
def get_string(self):
"""Returns a string representation of this object.
If this method is used to create an APIVersionRequest,
the resulting object will be an equivalent request.
"""
if self.is_null():
raise ValueError
return ("%(major)s.%(minor)s" %
{'major': self._ver_major, 'minor': self._ver_minor})

View File

@ -0,0 +1,30 @@
REST API Version History
========================
This documents the changes made to the REST API with every
microversion change. The description for each version should be a
verbose one which has enough information to be suitable for use in
user documentation.
3.0
---
The 3.0 Cinder API includes all v2 core APIs existing prior to
the introduction of microversions. The /v3 URL is used to call
3.0 APIs.
This it the initial version of the Cinder API which supports
microversions.
A user can specify a header in the API request::
OpenStack-Volume-microversion: <version>
where ``<version>`` is any valid api version for this API.
If no version is specified then the API will behave as if version 3.0
was requested.
The only API change in version 3.0 is versions, i.e.
GET http://localhost:8786/, which now returns information about
3.0 and later versions and their respective /v3 endpoints.
All other 3.0 APIs are functionally identical to version 2.0.

View File

@ -0,0 +1,48 @@
# Copyright 2014 IBM Corp.
# Copyright 2015 Clinton Knight
# 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 cinder import utils
class VersionedMethod(utils.ComparableMixin):
def __init__(self, name, start_version, end_version, experimental, func):
"""Versioning information for a single method.
Minimum and maximums are inclusive.
:param name: Name of the method
:param start_version: Minimum acceptable version
:param end_version: Maximum acceptable_version
:param func: Method to call
"""
self.name = name
self.start_version = start_version
self.end_version = end_version
self.experimental = experimental
self.func = func
def __str__(self):
args = {
'name': self.name,
'start': self.start_version,
'end': self.end_version
}
return ("Version Method %(name)s: min: %(start)s, max: %(end)s" % args)
def _cmpkey(self):
"""Return the value used by ComparableMixin for rich comparisons."""
return self.start_version

View File

@ -14,6 +14,7 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import functools
import inspect import inspect
import math import math
import time import time
@ -25,9 +26,13 @@ from oslo_log import log as logging
from oslo_log import versionutils from oslo_log import versionutils
from oslo_serialization import jsonutils from oslo_serialization import jsonutils
from oslo_utils import excutils from oslo_utils import excutils
from oslo_utils import strutils
import six import six
import webob import webob
import webob.exc
from cinder.api.openstack import api_version_request as api_version
from cinder.api.openstack import versioned_method
from cinder import exception from cinder import exception
from cinder import i18n from cinder import i18n
from cinder.i18n import _, _LE, _LI from cinder.i18n import _, _LE, _LI
@ -58,12 +63,22 @@ _MEDIA_TYPE_MAP = {
} }
# name of attribute to keep version method information
VER_METHOD_ATTR = 'versioned_methods'
# Name of header used by clients to request a specific version
# of the REST API
API_VERSION_REQUEST_HEADER = 'OpenStack-Volume-microversion'
class Request(webob.Request): class Request(webob.Request):
"""Add some OpenStack API-specific logic to the base webob.Request.""" """Add some OpenStack API-specific logic to the base webob.Request."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(Request, self).__init__(*args, **kwargs) super(Request, self).__init__(*args, **kwargs)
self._resource_cache = {} self._resource_cache = {}
if not hasattr(self, 'api_version_request'):
self.api_version_request = api_version.APIVersionRequest()
def cache_resource(self, resource_to_cache, id_attribute='id', name=None): def cache_resource(self, resource_to_cache, id_attribute='id', name=None):
"""Cache the given resource. """Cache the given resource.
@ -269,6 +284,45 @@ class Request(webob.Request):
all_languages = i18n.get_available_languages() all_languages = i18n.get_available_languages()
return self.accept_language.best_match(all_languages) return self.accept_language.best_match(all_languages)
def set_api_version_request(self, url):
"""Set API version request based on the request header information.
Microversions starts with /v3, so if a client sends a request for
version 1.0 or 2.0 with the /v3 endpoint, throw an exception.
Sending a header with any microversion to a /v1 or /v2 endpoint will
be ignored.
Note that a microversion must be set for the legacy endpoints. This
will appear as 1.0 and 2.0 for /v1 and /v2.
"""
if API_VERSION_REQUEST_HEADER in self.headers and 'v3' in url:
hdr_string = self.headers[API_VERSION_REQUEST_HEADER]
# 'latest' is a special keyword which is equivalent to requesting
# the maximum version of the API supported
if hdr_string == 'latest':
self.api_version_request = api_version.max_api_version()
else:
self.api_version_request = api_version.APIVersionRequest(
hdr_string)
# Check that the version requested is within the global
# minimum/maximum of supported API versions
if not self.api_version_request.matches(
api_version.min_api_version(),
api_version.max_api_version()):
raise exception.InvalidGlobalAPIVersion(
req_ver=self.api_version_request.get_string(),
min_ver=api_version.min_api_version().get_string(),
max_ver=api_version.max_api_version().get_string())
else:
if 'v1' in url:
self.api_version_request = api_version.legacy_api_version1()
elif 'v2' in url:
self.api_version_request = api_version.legacy_api_version2()
else:
self.api_version_request = api_version.APIVersionRequest(
api_version._MIN_API_VERSION)
class ActionDispatcher(object): class ActionDispatcher(object):
"""Maps method name to local methods through action name.""" """Maps method name to local methods through action name."""
@ -276,7 +330,7 @@ class ActionDispatcher(object):
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
"""Find and call local method.""" """Find and call local method."""
action = kwargs.pop('action', 'default') action = kwargs.pop('action', 'default')
action_method = getattr(self, str(action), self.default) action_method = getattr(self, six.text_type(action), self.default)
return action_method(*args, **kwargs) return action_method(*args, **kwargs)
def default(self, data): def default(self, data):
@ -571,7 +625,7 @@ class ResponseObject(object):
optional. optional.
""" """
def __init__(self, obj, code=None, **serializers): def __init__(self, obj, code=None, headers=None, **serializers):
"""Binds serializers with an object. """Binds serializers with an object.
Takes keyword arguments akin to the @serializer() decorator Takes keyword arguments akin to the @serializer() decorator
@ -584,7 +638,7 @@ class ResponseObject(object):
self.serializers = serializers self.serializers = serializers
self._default_code = 200 self._default_code = 200
self._code = code self._code = code
self._headers = {} self._headers = headers or {}
self.serializer = None self.serializer = None
self.media_type = None self.media_type = None
@ -677,8 +731,8 @@ class ResponseObject(object):
response = webob.Response() response = webob.Response()
response.status_int = self.code response.status_int = self.code
for hdr, value in self._headers.items(): for hdr, value in self._headers.items():
response.headers[hdr] = value response.headers[hdr] = six.text_type(value)
response.headers['Content-Type'] = content_type response.headers['Content-Type'] = six.text_type(content_type)
if self.obj is not None: if self.obj is not None:
body = serializer.serialize(self.obj) body = serializer.serialize(self.obj)
if isinstance(body, six.text_type): if isinstance(body, six.text_type):
@ -743,10 +797,13 @@ class ResourceExceptionHandler(object):
return True return True
if isinstance(ex_value, exception.NotAuthorized): if isinstance(ex_value, exception.NotAuthorized):
raise Fault(webob.exc.HTTPForbidden(explanation=ex_value.msg)) msg = six.text_type(ex_value)
raise Fault(webob.exc.HTTPForbidden(explanation=msg))
elif isinstance(ex_value, exception.VersionNotFoundForAPIMethod):
raise
elif isinstance(ex_value, exception.Invalid): elif isinstance(ex_value, exception.Invalid):
raise Fault(exception.ConvertedException( raise Fault(exception.ConvertedException(
code=ex_value.code, explanation=ex_value.msg)) code=ex_value.code, explanation=six.text_type(ex_value)))
elif isinstance(ex_value, TypeError): elif isinstance(ex_value, TypeError):
exc_info = (ex_type, ex_value, ex_traceback) exc_info = (ex_type, ex_value, ex_traceback)
LOG.error(_LE( LOG.error(_LE(
@ -754,10 +811,10 @@ class ResourceExceptionHandler(object):
ex_value, exc_info=exc_info) ex_value, exc_info=exc_info)
raise Fault(webob.exc.HTTPBadRequest()) raise Fault(webob.exc.HTTPBadRequest())
elif isinstance(ex_value, Fault): elif isinstance(ex_value, Fault):
LOG.info(_LI("Fault thrown: %s"), ex_value) LOG.info(_LI("Fault thrown: %s"), six.text_type(ex_value))
raise ex_value raise ex_value
elif isinstance(ex_value, webob.exc.HTTPException): elif isinstance(ex_value, webob.exc.HTTPException):
LOG.info(_LI("HTTP exception thrown: %s"), ex_value) LOG.info(_LI("HTTP exception thrown: %s"), six.text_type(ex_value))
raise Fault(ex_value) raise Fault(ex_value)
# We didn't handle the exception # We didn't handle the exception
@ -778,6 +835,7 @@ class Resource(wsgi.Application):
Exceptions derived from webob.exc.HTTPException will be automatically Exceptions derived from webob.exc.HTTPException will be automatically
wrapped in Fault() to provide API friendly error responses. wrapped in Fault() to provide API friendly error responses.
""" """
support_api_request_version = True
def __init__(self, controller, action_peek=None, **deserializers): def __init__(self, controller, action_peek=None, **deserializers):
"""Initialize Resource. """Initialize Resource.
@ -943,6 +1001,11 @@ class Resource(wsgi.Application):
with ResourceExceptionHandler(): with ResourceExceptionHandler():
response = ext(req=request, resp_obj=resp_obj, response = ext(req=request, resp_obj=resp_obj,
**action_args) **action_args)
except exception.VersionNotFoundForAPIMethod:
# If an attached extension (@wsgi.extends) for the
# method has no version match its not an error. We
# just don't run the extends code
continue
except Fault as ex: except Fault as ex:
response = ex response = ex
@ -960,6 +1023,17 @@ class Resource(wsgi.Application):
{"method": request.method, {"method": request.method,
"url": request.url}) "url": request.url})
if self.support_api_request_version:
# Set the version of the API requested based on the header
try:
request.set_api_version_request(request.url)
except exception.InvalidAPIVersionString as e:
return Fault(webob.exc.HTTPBadRequest(
explanation=six.text_type(e)))
except exception.InvalidGlobalAPIVersion as e:
return Fault(webob.exc.HTTPNotAcceptable(
explanation=six.text_type(e)))
# Identify the action, its arguments, and the requested # Identify the action, its arguments, and the requested
# content type # content type
action_args = self.get_action_args(request.environ) action_args = self.get_action_args(request.environ)
@ -992,6 +1066,16 @@ class Resource(wsgi.Application):
msg = _("Malformed request body") msg = _("Malformed request body")
return Fault(webob.exc.HTTPBadRequest(explanation=msg)) return Fault(webob.exc.HTTPBadRequest(explanation=msg))
if body:
msg = ("Action: '%(action)s', calling method: %(meth)s, body: "
"%(body)s") % {'action': action,
'body': six.text_type(body),
'meth': six.text_type(meth)}
LOG.debug(strutils.mask_password(msg))
else:
LOG.debug("Calling method '%(meth)s'",
{'meth': six.text_type(meth)})
# Now, deserialize the request body... # Now, deserialize the request body...
try: try:
if content_type: if content_type:
@ -1029,7 +1113,7 @@ class Resource(wsgi.Application):
# No exceptions; convert action_result into a # No exceptions; convert action_result into a
# ResponseObject # ResponseObject
resp_obj = None resp_obj = None
if type(action_result) is dict or action_result is None: if isinstance(action_result, dict) or action_result is None:
resp_obj = ResponseObject(action_result) resp_obj = ResponseObject(action_result)
elif isinstance(action_result, ResponseObject): elif isinstance(action_result, ResponseObject):
resp_obj = action_result resp_obj = action_result
@ -1063,6 +1147,21 @@ class Resource(wsgi.Application):
LOG.info(msg, msg_dict) LOG.info(msg, msg_dict)
if hasattr(response, 'headers'):
for hdr, val in response.headers.items():
# Headers must be utf-8 strings
try:
# python 2.x
response.headers[hdr] = val.encode('utf-8')
except Exception:
# python 3.x
response.headers[hdr] = six.text_type(val)
if not request.api_version_request.is_null():
response.headers[API_VERSION_REQUEST_HEADER] = (
request.api_version_request.get_string())
response.headers['Vary'] = API_VERSION_REQUEST_HEADER
return response return response
def get_method(self, request, action, content_type, body): def get_method(self, request, action, content_type, body):
@ -1101,7 +1200,13 @@ class Resource(wsgi.Application):
def dispatch(self, method, request, action_args): def dispatch(self, method, request, action_args):
"""Dispatch a call to the action-specific method.""" """Dispatch a call to the action-specific method."""
try:
return method(req=request, **action_args) return method(req=request, **action_args)
except exception.VersionNotFoundForAPIMethod:
# We deliberately don't return any message information
# about the exception to the user so it looks as if
# the method is simply not implemented.
return Fault(webob.exc.HTTPNotFound())
def action(name): def action(name):
@ -1161,9 +1266,22 @@ class ControllerMetaclass(type):
# Find all actions # Find all actions
actions = {} actions = {}
extensions = [] extensions = []
versioned_methods = None
# start with wsgi actions from base classes # start with wsgi actions from base classes
for base in bases: for base in bases:
actions.update(getattr(base, 'wsgi_actions', {})) actions.update(getattr(base, 'wsgi_actions', {}))
if base.__name__ == "Controller":
# NOTE(cyeoh): This resets the VER_METHOD_ATTR attribute
# between API controller class creations. This allows us
# to use a class decorator on the API methods that doesn't
# require naming explicitly what method is being versioned as
# it can be implicit based on the method decorated. It is a bit
# ugly.
if VER_METHOD_ATTR in base.__dict__:
versioned_methods = getattr(base, VER_METHOD_ATTR)
delattr(base, VER_METHOD_ATTR)
for key, value in cls_dict.items(): for key, value in cls_dict.items():
if not callable(value): if not callable(value):
continue continue
@ -1175,6 +1293,8 @@ class ControllerMetaclass(type):
# Add the actions and extensions to the class dict # Add the actions and extensions to the class dict
cls_dict['wsgi_actions'] = actions cls_dict['wsgi_actions'] = actions
cls_dict['wsgi_extensions'] = extensions cls_dict['wsgi_extensions'] = extensions
if versioned_methods:
cls_dict[VER_METHOD_ATTR] = versioned_methods
return super(ControllerMetaclass, mcs).__new__(mcs, name, bases, return super(ControllerMetaclass, mcs).__new__(mcs, name, bases,
cls_dict) cls_dict)
@ -1195,6 +1315,99 @@ class Controller(object):
else: else:
self._view_builder = None self._view_builder = None
def __getattribute__(self, key):
def version_select(*args, **kwargs):
"""Select and call the matching version of the specified method.
Look for the method which matches the name supplied and version
constraints and calls it with the supplied arguments.
:returns: Returns the result of the method called
:raises: VersionNotFoundForAPIMethod if there is no method which
matches the name and version constraints
"""
# The first arg to all versioned methods is always the request
# object. The version for the request is attached to the
# request object
if len(args) == 0:
version_request = kwargs['req'].api_version_request
else:
version_request = args[0].api_version_request
func_list = self.versioned_methods[key]
for func in func_list:
if version_request.matches_versioned_method(func):
# Update the version_select wrapper function so
# other decorator attributes like wsgi.response
# are still respected.
functools.update_wrapper(version_select, func.func)
return func.func(self, *args, **kwargs)
# No version match
raise exception.VersionNotFoundForAPIMethod(
version=version_request)
try:
version_meth_dict = object.__getattribute__(self, VER_METHOD_ATTR)
except AttributeError:
# No versioning on this class
return object.__getattribute__(self, key)
if (version_meth_dict and key in
object.__getattribute__(self, VER_METHOD_ATTR)):
return version_select
return object.__getattribute__(self, key)
# NOTE(cyeoh): This decorator MUST appear first (the outermost
# decorator) on an API method for it to work correctly
@classmethod
def api_version(cls, min_ver, max_ver=None, experimental=False):
"""Decorator for versioning API methods.
Add the decorator to any method which takes a request object
as the first parameter and belongs to a class which inherits from
wsgi.Controller.
:param min_ver: string representing minimum version
:param max_ver: optional string representing maximum version
"""
def decorator(f):
obj_min_ver = api_version.APIVersionRequest(min_ver)
if max_ver:
obj_max_ver = api_version.APIVersionRequest(max_ver)
else:
obj_max_ver = api_version.APIVersionRequest()
# Add to list of versioned methods registered
func_name = f.__name__
new_func = versioned_method.VersionedMethod(
func_name, obj_min_ver, obj_max_ver, experimental, f)
func_dict = getattr(cls, VER_METHOD_ATTR, {})
if not func_dict:
setattr(cls, VER_METHOD_ATTR, func_dict)
func_list = func_dict.get(func_name, [])
if not func_list:
func_dict[func_name] = func_list
func_list.append(new_func)
# Ensure the list is sorted by minimum version (reversed)
# so later when we work through the list in order we find
# the method which has the latest version which supports
# the version requested.
# TODO(cyeoh): Add check to ensure that there are no overlapping
# ranges of valid versions as that is ambiguous
func_list.sort(reverse=True)
return f
return decorator
@staticmethod @staticmethod
def is_valid_body(body, entity_name): def is_valid_body(body, entity_name):
if not (body and entity_name in body): if not (body and entity_name in body):
@ -1330,6 +1543,11 @@ class Fault(webob.exc.HTTPException):
if retry: if retry:
fault_data[fault_name]['retryAfter'] = retry fault_data[fault_name]['retryAfter'] = retry
if not req.api_version_request.is_null():
self.wrapped_exc.headers[API_VERSION_REQUEST_HEADER] = (
req.api_version_request.get_string())
self.wrapped_exc.headers['Vary'] = API_VERSION_REQUEST_HEADER
# 'code' is an attribute on the fault tag itself # 'code' is an attribute on the fault tag itself
metadata = {'attributes': {fault_name: 'code'}} metadata = {'attributes': {fault_name: 'code'}}

View File

@ -43,7 +43,7 @@ class APIRouter(cinder.api.openstack.APIRouter):
self.resources['versions'] = versions.create_resource() self.resources['versions'] = versions.create_resource()
mapper.connect("versions", "/", mapper.connect("versions", "/",
controller=self.resources['versions'], controller=self.resources['versions'],
action='show') action='index')
mapper.redirect("", "/") mapper.redirect("", "/")

View File

@ -43,7 +43,7 @@ class APIRouter(cinder.api.openstack.APIRouter):
self.resources['versions'] = versions.create_resource() self.resources['versions'] = versions.create_resource()
mapper.connect("versions", "/", mapper.connect("versions", "/",
controller=self.resources['versions'], controller=self.resources['versions'],
action='show') action='index')
mapper.redirect("", "/") mapper.redirect("", "/")

View File

@ -35,20 +35,7 @@ from cinder import volume as cinder_volume
from cinder.volume import utils as volume_utils from cinder.volume import utils as volume_utils
from cinder.volume import volume_types from cinder.volume import volume_types
query_volume_filters_opt = cfg.ListOpt('query_volume_filters',
default=['name', 'status', 'metadata',
'availability_zone',
'bootable'],
help="Volume filter options which "
"non-admin user could use to "
"query volumes. Default values "
"are: ['name', 'status', "
"'metadata', 'availability_zone',"
"'bootable']")
CONF = cfg.CONF CONF = cfg.CONF
CONF.register_opt(query_volume_filters_opt)
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
SCHEDULER_HINTS_NAMESPACE =\ SCHEDULER_HINTS_NAMESPACE =\

View File

99
cinder/api/v3/router.py Normal file
View File

@ -0,0 +1,99 @@
# Copyright 2011 OpenStack Foundation
# Copyright 2011 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# 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.
"""
WSGI middleware for OpenStack Volume API.
"""
from oslo_log import log as logging
from cinder.api import extensions
import cinder.api.openstack
from cinder.api.v2 import limits
from cinder.api.v2 import snapshot_metadata
from cinder.api.v2 import snapshots
from cinder.api.v2 import types
from cinder.api.v2 import volume_metadata
from cinder.api.v2 import volumes
from cinder.api import versions
LOG = logging.getLogger(__name__)
class APIRouter(cinder.api.openstack.APIRouter):
"""Routes requests on the API to the appropriate controller and method."""
ExtensionManager = extensions.ExtensionManager
def _setup_routes(self, mapper, ext_mgr):
self.resources['versions'] = versions.create_resource()
mapper.connect("versions", "/",
controller=self.resources['versions'],
action='index')
mapper.redirect("", "/")
self.resources['volumes'] = volumes.create_resource(ext_mgr)
mapper.resource("volume", "volumes",
controller=self.resources['volumes'],
collection={'detail': 'GET'},
member={'action': 'POST'})
self.resources['types'] = types.create_resource()
mapper.resource("type", "types",
controller=self.resources['types'],
member={'action': 'POST'})
self.resources['snapshots'] = snapshots.create_resource(ext_mgr)
mapper.resource("snapshot", "snapshots",
controller=self.resources['snapshots'],
collection={'detail': 'GET'},
member={'action': 'POST'})
self.resources['limits'] = limits.create_resource()
mapper.resource("limit", "limits",
controller=self.resources['limits'])
self.resources['snapshot_metadata'] = \
snapshot_metadata.create_resource()
snapshot_metadata_controller = self.resources['snapshot_metadata']
mapper.resource("snapshot_metadata", "metadata",
controller=snapshot_metadata_controller,
parent_resource=dict(member_name='snapshot',
collection_name='snapshots'))
mapper.connect("metadata",
"/{project_id}/snapshots/{snapshot_id}/metadata",
controller=snapshot_metadata_controller,
action='update_all',
conditions={"method": ['PUT']})
self.resources['volume_metadata'] = \
volume_metadata.create_resource()
volume_metadata_controller = self.resources['volume_metadata']
mapper.resource("volume_metadata", "metadata",
controller=volume_metadata_controller,
parent_resource=dict(member_name='volume',
collection_name='volumes'))
mapper.connect("metadata",
"/{project_id}/volumes/{volume_id}/metadata",
controller=volume_metadata_controller,
action='update_all',
conditions={"method": ['PUT']})

View File

@ -1,4 +1,5 @@
# Copyright 2010 OpenStack Foundation # Copyright 2010 OpenStack Foundation
# Copyright 2015 Clinton Knight
# All Rights Reserved. # All Rights Reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may # Licensed under the Apache License, Version 2.0 (the "License"); you may
@ -14,11 +15,15 @@
# under the License. # under the License.
import copy
import datetime import datetime
from lxml import etree from lxml import etree
from oslo_config import cfg from oslo_config import cfg
from cinder.api import extensions
from cinder.api import openstack
from cinder.api.openstack import api_version_request
from cinder.api.openstack import wsgi from cinder.api.openstack import wsgi
from cinder.api.views import versions as views_versions from cinder.api.views import versions as views_versions
from cinder.api import xmlutil from cinder.api import xmlutil
@ -26,67 +31,115 @@ from cinder.api import xmlutil
CONF = cfg.CONF CONF = cfg.CONF
_LINKS = [{
_KNOWN_VERSIONS = {
"v2.0": {
"id": "v2.0",
"status": "CURRENT",
"updated": "2012-11-21T11:33:21Z",
"links": [
{
"rel": "describedby", "rel": "describedby",
"type": "text/html", "type": "text/html",
"href": "http://docs.openstack.org/", "href": "http://docs.openstack.org/",
}]
_MEDIA_TYPES = [{
"base":
"application/json",
"type":
"application/vnd.openstack.volume+json;version=1",
}, },
], {"base":
"media-types": [ "application/xml",
{ "type":
"base": "application/xml", "application/vnd.openstack.volume+xml;version=1",
"type": "application/vnd.openstack.volume+xml;version=1",
},
{
"base": "application/json",
"type": "application/vnd.openstack.volume+json;version=1",
}
],
}, },
]
_KNOWN_VERSIONS = {
"v1.0": { "v1.0": {
"id": "v1.0", "id": "v1.0",
"status": "SUPPORTED", "status": "SUPPORTED",
"version": "",
"min_version": "",
"updated": "2014-06-28T12:20:21Z", "updated": "2014-06-28T12:20:21Z",
"links": [ "links": _LINKS,
{ "media-types": _MEDIA_TYPES,
"rel": "describedby",
"type": "text/html",
"href": "http://docs.openstack.org/",
}, },
], "v2.0": {
"media-types": [ "id": "v2.0",
{ "status": "SUPPORTED",
"base": "application/xml", "version": "",
"type": "application/vnd.openstack.volume+xml;version=1", "min_version": "",
"updated": "2014-06-28T12:20:21Z",
"links": _LINKS,
"media-types": _MEDIA_TYPES,
},
"v3.0": {
"id": "v3.0",
"status": "CURRENT",
"version": api_version_request._MAX_API_VERSION,
"min_version": api_version_request._MIN_API_VERSION,
"updated": "2016-02-08T12:20:21Z",
"links": _LINKS,
"media-types": _MEDIA_TYPES,
}, },
{
"base": "application/json",
"type": "application/vnd.openstack.volume+json;version=1",
}
],
}
} }
def get_supported_versions(): class Versions(openstack.APIRouter):
versions = {} """Route versions requests."""
if CONF.enable_v1_api: ExtensionManager = extensions.ExtensionManager
versions['v1.0'] = _KNOWN_VERSIONS['v1.0']
if CONF.enable_v2_api:
versions['v2.0'] = _KNOWN_VERSIONS['v2.0']
return versions def _setup_routes(self, mapper, ext_mgr):
self.resources['versions'] = create_resource()
mapper.connect('versions', '/',
controller=self.resources['versions'],
action='all')
mapper.redirect('', '/')
class VersionsController(wsgi.Controller):
def __init__(self):
super(VersionsController, self).__init__(None)
@wsgi.Controller.api_version('1.0')
def index(self, req): # pylint: disable=E0102
"""Return versions supported prior to the microversions epoch."""
builder = views_versions.get_view_builder(req)
known_versions = copy.deepcopy(_KNOWN_VERSIONS)
known_versions.pop('v2.0')
known_versions.pop('v3.0')
return builder.build_versions(known_versions)
@wsgi.Controller.api_version('2.0') # noqa
def index(self, req): # pylint: disable=E0102
"""Return versions supported prior to the microversions epoch."""
builder = views_versions.get_view_builder(req)
known_versions = copy.deepcopy(_KNOWN_VERSIONS)
known_versions.pop('v1.0')
known_versions.pop('v3.0')
return builder.build_versions(known_versions)
@wsgi.Controller.api_version('3.0') # noqa
def index(self, req): # pylint: disable=E0102
"""Return versions supported after the start of microversions."""
builder = views_versions.get_view_builder(req)
known_versions = copy.deepcopy(_KNOWN_VERSIONS)
known_versions.pop('v1.0')
known_versions.pop('v2.0')
return builder.build_versions(known_versions)
# NOTE (cknight): Calling the versions API without
# /v1, /v2, or /v3 in the URL will lead to this unversioned
# method, which should always return info about all
# available versions.
@wsgi.response(300)
def all(self, req):
"""Return all known versions."""
builder = views_versions.get_view_builder(req)
known_versions = copy.deepcopy(_KNOWN_VERSIONS)
return builder.build_versions(known_versions)
class MediaTypesTemplateElement(xmlutil.TemplateElement): class MediaTypesTemplateElement(xmlutil.TemplateElement):
def will_render(self, datum): def will_render(self, datum):
return 'media-types' in datum return 'media-types' in datum
@ -110,6 +163,7 @@ version_nsmap = {None: xmlutil.XMLNS_COMMON_V10, 'atom': xmlutil.XMLNS_ATOM}
class VersionTemplate(xmlutil.TemplateBuilder): class VersionTemplate(xmlutil.TemplateBuilder):
def construct(self): def construct(self):
root = xmlutil.TemplateElement('version', selector='version') root = xmlutil.TemplateElement('version', selector='version')
make_version(root) make_version(root)
@ -117,6 +171,7 @@ class VersionTemplate(xmlutil.TemplateBuilder):
class VersionsTemplate(xmlutil.TemplateBuilder): class VersionsTemplate(xmlutil.TemplateBuilder):
def construct(self): def construct(self):
root = xmlutil.TemplateElement('versions') root = xmlutil.TemplateElement('versions')
elem = xmlutil.SubTemplateElement(root, 'version', selector='versions') elem = xmlutil.SubTemplateElement(root, 'version', selector='versions')
@ -125,6 +180,7 @@ class VersionsTemplate(xmlutil.TemplateBuilder):
class ChoicesTemplate(xmlutil.TemplateBuilder): class ChoicesTemplate(xmlutil.TemplateBuilder):
def construct(self): def construct(self):
root = xmlutil.TemplateElement('choices') root = xmlutil.TemplateElement('choices')
elem = xmlutil.SubTemplateElement(root, 'version', selector='choices') elem = xmlutil.SubTemplateElement(root, 'version', selector='choices')
@ -209,6 +265,7 @@ class AtomSerializer(wsgi.XMLDictSerializer):
class VersionsAtomSerializer(AtomSerializer): class VersionsAtomSerializer(AtomSerializer):
def default(self, data): def default(self, data):
versions = data['versions'] versions = data['versions']
feed_id = self._get_base_url(versions[0]['links'][0]['href']) feed_id = self._get_base_url(versions[0]['links'][0]['href'])
@ -217,6 +274,7 @@ class VersionsAtomSerializer(AtomSerializer):
class VersionAtomSerializer(AtomSerializer): class VersionAtomSerializer(AtomSerializer):
def default(self, data): def default(self, data):
version = data['version'] version = data['version']
feed_id = version['links'][0]['href'] feed_id = version['links'][0]['href']
@ -224,46 +282,5 @@ class VersionAtomSerializer(AtomSerializer):
return self._to_xml(feed) return self._to_xml(feed)
class Versions(wsgi.Resource):
def __init__(self):
super(Versions, self).__init__(None)
@wsgi.serializers(xml=VersionsTemplate,
atom=VersionsAtomSerializer)
def index(self, req):
"""Return all versions."""
builder = views_versions.get_view_builder(req)
return builder.build_versions(get_supported_versions())
@wsgi.serializers(xml=ChoicesTemplate)
@wsgi.response(300)
def multi(self, req):
"""Return multiple choices."""
builder = views_versions.get_view_builder(req)
return builder.build_choices(get_supported_versions(), req)
def get_action_args(self, request_environment):
"""Parse dictionary created by routes library."""
args = {}
if request_environment['PATH_INFO'] == '/':
args['action'] = 'index'
else:
args['action'] = 'multi'
return args
class VolumeVersion(object):
@wsgi.serializers(xml=VersionTemplate,
atom=VersionAtomSerializer)
def show(self, req):
builder = views_versions.get_view_builder(req)
if 'v1' in builder.base_url:
return builder.build_version(_KNOWN_VERSIONS['v1.0'])
else:
return builder.build_version(_KNOWN_VERSIONS['v2.0'])
def create_resource(): def create_resource():
return wsgi.Resource(VolumeVersion()) return wsgi.Resource(VersionsController())

View File

@ -1,4 +1,5 @@
# Copyright 2010-2011 OpenStack Foundation # Copyright 2010-2011 OpenStack Foundation
# Copyright 2015 Clinton Knight
# All Rights Reserved. # All Rights Reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may # Licensed under the Apache License, Version 2.0 (the "License"); you may
@ -14,9 +15,10 @@
# under the License. # under the License.
import copy import copy
import os import re
from oslo_config import cfg from oslo_config import cfg
from six.moves import urllib
versions_opts = [ versions_opts = [
@ -45,57 +47,32 @@ class ViewBuilder(object):
""" """
self.base_url = base_url self.base_url = base_url
def build_choices(self, VERSIONS, req):
version_objs = []
for version in VERSIONS:
version = VERSIONS[version]
version_objs.append({
"id": version['id'],
"status": version['status'],
"links": [{"rel": "self",
"href": self.generate_href(version['id'],
req.path), }, ],
"media-types": version['media-types'], })
return dict(choices=version_objs)
def build_versions(self, versions): def build_versions(self, versions):
version_objs = [] views = [self._build_version(versions[key])
for version in sorted(versions.keys()): for key in sorted(list(versions.keys()))]
version = versions[version] return dict(versions=views)
version_objs.append({
"id": version['id'],
"status": version['status'],
"updated": version['updated'],
"links": self._build_links(version), })
return dict(versions=version_objs) def _build_version(self, version):
view = copy.deepcopy(version)
def build_version(self, version): view['links'] = self._build_links(version)
reval = copy.deepcopy(version) return view
reval['links'].insert(0, {
"rel": "self",
"href": self.base_url.rstrip('/') + '/', })
return dict(version=reval)
def _build_links(self, version_data): def _build_links(self, version_data):
"""Generate a container of links that refer to the provided version.""" """Generate a container of links that refer to the provided version."""
href = self.generate_href(version_data['id']) links = copy.deepcopy(version_data.get('links', {}))
version_num = version_data["id"].split('.')[0]
links = [{'rel': 'self', links.append({'rel': 'self',
'href': href, }, ] 'href': self._generate_href(version=version_num)})
return links return links
def generate_href(self, version, path=None): def _generate_href(self, version='v3', path=None):
"""Create an url that refers to a specific version_number.""" """Create a URL that refers to a specific version_number."""
if version.find('v1.') == 0: base_url = self._get_base_url_without_version()
version_number = 'v1' href = urllib.parse.urljoin(base_url, version).rstrip('/') + '/'
else:
version_number = 'v2'
if path: if path:
path = path.strip('/') href += path.lstrip('/')
return os.path.join(self.base_url, version_number, path) return href
else:
return os.path.join(self.base_url, version_number) + '/' def _get_base_url_without_version(self):
"""Get the base URL with out the /v1 suffix."""
return re.sub('v[1-9]+/?$', '', self.base_url)

View File

@ -103,7 +103,10 @@ global_opts = [
help=_("DEPRECATED: Deploy v1 of the Cinder API.")), help=_("DEPRECATED: Deploy v1 of the Cinder API.")),
cfg.BoolOpt('enable_v2_api', cfg.BoolOpt('enable_v2_api',
default=True, default=True,
help=_("Deploy v2 of the Cinder API.")), help=_("DEPRECATED: Deploy v2 of the Cinder API.")),
cfg.BoolOpt('enable_v3_api',
default=True,
help=_("Deploy v3 of the Cinder API.")),
cfg.BoolOpt('api_rate_limit', cfg.BoolOpt('api_rate_limit',
default=True, default=True,
help='Enables or disables rate limit of the API.'), help='Enables or disables rate limit of the API.'),

View File

@ -243,6 +243,20 @@ class InvalidUUID(Invalid):
message = _("Expected a uuid but received %(uuid)s.") message = _("Expected a uuid but received %(uuid)s.")
class InvalidAPIVersionString(Invalid):
message = _("API Version String %(version)s is of invalid format. Must "
"be of format MajorNum.MinorNum.")
class VersionNotFoundForAPIMethod(Invalid):
message = _("API version %(version)s is not supported on this method.")
class InvalidGlobalAPIVersion(Invalid):
message = _("Version %(req_ver)s is not supported by the API. Minimum "
"is %(min_ver)s and maximum is %(max_ver)s.")
class APIException(CinderException): class APIException(CinderException):
message = _("Error while requesting %(service)s API.") message = _("Error while requesting %(service)s API.")

View File

@ -17,7 +17,6 @@ import itertools
from cinder.api import common as cinder_api_common from cinder.api import common as cinder_api_common
from cinder.api.middleware import auth as cinder_api_middleware_auth from cinder.api.middleware import auth as cinder_api_middleware_auth
from cinder.api.middleware import sizelimit as cinder_api_middleware_sizelimit from cinder.api.middleware import sizelimit as cinder_api_middleware_sizelimit
from cinder.api.v2 import volumes as cinder_api_v2_volumes
from cinder.api.views import versions as cinder_api_views_versions from cinder.api.views import versions as cinder_api_views_versions
from cinder.backup import chunkeddriver as cinder_backup_chunkeddriver from cinder.backup import chunkeddriver as cinder_backup_chunkeddriver
from cinder.backup import driver as cinder_backup_driver from cinder.backup import driver as cinder_backup_driver
@ -333,7 +332,6 @@ def list_opts():
cinder_volume_drivers_hpe_hpe3parcommon.hpe3par_opts, cinder_volume_drivers_hpe_hpe3parcommon.hpe3par_opts,
cinder_volume_drivers_datera.d_opts, cinder_volume_drivers_datera.d_opts,
cinder_volume_drivers_blockdevice.volume_opts, cinder_volume_drivers_blockdevice.volume_opts,
[cinder_api_v2_volumes.query_volume_filters_opt],
cinder_volume_drivers_quobyte.volume_opts, cinder_volume_drivers_quobyte.volume_opts,
cinder_volume_drivers_vzstorage.vzstorage_opts, cinder_volume_drivers_vzstorage.vzstorage_opts,
cinder_volume_drivers_nfs.nfs_opts, cinder_volume_drivers_nfs.nfs_opts,

View File

@ -24,6 +24,7 @@ import webob.request
from cinder.api.middleware import auth from cinder.api.middleware import auth
from cinder.api.middleware import fault from cinder.api.middleware import fault
from cinder.api.openstack import api_version_request as api_version
from cinder.api.openstack import wsgi as os_wsgi from cinder.api.openstack import wsgi as os_wsgi
from cinder.api import urlmap from cinder.api import urlmap
from cinder.api.v2 import limits from cinder.api.v2 import limits
@ -78,7 +79,7 @@ def wsgi_app(inner_app_v2=None, fake_auth=True, fake_auth_context=None,
mapper = urlmap.URLMap() mapper = urlmap.URLMap()
mapper['/v2'] = api_v2 mapper['/v2'] = api_v2
mapper['/'] = fault.FaultWrapper(versions.Versions()) mapper['/'] = fault.FaultWrapper(versions.VersionsController())
return mapper return mapper
@ -106,17 +107,21 @@ class HTTPRequest(webob.Request):
@classmethod @classmethod
def blank(cls, *args, **kwargs): def blank(cls, *args, **kwargs):
if args is not None: if args is not None:
if args[0].find('v1') == 0: if 'v1' in args[0]:
kwargs['base_url'] = 'http://localhost/v1' kwargs['base_url'] = 'http://localhost/v1'
else: if 'v2' in args[0]:
kwargs['base_url'] = 'http://localhost/v2' kwargs['base_url'] = 'http://localhost/v2'
if 'v3' in args[0]:
kwargs['base_url'] = 'http://localhost/v3'
use_admin_context = kwargs.pop('use_admin_context', False) use_admin_context = kwargs.pop('use_admin_context', False)
version = kwargs.pop('version', api_version._MIN_API_VERSION)
out = os_wsgi.Request.blank(*args, **kwargs) out = os_wsgi.Request.blank(*args, **kwargs)
out.environ['cinder.context'] = FakeRequestContext( out.environ['cinder.context'] = FakeRequestContext(
'fake_user', 'fake_user',
'fakeproject', 'fakeproject',
is_admin=use_admin_context) is_admin=use_admin_context)
out.api_version_request = api_version.APIVersionRequest(version)
return out return out

View File

@ -0,0 +1,149 @@
# Copyright 2014 IBM Corp.
# Copyright 2015 Clinton Knight
# 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.
import ddt
import six
from cinder.api.openstack import api_version_request
from cinder import exception
from cinder import test
@ddt.ddt
class APIVersionRequestTests(test.TestCase):
def test_init(self):
result = api_version_request.APIVersionRequest()
self.assertIsNone(result._ver_major)
self.assertIsNone(result._ver_minor)
def test_min_version(self):
self.assertEqual(
api_version_request.APIVersionRequest(
api_version_request._MIN_API_VERSION),
api_version_request.min_api_version())
def test_max_api_version(self):
self.assertEqual(
api_version_request.APIVersionRequest(
api_version_request._MAX_API_VERSION),
api_version_request.max_api_version())
@ddt.data(
('1.1', 1, 1),
('2.10', 2, 10),
('5.234', 5, 234),
('12.5', 12, 5),
('2.0', 2, 0),
('2.200', 2, 200)
)
@ddt.unpack
def test_valid_version_strings(self, version_string, major, minor):
request = api_version_request.APIVersionRequest(version_string)
self.assertEqual(major, request._ver_major)
self.assertEqual(minor, request._ver_minor)
def test_null_version(self):
v = api_version_request.APIVersionRequest()
self.assertTrue(v.is_null())
@ddt.data('2', '200', '2.1.4', '200.23.66.3', '5 .3', '5. 3',
'5.03', '02.1', '2.001', '', ' 2.1', '2.1 ')
def test_invalid_version_strings(self, version_string):
self.assertRaises(exception.InvalidAPIVersionString,
api_version_request.APIVersionRequest,
version_string)
def test_cmpkey(self):
request = api_version_request.APIVersionRequest('1.2')
self.assertEqual((1, 2), request._cmpkey())
def test_version_comparisons(self):
v1 = api_version_request.APIVersionRequest('2.0')
v2 = api_version_request.APIVersionRequest('2.5')
v3 = api_version_request.APIVersionRequest('5.23')
v4 = api_version_request.APIVersionRequest('2.0')
v_null = api_version_request.APIVersionRequest()
self.assertTrue(v1 < v2)
self.assertTrue(v1 <= v2)
self.assertTrue(v3 > v2)
self.assertTrue(v3 >= v2)
self.assertTrue(v1 != v2)
self.assertTrue(v1 == v4)
self.assertTrue(v1 != v_null)
self.assertTrue(v_null == v_null)
self.assertFalse(v1 == '2.0')
def test_version_matches(self):
v1 = api_version_request.APIVersionRequest('2.0')
v2 = api_version_request.APIVersionRequest('2.5')
v3 = api_version_request.APIVersionRequest('2.45')
v4 = api_version_request.APIVersionRequest('3.3')
v5 = api_version_request.APIVersionRequest('3.23')
v6 = api_version_request.APIVersionRequest('2.0')
v7 = api_version_request.APIVersionRequest('3.3')
v8 = api_version_request.APIVersionRequest('4.0')
v_null = api_version_request.APIVersionRequest()
self.assertTrue(v2.matches(v1, v3))
self.assertTrue(v2.matches(v1, v_null))
self.assertTrue(v1.matches(v6, v2))
self.assertTrue(v4.matches(v2, v7))
self.assertTrue(v4.matches(v_null, v7))
self.assertTrue(v4.matches(v_null, v8))
self.assertFalse(v1.matches(v2, v3))
self.assertFalse(v5.matches(v2, v4))
self.assertFalse(v2.matches(v3, v1))
self.assertTrue(v1.matches(v_null, v_null))
self.assertRaises(ValueError, v_null.matches, v1, v3)
def test_matches_versioned_method(self):
request = api_version_request.APIVersionRequest('2.0')
self.assertRaises(exception.InvalidParameterValue,
request.matches_versioned_method,
'fake_method')
def test_get_string(self):
v1_string = '3.23'
v1 = api_version_request.APIVersionRequest(v1_string)
self.assertEqual(v1_string, v1.get_string())
self.assertRaises(ValueError,
api_version_request.APIVersionRequest().get_string)
@ddt.data(('1', '0'), ('1', '1'))
@ddt.unpack
def test_str(self, major, minor):
request_input = '%s.%s' % (major, minor)
request = api_version_request.APIVersionRequest(request_input)
request_string = six.text_type(request)
self.assertEqual('API Version Request '
'Major: %s, Minor: %s' % (major, minor),
request_string)

View File

@ -0,0 +1,36 @@
# Copyright 2015 Clinton Knight
# 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.
import six
from cinder.api.openstack import versioned_method
from cinder import test
class VersionedMethodTestCase(test.TestCase):
def test_str(self):
args = ('fake_name', 'fake_min', 'fake_max')
method = versioned_method.VersionedMethod(*(args + (False, None)))
method_string = six.text_type(method)
self.assertEqual('Version Method %s: min: %s, max: %s' % args,
method_string)
def test_cmpkey(self):
method = versioned_method.VersionedMethod(
'fake_name', 'fake_start_version', 'fake_end_version', False,
'fake_func')
self.assertEqual('fake_start_version', method._cmpkey())

View File

@ -797,6 +797,28 @@ class ResourceTest(test.TestCase):
self.assertEqual([2], called) self.assertEqual([2], called)
self.assertEqual('foo', response) self.assertEqual('foo', response)
def test_post_process_extensions_version_not_found(self):
class Controller(object):
def index(self, req, pants=None):
return pants
controller = Controller()
resource = wsgi.Resource(controller)
called = []
def extension1(req, resp_obj):
called.append(1)
return 'bar'
def extension2(req, resp_obj):
raise exception.VersionNotFoundForAPIMethod(version='fake_version')
response = resource.post_process_extensions([extension2, extension1],
None, None, {})
self.assertEqual([1], called)
self.assertEqual('bar', response)
def test_post_process_extensions_generator(self): def test_post_process_extensions_generator(self):
class Controller(object): class Controller(object):
def index(self, req, pants=None): def index(self, req, pants=None):

View File

@ -1,258 +0,0 @@
# Copyright 2011 Denali Systems, Inc.
# 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 cinder.api.openstack import wsgi
from cinder.api.v1 import router
from cinder.api.v1 import snapshots
from cinder.api.v1 import volumes
from cinder.api import versions
from cinder import test
from cinder.tests.unit.api import fakes
class FakeController(object):
def __init__(self, ext_mgr=None):
self.ext_mgr = ext_mgr
def index(self, req):
obj_type = req.path.split("/")[3]
return {obj_type: []}
def detail(self, req):
obj_type = req.path.split("/")[3]
return {obj_type: []}
def create_resource(ext_mgr):
return wsgi.Resource(FakeController(ext_mgr))
class VolumeRouterTestCase(test.TestCase):
def setUp(self):
super(VolumeRouterTestCase, self).setUp()
# NOTE(vish): versions is just returning text so, no need to stub.
self.stubs.Set(snapshots, 'create_resource', create_resource)
self.stubs.Set(volumes, 'create_resource', create_resource)
self.app = router.APIRouter()
def test_versions(self):
req = fakes.HTTPRequest.blank('')
req.method = 'GET'
req.content_type = 'application/json'
response = req.get_response(self.app)
self.assertEqual(302, response.status_int)
req = fakes.HTTPRequest.blank('/')
req.method = 'GET'
req.content_type = 'application/json'
response = req.get_response(self.app)
self.assertEqual(200, response.status_int)
def test_versions_action_args_index(self):
request_environment = {'PATH_INFO': '/'}
resource = versions.Versions()
result = resource.get_action_args(request_environment)
self.assertEqual('index', result['action'])
def test_versions_action_args_multi(self):
request_environment = {'PATH_INFO': '/fake/path'}
resource = versions.Versions()
result = resource.get_action_args(request_environment)
self.assertEqual('multi', result['action'])
def test_versions_get_most_recent_update(self):
res = versions.AtomSerializer()
fake_date_updated = [
{"updated": '2012-01-04T11:33:21Z'},
{"updated": '2012-11-21T11:33:21Z'}
]
result = res._get_most_recent_update(fake_date_updated)
self.assertEqual('2012-11-21T11:33:21Z', result)
def test_versions_create_version_entry(self):
res = versions.AtomSerializer()
vers = {
"id": "v2.0",
"status": "CURRENT",
"updated": "2015-05-07T11:33:21Z",
"links": [
{
"rel": "describedby",
"type": "application/pdf",
"href": "http://developer.openstack.org/api-ref-guides/"
"bk-api-ref-blockstorage-v2.pdf",
},
],
}
fake_result = {
'id': 'http://developer.openstack.org/api-ref-guides/'
'bk-api-ref-blockstorage-v2.pdf',
'title': 'Version v2.0',
'updated': '2015-05-07T11:33:21Z',
'link': {
'href': 'http://developer.openstack.org/api-ref-guides/'
'bk-api-ref-blockstorage-v2.pdf',
'type': 'application/pdf',
'rel': 'describedby'
},
'content': 'Version v2.0 CURRENT (2015-05-07T11:33:21Z)'
}
result_function = res._create_version_entry(vers)
result = {}
for subElement in result_function:
if subElement.text:
result[subElement.tag] = subElement.text
else:
result[subElement.tag] = subElement.attrib
self.assertEqual(fake_result, result)
def test_versions_create_feed(self):
res = versions.AtomSerializer()
vers = [
{
"id": "v2.0",
"status": "CURRENT",
"updated": "2015-05-07T11:33:21Z",
"links": [
{
"rel": "describedby",
"type": "application/pdf",
"href": "http://developer.openstack.org/"
"api-ref-guides/"
"bk-api-ref-blockstorage-v2.pdf",
},
],
},
{
"id": "v1.0",
"status": "CURRENT",
"updated": "2012-01-04T11:33:21Z",
"links": [
{
"rel": "describedby",
"type": "application/vnd.sun.wadl+xml",
"href": "http://docs.rackspacecloud.com/"
"servers/api/v1.1/application.wadl",
},
],
}
]
result = res._create_feed(vers, "fake_feed_title",
"http://developer.openstack.org/"
"api-ref-guides/"
"bk-api-ref-blockstorage-v1.pdf")
fake_data = {
'id': 'http://developer.openstack.org/api-ref-guides/'
'bk-api-ref-blockstorage-v1.pdf',
'title': 'fake_feed_title',
'updated': '2015-05-07T11:33:21Z',
}
data = {}
for subElement in result:
if subElement.text:
data[subElement.tag] = subElement.text
self.assertEqual(fake_data, data)
def test_versions_multi(self):
req = fakes.HTTPRequest.blank('/')
req.method = 'GET'
req.content_type = 'application/json'
resource = versions.Versions()
result = resource.dispatch(resource.multi, req, {})
ids = [v['id'] for v in result['choices']]
self.assertEqual(set(['v1.0', 'v2.0']), set(ids))
def test_versions_multi_disable_v1(self):
self.flags(enable_v1_api=False)
req = fakes.HTTPRequest.blank('/')
req.method = 'GET'
req.content_type = 'application/json'
resource = versions.Versions()
result = resource.dispatch(resource.multi, req, {})
ids = [v['id'] for v in result['choices']]
self.assertEqual(set(['v2.0']), set(ids))
def test_versions_multi_disable_v2(self):
self.flags(enable_v2_api=False)
req = fakes.HTTPRequest.blank('/')
req.method = 'GET'
req.content_type = 'application/json'
resource = versions.Versions()
result = resource.dispatch(resource.multi, req, {})
ids = [v['id'] for v in result['choices']]
self.assertEqual(set(['v1.0']), set(ids))
def test_versions_index(self):
req = fakes.HTTPRequest.blank('/')
req.method = 'GET'
req.content_type = 'application/json'
resource = versions.Versions()
result = resource.dispatch(resource.index, req, {})
ids = [v['id'] for v in result['versions']]
self.assertEqual(set(['v1.0', 'v2.0']), set(ids))
def test_versions_index_disable_v1(self):
self.flags(enable_v1_api=False)
req = fakes.HTTPRequest.blank('/')
req.method = 'GET'
req.content_type = 'application/json'
resource = versions.Versions()
result = resource.dispatch(resource.index, req, {})
ids = [v['id'] for v in result['versions']]
self.assertEqual(set(['v2.0']), set(ids))
def test_versions_index_disable_v2(self):
self.flags(enable_v2_api=False)
req = fakes.HTTPRequest.blank('/')
req.method = 'GET'
req.content_type = 'application/json'
resource = versions.Versions()
result = resource.dispatch(resource.index, req, {})
ids = [v['id'] for v in result['versions']]
self.assertEqual(set(['v1.0']), set(ids))
def test_volumes(self):
req = fakes.HTTPRequest.blank('/fakeproject/volumes')
req.method = 'GET'
req.content_type = 'application/json'
response = req.get_response(self.app)
self.assertEqual(200, response.status_int)
def test_volumes_detail(self):
req = fakes.HTTPRequest.blank('/fakeproject/volumes/detail')
req.method = 'GET'
req.content_type = 'application/json'
response = req.get_response(self.app)
self.assertEqual(200, response.status_int)
def test_types(self):
req = fakes.HTTPRequest.blank('/fakeproject/types')
req.method = 'GET'
req.content_type = 'application/json'
response = req.get_response(self.app)
self.assertEqual(200, response.status_int)
def test_snapshots(self):
req = fakes.HTTPRequest.blank('/fakeproject/snapshots')
req.method = 'GET'
req.content_type = 'application/json'
response = req.get_response(self.app)
self.assertEqual(200, response.status_int)
def test_snapshots_detail(self):
req = fakes.HTTPRequest.blank('/fakeproject/snapshots/detail')
req.method = 'GET'
req.content_type = 'application/json'
response = req.get_response(self.app)
self.assertEqual(200, response.status_int)

View File

@ -1,4 +1,4 @@
# Copyright (c) 2015 - 2016 Huawei Technologies Co., Ltd. # Copyright 2015 Clinton Knight
# All Rights Reserved. # All Rights Reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may # Licensed under the Apache License, Version 2.0 (the "License"); you may
@ -13,131 +13,186 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import webob import ddt
import mock
from oslo_serialization import jsonutils
from cinder.api.openstack import api_version_request
from cinder.api.openstack import wsgi
from cinder.api.v1 import router
from cinder.api import versions from cinder.api import versions
from cinder import test from cinder import test
from cinder.tests.unit.api import fakes
class VersionsTest(test.TestCase): version_header_name = 'OpenStack-Volume-microversion'
"""Test the version information returned from the API service."""
def test_get_version_list_public_endpoint(self): @ddt.ddt
req = webob.Request.blank('/', base_url='http://127.0.0.1:8776/') class VersionsControllerTestCase(test.TestCase):
req.accept = 'application/json'
self.override_config('public_endpoint', 'https://example.com:8776')
res = versions.Versions().index(req)
results = res['versions']
expected = [
{
'id': 'v1.0',
'status': 'SUPPORTED',
'updated': '2014-06-28T12:20:21Z',
'links': [{'rel': 'self',
'href': 'https://example.com:8776/v1/'}],
},
{
'id': 'v2.0',
'status': 'CURRENT',
'updated': '2012-11-21T11:33:21Z',
'links': [{'rel': 'self',
'href': 'https://example.com:8776/v2/'}],
},
]
self.assertEqual(expected, results)
def test_get_version_list(self): def setUp(self):
req = webob.Request.blank('/', base_url='http://127.0.0.1:8776/') super(VersionsControllerTestCase, self).setUp()
req.accept = 'application/json' self.wsgi_apps = (versions.Versions(), router.APIRouter())
res = versions.Versions().index(req)
results = res['versions']
expected = [
{
'id': 'v1.0',
'status': 'SUPPORTED',
'updated': '2014-06-28T12:20:21Z',
'links': [{'rel': 'self',
'href': 'http://127.0.0.1:8776/v1/'}],
},
{
'id': 'v2.0',
'status': 'CURRENT',
'updated': '2012-11-21T11:33:21Z',
'links': [{'rel': 'self',
'href': 'http://127.0.0.1:8776/v2/'}],
},
]
self.assertEqual(expected, results)
def test_get_version_detail_v1(self): @ddt.data('1.0', '2.0', '3.0')
req = webob.Request.blank('/', base_url='http://127.0.0.1:8776/v1') def test_versions_root(self, version):
req.accept = 'application/json' req = fakes.HTTPRequest.blank('/', base_url='http://localhost')
res = versions.VolumeVersion().show(req) req.method = 'GET'
expected = { req.content_type = 'application/json'
"version": {
"status": "SUPPORTED",
"updated": "2014-06-28T12:20:21Z",
"media-types": [
{
"base": "application/xml",
"type":
"application/vnd.openstack.volume+xml;version=1"
},
{
"base": "application/json",
"type":
"application/vnd.openstack.volume+json;version=1"
}
],
"id": "v1.0",
"links": [
{
"href": "http://127.0.0.1:8776/v1/",
"rel": "self"
},
{
"href": "http://docs.openstack.org/",
"type": "text/html",
"rel": "describedby"
}
]
}
}
self.assertEqual(expected, res)
def test_get_version_detail_v2(self): response = req.get_response(versions.Versions())
req = webob.Request.blank('/', base_url='http://127.0.0.1:8776/v2') self.assertEqual(300, response.status_int)
req.accept = 'application/json' body = jsonutils.loads(response.body)
res = versions.VolumeVersion().show(req) version_list = body['versions']
expected = {
"version": { ids = [v['id'] for v in version_list]
"status": "CURRENT", self.assertEqual({'v1.0', 'v2.0', 'v3.0'}, set(ids))
"updated": "2012-11-21T11:33:21Z",
"media-types": [ v1 = [v for v in version_list if v['id'] == 'v1.0'][0]
{ self.assertEqual('', v1.get('min_version'))
"base": "application/xml", self.assertEqual('', v1.get('version'))
"type":
"application/vnd.openstack.volume+xml;version=1" v2 = [v for v in version_list if v['id'] == 'v2.0'][0]
}, self.assertEqual('', v2.get('min_version'))
{ self.assertEqual('', v2.get('version'))
"base": "application/json",
"type": v3 = [v for v in version_list if v['id'] == 'v3.0'][0]
"application/vnd.openstack.volume+json;version=1" self.assertEqual(api_version_request._MAX_API_VERSION,
} v3.get('version'))
], self.assertEqual(api_version_request._MIN_API_VERSION,
"id": "v2.0", v3.get('min_version'))
"links": [
{ def test_versions_v1_no_header(self):
"href": "http://127.0.0.1:8776/v2/", req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v1')
"rel": "self" req.method = 'GET'
}, req.content_type = 'application/json'
{
"href": "http://docs.openstack.org/", response = req.get_response(router.APIRouter())
"type": "text/html", self.assertEqual(200, response.status_int)
"rel": "describedby"
} def test_versions_v2_no_header(self):
] req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v2')
} req.method = 'GET'
} req.content_type = 'application/json'
self.assertEqual(expected, res)
response = req.get_response(router.APIRouter())
self.assertEqual(200, response.status_int)
@ddt.data('1.0')
def test_versions_v1(self, version):
req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v1')
req.method = 'GET'
req.content_type = 'application/json'
if version is not None:
req.headers = {version_header_name: version}
response = req.get_response(router.APIRouter())
self.assertEqual(200, response.status_int)
body = jsonutils.loads(response.body)
version_list = body['versions']
ids = [v['id'] for v in version_list]
self.assertEqual({'v1.0'}, set(ids))
self.assertEqual('1.0', response.headers[version_header_name])
self.assertEqual(version, response.headers[version_header_name])
self.assertEqual(version_header_name, response.headers['Vary'])
self.assertEqual('', version_list[0].get('min_version'))
self.assertEqual('', version_list[0].get('version'))
@ddt.data('2.0')
def test_versions_v2(self, version):
req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v2')
req.method = 'GET'
req.content_type = 'application/json'
req.headers = {version_header_name: version}
response = req.get_response(router.APIRouter())
self.assertEqual(200, response.status_int)
body = jsonutils.loads(response.body)
version_list = body['versions']
ids = [v['id'] for v in version_list]
self.assertEqual({'v2.0'}, set(ids))
self.assertEqual('2.0', response.headers[version_header_name])
self.assertEqual(version, response.headers[version_header_name])
self.assertEqual(version_header_name, response.headers['Vary'])
self.assertEqual('', version_list[0].get('min_version'))
self.assertEqual('', version_list[0].get('version'))
@ddt.data('3.0', 'latest')
def test_versions_v3_0_and_latest(self, version):
req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v3')
req.method = 'GET'
req.content_type = 'application/json'
req.headers = {version_header_name: version}
response = req.get_response(router.APIRouter())
self.assertEqual(200, response.status_int)
body = jsonutils.loads(response.body)
version_list = body['versions']
ids = [v['id'] for v in version_list]
self.assertEqual({'v3.0'}, set(ids))
self.assertEqual('3.0', response.headers[version_header_name])
self.assertEqual(version_header_name, response.headers['Vary'])
self.assertEqual(api_version_request._MAX_API_VERSION,
version_list[0].get('version'))
self.assertEqual(api_version_request._MIN_API_VERSION,
version_list[0].get('min_version'))
def test_versions_version_latest(self):
req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v3')
req.method = 'GET'
req.content_type = 'application/json'
req.headers = {version_header_name: 'latest'}
response = req.get_response(router.APIRouter())
self.assertEqual(200, response.status_int)
def test_versions_version_invalid(self):
req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v3')
req.method = 'GET'
req.content_type = 'application/json'
req.headers = {version_header_name: '2.0.1'}
for app in self.wsgi_apps:
response = req.get_response(app)
self.assertEqual(400, response.status_int)
def test_versions_version_not_found(self):
api_version_request_4_0 = api_version_request.APIVersionRequest('4.0')
self.mock_object(api_version_request,
'max_api_version',
mock.Mock(return_value=api_version_request_4_0))
class Controller(wsgi.Controller):
@wsgi.Controller.api_version('3.0', '3.0')
def index(self, req):
return 'off'
req = fakes.HTTPRequest.blank('/tests', base_url='http://localhost/v3')
req.headers = {version_header_name: '3.5'}
app = fakes.TestRouter(Controller())
response = req.get_response(app)
self.assertEqual(404, response.status_int)
def test_versions_version_not_acceptable(self):
req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v3')
req.method = 'GET'
req.content_type = 'application/json'
req.headers = {version_header_name: '4.0'}
response = req.get_response(router.APIRouter())
self.assertEqual(406, response.status_int)
self.assertEqual('4.0', response.headers[version_header_name])
self.assertEqual(version_header_name, response.headers['Vary'])

View File

@ -406,8 +406,8 @@ class SnapshotApiTest(test.TestCase):
"""Check a page of snapshots list.""" """Check a page of snapshots list."""
# Since we are accessing v2 api directly we don't need to specify # Since we are accessing v2 api directly we don't need to specify
# v2 in the request path, if we did, we'd get /v2/v2 links back # v2 in the request path, if we did, we'd get /v2/v2 links back
request_path = '/%s/snapshots' % project request_path = '/v2/%s/snapshots' % project
expected_path = '/v2' + request_path expected_path = request_path
# Construct the query if there are kwargs # Construct the query if there are kwargs
if kwargs: if kwargs:

View File

@ -0,0 +1,159 @@
# Copyright 2015 Clinton Knight
# 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.
import copy
import ddt
import mock
from cinder.api.views import versions
from cinder import test
class FakeRequest(object):
def __init__(self, application_url):
self.application_url = application_url
URL_BASE = 'http://localhost/'
FAKE_HREF = URL_BASE + 'v1/'
FAKE_VERSIONS = {
"v1.0": {
"id": "v1.0",
"status": "CURRENT",
"version": "1.1",
"min_version": "1.0",
"updated": "2015-07-30T11:33:21Z",
"links": [
{
"rel": "describedby",
"type": "text/html",
"href": 'http://docs.openstack.org/',
},
],
"media-types": [
{
"base": "application/json",
"type": "application/vnd.openstack.share+json;version=1",
},
{
"base": "application/xml",
"type": "application/vnd.openstack.share+xml;version=1",
}
],
},
}
FAKE_LINKS = [
{
"rel": "describedby",
"type": "text/html",
"href": 'http://docs.openstack.org/',
},
{
'rel': 'self',
'href': FAKE_HREF
},
]
@ddt.ddt
class ViewBuilderTestCase(test.TestCase):
def _get_builder(self):
request = FakeRequest('fake')
return versions.get_view_builder(request)
def test_build_versions(self):
self.mock_object(versions.ViewBuilder,
'_build_links',
mock.Mock(return_value=FAKE_LINKS))
result = self._get_builder().build_versions(FAKE_VERSIONS)
expected = {'versions': list(FAKE_VERSIONS.values())}
expected['versions'][0]['links'] = FAKE_LINKS
self.assertEqual(expected, result)
def test_build_version(self):
self.mock_object(versions.ViewBuilder,
'_build_links',
mock.Mock(return_value=FAKE_LINKS))
result = self._get_builder()._build_version(FAKE_VERSIONS['v1.0'])
expected = copy.deepcopy(FAKE_VERSIONS['v1.0'])
expected['links'] = FAKE_LINKS
self.assertEqual(expected, result)
def test_build_links(self):
self.mock_object(versions.ViewBuilder,
'_generate_href',
mock.Mock(return_value=FAKE_HREF))
result = self._get_builder()._build_links(FAKE_VERSIONS['v1.0'])
self.assertEqual(FAKE_LINKS, result)
def test_generate_href_defaults(self):
self.mock_object(versions.ViewBuilder,
'_get_base_url_without_version',
mock.Mock(return_value=URL_BASE))
result = self._get_builder()._generate_href()
self.assertEqual('http://localhost/v1/', result)
@ddt.data(
('v2', None, URL_BASE + 'v2/'),
('/v2/', None, URL_BASE + 'v2/'),
('/v2/', 'fake_path', URL_BASE + 'v2/fake_path'),
('/v2/', '/fake_path/', URL_BASE + 'v2/fake_path/'),
)
@ddt.unpack
def test_generate_href_no_path(self, version, path, expected):
self.mock_object(versions.ViewBuilder,
'_get_base_url_without_version',
mock.Mock(return_value=URL_BASE))
result = self._get_builder()._generate_href(version=version,
path=path)
self.assertEqual(expected, result)
@ddt.data(
('http://1.1.1.1/', 'http://1.1.1.1/'),
('http://localhost/', 'http://localhost/'),
('http://1.1.1.1/v1/', 'http://1.1.1.1/'),
('http://1.1.1.1/v1', 'http://1.1.1.1/'),
('http://1.1.1.1/v11', 'http://1.1.1.1/'),
)
@ddt.unpack
def test_get_base_url_without_version(self, base_url, base_url_no_version):
request = FakeRequest(base_url)
builder = versions.get_view_builder(request)
result = builder._get_base_url_without_version()
self.assertEqual(base_url_no_version, result)

View File

@ -1361,3 +1361,53 @@ class LogTracingTestCase(test.TestCase):
host_stat['reserved_percentage']) host_stat['reserved_percentage'])
self.assertEqual(37.02, free) self.assertEqual(37.02, free)
class Comparable(utils.ComparableMixin):
def __init__(self, value):
self.value = value
def _cmpkey(self):
return self.value
class TestComparableMixin(test.TestCase):
def setUp(self):
super(TestComparableMixin, self).setUp()
self.one = Comparable(1)
self.two = Comparable(2)
def test_lt(self):
self.assertTrue(self.one < self.two)
self.assertFalse(self.two < self.one)
self.assertFalse(self.one < self.one)
def test_le(self):
self.assertTrue(self.one <= self.two)
self.assertFalse(self.two <= self.one)
self.assertTrue(self.one <= self.one)
def test_eq(self):
self.assertFalse(self.one == self.two)
self.assertFalse(self.two == self.one)
self.assertTrue(self.one == self.one)
def test_ge(self):
self.assertFalse(self.one >= self.two)
self.assertTrue(self.two >= self.one)
self.assertTrue(self.one >= self.one)
def test_gt(self):
self.assertFalse(self.one > self.two)
self.assertTrue(self.two > self.one)
self.assertFalse(self.one > self.one)
def test_ne(self):
self.assertTrue(self.one != self.two)
self.assertTrue(self.two != self.one)
self.assertFalse(self.one != self.one)
def test_compare(self):
self.assertEqual(NotImplemented,
self.one._compare(1, self.one._cmpkey))

View File

@ -772,6 +772,34 @@ def is_blk_device(dev):
return False return False
class ComparableMixin(object):
def _compare(self, other, method):
try:
return method(self._cmpkey(), other._cmpkey())
except (AttributeError, TypeError):
# _cmpkey not implemented, or return different type,
# so I can't compare with "other".
return NotImplemented
def __lt__(self, other):
return self._compare(other, lambda s, o: s < o)
def __le__(self, other):
return self._compare(other, lambda s, o: s <= o)
def __eq__(self, other):
return self._compare(other, lambda s, o: s == o)
def __ge__(self, other):
return self._compare(other, lambda s, o: s >= o)
def __gt__(self, other):
return self._compare(other, lambda s, o: s > o)
def __ne__(self, other):
return self._compare(other, lambda s, o: s != o)
def retry(exceptions, interval=1, retries=3, backoff_rate=2, def retry(exceptions, interval=1, retries=3, backoff_rate=2,
wait_random=False): wait_random=False):

View File

@ -0,0 +1,308 @@
API Microversions
=================
Background
----------
Cinder uses a framework we called 'API Microversions' for allowing changes
to the API while preserving backward compatibility. The basic idea is
that a user has to explicitly ask for their request to be treated with
a particular version of the API. So breaking changes can be added to
the API without breaking users who don't specifically ask for it. This
is done with an HTTP header ``OpenStack-Volume-microversion`` which
is a monotonically increasing semantic version number starting from
``3.0``.
If a user makes a request without specifying a version, they will get
the ``DEFAULT_API_VERSION`` as defined in
``cinder/api/openstack/api_version_request.py``. This value is currently ``3.0``
and is expected to remain so for quite a long time.
The Nova project was the first to implement microversions. For full
details please read Nova's `Kilo spec for microversions
<http://git.openstack.org/cgit/openstack/nova-specs/tree/specs/kilo/implemented/api-microversions.rst>`_
When do I need a new Microversion?
----------------------------------
A microversion is needed when the contract to the user is
changed. The user contract covers many kinds of information such as:
- the Request
- the list of resource urls which exist on the server
Example: adding a new shares/{ID}/foo which didn't exist in a
previous version of the code
- the list of query parameters that are valid on urls
Example: adding a new parameter ``is_yellow`` servers/{ID}?is_yellow=True
- the list of query parameter values for non free form fields
Example: parameter filter_by takes a small set of constants/enums "A",
"B", "C". Adding support for new enum "D".
- new headers accepted on a request
- the Response
- the list of attributes and data structures returned
Example: adding a new attribute 'locked': True/False to the output
of shares/{ID}
- the allowed values of non free form fields
Example: adding a new allowed ``status`` to shares/{ID}
- the list of status codes allowed for a particular request
Example: an API previously could return 200, 400, 403, 404 and the
change would make the API now also be allowed to return 409.
- changing a status code on a particular response
Example: changing the return code of an API from 501 to 400.
- new headers returned on a response
The following flow chart attempts to walk through the process of "do
we need a microversion".
.. graphviz::
digraph states {
label="Do I need a microversion?"
silent_fail[shape="diamond", style="", label="Did we silently
fail to do what is asked?"];
ret_500[shape="diamond", style="", label="Did we return a 500
before?"];
new_error[shape="diamond", style="", label="Are we changing what
status code is returned?"];
new_attr[shape="diamond", style="", label="Did we add or remove an
attribute to a payload?"];
new_param[shape="diamond", style="", label="Did we add or remove
an accepted query string parameter or value?"];
new_resource[shape="diamond", style="", label="Did we add or remove a
resource url?"];
no[shape="box", style=rounded, label="No microversion needed"];
yes[shape="box", style=rounded, label="Yes, you need a microversion"];
no2[shape="box", style=rounded, label="No microversion needed, it's
a bug"];
silent_fail -> ret_500[label="no"];
silent_fail -> no2[label="yes"];
ret_500 -> no2[label="yes [1]"];
ret_500 -> new_error[label="no"];
new_error -> new_attr[label="no"];
new_error -> yes[label="yes"];
new_attr -> new_param[label="no"];
new_attr -> yes[label="yes"];
new_param -> new_resource[label="no"];
new_param -> yes[label="yes"];
new_resource -> no[label="no"];
new_resource -> yes[label="yes"];
{rank=same; yes new_attr}
{rank=same; no2 ret_500}
{rank=min; silent_fail}
}
**Footnotes**
[1] - When fixing 500 errors that previously caused stack traces, try
to map the new error into the existing set of errors that API call
could previously return (400 if nothing else is appropriate). Changing
the set of allowed status codes from a request is changing the
contract, and should be part of a microversion.
The reason why we are so strict on contract is that we'd like
application writers to be able to know, for sure, what the contract is
at every microversion in Cinder. If they do not, they will need to write
conditional code in their application to handle ambiguities.
When in doubt, consider application authors. If it would work with no
client side changes on both Cinder versions, you probably don't need a
microversion. If, on the other hand, there is any ambiguity, a
microversion is probably needed.
In Code
-------
In ``cinder/api/openstack/wsgi.py`` we define an ``@api_version`` decorator
which is intended to be used on top-level Controller methods. It is
not appropriate for lower-level methods. Some examples:
Adding a new API method
~~~~~~~~~~~~~~~~~~~~~~~
In the controller class::
@wsgi.Controller.api_version("3.4")
def my_api_method(self, req, id):
....
This method would only be available if the caller had specified an
``OpenStack-Volume-microversion`` of >= ``3.4``. If they had specified a
lower version (or not specified it and received the default of ``3.1``)
the server would respond with ``HTTP/404``.
Removing an API method
~~~~~~~~~~~~~~~~~~~~~~
In the controller class::
@wsgi.Controller.api_version("3.1", "3.4")
def my_api_method(self, req, id):
....
This method would only be available if the caller had specified an
``OpenStack-Volume-microversion`` of <= ``3.4``. If ``3.5`` or later
is specified the server will respond with ``HTTP/404``.
Changing a method's behaviour
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In the controller class::
@wsgi.Controller.api_version("3.1", "3.3")
def my_api_method(self, req, id):
.... method_1 ...
@wsgi.Controller.api_version("3.4") # noqa
def my_api_method(self, req, id):
.... method_2 ...
If a caller specified ``3.1``, ``3.2`` or ``3.3`` (or received the
default of ``3.1``) they would see the result from ``method_1``,
``3.4`` or later ``method_2``.
It is vital that the two methods have the same name, so the second of
them will need ``# noqa`` to avoid failing flake8's ``F811`` rule. The
two methods may be different in any kind of semantics (schema
validation, return values, response codes, etc)
A method with only small changes between versions
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A method may have only small changes between microversions, in which
case you can decorate a private method::
@api_version("3.1", "3.4")
def _version_specific_func(self, req, arg1):
pass
@api_version(min_version="3.5") # noqa
def _version_specific_func(self, req, arg1):
pass
def show(self, req, id):
.... common stuff ....
self._version_specific_func(req, "foo")
.... common stuff ....
When not using decorators
~~~~~~~~~~~~~~~~~~~~~~~~~
When you don't want to use the ``@api_version`` decorator on a method
or you want to change behaviour within a method (say it leads to
simpler or simply a lot less code) you can directly test for the
requested version with a method as long as you have access to the api
request object (commonly called ``req``). Every API method has an
api_version_request object attached to the req object and that can be
used to modify behaviour based on its value::
def index(self, req):
<common code>
req_version = req.api_version_request
if req_version.matches("3.1", "3.5"):
....stuff....
elif req_version.matches("3.6", "3.10"):
....other stuff....
elif req_version > api_version_request.APIVersionRequest("3.10"):
....more stuff.....
<common code>
The first argument to the matches method is the minimum acceptable version
and the second is maximum acceptable version. A specified version can be null::
null_version = APIVersionRequest()
If the minimum version specified is null then there is no restriction on
the minimum version, and likewise if the maximum version is null there
is no restriction the maximum version. Alternatively a one sided comparison
can be used as in the example above.
Other necessary changes
-----------------------
If you are adding a patch which adds a new microversion, it is
necessary to add changes to other places which describe your change:
* Update ``REST_API_VERSION_HISTORY`` in
``cinder/api/openstack/api_version_request.py``
* Update ``_MAX_API_VERSION`` in
``cinder/api/openstack/api_version_request.py``
* Add a verbose description to
``cinder/api/openstack/rest_api_version_history.rst``. There should
be enough information that it could be used by the docs team for
release notes.
* Update the expected versions in affected tests.
Allocating a microversion
-------------------------
If you are adding a patch which adds a new microversion, it is
necessary to allocate the next microversion number. Except under
extremely unusual circumstances and this would have been mentioned in
the blueprint for the change, the minor number of ``_MAX_API_VERSION``
will be incremented. This will also be the new microversion number for
the API change.
It is possible that multiple microversion patches would be proposed in
parallel and the microversions would conflict between patches. This
will cause a merge conflict. We don't reserve a microversion for each
patch in advance as we don't know the final merge order. Developers
may need over time to rebase their patch calculating a new version
number as above based on the updated value of ``_MAX_API_VERSION``.
Testing Microversioned API Methods
----------------------------------
Unit tests for microversions should be put in cinder/tests/unit/api/v3/ .
Since all existing functionality is tested in cinder/tests/unit/api/v2,
these unit tests are not replicated in .../v3, and only new functionality
needs to be place in the .../v3/directory.
Testing a microversioned API method is very similar to a normal controller
method test, you just need to add the ``OpenStack-Volume-microversion``
header, for example::
req = fakes.HTTPRequest.blank('/testable/url/endpoint')
req.headers = {'OpenStack-Volume-microversion': '3.2'}
req.api_version_request = api_version.APIVersionRequest('3.6')
controller = controller.TestableController()
res = controller.index(req)
... assertions about the response ...

View File

@ -0,0 +1 @@
.. include:: ../../../cinder/api/openstack/rest_api_version_history.rst

View File

@ -27,6 +27,8 @@ Programming HowTos and Tutorials
:maxdepth: 3 :maxdepth: 3
development.environment development.environment
api_microversion_dev
api_microversion_history
unit_tests unit_tests
addmethod.openstackapi addmethod.openstackapi
drivers drivers

View File

@ -7,6 +7,7 @@ use = call:cinder.api:root_app_factory
/: apiversions /: apiversions
/v1: openstack_volume_api_v1 /v1: openstack_volume_api_v1
/v2: openstack_volume_api_v2 /v2: openstack_volume_api_v2
/v3: openstack_volume_api_v3
[composite:openstack_volume_api_v1] [composite:openstack_volume_api_v1]
use = call:cinder.api.middleware.auth:pipeline_factory use = call:cinder.api.middleware.auth:pipeline_factory
@ -20,14 +21,20 @@ noauth = cors request_id faultwrap sizelimit osprofiler noauth apiv2
keystone = cors request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv2 keystone = cors request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv2
keystone_nolimit = cors request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv2 keystone_nolimit = cors request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv2
[composite:openstack_volume_api_v3]
use = call:cinder.api.middleware.auth:pipeline_factory
noauth = cors request_id faultwrap sizelimit osprofiler noauth apiv3
keystone = cors request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv3
keystone_nolimit = cors request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv3
[filter:request_id] [filter:request_id]
paste.filter_factory = oslo_middleware.request_id:RequestId.factory paste.filter_factory = oslo_middleware.request_id:RequestId.factory
[filter:cors] [filter:cors]
paste.filter_factory = oslo_middleware.cors:filter_factory paste.filter_factory = oslo_middleware.cors:filter_factory
oslo_config_project = cinder oslo_config_project = cinder
latent_allow_headers = X-Auth-Token, X-Identity-Status, X-Roles, X-Service-Catalog, X-User-Id, X-Tenant-Id, X-OpenStack-Request-ID, X-Trace-Info, X-Trace-HMAC latent_allow_headers = X-Auth-Token, X-Identity-Status, X-Roles, X-Service-Catalog, X-User-Id, X-Tenant-Id, X-OpenStack-Request-ID, X-Trace-Info, X-Trace-HMAC, OpenStack-Volume-microversion
latent_expose_headers = X-Auth-Token, X-Subject-Token, X-Service-Token, X-OpenStack-Request-ID latent_expose_headers = X-Auth-Token, X-Subject-Token, X-Service-Token, X-OpenStack-Request-ID, OpenStack-Volume-microversion
latent_allow_methods = GET, PUT, POST, DELETE, PATCH latent_allow_methods = GET, PUT, POST, DELETE, PATCH
[filter:faultwrap] [filter:faultwrap]
@ -48,6 +55,9 @@ paste.app_factory = cinder.api.v1.router:APIRouter.factory
[app:apiv2] [app:apiv2]
paste.app_factory = cinder.api.v2.router:APIRouter.factory paste.app_factory = cinder.api.v2.router:APIRouter.factory
[app:apiv3]
paste.app_factory = cinder.api.v3.router:APIRouter.factory
[pipeline:apiversions] [pipeline:apiversions]
pipeline = cors faultwrap osvolumeversionapp pipeline = cors faultwrap osvolumeversionapp

View File

@ -0,0 +1,3 @@
---
features:
- Add support for API microversions, as well as /v3 API endpoint