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:
parent
7bef0f63c6
commit
57ae4622f2
34
HTTP-API.rst
34
HTTP-API.rst
|
@ -4,8 +4,6 @@ HTTP API
|
||||||
By default **ironic-inspector** listens on ``0.0.0.0:5050``, port
|
By default **ironic-inspector** listens on ``0.0.0.0:5050``, port
|
||||||
can be changed in configuration. Protocol is JSON over HTTP.
|
can be changed in configuration. Protocol is JSON over HTTP.
|
||||||
|
|
||||||
The HTTP API consist of these endpoints:
|
|
||||||
|
|
||||||
Start Introspection
|
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
|
This list highly depends on enabled plugins, provided above are
|
||||||
expected keys for the default set of plugins. See Plugins_ for details.
|
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:
|
Response:
|
||||||
|
|
||||||
* 200 - OK
|
* 200 - OK
|
||||||
|
@ -104,3 +106,31 @@ body will contain the following keys:
|
||||||
|
|
||||||
.. _Setting IPMI Credentials: https://github.com/openstack/ironic-inspector#setting-ipmi-credentials
|
.. _Setting IPMI Credentials: https://github.com/openstack/ironic-inspector#setting-ipmi-credentials
|
||||||
.. _Plugins: https://github.com/openstack/ironic-inspector#plugins
|
.. _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.
|
||||||
|
|
|
@ -40,10 +40,24 @@ CONF = cfg.CONF
|
||||||
app = flask.Flask(__name__)
|
app = flask.Flask(__name__)
|
||||||
LOG = logging.getLogger('ironic_inspector.main')
|
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):
|
def error_response(exc, code=500):
|
||||||
res = flask.jsonify(error={'message': str(exc)})
|
res = flask.jsonify(error={'message': str(exc)})
|
||||||
res.status_code = code
|
res.status_code = code
|
||||||
|
LOG.debug('Returning error to client: %s', exc)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@ -63,6 +77,41 @@ def convert_exceptions(func):
|
||||||
return wrapper
|
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'])
|
@app.route('/v1/continue', methods=['POST'])
|
||||||
@convert_exceptions
|
@convert_exceptions
|
||||||
def api_continue():
|
def api_continue():
|
||||||
|
|
|
@ -82,11 +82,14 @@ class Base(base.NodeTest):
|
||||||
|
|
||||||
self.node.power_state = 'power off'
|
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:
|
if data is not None:
|
||||||
data = json.dumps(data)
|
data = json.dumps(data)
|
||||||
endpoint = 'http://127.0.0.1:5050' + endpoint
|
endpoint = 'http://127.0.0.1:5050' + endpoint
|
||||||
headers = {'X-Auth-Token': 'token'}
|
headers = {'X-Auth-Token': 'token'}
|
||||||
|
if api_version:
|
||||||
|
headers[main._VERSION_HEADER] = '%d.%d' % api_version
|
||||||
res = getattr(requests, method.lower())(endpoint, data=data,
|
res = getattr(requests, method.lower())(endpoint, data=data,
|
||||||
headers=headers)
|
headers=headers)
|
||||||
if not expect_errors:
|
if not expect_errors:
|
||||||
|
|
|
@ -37,14 +37,16 @@ def _get_error(res):
|
||||||
return json.loads(res.data.decode('utf-8'))['error']['message']
|
return json.loads(res.data.decode('utf-8'))['error']['message']
|
||||||
|
|
||||||
|
|
||||||
class TestApi(test_base.BaseTest):
|
class BaseAPITest(test_base.BaseTest):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestApi, self).setUp()
|
super(BaseAPITest, self).setUp()
|
||||||
main.app.config['TESTING'] = True
|
main.app.config['TESTING'] = True
|
||||||
self.app = main.app.test_client()
|
self.app = main.app.test_client()
|
||||||
CONF.set_override('auth_strategy', 'noauth')
|
CONF.set_override('auth_strategy', 'noauth')
|
||||||
self.uuid = uuidutils.generate_uuid()
|
self.uuid = uuidutils.generate_uuid()
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiIntrospect(BaseAPITest):
|
||||||
@mock.patch.object(introspect, 'introspect', autospec=True)
|
@mock.patch.object(introspect, 'introspect', autospec=True)
|
||||||
def test_introspect_no_authentication(self, introspect_mock):
|
def test_introspect_no_authentication(self, introspect_mock):
|
||||||
CONF.set_override('auth_strategy', 'noauth')
|
CONF.set_override('auth_strategy', 'noauth')
|
||||||
|
@ -100,6 +102,8 @@ class TestApi(test_base.BaseTest):
|
||||||
res = self.app.post('/v1/introspection/%s' % uuid_dummy)
|
res = self.app.post('/v1/introspection/%s' % uuid_dummy)
|
||||||
self.assertEqual(400, res.status_code)
|
self.assertEqual(400, res.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiContinue(BaseAPITest):
|
||||||
@mock.patch.object(process, 'process', autospec=True)
|
@mock.patch.object(process, 'process', autospec=True)
|
||||||
def test_continue(self, process_mock):
|
def test_continue(self, process_mock):
|
||||||
# should be ignored
|
# should be ignored
|
||||||
|
@ -118,6 +122,8 @@ class TestApi(test_base.BaseTest):
|
||||||
process_mock.assert_called_once_with("JSON")
|
process_mock.assert_called_once_with("JSON")
|
||||||
self.assertEqual('boom', _get_error(res))
|
self.assertEqual('boom', _get_error(res))
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiGetStatus(BaseAPITest):
|
||||||
@mock.patch.object(node_cache, 'get_node', autospec=True)
|
@mock.patch.object(node_cache, 'get_node', autospec=True)
|
||||||
def test_get_introspection_in_progress(self, get_mock):
|
def test_get_introspection_in_progress(self, get_mock):
|
||||||
get_mock.return_value = node_cache.NodeInfo(uuid=self.uuid,
|
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'},
|
self.assertEqual({'finished': True, 'error': 'boom'},
|
||||||
json.loads(res.data.decode('utf-8')))
|
json.loads(res.data.decode('utf-8')))
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiMisc(BaseAPITest):
|
||||||
@mock.patch.object(node_cache, 'get_node', autospec=True)
|
@mock.patch.object(node_cache, 'get_node', autospec=True)
|
||||||
def test_404_expected(self, get_mock):
|
def test_404_expected(self, get_mock):
|
||||||
get_mock.side_effect = iter([utils.Error('boom', code=404)])
|
get_mock.side_effect = iter([utils.Error('boom', code=404)])
|
||||||
|
@ -169,6 +177,53 @@ class TestApi(test_base.BaseTest):
|
||||||
_get_error(res))
|
_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):
|
class TestPlugins(unittest.TestCase):
|
||||||
@mock.patch.object(example_plugin.ExampleProcessingHook,
|
@mock.patch.object(example_plugin.ExampleProcessingHook,
|
||||||
'before_processing', autospec=True)
|
'before_processing', autospec=True)
|
||||||
|
|
Loading…
Reference in New Issue