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
|
||||
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.
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue