Import functions from placement

This change imports two functions and a class from the microversion
handling implementation in the placement service. This code should be
generally useful for other implementations.

The added code provides functions for not just extracting the
microversion from HTTP headers, but for validating that the found
information is actually a properly formed microversion value.

'latest' is translated to whatever latest is in an ordered list of
versions and any found version is confirmed to be within the bounds of
and a member of that list of versions.

The README.rst has been updated to reflect the newly available methods.
This is a start at documentation, but we'll probably want more.

Change-Id: I267586c78308cc5520a88598c350a7e055783f3e
This commit is contained in:
Chris Dent 2017-08-22 12:33:19 +01:00
parent bd003b52a5
commit da0b4d66c9
3 changed files with 223 additions and 1 deletions

View File

@ -1,6 +1,13 @@
microversion_parse
==================
A small set of functions to manage OpenStack microversion headers that can
be used in middleware, application handlers and decorators to effectively
manage microversions.
get_version
-----------
A simple parser for OpenStack microversion headers::
import microversion_parse
@ -21,8 +28,10 @@ It processes microversion headers with the standard form::
OpenStack-API-Version: compute 2.1
In that case, the response will be '2.1'.
If provided with a ``legacy_headers`` argument, this is treated as
a list of headers to check for microversions. Some examples of
a list of additional headers to check for microversions. Some examples of
headers include::
OpenStack-telemetry-api-version: 2.1
@ -32,3 +41,33 @@ headers include::
If a version string cannot be found, ``None`` will be returned. If
the input is incorrect usual Python exceptions (ValueError,
TypeError) are allowed to raise to the caller.
parse_version_string
--------------------
A function to turn a version string into a ``Version``, a comparable
``namedtuple``::
version_tuple = microversion_parse.parse_version_string('2.1')
If the provided string is not a valid microversion string, ``TypeError``
is raised.
extract_version
---------------
Combines ``get_version`` and ``parse_version_string`` to find and validate
a microversion for a given service type in a collection of headers::
version_tuple = microversion_parse.extract_version(
headers, # a representation of headers, as accepted by get_version
service_type, # service type identify to match in headers
versions_list, # an ordered list of strings of version numbers that
# are the valid versions presented by this service
)
``latest`` will be translated to whatever the max version is in versions_list.
If the found version is not in versions_list a ``ValueError`` is raised.
Note that ``extract_version`` does not support ``legacy_headers``.

View File

