Update of resource_definitions content implemented

Now it is able to add, update, delete keys in resource
definition content.

Change-Id: I9624f97bf35eae15d6a7ddc2d5d42768292db4f0
This commit is contained in:
Alexander Kislitsky 2016-08-16 20:07:19 +03:00
parent 819fb9125a
commit b2a878e536
7 changed files with 380 additions and 1 deletions

View File

@ -29,6 +29,7 @@ from tuning_box.middleware import keystone
api_errors = {
'IntegrityError': {'status': 409}, # sqlalchemy IntegrityError
'TuningboxIntegrityError': {'status': 409},
'KeysOperationError': {'status': 409},
'TuningboxNotFound': {'status': 404}
}
api = flask_restful.Api(errors=api_errors)
@ -46,6 +47,11 @@ api.add_resource(
resource_definitions.ResourceDefinition,
'/resource_definition/<int:resource_definition_id>'
)
api.add_resource(
resource_definitions.ResourceDefinitionKeys,
'/resource_definition/<int:resource_definition_id>/'
'keys/<keys_operation:operation>'
)
# Resource values
api.add_resource(
@ -81,6 +87,12 @@ def handle_object_not_found(exc):
return response
def handle_keys_operation_error(exc):
response = flask.jsonify(msg=exc.args[0])
response.status_code = 409
return response
def build_app(configure_logging=True, with_keystone=True):
app = flask.Flask(__name__)
app.url_map.converters.update(converters.ALL)
@ -94,6 +106,8 @@ def build_app(configure_logging=True, with_keystone=True):
handle_integrity_error)
app.register_error_handler(errors.TuningboxNotFound,
handle_object_not_found)
app.register_error_handler(errors.KeysOperationError,
handle_keys_operation_error)
db.db.init_app(app)
if configure_logging:
log_level = app.config.get('LOG_LEVEL', 'INFO')

View File

