Implement optional API versioning

Also adds endpoints / and /v1 returning {} that a client can
use to learn current API versions.

Functional testing code is updated to support API versions.

Change-Id: I4ada89a0f5128dea2234ae652222dc9b7f408502
Implements: blueprint api-versioning
This commit is contained in:
Dmitry Tantsur 2015-07-23 15:34:18 +02:00
parent 7bef0f63c6
commit 57ae4622f2
4 changed files with 142 additions and 5 deletions

View File

@ -4,8 +4,6 @@ HTTP API
By default **ironic-inspector** listens on ``0.0.0.0:5050``, port
can be changed in configuration. Protocol is JSON over HTTP.
The HTTP API consist of these endpoints:
Start Introspection
~~~~~~~~~~~~~~~~~~~
@ -88,6 +86,10 @@ the ramdisk. Request body: JSON dictionary with at least these keys:
This list highly depends on enabled plugins, provided above are
expected keys for the default set of plugins. See Plugins_ for details.
.. note::
This endpoint is not expected to be versioned, though versioning will work
on it.
Response:
* 200 - OK
@ -104,3 +106,31 @@ body will contain the following keys:
.. _Setting IPMI Credentials: https://github.com/openstack/ironic-inspector#setting-ipmi-credentials
.. _Plugins: https://github.com/openstack/ironic-inspector#plugins
API Versioning
~~~~~~~~~~~~~~
The API supports optional API versioning. You can query for minimum and
maximum API version supported by the server. You can also declare required API
version in your requests, so that the server rejects request of unsupported
version.
.. note::
Versioning was introduced in **Ironic Inspector 2.1.0**.
All versions must be supplied as string in form of ``X.Y``, where ``X`` is a
major version and is always ``1`` for now, ``Y`` is a minor version.
* If ``X-OpenStack-Ironic-Inspector-API-Version`` header is sent with request,
the server will check if it supports this version. HTTP error 406 will be
returned for unsupported API version.
* All HTTP responses contain
``X-OpenStack-Ironic-Inspector-API-Minimum-Version`` and
``X-OpenStack-Ironic-Inspector-API-Maximum-Version`` headers with minimum
and maximum API versions supported by the server.
Version History
^^^^^^^^^^^^^^^
**1.0** version of API at the moment of introducing versioning.

View File

@ -40,10 +40,24 @@ CONF = cfg.CONF
app = flask.Flask(__name__)
LOG = logging.getLogger('ironic_inspector.main')
MINIMUM_API_VERSION = (1, 0)
CURRENT_API_VERSION = (1, 0)
_MIN_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Minimum-Version'
_MAX_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Maximum-Version'
_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Version'
def _format_version(ver):
return '%d.%d' % ver
_DEFAULT_API_VERSION = _format_version(MINIMUM_API_VERSION)
def error_response(exc, code=500):
res = flask.jsonify(error={'message': str(exc)})
res.status_code = code
LOG.debug('Returning error to client: %s', exc)
return res
@ -63,6 +77,41 @@ def convert_exceptions(func):
return wrapper
@app.before_request
def check_api_version():
requested = flask.request.headers.get(_VERSION_HEADER,
_DEFAULT_API_VERSION)
try:
requested = tuple(int(x) for x in requested.split('.'))
except (ValueError, TypeError):
return error_response(_('Malformed API version: expected string '
'in form of X.Y'), code=400)
if requested < MINIMUM_API_VERSION or requested > CURRENT_API_VERSION:
return error_response(_('Unsupported API version %(requested)s, '
'supported range is %(min)s to %(max)s') %
{'requested': _format_version(requested),
'min': _format_version(MINIMUM_API_VERSION),
'max': _format_version(CURRENT_API_VERSION)},
code=406)
@app.after_request
def add_version_headers(res):
res.headers[_MIN_VERSION_HEADER] = '%s.%s' % MINIMUM_API_VERSION
res.headers[_MAX_VERSION_HEADER] = '%s.%s' % CURRENT_API_VERSION
return res
@app.route('/', methods=['GET'])
@app.route('/v1', methods=['GET'])
@convert_exceptions
def api_root():
# TODO(dtantsur): this endpoint only returns API version now, it's possible
# we'll return something meaningful in addition later
return '{}', 200, {'Content-Type': 'application/json'}
@app.route('/v1/continue', methods=['POST'])
@convert_exceptions
def api_continue():

View File

