Skip to content

Commit

Permalink
fix(#341): Allow comma and brackets in matcher expressions
Browse files Browse the repository at this point in the history
  • Loading branch information
schlathoeltt committed Sep 9, 2023
1 parent 4058578 commit 8208424
Show file tree
Hide file tree
Showing 4 changed files with 194 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.integration.mapping.HeaderMapper;
Expand Down Expand Up @@ -531,4 +532,17 @@ public void testSpringIntegrationHeaderMapperMediaTypeResultIsConvertedOnInbound
//THEN
assertEquals("application/json", httpMessage.getHeaders().get("foo"));
}

@Test
public void testCustomStatusCodeIsSetOnOutbound(){

//GIVEN
message.setHeader(HttpMessageHeaders.HTTP_STATUS_CODE, "555");

//WHEN
final HttpEntity<?> httpEntity = messageConverter.convertOutbound(message, endpointConfiguration, testContext);

//THEN
assertEquals(HttpStatusCode.valueOf(555), ((ResponseEntity<?>) httpEntity).getStatusCode());
}
}
11 changes: 11 additions & 0 deletions src/manual/validation-hamcrest.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ receive("someEndpoint")
.expression("node-set:/TestRequest/OrderType", hasSize(3));
----

NOTE: If you want to match text containing any of the following characters: ' , ( )
You need to enclose the respective string in quotation marks when defining your matcher.
If you intend to match an actual single quote, it should be escaped with a backslash (\').

For example:
[source,xml]
----
anyOf(equalTo('text containing a \\' (quote) and a , (comma) '), anyOf(isEmptyOrNullString()))
anyOf(equalTo('text containing a backslash and quote \\\\' and a , (comma) '), anyOf(isEmptyOrNullString()))
----

.XML
[source,xml,indent=0,role="secondary"]
----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.citrusframework.context.TestContext;
Expand Down Expand Up @@ -84,11 +85,9 @@ public void validate(String fieldName, String value, List<String> controlParamet
}

String matcherName = matcherExpression.trim().substring(0, matcherExpression.trim().indexOf("("));
String[] matcherParameter = matcherExpression.trim().substring(matcherName.length() + 1, matcherExpression.trim().length() - 1).split(",");

for (int i = 0; i < matcherParameter.length; i++) {
matcherParameter[i] = VariableUtils.cutOffSingleQuotes(matcherParameter[i].trim());
}
String[] matcherParameter = determineNestedMatcherParameters(matcherExpression.trim()
.substring(matcherName.length() + 1, matcherExpression.trim().length() - 1));

try {
Matcher matcher = getMatcher(matcherName, matcherParameter, context);
Expand Down Expand Up @@ -125,7 +124,8 @@ public void validate(String fieldName, String value, List<String> controlParamet
* @return
*/
private Matcher<?> getMatcher(String matcherName, String[] matcherParameter, TestContext context) {
try {

try {
if (context.getReferenceResolver().isResolvable(matcherName, HamcrestMatcherProvider.class) ||
HamcrestMatcherProvider.canResolve(matcherName)) {
Optional<HamcrestMatcherProvider> matcherProvider = lookupMatcherProvider(matcherName, context);
Expand Down Expand Up @@ -174,15 +174,21 @@ private Matcher<?> getMatcher(String matcherName, String[] matcherParameter, Tes
List<Matcher<?>> nestedMatchers = new ArrayList<>();
for (String matcherExpression : matcherParameter) {
String nestedMatcherName = matcherExpression.trim().substring(0, matcherExpression.trim().indexOf("("));
String nestedMatcherParameter = matcherExpression.trim().substring(nestedMatcherName.length() + 1, matcherExpression.trim().length() - 1);
nestedMatchers.add(getMatcher(nestedMatcherName, new String[] { nestedMatcherParameter }, context));
String[] nestedMatcherParameters = determineNestedMatcherParameters(
matcherExpression.trim().substring(nestedMatcherName.length() + 1,
matcherExpression.trim().length() - 1));

nestedMatchers.add(getMatcher(nestedMatcherName, nestedMatcherParameters, context));
}

return (Matcher<?>) matcherMethod.invoke(null, nestedMatchers);
}
}

if (matchers.contains(matcherName)) {

unescapeQuotes(matcherParameter);

Method matcherMethod = ReflectionUtils.findMethod(Matchers.class, matcherName, String.class);

if (matcherMethod == null) {
Expand All @@ -209,6 +215,9 @@ private Matcher<?> getMatcher(String matcherName, String[] matcherParameter, Tes
}

if (collectionMatchers.contains(matcherName)) {

unescapeQuotes(matcherParameter);

Method matcherMethod = ReflectionUtils.findMethod(Matchers.class, matcherName, int.class);

if (matcherMethod != null) {
Expand Down Expand Up @@ -262,6 +271,18 @@ private Matcher<?> getMatcher(String matcherName, String[] matcherParameter, Tes
throw new CitrusRuntimeException("Unsupported matcher: " + matcherName);
}

/**
* Unescape the quotes in search expressions (\\' -> ').
* @param matcherParameters to unescape
*/
private static void unescapeQuotes(String[] matcherParameters) {
if (matcherParameters != null) {
for (int i=0;i< matcherParameters.length;i++) {
matcherParameters[i] = matcherParameters[i].replace("\\'","'");
}
}
}

/**
* Try to find matcher provider using different lookup strategies. Looks into reference resolver and resource path for matcher provider.
* @param matcherName
Expand Down Expand Up @@ -362,6 +383,35 @@ public List<String> extractControlValues(String controlExpression, Character del
}
}

/**
* Extracts parameters for a matcher from the raw parameter expression.
* Parameters refer to the contained parameters and matchers (first level),
* excluding nested ones.
* <p/>
* For example, given the expression:<br/>
* {@code "oneOf(greaterThan(5.0), allOf(lessThan(-1.0), greaterThan(-2.0)))"}
* <p/>
* The extracted parameters are:<br/>
* {@code "greaterThan(5.0)", "allOf(lessThan(-1.0), greaterThan(-2.0))"}.
* <p/>
* Note that nested container expressions "allOf(lessThan(-1.0), greaterThan(-2.0))" in
* the second parameter are treated as a single expression. They need to be treated
* separately in a recursive call to this method, when the parameters for the
* respective allOf() expression are extracted.
*
* @param rawExpression the full parameter expression of a container matcher
*/
public String[] determineNestedMatcherParameters(final String rawExpression) {
if (!StringUtils.hasText(rawExpression)) {
return new String[0];
}

Tokenizer tokenizer = new Tokenizer();
String tokenizedExpression = tokenizer.tokenize(rawExpression);
return tokenizer.restoreInto(tokenizedExpression.split(","));

}

/**
* Numeric value comparable automatically converts types to numeric values for
* comparison.
Expand Down Expand Up @@ -418,4 +468,79 @@ public String toString() {
}
}

/**
* Class that provides functionality to replace expressions that match
* {@link Tokenizer#TEXT_PARAMETER_PATTERN} with simple tokens of the form $$n$$.
* The reason for this is, that complex nested expressions
* may contain characters that interfere with further processing - e.g. ''', '(' and ')'
*/
private static class Tokenizer {

/**
* Regular expression with three alternative parts (ored) to match:
* <ol>
* <li> `(sometext)` - Quoted parameter block of a matcher.</li>
* <li> 'sometext' - Quoted text used as a parameter to a string matcher.</li>
* <li> (unquotedtext) - Unquoted text used as a parameter to a string matcher. This expression is non-greedy, meaning the first closing bracket will terminate the match.</li>
* </ol>
* <p/>
* Please note:
* - 'sometext' may contain an escaped quote.
* - 'unquotedtext' must not contain brackets or commas.
* <p/>
* To match quotes, commas, or brackets, you must quote the text. To match a quote, it should be escaped with a backslash.
* Therefore, the regex expressions explicitly match the escaped quote -> \\\\'
*/
private static final Pattern TEXT_PARAMETER_PATTERN
= Pattern.compile(
"\\('(?<quoted1>((?:[^']|\\\\')*)[^\\\\])'\\)"
+ "|('(?<quoted2>((?:[^']|\\\\')*)[^\\\\])')"
+ "|\\((?<unquoted>((?:[^']|\\\\')*?)[^\\\\])\\)");

private final List<String> originalTokenValues = new ArrayList<>();

/**
* Tokenize the given raw expression
*
* @param rawExpression
* @return the expression with all relevant subexpressions replaced by tokens
*/
public String tokenize(String rawExpression) {

String tokenizedExpression = rawExpression;
java.util.regex.Matcher matcher = TEXT_PARAMETER_PATTERN.matcher(rawExpression);
while (matcher.find()) {
String param = matcher.group("quoted1");
param = param != null ? param : matcher.group("quoted2");
param = param != null ? param : matcher.group("unquoted");
originalTokenValues.add(param);
tokenizedExpression = tokenizedExpression.replace(param, "$$" + originalTokenValues.size() + "$$");
}

return tokenizedExpression;
}

/**
* Restore the tokens back into the given expressions.
*
* @param expressions containing strings with tokens, generated by this tokenizer.
* @return expressions with the tokens being replaced with their original values.
*/
public String[] restoreInto(String[] expressions) {

for (int i = 0; i < expressions.length; i++) {
expressions[i] = VariableUtils.cutOffSingleQuotes(
replaceTokens(expressions[i], originalTokenValues).trim());
}

return expressions;
}

private String replaceTokens(String expression, List<String> params) {
for (int i = 0; i < params.size(); i++) {
expression = expression.replace("$$" + (i + 1) + "$$", params.get(i));
}
return expression;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
*/
public class HamcrestValidationMatcherTest extends AbstractTestNGUnitTest {

private HamcrestValidationMatcher validationMatcher = new HamcrestValidationMatcher();
private final HamcrestValidationMatcher validationMatcher = new HamcrestValidationMatcher();

@Override
protected TestContextFactory createTestContextFactory() {
Expand All @@ -49,7 +49,7 @@ public String getName() {

@Override
public Matcher<String> provideMatcher(String predicate) {
return new CustomMatcher<String>(String.format("path matching %s", predicate)) {
return new CustomMatcher<>(String.format("path matching %s", predicate)) {
@Override
public boolean matches(Object item) {
return ((item instanceof String) && new AntPathMatcher().match(predicate, (String) item));
Expand All @@ -63,7 +63,7 @@ public boolean matches(Object item) {
}

@Test(dataProvider = "testData")
public void testValidate(String path, String value, List<String> params) throws Exception {
public void testValidate(String path, String value, List<String> params) {
validationMatcher.validate( path, value, params, context);
}

Expand Down Expand Up @@ -123,16 +123,40 @@ public Object[][] testData() {
new Object[]{"foo", "[value1,value2,value3,value4,value5]", Collections.singletonList("everyItem(startsWith(value))") },
new Object[]{"foo", "[value1,value2,value3,value4,value5]", Collections.singletonList("hasItem(value2)") },
new Object[]{"foo", "[value1,value2,value3,value4,value5]", Collections.singletonList("hasItems(value2,value5)") },
new Object[]{"foo", "[a,b,c,d,e]", Collections.singletonList("hasItems('a','b','c')") },
new Object[]{"foo", "[a,b,c,d,e]", Collections.singletonList("hasItems(a, b, c)") },
new Object[]{"foo", "[a'a,b'b,c'c,d'd,e'e]", Collections.singletonList("hasItems('a\\'a','b\\'b','c\\'c')") },
new Object[]{"foo", "[a\\'a,b\\'b,c\\'c,d\\'d,e\\'e]", Collections.singletonList("hasItems('a\\\\'a','b\\\\'b','c\\\\'c')") },
new Object[]{"foo", "[\"value1\",\"value2\",\"value3\",\"value4\",\"value5\"]", Collections.singletonList("hasItems(value2,value5)") },
new Object[]{"foo", "[value1,value2,value3,value4,value5]", Collections.singletonList("contains(value1,value2,value3,value4,value5)") },
new Object[]{"foo", "[value1,value2,value3,value4,value5]", Collections.singletonList("containsInAnyOrder(value2,value4,value1,value3,value5)") },
new Object[]{"foo", "[a,b,c,d,e]", Collections.singletonList("contains('a','b','c','d','e')") },
new Object[]{"foo", "[a,b,c,d,e]", Collections.singletonList("contains(a,b,c,d,e)") },
new Object[]{"foo", "[a'a,b'b,c'c,d'd,e'e]", Collections.singletonList("contains('a\\'a','b\\'b','c\\'c','d\\'d','e\\'e')") },
new Object[]{"foo", "[a\\'a,b\\'b,c\\'c,d\\'d,e\\'e]", Collections.singletonList("contains('a\\\\'a','b\\\\'b','c\\\\'c','d\\\\'d','e\\\\'e')") },
new Object[]{"foo", "[\"unique_value\",\"different_unique_value\"]", Collections.singletonList("hasSize(2)") },
new Object[]{"foo", "[\"duplicate_value\",\"duplicate_value\"]", Collections.singletonList("hasSize(2)") }
new Object[]{"foo", "[\"duplicate_value\",\"duplicate_value\"]", Collections.singletonList("hasSize(2)") },
new Object[]{"foo", "text containing a , (comma) ", Collections.singletonList("anyOf(equalTo('text containing a , (comma) '), anyOf(isEmptyOrNullString()))")},
new Object[]{"foo", "", Collections.singletonList("anyOf(equalTo('text containing a , (comma) '), anyOf(isEmptyOrNullString()))")},
new Object[]{"foo", null, Collections.singletonList("anyOf(equalTo('text containing a , (comma) '), anyOf(isEmptyOrNullString()))")},
new Object[]{"foo", "text-equalTo(QA, Max", Collections.singletonList("anyOf(equalTo('text-equalTo(QA, Max'),anyOf(isEmptyOrNullString()))")},
new Object[]{"foo", "", Collections.singletonList("anyOf(equalTo('text-equalTo(QA, Max'),anyOf(isEmptyOrNullString()))")},
new Object[]{"foo", null, Collections.singletonList("anyOf(equalTo('text-equalTo(QA, Max'),anyOf(isEmptyOrNullString()))")},
new Object[]{"foo", "QA-equalTo(HH), Max", Collections.singletonList("anyOf(equalTo('QA-equalTo(HH), Max'),anyOf(isEmptyOrNullString()))")},
new Object[]{"foo", "text containing a ' (quote) and a , (comma) ", Collections.singletonList("anyOf(equalTo('text containing a \\' (quote) and a , (comma) '), anyOf(isEmptyOrNullString()))")},
new Object[]{"foo", "text containing a \\' (backslashquote) and a , (comma) ", Collections.singletonList("anyOf(equalTo('text containing a \\\\' (backslashquote) and a , (comma) '), anyOf(isEmptyOrNullString()))")},
new Object[]{"foo", "unquoted text may not include brackets or commas", Collections.singletonList("anyOf(equalTo(unquoted text may not include brackets or commas), anyOf(isEmptyOrNullString()))")},
new Object[]{"foo", "quoted \\' text may not include brackets or commas", Collections.singletonList("anyOf(equalTo(quoted \\\\' text may not include brackets or commas), anyOf(isEmptyOrNullString()))")},

new Object[]{"foo", "INSERT INTO todo_entries (id, title, description, done) values (1, 'Invite for meeting', 'Invite the group for a lunch meeting', 'false')",
Collections.singletonList("allOf(startsWith('INSERT INTO todo_entries (id, title, description, done)'))")},


};
}

@Test(dataProvider = "testDataFailed", expectedExceptions = ValidationException.class)
public void testValidateFailed(String path, String value, List<String> params) throws Exception {
public void testValidateFailed(String path, String value, List<String> params) {
validationMatcher.validate( path, value, params, context);
}

Expand Down Expand Up @@ -190,7 +214,14 @@ public Object[][] testDataFailed() {
new Object[]{"foo", "[value1,value2]", Collections.singletonList("hasItem(value5)") },
new Object[]{"foo", "[value1,value2]", Collections.singletonList("hasItems(value1,value2,value5)") },
new Object[]{"foo", "[value1,value2]", Collections.singletonList("contains(value1)") },
new Object[]{"foo", "[value1,value2]", Collections.singletonList("containsInAnyOrder(value2,value4)") }
new Object[]{"foo", "[value1,value2]", Collections.singletonList("containsInAnyOrder(value2,value4)") },
new Object[]{"foo", "notext-equalTo(QA, Max", Collections.singletonList("anyOf(equalTo('text-equalTo(QA, Max'),anyOf(isEmptyOrNullString()))")},
new Object[]{"foo", "aa", Collections.singletonList("anyOf(equalTo('text-equalTo(QA, Max'),anyOf(isEmptyOrNullString()))")},
new Object[]{"foo", "VA-equalTo(HH), Max", Collections.singletonList("anyOf(equalTo('QA-equalTo(HH), Max'),anyOf(isEmptyOrNullString()))")},
new Object[]{"foo", "notext containing a ' (quote) and a , (comma) ", Collections.singletonList("anyOf(equalTo('text containing a \\' (quote) and a , (comma) '), anyOf(isEmptyOrNullString()))")},
new Object[]{"foo", "notext containing a \\' (quote) and a , (comma) ", Collections.singletonList("anyOf(equalTo('text containing a \\\\' (quote) and a , (comma) '), anyOf(isEmptyOrNullString()))")},
new Object[]{"foo", "nounquoted text may not include brackets or commas", Collections.singletonList("anyOf(equalTo(unquoted text may not include brackets or commas), anyOf(isEmptyOrNullString()))")},

};
}
}

0 comments on commit 8208424

Please sign in to comment.