@ -15,6 +15,8 @@ import itertools
from werkzeug import routing
from werkzeug import urls
from tuning_box.library import resource_keys_operation
class Levels(routing.BaseConverter):
"""Converter that maps nested levels to list of tuples.
@ -56,7 +58,20 @@ class IdOrName(routing.BaseConverter):
def to_url(self, value):
return super(IdOrName, self).to_url(str(value))
class KeysOperation(routing.BaseConverter):
"""Converter that matches keys operations
Allowed operations: add, delete, erase
"""
regex = '(' + ')|('.join(
resource_keys_operation.KeysOperationMixin.OPERATIONS
) + ')'
ALL = {
'levels': Levels,
'id_or_name': IdOrName,
'keys_operation': KeysOperation
}

View File

@ -21,3 +21,23 @@ class TuningboxIntegrityError(BaseTuningboxError):
class TuningboxNotFound(BaseTuningboxError):
pass
class KeysOperationError(BaseTuningboxError):
pass
class UnknownKeysOperation(KeysOperationError):
pass
class KeysPathNotExisted(KeysOperationError):
pass
class KeysPathInvalid(KeysOperationError):
pass
class KeysPathUnreachable(KeysOperationError):
pass

View File

@ -10,12 +10,12 @@
# License for the specific language governing permissions and limitations
# under the License.
import flask
import flask_restful
from flask_restful import fields
from tuning_box import db
from tuning_box.library import resource_keys_operation
resource_definition_fields = {
'id': fields.Integer,
@ -87,3 +87,22 @@ class ResourceDefinition(flask_restful.Resource):
resource_definition_id)
db.db.session.delete(res_definition)
return None, 204
class ResourceDefinitionKeys(flask_restful.Resource,
resource_keys_operation.KeysOperationMixin):
@db.with_transaction
def _do_update(self, resource_definition_id, operation):
res_definition = db.ResourceDefinition.query.get_or_404(
resource_definition_id)
result = self.perform_operation(operation, res_definition.content,
flask.request.json)
res_definition.content = result
def put(self, resource_definition_id, operation):
return self.patch(resource_definition_id, operation)
def patch(self, resource_definition_id, operation):
self._do_update(resource_definition_id, operation)
return None, 204

View File

@ -0,0 +1,118 @@
# 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 copy
from tuning_box import errors
class KeysOperationMixin(object):
OPERATION_SET = 'set'
OPERATION_DELETE = 'delete'
OPERATIONS = (OPERATION_SET, OPERATION_DELETE)
def _check_out_of_index(self, cur_point, key, keys_path):
if isinstance(cur_point, (list, tuple)) and key >= len(cur_point):
raise errors.KeysPathNotExisted(
"Keys path doesn't exist {0}. "
"Failed on the key {1}".format(keys_path, key)
)
def _check_key_existed(self, cur_point, key, keys_path):
if isinstance(cur_point, dict) and key not in cur_point:
raise errors.KeysPathNotExisted(
"Keys path doesn't exist {0}. "
"Failed on the key {1}".format(keys_path, key)
)
def _check_path_is_reachable(self, cur_point, key, keys_path):
if not isinstance(cur_point, (list, tuple, dict)):
raise errors.KeysPathUnreachable(
"Leaf value {0} found on key {1} "
"in keys path {2}".format(cur_point, key, keys_path)
)
def do_set(self, storage, keys_paths):
"""Sets values from keys paths to storage.
Keys path is list of keys paths. If we have keys_paths
[['a', 'b', 'val']], then storage['a']['b'] will be set to 'val'.
Last value in the keys path is value to be set.
:param storage: original data
:param keys_paths: lists of keys paths to be set
:returns: result of merging keys_paths and storage
"""
storage_copy = copy.deepcopy(storage)
for keys_path in keys_paths:
cur_point = storage_copy
if len(keys_path) < 2:
raise errors.KeysPathInvalid(
"Keys path {0} invalid. Keys path should contain "
"at least one key and value".format(keys_path)
)
for key in keys_path[:-2]:
self._check_path_is_reachable(cur_point, key, keys_path)
self._check_out_of_index(cur_point, key, keys_path)
self._check_key_existed(cur_point, key, keys_path)
cur_point = cur_point[key]
assign_to = keys_path[-2]
self._check_out_of_index(cur_point, assign_to, keys_path)
cur_point[assign_to] = keys_path[-1]
return storage_copy
def do_delete(self, storage, keys_paths):
"""Deletes keys paths from storage.
Keys path is list of keys paths. If we have keys_paths
[['a', 'b']], then storage['a']['b'] will be removed.
:param storage: data
:param keys_paths: lists of keys paths to be deleted
:returns: result of keys_paths deletion from storage
"""
storage_copy = copy.deepcopy(storage)
for keys_path in keys_paths:
cur_point = storage_copy
if not keys_path:
continue
try:
for key in keys_path[:-1]:
cur_point = cur_point[key]
key = keys_path[-1]
self._check_path_is_reachable(cur_point, key, keys_path)
del cur_point[key]
except (KeyError, IndexError):
raise errors.KeysPathNotExisted(
"Keys path doesn't exist {0}. "
"Failed on the key {1}".format(keys_path, key)
)
return storage_copy
def perform_operation(self, operation, storage, keys_paths):
if operation == self.OPERATION_SET:
return self.do_set(storage, keys_paths)
elif operation == self.OPERATION_DELETE:
return self.do_delete(storage, keys_paths)
else:
raise errors.UnknownKeysOperation(
"Unknown operation: {0}. "
"Allowed operations: {1}".format(operation, self.OPERATIONS)
)

View File

@ -19,6 +19,7 @@ class TestResourceDefinitions(BaseTest):
collection_url = '/resource_definitions'
object_url = '/resource_definition/{0}'
object_keys_url = object_url + '/keys/{1}'
@property
def _resource_json(self):
@ -150,3 +151,63 @@ class TestResourceDefinitions(BaseTest):
self.assertEqual(204, res.status_code)
actual_res_def = self.client.get(self.object_url.format(res_id)).json
self.assertEqual(self._resource_json, actual_res_def)
def test_put_resource_definition_set_operation_error(self):
self.app.config["PROPAGATE_EXCEPTIONS"] = True
self._fixture()
res_id = self._resource_json['id']
data = [['a', 'b', 'c', 'value']]
res = self.client.put(self.object_keys_url.format(res_id, 'set'),
data=data)
self.assertEqual(409, res.status_code)
def test_put_resource_definition_set(self):
self._fixture()
res_id = self._resource_json['id']
data = [['key', 'key_value'], ['key_x', 'key_x_value']]
res = self.client.put(
self.object_keys_url.format(res_id, 'set'),
data=data
)
self.assertEqual(204, res.status_code)
res = self.client.get(self.object_url.format(res_id))
self.assertEqual(200, res.status_code)
actual = res.json
self.assertEqual({'key': 'key_value', 'key_x': 'key_x_value'},
actual['content'])
def test_put_resource_definition_delete(self):
self._fixture()
res_id = self._resource_json['id']
data = [['key']]
res = self.client.put(
self.object_keys_url.format(res_id, 'delete'),
data=data
)
self.assertEqual(204, res.status_code)
res = self.client.get(self.object_url.format(res_id))
self.assertEqual(200, res.status_code)
actual = res.json
self.assertEqual({}, actual['content'])
def test_put_resource_definition_delete_no_key(self):
self.app.config["PROPAGATE_EXCEPTIONS"] = True
self._fixture()
res_id = self._resource_json['id']
data = [['fake_key']]
res = self.client.put(
self.object_keys_url.format(res_id, 'delete'),
data=data
)
self.assertEqual(409, res.status_code)
res = self.client.get(self.object_url.format(res_id))
self.assertEqual(200, res.status_code)
actual = res.json
self.assertEqual(self._resource_json['content'], actual['content'])

View File

@ -0,0 +1,132 @@
# 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.
from tuning_box import errors
from tuning_box.library import resource_keys_operation
from tuning_box.tests.test_app import BaseTest
class TestResourceDefinitions(BaseTest):
processor = resource_keys_operation.KeysOperationMixin()
def test_unknown_operation(self):
self.assertRaises(errors.UnknownKeysOperation,
self.processor.perform_operation,
'fake_operation', {}, [])
def test_set_new(self):
keys = [['a', {}]]
data = {}
result = self.processor.do_set(data, keys)
self.assertEqual({'a': {}}, result)
keys = [['a', {}], ['a', 'b', []]]
data = {}
result = self.processor.do_set(data, keys)
self.assertEqual({'a': {'b': []}}, result)
keys = [['a', 0, 'b', 'c_updated']]
data = {'a': [{'b': 'c'}]}
result = self.processor.do_set(data, keys)
self.assertEqual({'a': [{'b': 'c_updated'}]}, result)
keys = [['a', 'b']]
data = {'a': {'b': 'c'}}
result = self.processor.do_set(data, keys)
self.assertEqual({'a': 'b'}, result)
def test_set_empty(self):
keys = [['a', 'b', '']]
data = {'a': {'b': 'value'}}
result = self.processor.do_set(data, keys)
self.assertEqual({'a': {'b': ''}}, result)
def test_set_not_modifies_storage(self):
keys = [['a', 'c', 'value_c']]
data = {'a': {'b': 'value_b'}}
result = self.processor.do_set(data, keys)
self.assertEqual({'a': {'b': 'value_b'}}, data)
self.assertEqual({'a': {'c': 'value_c', 'b': 'value_b'}}, result)
def test_set_invalid_keys_path(self):
self.assertRaises(errors.KeysPathInvalid, self.processor.do_set,
{}, [[]])
self.assertRaises(errors.KeysPathInvalid, self.processor.do_set,
{}, [['a']])
def test_set_key_path_not_existed(self):
keys = [['a', 'b', 'c']]
data = {}
self.assertRaises(errors.KeysPathNotExisted, self.processor.do_set,
data, keys)
keys = [['a', 1, 'b']]
data = {'a': [{'b': 'c'}]}
self.assertRaises(errors.KeysPathNotExisted, self.processor.do_set,
data, keys)
def test_set_key_path_unreachable(self):
keys = [['a', 'b', 'c', 'd', 'e']]
data = {'a': {'b': 'c'}}
self.assertRaises(errors.KeysPathUnreachable, self.processor.do_set,
data, keys)
def test_delete_key_path_not_existed(self):
keys = [['a', 'b']]
data = {}
self.assertRaises(errors.KeysPathNotExisted, self.processor.do_delete,
data, keys)
keys = [[1]]
data = ['a']
self.assertRaises(errors.KeysPathNotExisted, self.processor.do_delete,
data, keys)
def test_delete_key_path_unreachable(self):
keys = [['a', 'b', 'value_b']]
data = {'a': {'b': 'value_b'}}
self.assertRaises(errors.KeysPathUnreachable, self.processor.do_delete,
data, keys)
keys = [['a', 'b', 'value_c']]
data = {'a': {'b': 'value_b'}}
self.assertRaises(errors.KeysPathUnreachable, self.processor.do_delete,
data, keys)
def test_delete(self):
keys = [['a']]
data = {'a': 'val_a', 'b': {'a': 'val_b_a'}}
result = self.processor.do_delete(data, keys)
self.assertEqual({'b': {'a': 'val_b_a'}}, result)
keys = [[0]]
data = ['a']
result = self.processor.do_delete(data, keys)
self.assertEqual([], result)
keys = [['a', 0, 'b']]
data = {'a': [{'b': 'val_a_0_b', 'c': 'val_a_0_c'}, 'd']}
result = self.processor.do_delete(data, keys)
self.assertEqual({'a': [{'c': 'val_a_0_c'}, 'd']}, result)
keys = [['a', 'b'], ['a']]
data = {'a': {'b': 'val_a_b', 'c': 'val_a_c'}, 'b': 'val_b'}
result = self.processor.do_delete(data, keys)
self.assertEqual({'b': 'val_b'}, result)
def test_delete_not_modifies_storage(self):
keys = [['a', 'b']]
data = {'a': {'b': 'value_b'}}
result = self.processor.do_delete(data, keys)
self.assertEqual({'a': {'b': 'value_b'}}, data)
self.assertEqual({'a': {}}, result)