@ -82,11 +82,14 @@ class Base(base.NodeTest):
self.node.power_state = 'power off'
def call(self, method, endpoint, data=None, expect_errors=False):
def call(self, method, endpoint, data=None, expect_errors=False,
api_version=None):
if data is not None:
data = json.dumps(data)
endpoint = 'http://127.0.0.1:5050' + endpoint
headers = {'X-Auth-Token': 'token'}
if api_version:
headers[main._VERSION_HEADER] = '%d.%d' % api_version
res = getattr(requests, method.lower())(endpoint, data=data,
headers=headers)
if not expect_errors:

View File

@ -37,14 +37,16 @@ def _get_error(res):
return json.loads(res.data.decode('utf-8'))['error']['message']
class TestApi(test_base.BaseTest):
class BaseAPITest(test_base.BaseTest):
def setUp(self):
super(TestApi, self).setUp()
super(BaseAPITest, self).setUp()
main.app.config['TESTING'] = True
self.app = main.app.test_client()
CONF.set_override('auth_strategy', 'noauth')
self.uuid = uuidutils.generate_uuid()
class TestApiIntrospect(BaseAPITest):
@mock.patch.object(introspect, 'introspect', autospec=True)
def test_introspect_no_authentication(self, introspect_mock):
CONF.set_override('auth_strategy', 'noauth')
@ -100,6 +102,8 @@ class TestApi(test_base.BaseTest):
res = self.app.post('/v1/introspection/%s' % uuid_dummy)
self.assertEqual(400, res.status_code)
class TestApiContinue(BaseAPITest):
@mock.patch.object(process, 'process', autospec=True)
def test_continue(self, process_mock):
# should be ignored
@ -118,6 +122,8 @@ class TestApi(test_base.BaseTest):
process_mock.assert_called_once_with("JSON")
self.assertEqual('boom', _get_error(res))
class TestApiGetStatus(BaseAPITest):
@mock.patch.object(node_cache, 'get_node', autospec=True)
def test_get_introspection_in_progress(self, get_mock):
get_mock.return_value = node_cache.NodeInfo(uuid=self.uuid,
@ -138,6 +144,8 @@ class TestApi(test_base.BaseTest):
self.assertEqual({'finished': True, 'error': 'boom'},
json.loads(res.data.decode('utf-8')))
class TestApiMisc(BaseAPITest):
@mock.patch.object(node_cache, 'get_node', autospec=True)
def test_404_expected(self, get_mock):
get_mock.side_effect = iter([utils.Error('boom', code=404)])
@ -169,6 +177,53 @@ class TestApi(test_base.BaseTest):
_get_error(res))
class TestApiVersions(BaseAPITest):
def _check_version_present(self, res):
self.assertEqual('%d.%d' % main.MINIMUM_API_VERSION,
res.headers.get(main._MIN_VERSION_HEADER))
self.assertEqual('%d.%d' % main.CURRENT_API_VERSION,
res.headers.get(main._MAX_VERSION_HEADER))
def test_root_endpoints(self):
for endpoint in '/', '/v1':
res = self.app.get(endpoint)
self.assertEqual(200, res.status_code)
self._check_version_present(res)
def test_404_unexpected(self):
# API version on unknown pages
self._check_version_present(self.app.get('/v1/foobar'))
@mock.patch.object(node_cache, 'get_node', autospec=True)
def test_usual_requests(self, get_mock):
get_mock.return_value = node_cache.NodeInfo(uuid=self.uuid,
started_at=42.0)
# Successfull
self._check_version_present(
self.app.post('/v1/introspection/%s' % self.uuid))
# With error
self._check_version_present(
self.app.post('/v1/introspection/foobar'))
def test_request_correct_version(self):
headers = {main._VERSION_HEADER:
main._format_version(main.CURRENT_API_VERSION)}
self._check_version_present(self.app.get('/', headers=headers))
def test_request_unsupported_version(self):
bad_version = (main.CURRENT_API_VERSION[0],
main.CURRENT_API_VERSION[1] + 1)
headers = {main._VERSION_HEADER:
main._format_version(bad_version)}
res = self.app.get('/', headers=headers)
self._check_version_present(res)
self.assertEqual(406, res.status_code)
error = _get_error(res)
self.assertIn('%d.%d' % bad_version, error)
self.assertIn('%d.%d' % main.MINIMUM_API_VERSION, error)
self.assertIn('%d.%d' % main.CURRENT_API_VERSION, error)
class TestPlugins(unittest.TestCase):
@mock.patch.object(example_plugin.ExampleProcessingHook,
'before_processing', autospec=True)