Merge "Introduce API micro version"

This commit is contained in:
Jenkins
2017-04-26 02:34:32 +00:00
committed by Gerrit Code Review
11 changed files with 515 additions and 16 deletions

View File

@@ -12,6 +12,19 @@
# License for the specific language governing permissions and limitations
# under the License.
import operator
import six
from pecan import rest
from zun.api.controllers import versions
from zun.api import versioned_method
from zun.common import exception
from zun.common.i18n import _
# name of attribute to keep version method information
VER_METHOD_ATTR = 'versioned_methods'
class APIBase(object):
@@ -45,3 +58,171 @@ class APIBase(object):
for k in self.as_dict():
if k not in except_list:
setattr(self, k, None)
class ControllerMetaclass(type):
"""Controller metaclass.
This metaclass automates the task of assembling a dictionary
mapping action keys to method names.
"""
def __new__(mcs, name, bases, cls_dict):
"""Adds version function dictionary to the class."""
versioned_methods = None
for base in bases:
if base.__name__ == "Controller":
# NOTE(cyeoh): This resets the VER_METHOD_ATTR attribute
# between API controller class creations. This allows us
# to use a class decorator on the API methods that doesn't
# require naming explicitly what method is being versioned as
# it can be implicit based on the method decorated. It is a bit
# ugly.
if VER_METHOD_ATTR in base.__dict__:
versioned_methods = getattr(base, VER_METHOD_ATTR)
delattr(base, VER_METHOD_ATTR)
if versioned_methods:
cls_dict[VER_METHOD_ATTR] = versioned_methods
return super(ControllerMetaclass, mcs).__new__(mcs, name, bases,
cls_dict)
@six.add_metaclass(ControllerMetaclass)
class Controller(rest.RestController):
"""Base Rest Controller"""
def __getattribute__(self, key):
def version_select():
"""Select the correct method based on version
@return: Returns the correct versioned method
@raises: HTTPNotAcceptable if there is no method which
matches the name and version constraints
"""
from pecan import request
ver = request.version
func_list = self.versioned_methods[key]
for func in func_list:
if ver.matches(func.start_version, func.end_version):
return func.func
raise exception.HTTPNotAcceptable(_(
"Version %(ver)s was requested but the requested API %(api)s "
"is not supported for this version.") % {'ver': ver,
'api': key})
try:
version_meth_dict = object.__getattribute__(self, VER_METHOD_ATTR)
except AttributeError:
# No versioning on this class
return object.__getattribute__(self, key)
if version_meth_dict and key in version_meth_dict:
return version_select().__get__(self, self.__class__)
return object.__getattribute__(self, key)
# NOTE: This decorator MUST appear first (the outermost
# decorator) on an API method for it to work correctly
@classmethod
def api_version(cls, min_ver, max_ver=None):
"""Decorator for versioning api methods.
Add the decorator to any pecan method that has been exposed.
This decorator will store the method, min version, and max
version in a list for each api. It will check that there is no
overlap between versions and methods. When the api is called the
controller will use the list for each api to determine which
method to call.
Example:
@base.Controller.api_version("1.1", "1.2")
def get_one(self, container_id):
{...code for versions 1.1 to 1.2...}
@base.Controller.api_version("1.3")
def get_one(self, container_id):
{...code for versions 1.3 to latest}
@min_ver: string representing minimum version
@max_ver: optional string representing maximum version
@raises: ApiVersionsIntersect if an version overlap is found between
method versions.
"""
def decorator(f):
obj_min_ver = versions.Version('', '', '', min_ver)
if max_ver:
obj_max_ver = versions.Version('', '', '', max_ver)
else:
obj_max_ver = versions.Version('', '', '',
versions.CURRENT_MAX_VER)
# Add to list of versioned methods registered
func_name = f.__name__
new_func = versioned_method.VersionedMethod(
func_name, obj_min_ver, obj_max_ver, f)
func_dict = getattr(cls, VER_METHOD_ATTR, {})
if not func_dict:
setattr(cls, VER_METHOD_ATTR, func_dict)
func_list = func_dict.get(func_name, [])
if not func_list:
func_dict[func_name] = func_list
func_list.append(new_func)
is_intersect = Controller.check_for_versions_intersection(
func_list)
if is_intersect:
raise exception.ApiVersionsIntersect(
name=new_func.name,
min_ver=new_func.start_version,
max_ver=new_func.end_version
)
# Ensure the list is sorted by minimum version (reversed)
# so later when we work through the list in order we find
# the method which has the latest version which supports
# the version requested.
func_list.sort(key=lambda f: f.start_version, reverse=True)
return f
return decorator
@staticmethod
def check_for_versions_intersection(func_list):
"""Determines whether function list intersections
General algorithm:
https://en.wikipedia.org/wiki/Intersection_algorithm
:param func_list: list of VersionedMethod objects
:return: boolean
"""
pairs = []
counter = 0
for f in func_list:
pairs.append((f.start_version, 1))
pairs.append((f.end_version, -1))
pairs.sort(key=operator.itemgetter(1), reverse=True)
pairs.sort(key=operator.itemgetter(0))
for p in pairs:
counter += p[1]
if counter > 1:
return True
return False