@ -20,6 +20,20 @@ ENVIRON_HTTP_HEADER_FMT = 'http_{}'
STANDARD_HEADER = 'openstack-api-version'
class Version(collections.namedtuple('Version', 'major minor')):
"""A namedtuple containing major and minor values.
Since it is a tuple, it is automatically comparable.
"""
def __str__(self):
return '%s.%s' % (self.major, self.minor)
def matches(self, min_version, max_version):
"""Is this version within min_version and max_version."""
return min_version <= self <= max_version
def get_version(headers, service_type, legacy_headers=None):
"""Parse a microversion out of headers
@ -129,3 +143,56 @@ def _extract_header_value(headers, header_name):
header_name.replace('-', '_'))
value = headers[wsgi_header_name]
return value
def parse_version_string(version_string):
"""Turn a version string into a Version
:param version_string: A string of two numerals, X.Y.
:returns: a Version
:raises: TypeError
"""
try:
# The combination of int and a limited split with the
# named tuple means that this incantation will raise
# ValueError, TypeError or AttributeError when the incoming
# data is poorly formed but will, however, naturally adapt to
# extraneous whitespace.
return Version(*(int(value) for value
in version_string.split('.', 1)))
except (ValueError, TypeError, AttributeError) as exc:
raise TypeError('invalid version string: %s; %s' % (
version_string, exc))
def extract_version(headers, service_type, versions_list):
"""Extract the microversion from the headers.
There may be multiple headers and some which don't match our
service.
If no version is found then the extracted version is the minimum
available version.
:param headers: Request headers as dict list or WSGI environ
:param service_type: The service_type as a string
:param versions_list: List of all possible microversions as strings,
sorted from earliest to latest version.
:returns: a Version
:raises: ValueError
"""
found_version = get_version(headers, service_type=service_type)
min_version_string = versions_list[0]
max_version_string = versions_list[-1]
# If there was no version found in the headers, choose the minimum
# available version.
version_string = found_version or min_version_string
if version_string == 'latest':
version_string = max_version_string
request_version = parse_version_string(version_string)
# We need a version that is in versions_list. This gives us the option
# to administratively disable a version if we really need to.
if str(request_version) in versions_list:
return request_version
raise ValueError('Unacceptable version header: %s' % version_string)

View File

@ -0,0 +1,116 @@
# 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 testtools
import microversion_parse
class TestVersion(testtools.TestCase):
def setUp(self):
super(TestVersion, self).setUp()
self.version = microversion_parse.Version(1, 5)
def test_version_is_tuple(self):
self.assertEqual((1, 5), self.version)
def test_version_stringifies(self):
self.assertEqual('1.5', str(self.version))
def test_version_matches(self):
max_version = microversion_parse.Version(1, 20)
min_version = microversion_parse.Version(1, 3)
self.assertTrue(self.version.matches(min_version, max_version))
self.assertFalse(self.version.matches(max_version, min_version))
def test_version_matches_inclusive(self):
max_version = microversion_parse.Version(1, 5)
min_version = microversion_parse.Version(1, 5)
self.assertTrue(self.version.matches(min_version, max_version))
def test_version_init_failure(self):
self.assertRaises(TypeError, microversion_parse.Version, 1, 2, 3)
class TestParseVersionString(testtools.TestCase):
def test_good_version(self):
version = microversion_parse.parse_version_string('1.1')
self.assertEqual((1, 1), version)
self.assertEqual(microversion_parse.Version(1, 1), version)
def test_adapt_whitespace(self):
version = microversion_parse.parse_version_string(' 1.1 ')
self.assertEqual((1, 1), version)
self.assertEqual(microversion_parse.Version(1, 1), version)
def test_non_numeric(self):
self.assertRaises(TypeError,
microversion_parse.parse_version_string,
'hello')
def test_mixed_alphanumeric(self):
self.assertRaises(TypeError,
microversion_parse.parse_version_string,
'1.a')
def test_too_many_numeric(self):
self.assertRaises(TypeError,
microversion_parse.parse_version_string,
'1.1.1')
def test_not_string(self):
self.assertRaises(TypeError,
microversion_parse.parse_version_string,
1.1)
class TestExtractVersion(testtools.TestCase):
def setUp(self):
super(TestExtractVersion, self).setUp()
self.headers = [
('OpenStack-API-Version', 'service1 1.2'),
('OpenStack-API-Version', 'service2 1.5'),
('OpenStack-API-Version', 'service3 latest'),
('OpenStack-API-Version', 'service4 2.5'),
]
self.version_list = ['1.1', '1.2', '1.3', '1.4',
'2.1', '2.2', '2.3', '2.4']
def test_simple_extract(self):
version = microversion_parse.extract_version(
self.headers, 'service1', self.version_list)
self.assertEqual((1, 2), version)
def test_default_min(self):
version = microversion_parse.extract_version(
self.headers, 'notlisted', self.version_list)
self.assertEqual((1, 1), version)
def test_latest(self):
version = microversion_parse.extract_version(
self.headers, 'service3', self.version_list)
self.assertEqual((2, 4), version)
def test_version_disabled(self):
self.assertRaises(ValueError, microversion_parse.extract_version,
self.headers, 'service2', self.version_list)
def test_version_out_of_range(self):
self.assertRaises(ValueError, microversion_parse.extract_version,
self.headers, 'service4', self.version_list)