diff --git a/README.rst b/README.rst index 04fdec2..6e7e09b 100644 --- a/README.rst +++ b/README.rst @@ -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``. diff --git a/microversion_parse/__init__.py b/microversion_parse/__init__.py index 6fc8071..eae6398 100644 --- a/microversion_parse/__init__.py +++ b/microversion_parse/__init__.py @@ -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) diff --git a/microversion_parse/tests/test_extract_version.py b/microversion_parse/tests/test_extract_version.py new file mode 100644 index 0000000..af63ffc --- /dev/null +++ b/microversion_parse/tests/test_extract_version.py @@ -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)