View File

@@ -16,6 +16,7 @@ from pecan import rest
from zun.api.controllers import base
from zun.api.controllers import link
from zun.api.controllers import v1
from zun.api.controllers import versions
class Version(base.APIBase):
@@ -24,14 +25,20 @@ class Version(base.APIBase):
fields = (
'id',
'links',
'status',
'max_version',
'min_version'
)
@staticmethod
def convert(id):
def convert(id, status, max, min):
version = Version()
version.id = id
version.links = [link.make_link('self', pecan.request.host_url,
id, '', bookmark=True)]
version.status = status
version.max_version = max
version.min_version = min
return version
@@ -50,8 +57,13 @@ class Root(base.APIBase):
root.name = "OpenStack Zun API"
root.description = ("Zun is an OpenStack project which aims to "
"provide container management.")
root.versions = [Version.convert('v1')]
root.default_version = Version.convert('v1')
root.versions = [Version.convert('v1', "CURRENT",
versions.CURRENT_MAX_VER,
versions.BASE_VER)]
root.default_version = Version.convert('v1', "CURRENT",
versions.CURRENT_MAX_VER,
versions.BASE_VER)
return root

View File

@@ -20,17 +20,31 @@ NOTE: IN PROGRESS AND NOT FULLY IMPLEMENTED.
from oslo_log import log as logging
import pecan
from pecan import rest
from zun.api.controllers import base as controllers_base
from zun.api.controllers import link
from zun.api.controllers.v1 import containers as container_controller
from zun.api.controllers.v1 import images as image_controller
from zun.api.controllers.v1 import zun_services
from zun.api.controllers import versions as ver
from zun.api import http_error
from zun.common.i18n import _
LOG = logging.getLogger(__name__)
BASE_VERSION = 1
MIN_VER_STR = '%s %s' % (ver.Version.service_string, ver.BASE_VER)
MAX_VER_STR = '%s %s' % (ver.Version.service_string, ver.CURRENT_MAX_VER)
MIN_VER = ver.Version({ver.Version.string: MIN_VER_STR},
MIN_VER_STR, MAX_VER_STR)
MAX_VER = ver.Version({ver.Version.string: MAX_VER_STR},
MIN_VER_STR, MAX_VER_STR)
class MediaType(controllers_base.APIBase):
"""A media type representation."""
@@ -86,7 +100,7 @@ class V1(controllers_base.APIBase):
return v1
class Controller(rest.RestController):
class Controller(controllers_base.Controller):
"""Version 1 API controller root."""
services = zun_services.ZunServiceController()
@@ -97,8 +111,47 @@ class Controller(rest.RestController):
def get(self):
return V1.convert()
def _check_version(self, version, headers=None):
if headers is None:
headers = {}
# ensure that major version in the URL matches the header
if version.major != BASE_VERSION:
raise http_error.HTTPNotAcceptableAPIVersion(_(
"Mutually exclusive versions requested. Version %(ver)s "
"requested but not supported by this service. "
"The supported version range is: "
"[%(min)s, %(max)s].") % {'ver': version,
'min': MIN_VER_STR,
'max': MAX_VER_STR},
headers=headers,
max_version=str(MAX_VER),
min_version=str(MIN_VER))
# ensure the minor version is within the supported range
if version < MIN_VER or version > MAX_VER:
raise http_error.HTTPNotAcceptableAPIVersion(_(
"Version %(ver)s was requested but the minor version is not "
"supported by this service. The supported version range is: "
"[%(min)s, %(max)s].") % {'ver': version, 'min': MIN_VER_STR,
'max': MAX_VER_STR},
headers=headers,
max_version=str(MAX_VER),
min_version=str(MIN_VER))
@pecan.expose()
def _route(self, args):
version = ver.Version(
pecan.request.headers, MIN_VER_STR, MAX_VER_STR)
# Always set the basic version headers
pecan.response.headers[ver.Version.min_string] = MIN_VER_STR
pecan.response.headers[ver.Version.max_string] = MAX_VER_STR
pecan.response.headers[ver.Version.string] = " ".join(
[ver.Version.service_string, str(version)])
pecan.response.headers["vary"] = ver.Version.string
# assert that requested version is supported
self._check_version(version, pecan.response.headers)
pecan.request.version = version
if pecan.request.body:
msg = ("Processing request: url: %(url)s, %(method)s, "
"body: %(body)s" %

View File

@@ -16,9 +16,9 @@
from oslo_log import log as logging
from oslo_utils import strutils
import pecan
from pecan import rest
import six
from zun.api.controllers import base
from zun.api.controllers import link
from zun.api.controllers.v1 import collection
from zun.api.controllers.v1.schemas import containers as schema
@@ -74,7 +74,7 @@ class ContainerCollection(collection.Collection):
return collection
class ContainersController(rest.RestController):
class ContainersController(base.Controller):
"""Controller for Containers."""
_custom_actions = {

View File

@@ -13,8 +13,8 @@
from oslo_log import log as logging
from oslo_utils import strutils
import pecan
from pecan import rest
from zun.api.controllers import base
from zun.api.controllers import link
from zun.api.controllers.v1 import collection
from zun.api.controllers.v1.schemas import images as schema
@@ -51,7 +51,7 @@ class ImageCollection(collection.Collection):
return collection
class ImagesController(rest.RestController):
class ImagesController(base.Controller):
'''Controller for Images'''
_custom_actions = {

View File

@@ -11,8 +11,8 @@
# under the License.
import pecan
from pecan import rest
from zun.api.controllers import base
from zun.api.controllers.v1 import collection
from zun.api import servicegroup as svcgrp_api
from zun.common import exception
@@ -48,7 +48,7 @@ class ZunServiceCollection(collection.Collection):
return collection
class ZunServiceController(rest.RestController):
class ZunServiceController(base.Controller):
"""REST controller for zun-services."""
def __init__(self, **kwargs):

View File

@@ -0,0 +1,138 @@
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from webob import exc
from zun.common.i18n import _
# NOTE(yuntong): v1.0 is reserved to indicate Ocata's API, but is not presently
# supported by the API service. All changes between Ocata and the
# point where we added microversioning are considered backwards-
# compatible, but are not specifically discoverable at this time.
#
# The v1.1 version indicates this "initial" version as being
# different from Ocata (v1.0), and includes the following changes:
#
# Add details of new api versions here:
BASE_VER = '1.1'
CURRENT_MAX_VER = '1.1'
class Version(object):
"""API Version object."""
string = 'OpenStack-API-Version'
"""HTTP Header string carrying the requested version"""
min_string = 'OpenStack-API-Minimum-Version'
"""HTTP response header"""
max_string = 'OpenStack-API-Maximum-Version'
"""HTTP response header"""
service_string = 'container'
def __init__(self, headers, default_version, latest_version,
from_string=None):
"""Create an API Version object from the supplied headers.
:param headers: webob headers
:param default_version: version to use if not specified in headers
:param latest_version: version to use if latest is requested
:param from_string: create the version from string not headers
:raises: webob.HTTPNotAcceptable
"""
if from_string:
(self.major, self.minor) = tuple(int(i)
for i in from_string.split('.'))
else:
(self.major, self.minor) = Version.parse_headers(headers,
default_version,
latest_version)
def __repr__(self):
return '%s.%s' % (self.major, self.minor)
@staticmethod
def parse_headers(headers, default_version, latest_version):
"""Determine the API version requested based on the headers supplied.
:param headers: webob headers
:param default_version: version to use if not specified in headers
:param latest_version: version to use if latest is requested
:returns: a tuple of (major, minor) version numbers
:raises: webob.HTTPNotAcceptable
"""
version_hdr = headers.get(Version.string, default_version)
try:
version_service, version_str = version_hdr.split()
except ValueError:
raise exc.HTTPNotAcceptable(_(
"Invalid service type for %s header") % Version.string)
if version_str.lower() == 'latest':
version_service, version_str = latest_version.split()
if version_service != Version.service_string:
raise exc.HTTPNotAcceptable(_(
"Invalid service type for %s header") % Version.string)
try:
version = tuple(int(i) for i in version_str.split('.'))
except ValueError:
version = ()
if len(version) != 2:
raise exc.HTTPNotAcceptable(_(
"Invalid value for %s header") % Version.string)
return version
def is_null(self):
return self.major == 0 and self.minor == 0
def matches(self, start_version, end_version):
if self.is_null():
raise ValueError
return start_version <= self <= end_version
def __lt__(self, other):
if self.major < other.major:
return True
if self.major == other.major and self.minor < other.minor:
return True
return False
def __gt__(self, other):
if self.major > other.major:
return True
if self.major == other.major and self.minor > other.minor:
return True
return False
def __eq__(self, other):
return self.major == other.major and self.minor == other.minor
def __le__(self, other):
return self < other or self == other
def __ne__(self, other):
return not self.__eq__(other)
def __ge__(self, other):
return self > other or self == other

70
zun/api/http_error.py Normal file
View File

@@ -0,0 +1,70 @@
# 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 json
import six
from webob import exc
class HTTPNotAcceptableAPIVersion(exc.HTTPNotAcceptable):
# subclass of :class:`~HTTPNotAcceptable`
#
# This indicates the resource identified by the request is only
# capable of generating response entities which have content
# characteristics not acceptable according to the accept headers
# sent in the request.
#
# code: 406, title: Not Acceptable
#
# differences from webob.exc.HTTPNotAcceptable:
#
# - additional max and min version parameters
# - additional error info for code, title, and links
code = 406
title = 'Not Acceptable'
max_version = ''
min_version = ''
def __init__(self, detail=None, headers=None, comment=None,
body_template=None, max_version='', min_version='', **kw):
super(HTTPNotAcceptableAPIVersion, self).__init__(
detail=detail, headers=headers, comment=comment,
body_template=body_template, **kw)
self.max_version = max_version
self.min_version = min_version
def __call__(self, environ, start_response):
for err_str in self.app_iter:
err = {}
try:
err = json.loads(err_str.decode('utf-8'))
except ValueError:
pass
links = {'rel': 'help', 'href': 'http://developer.openstack.org'
'/api-guide/compute/microversions.html'}
err['max_version'] = self.max_version
err['min_version'] = self.min_version
err['code'] = "zun.microversion-unsupported"
err['links'] = [links]
err['title'] = "Requested microversion is unsupported"
self.app_iter = [six.b(json.dumps(err))]
self.headers['Content-Length'] = str(len(self.app_iter[0]))
return super(HTTPNotAcceptableAPIVersion, self).__call__(
environ, start_response)

View File

@@ -0,0 +1,35 @@
# Copyright 2014 IBM Corp.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
class VersionedMethod(object):
def __init__(self, name, start_version, end_version, func):
"""Versioning information for a single method
@name: Name of the method
@start_version: Minimum acceptable version
@end_version: Maximum acceptable_version
@func: Method to call
Minimum and maximums are inclusive
"""
self.name = name
self.start_version = start_version
self.end_version = end_version
self.func = func
def __str__(self):
return ("Version Method %s: min: %s, max: %s"
% (self.name, self.start_version, self.end_version))

View File

@@ -470,3 +470,8 @@ class SchedulerHostFilterNotFound(NotFound):
class ClassNotFound(NotFound):
msg_fmt = _("Class %(class_name)s could not be found: %(exception)s")
class ApiVersionsIntersect(ZunException):
message = _("Version of %(name)s %(min_ver)s %(max_ver)s intersects "
"with another versions.")

View File

@@ -23,14 +23,19 @@ class TestRootController(api_base.FunctionalTest):
super(TestRootController, self).setUp()
self.root_expected = {
u'default_version':
{u'id': u'v1', u'links':
[{u'href': u'http://localhost/v1/', u'rel': u'self'}]},
{u'id': u'v1',
u'links': [{u'href': u'http://localhost/v1/', u'rel': u'self'}],
u'max_version': u'1.1',
u'min_version': u'1.1',
u'status': u'CURRENT'},
u'description': u'Zun is an OpenStack project which '
'aims to provide container management.',
u'versions': [{u'id': u'v1',
u'links':
[{u'href': u'http://localhost/v1/',
u'rel': u'self'}]}]}
u'links': [{u'href': u'http://localhost/v1/',
u'rel': u'self'}],
u'max_version': u'1.1',
u'min_version': u'1.1',
u'status': u'CURRENT'}]}
self.v1_expected = {
u'media_types':