Merge "cinder-api-microversions code"
This commit is contained in:
commit
26ac08ca79
@ -28,11 +28,7 @@ LOG = logging.getLogger(__name__)
|
||||
|
||||
def root_app_factory(loader, global_conf, **local_conf):
|
||||
if CONF.enable_v1_api:
|
||||
LOG.warning(_LW('The v1 api is deprecated and will be removed in the '
|
||||
'Liberty release. You should set enable_v1_api=false '
|
||||
'and enable_v2_api=true in your cinder.conf file.'))
|
||||
else:
|
||||
del local_conf['/v1']
|
||||
if not CONF.enable_v2_api:
|
||||
del local_conf['/v2']
|
||||
LOG.warning(_LW('The v1 api is deprecated and is not under active '
|
||||
'development. You should set enable_v1_api=false '
|
||||
'and enable_v3_api=true in your cinder.conf file.'))
|
||||
return paste.urlmap.urlmap_factory(loader, global_conf, **local_conf)
|
||||
|
@ -38,6 +38,16 @@ api_common_opts = [
|
||||
help='Base URL that will be presented to users in links '
|
||||
'to the OpenStack Volume API',
|
||||
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
|
||||
|
164
cinder/api/openstack/api_version_request.py
Normal file
164
cinder/api/openstack/api_version_request.py
Normal 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})
|
30
cinder/api/openstack/rest_api_version_history.rst
Normal file
30
cinder/api/openstack/rest_api_version_history.rst
Normal 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.
|
48
cinder/api/openstack/versioned_method.py
Normal file
48
cinder/api/openstack/versioned_method.py
Normal 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
|
@ -14,6 +14,7 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import math
|
||||
import time
|
||||
@ -25,9 +26,13 @@ from oslo_log import log as logging
|
||||
from oslo_log import versionutils
|
||||
from oslo_serialization import jsonutils
|
||||
from oslo_utils import excutils
|
||||
from oslo_utils import strutils
|
||||
import six
|
||||
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 i18n
|
||||
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):
|
||||
"""Add some OpenStack API-specific logic to the base webob.Request."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Request, self).__init__(*args, **kwargs)
|
||||
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):
|
||||
"""Cache the given resource.
|
||||
@ -269,6 +284,45 @@ class Request(webob.Request):
|
||||
all_languages = i18n.get_available_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):
|
||||
"""Maps method name to local methods through action name."""
|
||||
@ -276,7 +330,7 @@ class ActionDispatcher(object):
|
||||
def dispatch(self, *args, **kwargs):
|
||||
"""Find and call local method."""
|
||||
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)
|
||||
|
||||
def default(self, data):
|
||||
@ -571,7 +625,7 @@ class ResponseObject(object):
|
||||
optional.
|
||||
"""
|
||||
|
||||
def __init__(self, obj, code=None, **serializers):
|
||||
def __init__(self, obj, code=None, headers=None, **serializers):
|
||||
"""Binds serializers with an object.
|
||||
|
||||
Takes keyword arguments akin to the @serializer() decorator
|
||||
@ -584,7 +638,7 @@ class ResponseObject(object):
|
||||
self.serializers = serializers
|
||||
self._default_code = 200
|
||||
self._code = code
|
||||
self._headers = {}
|
||||
self._headers = headers or {}
|
||||
self.serializer = None
|
||||
self.media_type = None
|
||||
|
||||
@ -677,8 +731,8 @@ class ResponseObject(object):
|
||||
response = webob.Response()
|
||||
response.status_int = self.code
|
||||
for hdr, value in self._headers.items():
|
||||
response.headers[hdr] = value
|
||||
response.headers['Content-Type'] = content_type
|
||||
response.headers[hdr] = six.text_type(value)
|
||||
response.headers['Content-Type'] = six.text_type(content_type)
|
||||
if self.obj is not None:
|
||||
body = serializer.serialize(self.obj)
|
||||
if isinstance(body, six.text_type):
|
||||
@ -743,10 +797,13 @@ class ResourceExceptionHandler(object):
|
||||
return True
|
||||
|
||||
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):
|
||||
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):
|
||||
exc_info = (ex_type, ex_value, ex_traceback)
|
||||
LOG.error(_LE(
|
||||
@ -754,10 +811,10 @@ class ResourceExceptionHandler(object):
|
||||
ex_value, exc_info=exc_info)
|
||||
raise Fault(webob.exc.HTTPBadRequest())
|
||||
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
|
||||
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)
|
||||
|
||||
# We didn't handle the exception
|
||||
@ -778,6 +835,7 @@ class Resource(wsgi.Application):
|
||||
Exceptions derived from webob.exc.HTTPException will be automatically
|
||||
wrapped in Fault() to provide API friendly error responses.
|
||||
"""
|
||||
support_api_request_version = True
|
||||
|
||||
def __init__(self, controller, action_peek=None, **deserializers):
|
||||
"""Initialize Resource.
|
||||
@ -943,6 +1001,11 @@ class Resource(wsgi.Application):
|
||||
with ResourceExceptionHandler():
|
||||
response = ext(req=request, resp_obj=resp_obj,
|
||||
**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:
|
||||
response = ex
|
||||
|
||||
@ -960,6 +1023,17 @@ class Resource(wsgi.Application):
|
||||
{"method": request.method,
|
||||
"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
|
||||
# content type
|
||||
action_args = self.get_action_args(request.environ)
|
||||
@ -992,6 +1066,16 @@ class Resource(wsgi.Application):
|
||||
msg = _("Malformed request body")
|
||||
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...
|
||||
try:
|
||||
if content_type:
|
||||
@ -1029,7 +1113,7 @@ class Resource(wsgi.Application):
|
||||
# No exceptions; convert action_result into a
|
||||
# ResponseObject
|
||||
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)
|
||||
elif isinstance(action_result, ResponseObject):
|
||||
resp_obj = action_result
|
||||
@ -1063,6 +1147,21 @@ class Resource(wsgi.Application):
|
||||
|
||||
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
|
||||
|
||||
def get_method(self, request, action, content_type, body):
|
||||
@ -1101,7 +1200,13 @@ class Resource(wsgi.Application):
|
||||
def dispatch(self, method, request, action_args):
|
||||
"""Dispatch a call to the action-specific method."""
|
||||
|
||||
try:
|
||||
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):
|
||||
@ -1161,9 +1266,22 @@ class ControllerMetaclass(type):
|
||||
# Find all actions
|
||||
actions = {}
|
||||
extensions = []
|
||||
versioned_methods = None
|
||||
# start with wsgi actions from base classes
|
||||
for base in bases:
|
||||
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():
|
||||
if not callable(value):
|
||||
continue
|
||||
@ -1175,6 +1293,8 @@ class ControllerMetaclass(type):
|
||||
# Add the actions and extensions to the class dict
|
||||
cls_dict['wsgi_actions'] = actions
|
||||
cls_dict['wsgi_extensions'] = extensions
|
||||
if versioned_methods:
|
||||
cls_dict[VER_METHOD_ATTR] = versioned_methods
|
||||
|
||||
return super(ControllerMetaclass, mcs).__new__(mcs, name, bases,
|
||||
cls_dict)
|
||||
@ -1195,6 +1315,99 @@ class Controller(object):
|
||||
else:
|
||||
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
|
||||
def is_valid_body(body, entity_name):
|
||||
if not (body and entity_name in body):
|
||||
@ -1330,6 +1543,11 @@ class Fault(webob.exc.HTTPException):
|
||||
if 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
|
||||
metadata = {'attributes': {fault_name: 'code'}}
|
||||
|
||||
|
@ -43,7 +43,7 @@ class APIRouter(cinder.api.openstack.APIRouter):
|
||||
self.resources['versions'] = versions.create_resource()
|
||||
mapper.connect("versions", "/",
|
||||
controller=self.resources['versions'],
|
||||
action='show')
|
||||
action='index')
|
||||
|
||||
mapper.redirect("", "/")
|
||||
|
||||
|
@ -43,7 +43,7 @@ class APIRouter(cinder.api.openstack.APIRouter):
|
||||
self.resources['versions'] = versions.create_resource()
|
||||
mapper.connect("versions", "/",
|
||||
controller=self.resources['versions'],
|
||||
action='show')
|
||||
action='index')
|
||||
|
||||
mapper.redirect("", "/")
|
||||
|
||||
|
@ -35,20 +35,7 @@ from cinder import volume as cinder_volume
|
||||
from cinder.volume import utils as volume_utils
|
||||
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.register_opt(query_volume_filters_opt)
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
SCHEDULER_HINTS_NAMESPACE =\
|
||||
|
0
cinder/api/v3/__init__.py
Normal file
0
cinder/api/v3/__init__.py
Normal file
99
cinder/api/v3/router.py
Normal file
99
cinder/api/v3/router.py
Normal 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']})
|
@ -1,4 +1,5 @@
|
||||
# Copyright 2010 OpenStack Foundation
|
||||
# Copyright 2015 Clinton Knight
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
@ -14,11 +15,15 @@
|
||||
# under the License.
|
||||
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
|
||||
from lxml import etree
|
||||
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.views import versions as views_versions
|
||||
from cinder.api import xmlutil
|
||||
@ -26,67 +31,115 @@ from cinder.api import xmlutil
|
||||
|
||||
CONF = cfg.CONF
|
||||
|
||||
|
||||
_KNOWN_VERSIONS = {
|
||||
"v2.0": {
|
||||
"id": "v2.0",
|
||||
"status": "CURRENT",
|
||||
"updated": "2012-11-21T11:33:21Z",
|
||||
"links": [
|
||||
{
|
||||
_LINKS = [{
|
||||
"rel": "describedby",
|
||||
"type": "text/html",
|
||||
"href": "http://docs.openstack.org/",
|
||||
}]
|
||||
|
||||
_MEDIA_TYPES = [{
|
||||
"base":
|
||||
"application/json",
|
||||
"type":
|
||||
"application/vnd.openstack.volume+json;version=1",
|
||||
},
|
||||
{"base":
|
||||
"application/xml",
|
||||
"type":
|
||||
"application/vnd.openstack.volume+xml;version=1",
|
||||
},
|
||||
],
|
||||
"media-types": [
|
||||
{
|
||||
"base": "application/xml",
|
||||
"type": "application/vnd.openstack.volume+xml;version=1",
|
||||
},
|
||||
{
|
||||
"base": "application/json",
|
||||
"type": "application/vnd.openstack.volume+json;version=1",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
_KNOWN_VERSIONS = {
|
||||
"v1.0": {
|
||||
"id": "v1.0",
|
||||
"status": "SUPPORTED",
|
||||
"version": "",
|
||||
"min_version": "",
|
||||
"updated": "2014-06-28T12:20:21Z",
|
||||
"links": [
|
||||
{
|
||||
"rel": "describedby",
|
||||
"type": "text/html",
|
||||
"href": "http://docs.openstack.org/",
|
||||
"links": _LINKS,
|
||||
"media-types": _MEDIA_TYPES,
|
||||
},
|
||||
],
|
||||
"media-types": [
|
||||
{
|
||||
"base": "application/xml",
|
||||
"type": "application/vnd.openstack.volume+xml;version=1",
|
||||
"v2.0": {
|
||||
"id": "v2.0",
|
||||
"status": "SUPPORTED",
|
||||
"version": "",
|
||||
"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():
|
||||
versions = {}
|
||||
class Versions(openstack.APIRouter):
|
||||
"""Route versions requests."""
|
||||
|
||||
if CONF.enable_v1_api:
|
||||
versions['v1.0'] = _KNOWN_VERSIONS['v1.0']
|
||||
if CONF.enable_v2_api:
|
||||
versions['v2.0'] = _KNOWN_VERSIONS['v2.0']
|
||||
ExtensionManager = extensions.ExtensionManager
|
||||
|
||||
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):
|
||||
|
||||
def will_render(self, 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):
|
||||
|
||||
def construct(self):
|
||||
root = xmlutil.TemplateElement('version', selector='version')
|
||||
make_version(root)
|
||||
@ -117,6 +171,7 @@ class VersionTemplate(xmlutil.TemplateBuilder):
|
||||
|
||||
|
||||
class VersionsTemplate(xmlutil.TemplateBuilder):
|
||||
|
||||
def construct(self):
|
||||
root = xmlutil.TemplateElement('versions')
|
||||
elem = xmlutil.SubTemplateElement(root, 'version', selector='versions')
|
||||
@ -125,6 +180,7 @@ class VersionsTemplate(xmlutil.TemplateBuilder):
|
||||
|
||||
|
||||
class ChoicesTemplate(xmlutil.TemplateBuilder):
|
||||
|
||||
def construct(self):
|
||||
root = xmlutil.TemplateElement('choices')
|
||||
elem = xmlutil.SubTemplateElement(root, 'version', selector='choices')
|
||||
@ -209,6 +265,7 @@ class AtomSerializer(wsgi.XMLDictSerializer):
|
||||
|
||||
|
||||
class VersionsAtomSerializer(AtomSerializer):
|
||||
|
||||
def default(self, data):
|
||||
versions = data['versions']
|
||||
feed_id = self._get_base_url(versions[0]['links'][0]['href'])
|
||||
@ -217,6 +274,7 @@ class VersionsAtomSerializer(AtomSerializer):
|
||||
|
||||
|
||||
class VersionAtomSerializer(AtomSerializer):
|
||||
|
||||
def default(self, data):
|
||||
version = data['version']
|
||||
feed_id = version['links'][0]['href']
|
||||
@ -224,46 +282,5 @@ class VersionAtomSerializer(AtomSerializer):
|
||||
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():
|
||||
return wsgi.Resource(VolumeVersion())
|
||||
return wsgi.Resource(VersionsController())
|
||||
|
@ -1,4 +1,5 @@
|
||||
# Copyright 2010-2011 OpenStack Foundation
|
||||
# Copyright 2015 Clinton Knight
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
@ -14,9 +15,10 @@
|
||||
# under the License.
|
||||
|
||||
import copy
|
||||
import os
|
||||
import re
|
||||
|
||||
from oslo_config import cfg
|
||||
from six.moves import urllib
|
||||
|
||||
|
||||
versions_opts = [
|
||||
@ -45,57 +47,32 @@ class ViewBuilder(object):
|
||||
"""
|
||||
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):
|
||||
version_objs = []
|
||||
for version in sorted(versions.keys()):
|
||||
version = versions[version]
|
||||
version_objs.append({
|
||||
"id": version['id'],
|
||||
"status": version['status'],
|
||||
"updated": version['updated'],
|
||||
"links": self._build_links(version), })
|
||||
views = [self._build_version(versions[key])
|
||||
for key in sorted(list(versions.keys()))]
|
||||
return dict(versions=views)
|
||||
|
||||
return dict(versions=version_objs)
|
||||
|
||||
def build_version(self, version):
|
||||
reval = copy.deepcopy(version)
|
||||
reval['links'].insert(0, {
|
||||
"rel": "self",
|
||||
"href": self.base_url.rstrip('/') + '/', })
|
||||
return dict(version=reval)
|
||||
def _build_version(self, version):
|
||||
view = copy.deepcopy(version)
|
||||
view['links'] = self._build_links(version)
|
||||
return view
|
||||
|
||||
def _build_links(self, version_data):
|
||||
"""Generate a container of links that refer to the provided version."""
|
||||
href = self.generate_href(version_data['id'])
|
||||
|
||||
links = [{'rel': 'self',
|
||||
'href': href, }, ]
|
||||
|
||||
links = copy.deepcopy(version_data.get('links', {}))
|
||||
version_num = version_data["id"].split('.')[0]
|
||||
links.append({'rel': 'self',
|
||||
'href': self._generate_href(version=version_num)})
|
||||
return links
|
||||
|
||||
def generate_href(self, version, path=None):
|
||||
"""Create an url that refers to a specific version_number."""
|
||||
if version.find('v1.') == 0:
|
||||
version_number = 'v1'
|
||||
else:
|
||||
version_number = 'v2'
|
||||
|
||||
def _generate_href(self, version='v3', path=None):
|
||||
"""Create a URL that refers to a specific version_number."""
|
||||
base_url = self._get_base_url_without_version()
|
||||
href = urllib.parse.urljoin(base_url, version).rstrip('/') + '/'
|
||||
if path:
|
||||
path = path.strip('/')
|
||||
return os.path.join(self.base_url, version_number, path)
|
||||
else:
|
||||
return os.path.join(self.base_url, version_number) + '/'
|
||||
href += path.lstrip('/')
|
||||
return href
|
||||
|
||||
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)
|
||||
|
@ -103,7 +103,10 @@ global_opts = [
|
||||
help=_("DEPRECATED: Deploy v1 of the Cinder API.")),
|
||||
cfg.BoolOpt('enable_v2_api',
|
||||
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',
|
||||
default=True,
|
||||
help='Enables or disables rate limit of the API.'),
|
||||
|
@ -243,6 +243,20 @@ class InvalidUUID(Invalid):
|
||||
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):
|
||||
message = _("Error while requesting %(service)s API.")
|
||||
|
||||
|
@ -17,7 +17,6 @@ import itertools
|
||||
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 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.backup import api as cinder_backup_api
|
||||
from cinder.backup import chunkeddriver as cinder_backup_chunkeddriver
|
||||
@ -330,7 +329,6 @@ def list_opts():
|
||||
cinder_volume_drivers_hpe_hpe3parcommon.hpe3par_opts,
|
||||
cinder_volume_drivers_datera.d_opts,
|
||||
cinder_volume_drivers_blockdevice.volume_opts,
|
||||
[cinder_api_v2_volumes.query_volume_filters_opt],
|
||||
cinder_volume_drivers_quobyte.volume_opts,
|
||||
cinder_volume_drivers_vzstorage.vzstorage_opts,
|
||||
cinder_volume_drivers_nfs.nfs_opts,
|
||||
|
@ -24,6 +24,7 @@ import webob.request
|
||||
|
||||
from cinder.api.middleware import auth
|
||||
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 import urlmap
|
||||
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['/v2'] = api_v2
|
||||
mapper['/'] = fault.FaultWrapper(versions.Versions())
|
||||
mapper['/'] = fault.FaultWrapper(versions.VersionsController())
|
||||
return mapper
|
||||
|
||||
|
||||
@ -106,17 +107,21 @@ class HTTPRequest(webob.Request):
|
||||
@classmethod
|
||||
def blank(cls, *args, **kwargs):
|
||||
if args is not None:
|
||||
if args[0].find('v1') == 0:
|
||||
if 'v1' in args[0]:
|
||||
kwargs['base_url'] = 'http://localhost/v1'
|
||||
else:
|
||||
if 'v2' in args[0]:
|
||||
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)
|
||||
version = kwargs.pop('version', api_version._MIN_API_VERSION)
|
||||
out = os_wsgi.Request.blank(*args, **kwargs)
|
||||
out.environ['cinder.context'] = FakeRequestContext(
|
||||
'fake_user',
|
||||
'fakeproject',
|
||||
is_admin=use_admin_context)
|
||||
out.api_version_request = api_version.APIVersionRequest(version)
|
||||
return out
|
||||
|
||||
|
||||
|
149
cinder/tests/unit/api/openstack/test_api_version_request.py
Normal file
149
cinder/tests/unit/api/openstack/test_api_version_request.py
Normal 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)
|
36
cinder/tests/unit/api/openstack/test_versioned_method.py
Normal file
36
cinder/tests/unit/api/openstack/test_versioned_method.py
Normal 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())
|
@ -797,6 +797,28 @@ class ResourceTest(test.TestCase):
|
||||
self.assertEqual([2], called)
|
||||
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):
|
||||
class Controller(object):
|
||||
def index(self, req, pants=None):
|
||||
|
@ -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)
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2015 - 2016 Huawei Technologies Co., Ltd.
|
||||
# Copyright 2015 Clinton Knight
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# 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
|
||||
# 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 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):
|
||||
req = webob.Request.blank('/', base_url='http://127.0.0.1:8776/')
|
||||
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)
|
||||
@ddt.ddt
|
||||
class VersionsControllerTestCase(test.TestCase):
|
||||
|
||||
def test_get_version_list(self):
|
||||
req = webob.Request.blank('/', base_url='http://127.0.0.1:8776/')
|
||||
req.accept = 'application/json'
|
||||
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 setUp(self):
|
||||
super(VersionsControllerTestCase, self).setUp()
|
||||
self.wsgi_apps = (versions.Versions(), router.APIRouter())
|
||||
|
||||
def test_get_version_detail_v1(self):
|
||||
req = webob.Request.blank('/', base_url='http://127.0.0.1:8776/v1')
|
||||
req.accept = 'application/json'
|
||||
res = versions.VolumeVersion().show(req)
|
||||
expected = {
|
||||
"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)
|
||||
@ddt.data('1.0', '2.0', '3.0')
|
||||
def test_versions_root(self, version):
|
||||
req = fakes.HTTPRequest.blank('/', base_url='http://localhost')
|
||||
req.method = 'GET'
|
||||
req.content_type = 'application/json'
|
||||
|
||||
def test_get_version_detail_v2(self):
|
||||
req = webob.Request.blank('/', base_url='http://127.0.0.1:8776/v2')
|
||||
req.accept = 'application/json'
|
||||
res = versions.VolumeVersion().show(req)
|
||||
expected = {
|
||||
"version": {
|
||||
"status": "CURRENT",
|
||||
"updated": "2012-11-21T11:33: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": "v2.0",
|
||||
"links": [
|
||||
{
|
||||
"href": "http://127.0.0.1:8776/v2/",
|
||||
"rel": "self"
|
||||
},
|
||||
{
|
||||
"href": "http://docs.openstack.org/",
|
||||
"type": "text/html",
|
||||
"rel": "describedby"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
self.assertEqual(expected, res)
|
||||
response = req.get_response(versions.Versions())
|
||||
self.assertEqual(300, response.status_int)
|
||||
body = jsonutils.loads(response.body)
|
||||
version_list = body['versions']
|
||||
|
||||
ids = [v['id'] for v in version_list]
|
||||
self.assertEqual({'v1.0', 'v2.0', 'v3.0'}, set(ids))
|
||||
|
||||
v1 = [v for v in version_list if v['id'] == 'v1.0'][0]
|
||||
self.assertEqual('', v1.get('min_version'))
|
||||
self.assertEqual('', v1.get('version'))
|
||||
|
||||
v2 = [v for v in version_list if v['id'] == 'v2.0'][0]
|
||||
self.assertEqual('', v2.get('min_version'))
|
||||
self.assertEqual('', v2.get('version'))
|
||||
|
||||
v3 = [v for v in version_list if v['id'] == 'v3.0'][0]
|
||||
self.assertEqual(api_version_request._MAX_API_VERSION,
|
||||
v3.get('version'))
|
||||
self.assertEqual(api_version_request._MIN_API_VERSION,
|
||||
v3.get('min_version'))
|
||||
|
||||
def test_versions_v1_no_header(self):
|
||||
req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v1')
|
||||
req.method = 'GET'
|
||||
req.content_type = 'application/json'
|
||||
|
||||
response = req.get_response(router.APIRouter())
|
||||
self.assertEqual(200, response.status_int)
|
||||
|
||||
def test_versions_v2_no_header(self):
|
||||
req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v2')
|
||||
req.method = 'GET'
|
||||
req.content_type = 'application/json'
|
||||
|
||||
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'])
|
||||
|
@ -406,8 +406,8 @@ class SnapshotApiTest(test.TestCase):
|
||||
"""Check a page of snapshots list."""
|
||||
# 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
|
||||
request_path = '/%s/snapshots' % project
|
||||
expected_path = '/v2' + request_path
|
||||
request_path = '/v2/%s/snapshots' % project
|
||||
expected_path = request_path
|
||||
|
||||
# Construct the query if there are kwargs
|
||||
if kwargs:
|
||||
|
159
cinder/tests/unit/api/views/test_versions.py
Normal file
159
cinder/tests/unit/api/views/test_versions.py
Normal 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)
|
@ -1361,3 +1361,53 @@ class LogTracingTestCase(test.TestCase):
|
||||
host_stat['reserved_percentage'])
|
||||
|
||||
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))
|
||||
|
@ -778,6 +778,34 @@ def is_blk_device(dev):
|
||||
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,
|
||||
wait_random=False):
|
||||
|
||||
|
308
doc/source/devref/api_microversion_dev.rst
Normal file
308
doc/source/devref/api_microversion_dev.rst
Normal 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 ...
|
||||
|
1
doc/source/devref/api_microversion_history.rst
Normal file
1
doc/source/devref/api_microversion_history.rst
Normal file
@ -0,0 +1 @@
|
||||
.. include:: ../../../cinder/api/openstack/rest_api_version_history.rst
|
@ -27,6 +27,8 @@ Programming HowTos and Tutorials
|
||||
:maxdepth: 3
|
||||
|
||||
development.environment
|
||||
api_microversion_dev
|
||||
api_microversion_history
|
||||
unit_tests
|
||||
addmethod.openstackapi
|
||||
drivers
|
||||
|
@ -7,6 +7,7 @@ use = call:cinder.api:root_app_factory
|
||||
/: apiversions
|
||||
/v1: openstack_volume_api_v1
|
||||
/v2: openstack_volume_api_v2
|
||||
/v3: openstack_volume_api_v3
|
||||
|
||||
[composite:openstack_volume_api_v1]
|
||||
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_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]
|
||||
paste.filter_factory = oslo_middleware.request_id:RequestId.factory
|
||||
|
||||
[filter:cors]
|
||||
paste.filter_factory = oslo_middleware.cors:filter_factory
|
||||
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_expose_headers = X-Auth-Token, X-Subject-Token, X-Service-Token, X-OpenStack-Request-ID
|
||||
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, OpenStack-Volume-microversion
|
||||
latent_allow_methods = GET, PUT, POST, DELETE, PATCH
|
||||
|
||||
[filter:faultwrap]
|
||||
@ -48,6 +55,9 @@ paste.app_factory = cinder.api.v1.router:APIRouter.factory
|
||||
[app:apiv2]
|
||||
paste.app_factory = cinder.api.v2.router:APIRouter.factory
|
||||
|
||||
[app:apiv3]
|
||||
paste.app_factory = cinder.api.v3.router:APIRouter.factory
|
||||
|
||||
[pipeline:apiversions]
|
||||
pipeline = cors faultwrap osvolumeversionapp
|
||||
|
||||
|
@ -0,0 +1,3 @@
|
||||
---
|
||||
features:
|
||||
- Add support for API microversions, as well as /v3 API endpoint
|
Loading…
Reference in New Issue
Block a user