Commit 20f20531 authored by Danijel Dragicevic's avatar Danijel Dragicevic

Merge development branch

parents 162d5e57 7933dc0f
# AssertObjectEquals Extension
# Assert Object Equals Extension
Add description ...
...
...
Add this dependency to your application pom.xml
To install this Module in the Anypoint Studio add this dependency to your application pom.xml
```
<groupId>de.codecentric.mule.modules</groupId>
<artifactId>assert-object-equals-module</artifactId>
<version>1.0.0-SNAPSHOT</version>
<version>1.0.175-SNAPSHOT</version>
<classifier>mule-plugin</classifier>
```
Since this is not yet published on Maven repository, you have to install this project to your local Maven repository.
Please use following command:
mvn clean install -DskipTests
\ No newline at end of file
......@@ -6,9 +6,9 @@
<modelVersion>4.0.0</modelVersion>
<groupId>de.codecentric.mule.modules</groupId>
<artifactId>assert-object-equals-module</artifactId>
<version>1.0.0-SNAPSHOT</version>
<version>1.0.175-SNAPSHOT</version>
<packaging>mule-extension</packaging>
<name>AssertObjectEquals Extension</name>
<name>Assert Object Equals Extension</name>
<parent>
<groupId>org.mule.extensions</groupId>
......@@ -16,4 +16,26 @@
<version>1.1.3</version>
</parent>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.9.8</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.8</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.0</version>
</dependency>
<dependency>
<groupId>org.xmlunit</groupId>
<artifactId>xmlunit-core</artifactId>
<version>2.3.0</version>
</dependency>
</dependencies>
</project>
package de.codecentric.mule.modules.assertobjectequals.internal;
import org.mule.runtime.extension.api.annotation.Extension;
import org.mule.runtime.extension.api.annotation.Configurations;
import org.mule.runtime.extension.api.annotation.Extension;
import org.mule.runtime.extension.api.annotation.dsl.xml.Xml;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.util.ArrayList;
import java.util.List;
/**
* This is the main class of an extension, is the entry point from which configurations, connection providers, operations
......@@ -14,4 +20,27 @@ import org.mule.runtime.extension.api.annotation.dsl.xml.Xml;
@Configurations(AssertObjectEqualsConfiguration.class)
public class AssertObjectEqualsExtension {
/**
* This method is used for calling implementation logic ONLY in testing/development purposes.
* Once we reach "production" stage this method should be completely removed.
*/
public static void main(String[] args) throws Exception{
//This is the expected file from my local disk. I'm imitating sending of real file from srt/test/resources directory to the mule component.
File expectedFile = new File("/Users/ddanijel/AnypointStudio/studio-workspace/test-aoe-module/src/test/resources/expected-file.xml");
FileInputStream expectedFileInputStream = new FileInputStream(expectedFile);
BufferedInputStream expectedFileBuffered = new BufferedInputStream(expectedFileInputStream);
//This is the actual file from my local disk. I'm imitating sending of real file from srt/test/resources directory to the mule component.
File actualFile = new File("/Users/ddanijel/AnypointStudio/studio-workspace/test-aoe-module/src/test/resources/actual-file.xml");
FileInputStream actualFileInputStream = new FileInputStream(actualFile);
BufferedInputStream actualFileBuffered = new BufferedInputStream(actualFileInputStream);
List<String> additionalOptions = new ArrayList<>();
additionalOptions.add("['addresses'][#]['timestamp'] ignore");
AssertObjectEqualsOperations aoe = new AssertObjectEqualsOperations();
//aoe.compareObjects(expectedFileBuffered, actualFileBuffered, additionalOptions);
aoe.compareXml(expectedFileBuffered, actualFileBuffered, "IGNORE_COMMENTS");
}
}
package de.codecentric.mule.modules.assertobjectequals.internal;
import static org.mule.runtime.extension.api.annotation.param.MediaType.ANY;
import org.mule.runtime.extension.api.annotation.param.MediaType;
import org.mule.runtime.extension.api.annotation.param.Config;
import org.mule.runtime.extension.api.annotation.param.Connection;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.mule.runtime.extension.api.annotation.param.Optional;
import org.mule.runtime.extension.api.annotation.param.display.DisplayName;
import org.mule.runtime.extension.api.annotation.param.display.Example;
import org.mule.runtime.extension.api.annotation.values.OfValues;
import org.xmlunit.builder.DiffBuilder;
import org.xmlunit.diff.DefaultComparisonFormatter;
import org.xmlunit.diff.Diff;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
/**
* This class is a container for operations, every public method in this class will be taken as an extension operation.
......@@ -13,25 +20,167 @@ import org.mule.runtime.extension.api.annotation.param.Connection;
public class AssertObjectEqualsOperations {
/**
* Example of an operation that uses the configuration and a connection instance to perform some action.
* Compare two objects. Drill down into {@link Map} and {@link List}, use {@link Object#equals(Object)} for all other
* classes.
*
* @param expected
* The expected value. Automatic conversions are provided:
* <ul>
* <li>InputStream is read/parsed as Json</li>
* <li>byte[] is parsed as Json</li>
* <li>String is parsed as Json when it starts with [ or { (after <code>trim()</code></li>
* </ul>
* Remember: Encoding for Json is always UTF8
*
* @param actual
* The actual value. Automatic conversions are provided:
* <ul>
* <li>InputStream is read/parsed as Json</li>
* <li>byte[] is parsed as Json</li>
* <li>String is parsed as Json when it starts with [ or { (after <code>trim()</code></li>
* </ul>
* Remember: Encoding for Json is always UTF8
*
* @param pathOptions
* Options for path patterns to control the comparison. Syntax of one List entry: Zero to <code>n</code> path
* parts. The parts can have the following syntax:
* <ul>
* <li><code>?</code>: Wildcard one, matches one element in a path</li>
* <li><code>*</code>: Wildcard any, matches zero to <code>n</code> elements in a path</li>
* <li><code>[#]</code>: List wildcard, matches a list entry with any index</li>
* <li><code>[0]</code>: Matches a list entry with the given number. 0 or positive numbers: Count from
* beginning, negative number: Cound from end (-1 is last element)</li>
* <li><code>['.*']</code>: Matches a map entry where the key must match the given regular expression. If you
* need a ' in the expression, just write ''. The example '.*' matches all keys.</li>
* </ul>
* A space as separator. One or more of the following options (case not relevant):
*
* CONTAINS_ONLY_ON_MAPS: The actual value entry set of maps can contain more values than the expected set. So you tests do not fail
* when there are more elements than expected in the result.
*
* CHECK_MAP_ORDER: The order of map entries is checked. The default is to ignore order of map entries.
*
* IGNORE: The actual node and its subtree is ignored completely.
*
* @return The original payload.
*
* @throws Exception
* When comparison fails or on technical problems (e.g. parsing)
*/
@MediaType(value = ANY, strict = false)
public String retrieveInfo(@Config AssertObjectEqualsConfiguration configuration, @Connection AssertObjectEqualsConnection connection){
return "Using Configuration [" + configuration.getConfigId() + "] with Connection id [" + connection.getId() + "]";
public void compareObjects(
@DisplayName("Expected value") @Example("#[MunitTools::getResourceAsStream()]") Object expected,
@DisplayName("Actual value") @Example("#[payload]") Object actual,
@DisplayName("Contains only on maps") @Optional(defaultValue = "false") boolean containsOnlyOnMaps,
@DisplayName("Check map order") @Optional(defaultValue = "false") boolean checkMapOrder,
@DisplayName("Path patterns+options") List<String> pathOptions)
throws Exception {
Object expectedObj = convert2Object(expected);
Object actualObj = convert2Object(actual);
ObjectComparator comparator = createComparator(containsOnlyOnMaps, checkMapOrder, pathOptions == null ? new ArrayList<String>() : pathOptions);
Collection<String> diff = comparator.compare(expectedObj, actualObj);
if (!diff.isEmpty()) {
StringBuilder messageBuilder = new StringBuilder();
for (String s : diff) {
if (messageBuilder.length() > 0) {
messageBuilder.append(System.lineSeparator());
}
messageBuilder.append(s);
}
throw new AssertionError("\n" + messageBuilder);
}
}
/**
* Example of a simple operation that receives a string parameter and returns a new string message that will be set on the payload.
* Compare two XML documents. See <a href="https://github.com/xmlunit/user-guide/wiki/">XMLUnit Wiki</a>} how this works
*
* @param expected
* The expected value, XML as String, InputStream, byte[] or DOM tree.
* @param actual
* The actual value, XML as String, InputStream, byte[] or DOM tree.
* @param xmlCompareOption
* How to compare the XML documents.
*
* IGNORE_COMMENTS: Will remove all comment-Tags "<!-- Comment -->" from test- and control-XML before
* comparing.
*
* IGNORE_WHITESPACE: Ignore whitespace by removing all empty text nodes and trimming the non-empty ones.
*
* NORMALIZE_WHITESPACE: Normalize Text-Elements by removing all empty text nodes and normalizing the
* non-empty ones.
*
* @return The original payload.
*
* @throws Exception
* When comparison fails or on technical problems (e.g. parsing)
*/
@MediaType(value = ANY, strict = false)
public String sayHi(String person) {
return buildHelloMessage(person);
public void compareXml(
@DisplayName("Expected value") @Example("#[MunitTools::getResourceAsStream()]") Object expected,
@DisplayName("Actual value") @Example("#[payload]") Object actual,
@DisplayName("XML compare option") @OfValues(XmlCompareOption.class) String xmlCompareOption)
throws Exception {
DiffBuilder diffBuilder = DiffBuilder.compare(expected).withTest(actual);
switch (xmlCompareOption) {
case "IGNORE_COMMENTS":
diffBuilder = diffBuilder.ignoreComments();
break;
case "IGNORE_WHITESPACE":
diffBuilder = diffBuilder.ignoreWhitespace();
break;
case "NORMALIZE_WHITESPACE":
diffBuilder = diffBuilder.normalizeWhitespace();
break;
default:
throw new IllegalArgumentException("I forgot to implement for a new enum constant.");
}
Diff diff = diffBuilder.build();
if (diff.hasDifferences()) {
throw new AssertionError(diff.toString(new DefaultComparisonFormatter()));
}
}
/**
* Private Methods are not exposed as operations
*/
private String buildHelloMessage(String person) {
return "Hello " + person + "!!!";
private Object convert2Object(Object value) throws JsonProcessingException, IOException {
if (value == null) {
return null;
} else if (value instanceof InputStream) {
return new ObjectMapper().readerFor(Object.class).readValue((InputStream) value);
} else if (value instanceof byte[]) {
return new ObjectMapper().readerFor(Object.class).readValue((byte[]) value);
} else if (value instanceof CharSequence) {
String trimmed = ((CharSequence) value).toString().trim();
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
return new ObjectMapper().readerFor(Object.class).readValue(trimmed);
} else {
return value;
}
} else {
return value;
}
}
private ObjectComparator createComparator(boolean containsOnlyOnMaps, boolean checkMapOrder, List<String> pathOptionsStrings) {
PathPatternParser ppp = new PathPatternParser();
Collection<PathPattern> patterns = new ArrayList<>();
for (String pathOptionString : pathOptionsStrings) {
patterns.add(ppp.parse(pathOptionString));
}
EnumSet<PathOption> rootOptions = EnumSet.noneOf(PathOption.class);
if (containsOnlyOnMaps) {
rootOptions.add(PathOption.CONTAINS_ONLY_ON_MAPS);
}
if (checkMapOrder) {
rootOptions.add(PathOption.CHECK_MAP_ORDER);
}
ObjectCompareOptionsFactory optionFactory = new PatternBasedOptionsFactory(rootOptions, patterns);
return new ObjectComparator(optionFactory);
}
}
package de.codecentric.mule.modules.assertobjectequals.internal;
import java.util.ArrayList;
import java.util.Collection;
import java.util.EnumSet;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Compare two objects, drill down into List an
*/
public class ObjectComparator {
private ObjectCompareOptionsFactory optionFactory;
private class State {
final Path path;
final Object expected;
final Object actual;
final EnumSet<PathOption> options;
private State(Path path, Object expected, Object actual, EnumSet<PathOption> options) {
this.path = path;
this.expected = expected;
this.actual = actual;
this.options = options;
}
public State(Object expected, Object actual) {
path = new Path();
this.expected = expected;
this.actual = actual;
options = optionFactory.createOptions(null, path);
}
public State listEntry(int listIndex, int listSize, Object expected, Object actual) {
Path next = path.listEntry(listIndex, listSize);
return new State(next, expected, actual, optionFactory.createOptions(options, next));
}
public State mapEntry(String key, Object expected, Object actual) {
Path next = path.mapEntry(key);
return new State(next, expected, actual, optionFactory.createOptions(options, next));
}
}
public ObjectComparator(ObjectCompareOptionsFactory optionFactory) {
this.optionFactory = optionFactory;
}
/**
* Compare two objects. Drill down into {@link Map} and {@link List}, use {@link Object#equals(Object)} for all other
* classes.
*
* @param expected
* The expected value.
* @param actual
* The actual value.
* @return Textual description of the differences.
*/
public Collection<String> compare(Object expected, Object actual) {
State state = new State(expected, actual);
Collection<String> diffs = new ArrayList<String>();
compare(state, diffs);
return diffs;
}
private void compare(State state, Collection<String> diffs) {
if (state.options.contains(PathOption.IGNORE)) {
return;
}
if (state.expected == null) {
if (state.actual == null) {
// ok, null equals null
} else { // actual != null
diffs.add("at '" + state.path + "', expected is null, actual " + state.actual);
}
} else { // expected != null
if (state.actual == null) {
diffs.add("at '" + state.path + "', expected " + state.expected + ", actual is null");
} else { // actual != null
compareNonNullObjects(state, diffs);
}
}
}
private void compareNonNullObjects(State state, Collection<String> diffs) {
if (state.expected instanceof List) {
if (state.actual instanceof List) {
compareLists(state, diffs);
} else {
diffs.add("at '" + state.path + "', expected List, but found " + state.actual.getClass().getName());
}
} else if (state.expected instanceof Map) {
if (state.actual instanceof Map) {
compareMaps(state, diffs);
} else {
diffs.add("at '" + state.path + "', expected Map, but found " + state.actual.getClass().getName());
}
} else {
if (!state.expected.equals(state.actual)) {
diffs.add("at '" + state.path + "', expected " + state.expected + ", but found " + state.actual);
}
}
}
private void compareLists(State state, Collection<String> diffs) {
@SuppressWarnings("unchecked")
List<Object> expected = (List<Object>) state.expected;
@SuppressWarnings("unchecked")
List<Object> actual = (List<Object>) state.actual;
if (expected.size() != actual.size()) {
diffs.add("at '" + state.path + "', expected size " + expected.size() + ", actual " + actual.size());
return;
}
int size = expected.size();
Iterator<Object> eIter = expected.iterator();
Iterator<Object> aIter = actual.iterator();
for (int i = 0; i < size && eIter.hasNext() && aIter.hasNext(); i++) {
State nextState = state.listEntry(i, size, eIter.next(), aIter.next());
compare(nextState, diffs);
}
}
private void compareMaps(State state, Collection<String> diffs) {
if (compareMapKeysOnly(state, diffs)) {
return;
}
@SuppressWarnings("unchecked")
Map<Object, Object> expected = (Map<Object, Object>) state.expected;
@SuppressWarnings("unchecked")
Map<Object, Object> actual = (Map<Object, Object>) state.actual;
for (Map.Entry<Object, Object> entry : expected.entrySet()) {
Object expectedKey = entry.getKey();
compare(state.mapEntry(expectedKey.toString(), entry.getValue(), actual.get(expectedKey)), diffs);
}
}
private boolean compareMapKeysOnly(State state, Collection<String> diffs) {
@SuppressWarnings("unchecked")
Map<Object, Object> expected = (Map<Object, Object>) state.expected;
@SuppressWarnings("unchecked")
Map<Object, Object> actual = (Map<Object, Object>) state.actual;
// In all cases, expected keys must be a sub set of actual keys
Set<Object> keys = new LinkedHashSet<Object>(expected.keySet());
keys.removeAll(actual.keySet());
if (!keys.isEmpty()) {
diffs.add("at '" + state.path + "', objects missing in actual: " + collectionToString(keys));
return true;
}
// The other way is only relevant when we *don't* have a contains only
if (!state.options.contains(PathOption.CONTAINS_ONLY_ON_MAPS)) {
keys = new LinkedHashSet<Object>(actual.keySet());
keys.removeAll(expected.keySet());
if (!keys.isEmpty()) {
diffs.add("at '" + state.path + "', objects missing in expected: " + collectionToString(keys));
return true;
}
}
if (state.options.contains(PathOption.CHECK_MAP_ORDER)) {
return checkOrder(state.path, expected.keySet(), actual.keySet(), diffs);
}
return false;
}
private boolean checkOrder(Path path, Set<Object> expectedKeys, Set<Object> actualKeysOrig, Collection<String> diffs) {
Set<Object> actualKeys = new LinkedHashSet<Object>(actualKeysOrig);
// Remove all keys which are *not* in expected
Iterator<Object> actualIter = actualKeys.iterator();
while (actualIter.hasNext()) {
if (!expectedKeys.contains(actualIter.next())) {
actualIter.remove();
}
}
// Now the two sets should be equal (and in same order)
if (expectedKeys.size() != actualKeys.size()) {
throw new RuntimeException("at " + path + " unexpected size mismatch");
}
Iterator<Object> expectedIter = expectedKeys.iterator();
actualIter = actualKeys.iterator();
int size = expectedKeys.size();
for (int i = 0; i < size && expectedIter.hasNext() && actualIter.hasNext(); i++) {
Object eKey = expectedIter.next();
Object aKey = actualIter.next();
if (!eKey.equals(aKey)) {
diffs.add("at '" + path + "', expect key " + eKey + ", actual " + aKey);
return true;
}
}
return false;
}
private String collectionToString(Set<Object> col) {
StringBuilder sb = new StringBuilder();
for (Object o : col) {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append(o.toString());
}
return sb.toString();
}
}
package de.codecentric.mule.modules.assertobjectequals.internal;
import java.util.EnumSet;
public interface ObjectCompareOptionsFactory {
/**
* @param inherited
* Options of parent path
* @param path
* A path
* @return Options controlling the comparison for a path.
*/
public EnumSet<PathOption> createOptions(EnumSet<PathOption> inherited, Path path);
}
package de.codecentric.mule.modules.assertobjectequals.internal;
/**
* Representation of a path in an object tree.
*/
public class Path {
private Path predecessor;
private String key;
private int index;
private int listSize;
/**
* Create root.
*/
public Path() {
}
private Path(Path predecessor) {
this.predecessor = predecessor;
}
private Path(String key, Path predecessor) {
this(predecessor);
if (key == null) {
throw new NullPointerException("key is null");
}
this.key = key;
}
private Path(int listIndex, int listSize, Path predecessor) {
this(predecessor);
if (listSize < 0) {
throw new IllegalArgumentException();
}
if (listIndex < 0 || listIndex >= listSize) {
throw new IllegalArgumentException("Illegal index: " + listIndex);
}
index = listIndex;
this.listSize = listSize;
}
/**
* Create a new path based on this with a list index.
*
* @param listIndex
* The index, should start at 0.
* @param listSize
* Must be greater than <code>listIndex</code>
* @return Created entry
*/
public Path listEntry(int listIndex, int listSize) {
return new Path(listIndex, listSize, this);
}
/**
* Create a new path based on this with a map key.
*
* @param key
* The map key.
* @return Created entry
*/
public Path mapEntry(String key) {
return new Path(key, this);
}
public boolean isRoot() {
return predecessor == null;
}
public boolean isList() {
return !isRoot() && key == null;
}
public boolean isMap() {
return !isRoot() && key != null;
}
public Path getPredecessor() {
if (isRoot()) {
throw new IllegalStateException("root has no predecessor");
}
return predecessor;
}
public String getKey() {
return key;
}
public int getIndex() {
return index;
}
public int getListSize() {
return listSize;
}
@Override
public String toString() {
String result;
if (isRoot()) {
result = "";
} else {
result = predecessor.toString() + meToString();
}
return result;
}
private String meToString() {
if (key != null) {
return "['" + key + "']";
} else {
return "[" + index + "]";
}
}
}
package de.codecentric.mule.modules.assertobjectequals.internal;
public enum PathOption {
/**
* The actual value entry set of maps can contain more values than the expected set. So you tests do not fail when