diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..fda2fd7944c5fcb5df29d289e91c58f20f864e01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea/ +.mule/ +services/ +target/ \ No newline at end of file diff --git a/README.md b/README.md index 55f109a6aef9ec807adea3a319aba4204024b3be..3ad9258df97ef4f703116e191f269fed7bab2fb5 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,14 @@ -# 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 ``` de.codecentric.mule.modules assert-object-equals-module -1.0.0-SNAPSHOT +1.0.175-SNAPSHOT mule-plugin ``` + +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 diff --git a/pom.xml b/pom.xml index 2bc96a40be01f71984d93c9674b489ab76ad6451..85be6dc50fe9fce9b6d295b26034c6cabe325c47 100644 --- a/pom.xml +++ b/pom.xml @@ -6,9 +6,9 @@ 4.0.0 de.codecentric.mule.modules assert-object-equals-module - 1.0.0-SNAPSHOT + 1.0.175-SNAPSHOT mule-extension - AssertObjectEquals Extension + Assert Object Equals Extension org.mule.extensions @@ -16,4 +16,26 @@ 1.1.3 + + + com.fasterxml.jackson.core + jackson-core + 2.9.8 + + + com.fasterxml.jackson.core + jackson-databind + 2.9.8 + + + org.apache.commons + commons-lang3 + 3.0 + + + org.xmlunit + xmlunit-core + 2.3.0 + + diff --git a/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/AssertObjectEqualsExtension.java b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/AssertObjectEqualsExtension.java index 95dafef62d9d919a614d1f8eaa0226189fb391c3..ba59a11cf4a8cae623d2fe565e0445271241b0ef 100644 --- a/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/AssertObjectEqualsExtension.java +++ b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/AssertObjectEqualsExtension.java @@ -1,9 +1,15 @@ 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 additionalOptions = new ArrayList<>(); + additionalOptions.add("['addresses'][#]['timestamp'] ignore"); + + AssertObjectEqualsOperations aoe = new AssertObjectEqualsOperations(); + //aoe.compareObjects(expectedFileBuffered, actualFileBuffered, additionalOptions); + aoe.compareXml(expectedFileBuffered, actualFileBuffered, "IGNORE_COMMENTS"); + } } diff --git a/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/AssertObjectEqualsOperations.java b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/AssertObjectEqualsOperations.java index ef332fd52be1463cd5b9620fc1dfdbe077b95ae4..1272de8f030b35a86e6fc9d82dd37542ced7311b 100644 --- a/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/AssertObjectEqualsOperations.java +++ b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/AssertObjectEqualsOperations.java @@ -1,11 +1,18 @@ 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: + * + * Remember: Encoding for Json is always UTF8 + * + * @param actual + * The actual value. Automatic conversions are provided: + * + * Remember: Encoding for Json is always UTF8 + * + * @param pathOptions + * Options for path patterns to control the comparison. Syntax of one List entry: Zero to n path + * parts. The parts can have the following syntax: + * + * 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 pathOptions) + + throws Exception { + + Object expectedObj = convert2Object(expected); + Object actualObj = convert2Object(actual); + ObjectComparator comparator = createComparator(containsOnlyOnMaps, checkMapOrder, pathOptions == null ? new ArrayList() : pathOptions); + Collection 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 XMLUnit Wiki} 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 "" 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 pathOptionsStrings) { + PathPatternParser ppp = new PathPatternParser(); + Collection patterns = new ArrayList<>(); + + for (String pathOptionString : pathOptionsStrings) { + patterns.add(ppp.parse(pathOptionString)); + } + EnumSet 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); + } } diff --git a/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/ObjectComparator.java b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/ObjectComparator.java new file mode 100644 index 0000000000000000000000000000000000000000..cddca4f0040bd6774d50dbe5ca85fa7306ef0021 --- /dev/null +++ b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/ObjectComparator.java @@ -0,0 +1,209 @@ +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 options; + + private State(Path path, Object expected, Object actual, EnumSet 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 compare(Object expected, Object actual) { + State state = new State(expected, actual); + Collection diffs = new ArrayList(); + compare(state, diffs); + return diffs; + } + + private void compare(State state, Collection 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 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 diffs) { + @SuppressWarnings("unchecked") + List expected = (List) state.expected; + @SuppressWarnings("unchecked") + List actual = (List) 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 eIter = expected.iterator(); + Iterator 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 diffs) { + if (compareMapKeysOnly(state, diffs)) { + return; + } + + @SuppressWarnings("unchecked") + Map expected = (Map) state.expected; + @SuppressWarnings("unchecked") + Map actual = (Map) state.actual; + + for (Map.Entry entry : expected.entrySet()) { + Object expectedKey = entry.getKey(); + compare(state.mapEntry(expectedKey.toString(), entry.getValue(), actual.get(expectedKey)), diffs); + } + } + + private boolean compareMapKeysOnly(State state, Collection diffs) { + @SuppressWarnings("unchecked") + Map expected = (Map) state.expected; + @SuppressWarnings("unchecked") + Map actual = (Map) state.actual; + + // In all cases, expected keys must be a sub set of actual keys + Set keys = new LinkedHashSet(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(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 expectedKeys, Set actualKeysOrig, Collection diffs) { + Set actualKeys = new LinkedHashSet(actualKeysOrig); + // Remove all keys which are *not* in expected + Iterator 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 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 col) { + StringBuilder sb = new StringBuilder(); + for (Object o : col) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(o.toString()); + } + return sb.toString(); + } +} diff --git a/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/ObjectCompareOptionsFactory.java b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/ObjectCompareOptionsFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..5d875edc22fa02df7d43bda6ce11888b34ea3ef6 --- /dev/null +++ b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/ObjectCompareOptionsFactory.java @@ -0,0 +1,15 @@ +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 createOptions(EnumSet inherited, Path path); +} diff --git a/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/Path.java b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/Path.java new file mode 100644 index 0000000000000000000000000000000000000000..19bf6702ea2d2295b412b56db50e87a4d13758f8 --- /dev/null +++ b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/Path.java @@ -0,0 +1,115 @@ +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 listIndex + * @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 + "]"; + } + } +} diff --git a/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/PathOption.java b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/PathOption.java new file mode 100644 index 0000000000000000000000000000000000000000..76df76490f2abd55e817cf1d70a38075ee03b792 --- /dev/null +++ b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/PathOption.java @@ -0,0 +1,20 @@ +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 + * there are more elements than expected in the result + */ + CONTAINS_ONLY_ON_MAPS, + + /** + * The order of map entries is checked. The default is to ignore order of map entries. + */ + CHECK_MAP_ORDER, + + /** + * The actual node and its subtree is ignored completely. + */ + IGNORE +} diff --git a/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/PathPattern.java b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/PathPattern.java new file mode 100644 index 0000000000000000000000000000000000000000..542aa8e1bbbf495139fc89c1f1aab7d4b92e9b1d --- /dev/null +++ b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/PathPattern.java @@ -0,0 +1,121 @@ +package de.codecentric.mule.modules.assertobjectequals.internal; + +import java.util.Arrays; +import java.util.EnumSet; + +/** + * Can match against a {@link Path}. + */ +public class PathPattern { + private PatternEntry[] entries; + private EnumSet options; + + /** + * @param entries + * Entries, array will be copied to avoid state leakage. + * @param options + * Option for pattern + */ + public PathPattern(PatternEntry[] entries, EnumSet options) { + this.entries = Arrays.copyOf(entries, entries.length); + this.options = EnumSet.copyOf(options); + } + + /** + * @return number of {@link PatternEntry}s + */ + public int size() { + return entries.length; + } + + public PatternEntry getEntry(int index) { + return entries[index]; + } + + /** + * @param path + * Path to match against. + * @return Does it match? + */ + public boolean matches(Path path) { + return matches(path, entries.length - 1); + } + + public EnumSet getOptions() { + return options; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + for (PatternEntry entry : entries) { + sb.append(entry.toString()); + } + return sb.toString(); + } + + private boolean matches(Path path, int start) { + if (start == -1) { + return path.isRoot(); + } else { + PatternEntry pe = entries[start]; + if (path.isRoot()) { + // WILDCARD_ANY matches the empty path + return pe.getType() == PatternEntry.PatternEntryType.WILDCARD_ANY && start == 0; + } + switch (pe.getType()) { + case LIST: + return matchesList(path, pe) && matches(path.getPredecessor(), start - 1); + case MAP: + return matchesMap(path, pe) && matches(path.getPredecessor(), start - 1); + case WILDCARD_ONE: + return matches(path.getPredecessor(), start - 1); + case WILDCARD_ANY: + return matchesWildcardAny(path, start); + default: + throw new IllegalStateException("Unknown enum constant"); + } + + } + } + + private boolean matchesList(Path path, PatternEntry pe) { + if (!path.isList()) { + return false; + } + if (pe.getListIndex() == null) { + return true; + } + int index = pe.getListIndex(); + if (index >= 0 && path.getIndex() == index) { + return true; + } + if (index < 0 && path.getIndex() == path.getListSize() + index) { + return true; + } + return false; + } + + private boolean matchesMap(Path path, PatternEntry pe) { + if (!path.isMap()) { + return false; + } + return pe.getKeyPattern().matcher(path.getKey()).matches(); + } + + private boolean matchesWildcardAny(Path path, int start) { + // wildcard matches nothing + if (matches(path, start - 1)) { + return true; + } + // wildcard matches one path element + if (matches(path.getPredecessor(), start - 1)) { + return true; + } + // wildcard matches more than path element (apply wildcard again) + if (matches(path.getPredecessor(), start)) { + return true; + } + return false; + } +} diff --git a/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/PathPatternParser.java b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/PathPatternParser.java new file mode 100644 index 0000000000000000000000000000000000000000..a0b4782ba5cb21ccc97b6222277418dacdef916b --- /dev/null +++ b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/PathPatternParser.java @@ -0,0 +1,208 @@ +package de.codecentric.mule.modules.assertobjectequals.internal; + +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Parses String patterns into a {@link PathPattern}. The String must contain zero to + * n parts. The parts can have the following syntax: + *
    + *
  • ?: Wildcard one, matches one element in a path
  • + *
  • *: Wildcard any, matches zero to n elements in a path
  • + *
  • [#]: List wildcard, matches a list entry with any index
  • + *
  • [0]: Matches a list entry with the given number. 0 or positive numbers: Count from beginning, + * negative number: Cound from end (-1 is last element)
  • + *
  • ['.*']: 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.
  • + *
+ */ +public class PathPatternParser { + static class State { + final String input; + int position; + + State(String input) { + this.input = input; + } + + boolean eof() { + return position == input.length(); + } + + char peek() { + assertNotEof(); + return input.charAt(position); + } + + char peek(int delta) { + return input.charAt(position + delta); + } + + char next() { + assertNotEof(); + return input.charAt(position++); + } + + void nextExpected(char c) { + if (c != peek()) { + throw new IllegalArgumentException("Expect '" + c + "' at position " + position + " but found '" + peek() + "'"); + } + next(); + } + + private void assertNotEof() { + if (eof()) { + throw new IllegalArgumentException("unexpected end of path pattern"); + } + } + + int getPosition() { + return position; + } + + @Override + public String toString() { + if (eof()) { + return "EOF"; + } else { + StringBuilder sb = new StringBuilder(2 * input.length() + 3); + sb.append(input).append(System.lineSeparator()); + for (int i = 0; i < position; i++) { + sb.append(' '); + } + sb.append('^'); + return sb.toString(); + } + } + } + + public PathPattern parse(String input) { + State state = new State(input); + PatternEntry[] entries = parseEntries(state); + EnumSet options = parseOptions(state); + return new PathPattern(entries, options); + } + + private PatternEntry[] parseEntries(State state) { + List entries = new ArrayList<>(); + skipWhitespace(state); + while (!state.eof() && isPathStartCharacter(state.peek())) { + switch (state.peek()) { + case '?': + entries.add(PatternEntry.createWildcardOne()); + state.next(); + break; + case '*': + entries.add(PatternEntry.createWildcardAny()); + state.next(); + break; + case '[': + state.next(); + entries.add(listOrMap(state)); + break; + default: + throw new IllegalArgumentException("Unknown character '" + state.peek() + "' at position " + state.getPosition()); + } + } + return entries.toArray(new PatternEntry[entries.size()]); + } + + private boolean isPathStartCharacter(char ch) { + return ch == '?' || ch == '*' || ch == '['; + } + + private PatternEntry listOrMap(State state) { + if (state.peek() == '\'') { + return map(state); + } else { + return list(state); + } + } + + private PatternEntry map(State state) { + state.nextExpected('\''); + StringBuilder sb = new StringBuilder(); + while (!(state.peek() == '\'' && state.peek(1) == ']')) { + if (state.peek() == '\'') { + state.next(); // skip first ' + if (state.peek() == '\'') { + sb.append(state.next()); // skip second ' + } else { + throw new IllegalArgumentException( + "' must be followed by ' or ], not '" + state.peek() + "' at position " + state.getPosition()); + } + } else { + sb.append(state.next()); + } + } + state.nextExpected('\''); + state.nextExpected(']'); + return PatternEntry.createMap(Pattern.compile(sb.toString())); + } + + private PatternEntry list(State state) { + if (state.peek() == '#') { + state.next(); + state.nextExpected(']'); + return PatternEntry.createList(null); + } + StringBuilder sb = new StringBuilder(); + if (state.peek() == '-') { + sb.append(state.next()); + } + while (state.peek() != ']') { + sb.append(state.next()); + } + state.next(); + int index = Integer.parseInt(sb.toString()); + return PatternEntry.createList(index); + } + + private EnumSet parseOptions(State state) { + EnumSet options = EnumSet.noneOf(PathOption.class); + while (!state.eof()) { + String word = readNextWord(state); + if (StringUtils.isEmpty(word)) { + if (!state.eof()) { + throw new IllegalArgumentException("'" + state.peek() + "' is not valid as start of option."); + } + } else { + options.add(stringToOption(word)); + } + } + return options; + } + + private PathOption stringToOption(String word) { + try { + return Enum.valueOf(PathOption.class, word.toUpperCase()); + } catch (IllegalArgumentException e) { + StringBuilder sb = new StringBuilder(); + sb.append("Illegal path option \"").append(word).append("\", valid options are: "); + for (PathOption option : PathOption.values()) { + sb.append(option.toString()).append(", "); + } + String message = sb.substring(0, sb.length() - 2); + throw new IllegalArgumentException(message); + } + } + + private String readNextWord(State state) { + skipWhitespace(state); + StringBuilder sb = new StringBuilder(); + while (!state.eof() && (Character.isLetterOrDigit(state.peek()) || state.peek() == '_')) { + sb.append(state.next()); + } + return sb.toString(); + } + + private void skipWhitespace(State state) { + while (!state.eof() && Character.isWhitespace(state.peek())) { + state.next(); + } + } +} diff --git a/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/PatternBasedOptionsFactory.java b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/PatternBasedOptionsFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..90c79e9d19116ae7f2710d4817b5806855c0e95e --- /dev/null +++ b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/PatternBasedOptionsFactory.java @@ -0,0 +1,29 @@ +package de.codecentric.mule.modules.assertobjectequals.internal; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.EnumSet; + +public class PatternBasedOptionsFactory implements ObjectCompareOptionsFactory { + private EnumSet rootOptions; + private Collection patterns; + + public PatternBasedOptionsFactory(EnumSet rootOptions, Collection patterns) { + this.rootOptions = EnumSet.copyOf(rootOptions); + this.patterns = new ArrayList<>(patterns); + } + + @Override + public EnumSet createOptions(EnumSet inherited, Path path) { + for (PathPattern pp : patterns) { + if (pp.matches(path)) { + return EnumSet.copyOf(pp.getOptions()); + } + } + return EnumSet.copyOf(inherited == null ? rootOptions : inherited); + } + + public EnumSet getRootOptions() { + return EnumSet.copyOf(rootOptions); + } +} diff --git a/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/PatternEntry.java b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/PatternEntry.java new file mode 100644 index 0000000000000000000000000000000000000000..67dd3e02a03daf4ed5008fdf0c5b87dca601b4b3 --- /dev/null +++ b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/PatternEntry.java @@ -0,0 +1,101 @@ +package de.codecentric.mule.modules.assertobjectequals.internal; + +import java.util.regex.Pattern; + +public class PatternEntry { + enum PatternEntryType { + MAP, LIST, WILDCARD_ONE, WILDCARD_ANY + } + + private final PatternEntryType type; + private Pattern keyPattern; + private Integer listIndex; + + private PatternEntry(PatternEntryType type) { + this.type = type; + } + + public static PatternEntry createMap(Pattern keyPattern) { + PatternEntry pe = new PatternEntry(PatternEntryType.MAP); + pe.keyPattern = keyPattern == null ? Pattern.compile(".*") : keyPattern; + return pe; + } + + /** + * @param listIndex + * 0 or positive value: Count from beginning, negative value: count from end (-1 is last), + * null: match any list entry. + * @return Created entry. + */ + public static PatternEntry createList(Integer listIndex) { + PatternEntry pe = new PatternEntry(PatternEntryType.LIST); + pe.listIndex = listIndex; + return pe; + } + + public static PatternEntry createWildcardAny() { + PatternEntry pe = new PatternEntry(PatternEntryType.WILDCARD_ANY); + return pe; + } + + public static PatternEntry createWildcardOne() { + PatternEntry pe = new PatternEntry(PatternEntryType.WILDCARD_ONE); + return pe; + } + + public PatternEntryType getType() { + return type; + } + + public Pattern getKeyPattern() { + if (type != PatternEntryType.MAP) { + throw new IllegalStateException("type is " + type); + } + return keyPattern; + } + + /** + * @return 0 or positive value: Count from beginning, negative value: count from end (-1 is last), null + * : match any list entry. + */ + public Integer getListIndex() { + if (type != PatternEntryType.LIST) { + throw new IllegalStateException("type is " + type); + } + return listIndex; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + switch (type) { + case LIST: + sb.append('['); + if (listIndex == null) { + sb.append('#'); + } else { + sb.append(Integer.toString(listIndex)); + } + sb.append(']'); + break; + case MAP: + sb.append("['"); + for (char ch : keyPattern.toString().toCharArray()) { + if (ch == '\'') { + sb.append("''"); + } else { + sb.append(ch); + } + } + sb.append("']"); + break; + case WILDCARD_ANY: + sb.append('*'); + break; + case WILDCARD_ONE: + sb.append('?'); + break; + } + return sb.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/XmlCompareOption.java b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/XmlCompareOption.java new file mode 100644 index 0000000000000000000000000000000000000000..65b5a6839c5c3258a01cb777b003174372480c22 --- /dev/null +++ b/src/main/java/de/codecentric/mule/modules/assertobjectequals/internal/XmlCompareOption.java @@ -0,0 +1,25 @@ +package de.codecentric.mule.modules.assertobjectequals.internal; + +import org.mule.runtime.api.value.Value; +import org.mule.runtime.extension.api.values.ValueBuilder; +import org.mule.runtime.extension.api.values.ValueProvider; +import org.mule.runtime.extension.api.values.ValueResolvingException; + +import java.util.Set; + + +public class XmlCompareOption implements ValueProvider { + + /** + * Resolves and provides a {@link Set} of {@link Value values} which represents a set of possible and valid values for + * a parameter. + * + * @return a {@link Set} of {@link Value values}. + * + * @throws ValueResolvingException if an error occurs during the resolving + */ + @Override + public Set resolve() throws ValueResolvingException { + return ValueBuilder.getValuesFor("IGNORE_COMMENTS", "IGNORE_WHITESPACE", "NORMALIZE_WHITESPACE"); + } +}