Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add property transform for contract tests #843

Merged
merged 21 commits into from
Jan 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/pr-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ jobs:
with:
python-version: ${{ matrix.python }}
cache: 'pip'
- name: Install PYJQ dependencies on Macos
run: |
brew install autoconf automake libtool
brew install jq
if: matrix.os == 'macos-latest'
- name: Install PYJQ on non-Windows
run: |
pip install pyjq
if: matrix.os != 'windows-latest'
- name: Install dependencies
run: |
pip install --upgrade 'attrs==19.2.0' wheel -r requirements.txt
Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,31 @@ cfn test --enforce-timeout 60 -- -k contract_delete_update # combine arguments
cfn test --log-group-name cw_log_group --log-role-arn log_delivery_role_arn # Handler logs generated by contract tests will be delivered to the specified cw_log_group using the credentials from log_delivery_role_arn
```

Note: To use your type configuration in contract tests, you will need to save your type configuration json file in `~/.cfn-cli/typeConfiguration.json`.
Note:
* To use your type configuration in contract tests, you will need to save your type configuration json file in `~/.cfn-cli/typeConfiguration.json`.

* To use `propertyTransform` in schema, you will need to install [PYJQ](https://pypi.org/project/pyjq/). This feature will not be available to use with contract tests on Windows OS

Install PYJQ for Linux system

```bash
yum install autoconf automake libtool
pip install pyjq
```

Install PYJQ for macOS system

```bash
brew install autoconf automake libtool
brew install jq
pip install pyjq
```

Install PYJQ for Ubuntu system

```bash
pip install pyjq
```

### Command: validate

Expand Down
69 changes: 65 additions & 4 deletions src/rpdk/core/contract/resource_client.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# pylint: disable=import-outside-toplevel
# pylint: disable=R0904
import copy
import json
import logging
import re
import sys
import time
from time import sleep
from typing import Any, Dict, Tuple
from uuid import uuid4

import docker
Expand Down Expand Up @@ -226,13 +229,50 @@ 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 = [
{fragment_decode(prop, prefix="") for prop in identifier}
for identifier in additional_identifiers
]

def transform_model(self, input_model):
if not self.property_transform:
return None
ammokhov marked this conversation as resolved.
Show resolved Hide resolved
# When CT input and output not equal, and with property transform
# Need to check system as property transform for CT not supported on Windows
if sys.platform.startswith("win"):
raise EnvironmentError(
"Property transform not available with contract tests on Windows OS"
)

import pyjq

transformed_input_model = copy.deepcopy(input_model)
for key in self.property_transform_keys:
path = "/" + "/".join(key)
expression = self.property_transform[path]
transformed_value = pyjq.first(expression, transformed_input_model)
# key is a tuple like ("properties", "A", "B")
# input model is like: {"A": {"B": "valueB"}}
# use key[1:] here to remove "properties"
transformed_input_model = self.update_property(
transformed_input_model, transformed_value, key[1:]
)

return transformed_input_model

def update_property(
self, model: Dict[str, Any], value: Any, path: Tuple[str, ...]
) -> Dict[str, Any]:
if len(path) > 1:
model[path[0]] = 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
Expand Down Expand Up @@ -393,7 +433,17 @@ 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):
try:
self.compare_model(inputs, outputs)
except AssertionError as exception:
transformed_inputs = self.transform_model(inputs)
if transformed_inputs:
self.compare_model(transformed_inputs, outputs)
else:
raise exception

def compare_model(self, inputs, outputs, path=()):
assertion_error_message = (
"All properties specified in the request MUST "
"be present in the model returned, and they MUST"
Expand All @@ -405,7 +455,11 @@ 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_model(
inputs[key],
outputs[key],
new_path,
)
elif isinstance(inputs[key], list):
assert len(inputs[key]) == len(outputs[key])

Expand All @@ -414,7 +468,10 @@ def compare(self, inputs, outputs, path=()):
)

self.compare_collection(
inputs[key], outputs[key], is_ordered, new_path
inputs[key],
outputs[key],
is_ordered,
new_path,
)
else:
assert inputs[key] == outputs[key], assertion_error_message
Expand All @@ -426,7 +483,11 @@ def compare(self, inputs, outputs, path=()):
def compare_collection(self, inputs, outputs, is_ordered, path):
if is_ordered:
for index in range(len(inputs)): # pylint: disable=C0200
self.compare(inputs[index], outputs[index], path)
self.compare_model(
inputs[index],
outputs[index],
path,
)
return

assert {item_hash(item) for item in inputs} == {
Expand Down
6 changes: 2 additions & 4 deletions src/rpdk/core/contract/suite/resource/handler_commons.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,5 @@ 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
)
resource_client.compare(
pruned_input_model,
pruned_output_model,
)

resource_client.compare(pruned_input_model, pruned_output_model)
87 changes: 87 additions & 0 deletions tests/contract/test_resource_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": []}}


Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -692,6 +748,37 @@ 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}}}
expected_inputs = {"a": "ValueA", "b": {"c": {"d": "ValueDTest", "e": 1}}}

transformed_inputs = resource_client_inputs_property_transform.transform_model(
inputs
)

assert transformed_inputs == expected_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)


def test_compare_with_transform_should_throw_exception(
resource_client_inputs_property_transform,
):
inputs = {"a": "ValueA", "b": {"c": {"d": "ValueD", "e": 1}}}
outputs = {"a": "ValueA", "b": {"c": {"d": "D", "e": 1}}}

try:
resource_client_inputs_property_transform.compare(inputs, outputs)
except AssertionError:
logging.debug("This test expects Assertion Exception to be thrown")


def test_strategy(resource_client):
schema = {
"properties": {
Expand Down