From b2a878e536e1bc5703bf6d256e6682dc2e205bcf Mon Sep 17 00:00:00 2001 From: Alexander Kislitsky Date: Tue, 16 Aug 2016 20:07:19 +0300 Subject: [PATCH] Update of resource_definitions content implemented Now it is able to add, update, delete keys in resource definition content. Change-Id: I9624f97bf35eae15d6a7ddc2d5d42768292db4f0 --- tuning_box/app.py | 14 ++ tuning_box/converters.py | 15 ++ tuning_box/errors.py | 20 +++ tuning_box/library/resource_definitions.py | 21 ++- tuning_box/library/resource_keys_operation.py | 118 ++++++++++++++++ .../library/test_resource_definitions.py | 61 ++++++++ .../library/test_resource_key_operations.py | 132 ++++++++++++++++++ 7 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 tuning_box/library/resource_keys_operation.py create mode 100644 tuning_box/tests/library/test_resource_key_operations.py diff --git a/tuning_box/app.py b/tuning_box/app.py index 2bcba7f..f185ff8 100644 --- a/tuning_box/app.py +++ b/tuning_box/app.py @@ -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/' ) +api.add_resource( + resource_definitions.ResourceDefinitionKeys, + '/resource_definition//' + 'keys/' +) # 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') diff --git a/tuning_box/converters.py b/tuning_box/converters.py index 9944d71..a5fa766 100644 --- a/tuning_box/converters.py +++ b/tuning_box/converters.py @@ -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 } diff --git a/tuning_box/errors.py b/tuning_box/errors.py index 196bd45..c374bf2 100644 --- a/tuning_box/errors.py +++ b/tuning_box/errors.py @@ -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 diff --git a/tuning_box/library/resource_definitions.py b/tuning_box/library/resource_definitions.py index bb48d66..5554a9e 100644 --- a/tuning_box/library/resource_definitions.py +++ b/tuning_box/library/resource_definitions.py @@ -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 diff --git a/tuning_box/library/resource_keys_operation.py b/tuning_box/library/resource_keys_operation.py new file mode 100644 index 0000000..3001088 --- /dev/null +++ b/tuning_box/library/resource_keys_operation.py @@ -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) + ) diff --git a/tuning_box/tests/library/test_resource_definitions.py b/tuning_box/tests/library/test_resource_definitions.py index 4ec80ae..409dfe3 100644 --- a/tuning_box/tests/library/test_resource_definitions.py +++ b/tuning_box/tests/library/test_resource_definitions.py @@ -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']) diff --git a/tuning_box/tests/library/test_resource_key_operations.py b/tuning_box/tests/library/test_resource_key_operations.py new file mode 100644 index 0000000..10f1900 --- /dev/null +++ b/tuning_box/tests/library/test_resource_key_operations.py @@ -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)