From e6cadc49c9a0ab273fb5bad7b9e970df8c7c786f Mon Sep 17 00:00:00 2001 From: Jinmei Zheng Date: Thu, 28 Oct 2021 16:17:57 -0700 Subject: [PATCH] Add property transform for contract tests --- requirements.txt | 1 + setup.cfg | 2 +- src/rpdk/core/contract/resource_client.py | 52 +++++++-- .../core/contract/suite/handler_commons.py | 6 ++ tests/contract/test_resource_client.py | 100 +++++++++++++++++- 5 files changed, 149 insertions(+), 12 deletions(-) diff --git a/requirements.txt b/requirements.txt index f3dd1a16..04d6f6b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ pytest-cov>=2.7.1 pytest-random-order>=1.0.4 hypothesis>=4.32.3 pytest-localserver>=0.5.0 +pyjq==2.5.2 # commit hooks pre-commit>=1.18.1 diff --git a/setup.cfg b/setup.cfg index 276d678d..e0a82d68 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,7 +29,7 @@ include_trailing_comma = true combine_as_imports = True force_grid_wrap = 0 known_first_party = rpdk -known_third_party = boto3,botocore,cfn_tools,cfnlint,colorama,docker,hypothesis,jinja2,jsonschema,nested_lookup,ordered_set,pkg_resources,pytest,pytest_localserver,setuptools,yaml +known_third_party = boto3,botocore,cfn_tools,cfnlint,colorama,docker,hypothesis,jinja2,jsonschema,nested_lookup,ordered_set,pkg_resources,pyjq,pytest,pytest_localserver,setuptools,yaml [tool:pytest] # can't do anything about 3rd part modules, so don't spam us diff --git a/src/rpdk/core/contract/resource_client.py b/src/rpdk/core/contract/resource_client.py index 99d1a943..296fd42a 100644 --- a/src/rpdk/core/contract/resource_client.py +++ b/src/rpdk/core/contract/resource_client.py @@ -8,6 +8,7 @@ from uuid import uuid4 import docker +import pyjq from botocore import UNSIGNED from botocore.config import Config @@ -225,6 +226,8 @@ def _update_schema(self, schema): self.write_only_paths = self._properties_to_paths("writeOnlyProperties") self.create_only_paths = self._properties_to_paths("createOnlyProperties") self.properties_without_insertion_order = self.get_metadata() + self.property_transform_keys = self._properties_to_paths("propertyTransform") + self.property_transform = self._schema.get("propertyTransform") additional_identifiers = self._schema.get("additionalIdentifiers", []) self._additional_identifiers_paths = [ @@ -232,6 +235,24 @@ def _update_schema(self, schema): for identifier in additional_identifiers ] + def transform_model(self, input_model): + if not self.property_transform: + return None + for key in self.property_transform_keys: + path = "/" + "/".join(key) + expression = self.property_transform[path] + tranformed_value = pyjq.first(expression, input_model) + input_model = self.update_property(input_model, tranformed_value, key[1:]) + + return input_model + + def update_property(self, model, value, path): + if len(path) > 1: + model = self.update_property(model[path[0]], value, path[1:]) + elif len(path) == 1: + model[path[0]] = value + return model + def has_only_writable_identifiers(self): return all( path in self.create_only_paths for path in self.primary_identifier_paths @@ -395,7 +416,7 @@ def generate_invalid_update_example(self, create_model): example = override_properties(self.invalid_strategy.example(), overrides) return {**create_model, **example} - def compare(self, inputs, outputs, path=()): + def compare(self, inputs, outputs, transformed_inputs, path=()): assertion_error_message = ( "All properties specified in the request MUST " "be present in the model returned, and they MUST" @@ -407,7 +428,12 @@ def compare(self, inputs, outputs, path=()): for key in inputs: new_path = path + (key,) if isinstance(inputs[key], dict): - self.compare(inputs[key], outputs[key], new_path) + self.compare( + inputs[key], + outputs[key], + transformed_inputs[key] if transformed_inputs else None, + new_path, + ) elif isinstance(inputs[key], list): assert len(inputs[key]) == len(outputs[key]) @@ -415,19 +441,33 @@ def compare(self, inputs, outputs, path=()): "insertionOrder", True ) self.compare_collection( - inputs[key], outputs[key], is_ordered, new_path + inputs[key], + outputs[key], + is_ordered, + new_path, + transformed_inputs[key] if transformed_inputs else None, ) else: assert inputs[key] == outputs[key], assertion_error_message else: assert inputs == outputs, assertion_error_message except Exception as exception: - raise AssertionError(assertion_error_message) from exception + # When input model not equal to output model, and there is transformed model, + # need to compare transformed model with output model also + if transformed_inputs: + self.compare(transformed_inputs, outputs, None) + else: + raise AssertionError(assertion_error_message) from exception - def compare_collection(self, inputs, outputs, is_ordered, path): + def compare_collection(self, inputs, outputs, is_ordered, path, transformed_inputs): if is_ordered: for index in range(len(inputs)): # pylint: disable=C0200 - self.compare(inputs[index], outputs[index], path) + self.compare( + inputs[index], + outputs[index], + transformed_inputs[index] if transformed_inputs else None, + path, + ) return assert {item_hash(item) for item in inputs} == { diff --git a/src/rpdk/core/contract/suite/handler_commons.py b/src/rpdk/core/contract/suite/handler_commons.py index 492cd336..744bf9f3 100644 --- a/src/rpdk/core/contract/suite/handler_commons.py +++ b/src/rpdk/core/contract/suite/handler_commons.py @@ -164,7 +164,13 @@ def test_input_equals_output(resource_client, input_model, output_model): pruned_output_model = prune_properties_if_not_exist_in_path( pruned_output_model, pruned_input_model, resource_client.create_only_paths ) + + transformed_input_model = resource_client.transform_model( + copy.deepcopy(pruned_input_model) + ) + resource_client.compare( pruned_input_model, pruned_output_model, + transformed_input_model ) diff --git a/tests/contract/test_resource_client.py b/tests/contract/test_resource_client.py index e8055deb..423c270c 100644 --- a/tests/contract/test_resource_client.py +++ b/tests/contract/test_resource_client.py @@ -130,6 +130,23 @@ "handlers": {"create": {}, "delete": {}, "read": {}}, } +SCHEMA_WITH_PROPERTY_TRANSFORM = { + "properties": { + "a": {"type": "string"}, + "b": {"$ref": "#/definitions/c"}, + }, + "definitions": { + "c": { + "type": "object", + "properties": {"d": {"type": "String"}, "e": {"type": "integer"}}, + } + }, + "readOnlyProperties": ["/properties/a"], + "primaryIdentifier": ["/properties/a"], + "handlers": {"create": {}, "delete": {}, "read": {}}, + "propertyTransform": {"/properties/b/c/d": '.b.c.d + "Test"'}, +} + EMPTY_SCHEMA = {"handlers": {"create": [], "delete": [], "read": []}} @@ -334,6 +351,45 @@ def resource_client_inputs_composite_key(): return client +@pytest.fixture +def resource_client_inputs_property_transform(): + endpoint = "https://" + patch_sesh = patch( + "rpdk.core.contract.resource_client.create_sdk_session", autospec=True + ) + patch_creds = patch( + "rpdk.core.contract.resource_client.get_temporary_credentials", + autospec=True, + return_value={}, + ) + patch_account = patch( + "rpdk.core.contract.resource_client.get_account", + autospec=True, + return_value=ACCOUNT, + ) + with patch_sesh as mock_create_sesh, patch_creds as mock_creds: + with patch_account as mock_account: + mock_sesh = mock_create_sesh.return_value + mock_sesh.region_name = DEFAULT_REGION + client = ResourceClient( + DEFAULT_FUNCTION, + endpoint, + DEFAULT_REGION, + SCHEMA_WITH_PROPERTY_TRANSFORM, + EMPTY_OVERRIDE, + ) + + mock_sesh.client.assert_called_once_with("lambda", endpoint_url=endpoint) + mock_creds.assert_called_once_with(mock_sesh, LOWER_CAMEL_CRED_KEYS, None) + mock_account.assert_called_once_with(mock_sesh, {}) + assert client._function_name == DEFAULT_FUNCTION + assert client._schema == SCHEMA_WITH_PROPERTY_TRANSFORM + assert client._overrides == EMPTY_OVERRIDE + assert client.account == ACCOUNT + + return client + + def test_prune_properties(): document = { "foo": "bar", @@ -692,6 +748,40 @@ def test_update_schema(resource_client): assert resource_client.create_only_paths == {("properties", "d")} +def test_transform_model(resource_client_inputs_property_transform): + inputs = {"a": "ValueA", "b": {"c": {"d": "ValueD", "e": 1}}} + transformed_inputs = {"a": "ValueA", "b": {"c": {"d": "ValueDTest", "e": 1}}} + + resource_client_inputs_property_transform.transform_model(inputs) + + assert inputs == transformed_inputs + + +def test_compare_with_transform_should_pass(resource_client_inputs_property_transform): + inputs = {"a": "ValueA", "b": {"c": {"d": "ValueD", "e": 1}}} + transformed_inputs = {"a": "ValueA", "b": {"c": {"d": "ValueDTest", "e": 1}}} + outputs = {"a": "ValueA", "b": {"c": {"d": "ValueDTest", "e": 1}}} + + resource_client_inputs_property_transform.compare( + inputs, outputs, transformed_inputs + ) + + +def test_compare_with_transform_should_throw_exception( + resource_client_inputs_property_transform, +): + inputs = {"a": "ValueA", "b": {"c": {"d": "ValueD", "e": 1}}} + transformed_inputs = {"a": "ValueA", "b": {"c": {"d": "ValueDTest", "e": 1}}} + outputs = {"a": "ValueA", "b": {"c": {"d": "D", "e": 1}}} + + try: + resource_client_inputs_property_transform.compare( + inputs, outputs, transformed_inputs + ) + except AssertionError: + logging.debug("This test expects Assertion Exception to be thrown") + + def test_strategy(resource_client): schema = { "properties": { @@ -1566,7 +1656,7 @@ def test_compare_should_pass(resource_client): "h": [{"d": 1, "e": 3}, {"d": 2}], "i": ["abc", "ghi"], } - resource_client.compare(inputs, outputs) + resource_client.compare(inputs, outputs, None) def test_compare_should_throw_exception(resource_client): @@ -1579,7 +1669,7 @@ def test_compare_should_throw_exception(resource_client): "h": [{"d": 1}], } try: - resource_client.compare(inputs, outputs) + resource_client.compare(inputs, outputs, None) except AssertionError: logging.debug("This test expects Assertion Exception to be thrown") @@ -1715,7 +1805,7 @@ def test_compare_should_throw_exception(resource_client): def test_compare_collection(resource_client, inputs, outputs, schema_fragment): resource_client._update_schema(schema_fragment) - resource_client.compare(inputs, outputs) + resource_client.compare(inputs, outputs, None) def test_compare_should_throw_key_error(resource_client): @@ -1724,7 +1814,7 @@ def test_compare_should_throw_key_error(resource_client): outputs = {"b": {"d": 1, "e": 2}, "f": [{"d": 1, "e": 2}, {"d": 2, "e": 3}]} try: - resource_client.compare(inputs, outputs) + resource_client.compare(inputs, outputs, None) except AssertionError: logging.debug("This test expects Assertion Exception to be thrown") @@ -1739,6 +1829,6 @@ def test_compare_ordered_list_throws_assertion_exception(resource_client): "i": ["abc", "ghi", "tt"], } try: - resource_client.compare(inputs, outputs) + resource_client.compare(inputs, outputs, None) except AssertionError: logging.debug("This test expects Assertion Exception to be thrown")