diff --git a/core/citrus-api/pom.xml b/core/citrus-api/pom.xml index b78b36ff2b..a7d5698b02 100644 --- a/core/citrus-api/pom.xml +++ b/core/citrus-api/pom.xml @@ -13,6 +13,15 @@ Citrus :: Core :: API Citrus API and basic interfaces + + + org.assertj + assertj-core + ${assertj.version} + test + + + diff --git a/core/citrus-api/src/main/java/org/citrusframework/functions/FunctionParameterHelper.java b/core/citrus-api/src/main/java/org/citrusframework/functions/FunctionParameterHelper.java index 355f9d3a02..4bcbfc8174 100644 --- a/core/citrus-api/src/main/java/org/citrusframework/functions/FunctionParameterHelper.java +++ b/core/citrus-api/src/main/java/org/citrusframework/functions/FunctionParameterHelper.java @@ -1,5 +1,5 @@ /* - * Copyright 2006-2010 the original author or authors. + * Copyright 2006-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,80 +16,111 @@ package org.citrusframework.functions; -import java.util.ArrayList; import java.util.List; -import java.util.StringTokenizer; +import java.util.Stack; /** * Helper class parsing a parameter string and converting the tokens to a parameter list. - * + * * @author Christoph Deppisch */ public final class FunctionParameterHelper { - + /** * Prevent class instantiation. */ private FunctionParameterHelper() {} - + /** * Convert a parameter string to a list of parameters. - * + * * @param parameterString comma separated parameter string. * @return list of parameters. */ public static List getParameterList(String parameterString) { - List parameterList = new ArrayList<>(); + return new ParameterParser(parameterString).parse(); + } + + public static class ParameterParser { + + private final String parameterString; + private final Stack parameterList = new Stack<>(); + private String currentParameter = ""; + private int lastQuoteIndex = -1; + private boolean isBetweenParams = false; - StringTokenizer tok = new StringTokenizer(parameterString, ","); - while (tok.hasMoreElements()) { - String param = tok.nextToken().trim(); - parameterList.add(cutOffSingleQuotes(param)); + public ParameterParser(String parameterString) { + this.parameterString = parameterString; } - List postProcessed = new ArrayList<>(); - for (int i = 0; i < parameterList.size(); i++) { - int next = i + 1; - - String processed = parameterList.get(i); - - if (processed.startsWith("'") && !processed.endsWith("'")) { - while (next < parameterList.size()) { - if (parameterString.contains(processed + ", " + parameterList.get(next))) { - processed += ", " + parameterList.get(next); - } else if (parameterString.contains(processed + "," + parameterList.get(next))) { - processed += "," + parameterList.get(next); - } else if (parameterString.contains(processed + " , " + parameterList.get(next))) { - processed += " , " + parameterList.get(next); - } else { - processed += parameterList.get(next); - } - - i++; - if (parameterList.get(next).endsWith("'")) { - break; - } else { - next++; - } - } + public List parse() { + parameterList.clear(); + for (int i = 0; i < parameterString.length(); i++) { + parseCharacterAt(i); + } + return parameterList.stream().toList(); + } + private void parseCharacterAt(int i) { + char c = parameterString.charAt(i); + if (isParameterSeparatingComma(c)) { + isBetweenParams = true; + addCurrentParamIfNotEmpty(); + } else if (isNestedSingleQuote(c)) { + lastQuoteIndex = i; + appendCurrentValueToLastParameter(); + } else if (isStartingSingleQuote(c)) { + isBetweenParams = false; + lastQuoteIndex = i; + } else if (isSingleQuote(c)) { // closing quote + addCurrentParamIfNotEmpty(); + } else { + if (isBetweenParams && !String.valueOf(c).matches("\\s")) isBetweenParams = false; + if (!isBetweenParams) currentParameter += c; + } + if (isLastChar(i)) { // TestFramework! + addCurrentParamIfNotEmpty(); } + } - postProcessed.add(cutOffSingleQuotes(processed)); + private void appendCurrentValueToLastParameter() { + currentParameter = "%s'%s'".formatted(parameterList.pop(), currentParameter); } - return postProcessed; - } + private boolean isLastChar(int i) { + return i == parameterString.length() - 1; + } + + private boolean isNestedSingleQuote(char c) { + return isSingleQuote(c) && isNotWithinSingleQuotes() && !currentParameter.trim().isEmpty(); + } - private static String cutOffSingleQuotes(String param) { - if (param.equals("'")) { - return ""; + private boolean isStartingSingleQuote(char c) { + return isSingleQuote(c) && isNotWithinSingleQuotes(); } - if (param.length() > 1 && param.charAt(0) == '\'' && param.charAt(param.length()-1) == '\'') { - return param.substring(1, param.length()-1); + private boolean isParameterSeparatingComma(char c) { + return isComma(c) && isNotWithinSingleQuotes(); } - return param; + private boolean isComma(char c) { + return c == ','; + } + + private boolean isNotWithinSingleQuotes() { + return lastQuoteIndex < 0; + } + + private static boolean isSingleQuote(char c) { + return c == '\''; + } + + private void addCurrentParamIfNotEmpty() { + if (!currentParameter.replaceAll("^'|'$", "").isEmpty()) { + parameterList.add(currentParameter); + } + lastQuoteIndex = -1; + currentParameter = ""; + } } } diff --git a/core/citrus-api/src/test/java/org/citrusframework/functions/FunctionParameterHelperTest.java b/core/citrus-api/src/test/java/org/citrusframework/functions/FunctionParameterHelperTest.java new file mode 100644 index 0000000000..e421f41e5d --- /dev/null +++ b/core/citrus-api/src/test/java/org/citrusframework/functions/FunctionParameterHelperTest.java @@ -0,0 +1,132 @@ +/* + * Copyright 2006-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.functions; + +import org.citrusframework.functions.FunctionParameterHelper.ParameterParser; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.citrusframework.functions.FunctionParameterHelper.getParameterList; + +class FunctionParameterHelperTest { + + @Test + void shouldOneParam() { + var result = getParameterList("lorem"); + assertThat(result).containsExactly("lorem"); + } + + @Test + void shouldTwoParam() { + var result = getParameterList("lorem, ipsum"); + assertThat(result).containsExactly("lorem", "ipsum"); + } + + @Test + void shouldTwoParam_oneQuoted() { + var result = getParameterList("lorem, 'ipsum'"); + assertThat(result).containsExactly("lorem", "ipsum"); + } + + @Test + void shouldTwoParam_withCommaInParam() { + var result = getParameterList("'lorem, dolor', 'ipsum'"); + assertThat(result).containsExactly("lorem, dolor", "ipsum"); + } + + @Test + void shouldTwoParam_withLinebreak() { + var result = getParameterList("'lorem, dolor', 'ipsum\n sit'"); + assertThat(result).containsExactly("lorem, dolor", "ipsum\n sit"); + } + + @Test + void shouldTwoParam_withLinebreakAfterComma() { + var result = getParameterList("'lorem,\n dolor', 'ipsum sit'"); + assertThat(result).containsExactly("lorem,\n dolor", "ipsum sit"); + } + + @Test + void shouldTwoParam_withWhitespacesAfterComma() { + var result = getParameterList("'lorem, dolor', 'ipsum sit'"); + assertThat(result).containsExactly("lorem, dolor", "ipsum sit"); + } + + @Test + void shouldConvertSingleLineJson() { + String json = """ + {"myValues": ["O15o3a8","PhDjdSruZgG"]}"""; + var result = getParameterList(wrappedInSingleQuotes(json)); + assertThat(result).containsExactly(json); + } + + @Test + void shouldConvertMultiLineJson() { + // language=JSON + String json = """ + { + "id": 133, + "myValues": [ + "O15o3a8", + "PhDjdSruZgG", + "I2qrC1Mu, PmSsd8LPLe" + ] + }"""; + var result = getParameterList(wrappedInSingleQuotes(json)); + assertThat(result).containsExactly(json); + } + + @Test + void shouldConvertNestedSingleQuotedStrings() { + // language=JSON + String json = """ + ["part of first param", "also 'part' of first param"]"""; + var result = getParameterList(wrappedInSingleQuotes(json)); + assertThat(result).hasSize(1).containsExactly(json); + } + + @Test + void shouldConvertIdempotent() { + // language=JSON + String json = """ + ["part of first param", "also 'part' of first param"]"""; + + var parser = new ParameterParser(wrappedInSingleQuotes(json)); + var result1 = parser.parse(); + var result2 = parser.parse(); + + assertThat(result1).isEqualTo(result2).hasSize(1).containsExactly(json); + } + + @Test + void cannotConvertSpecialNestedSingleQuotedStrings() { + String threeParams = """ + '["part of first param", "following comma will be missing ',' should also be first param"]', 'lorem', ipsum"""; + var parser = new ParameterParser(threeParams); + var result = parser.parse(); + assertThat(result).containsExactly( + "[\"part of first param\", \"following comma will be missing ", + " should also be first param\"]", + "lorem", + "ipsum" + ); + } + + private static String wrappedInSingleQuotes(String parameterString) { + return "'%s'".formatted(parameterString); + } +} diff --git a/core/citrus-base/pom.xml b/core/citrus-base/pom.xml index 8535110be8..1b058d6599 100644 --- a/core/citrus-base/pom.xml +++ b/core/citrus-base/pom.xml @@ -53,6 +53,12 @@ groovy-xml test + + org.assertj + assertj-core + ${assertj.version} + test + diff --git a/core/citrus-base/src/test/java/org/citrusframework/functions/FunctionUtilsTest.java b/core/citrus-base/src/test/java/org/citrusframework/functions/FunctionUtilsTest.java index 0612ad58f0..2fb22ac4ec 100644 --- a/core/citrus-base/src/test/java/org/citrusframework/functions/FunctionUtilsTest.java +++ b/core/citrus-base/src/test/java/org/citrusframework/functions/FunctionUtilsTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2006-2010 the original author or authors. + * Copyright 2006-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,16 +16,24 @@ package org.citrusframework.functions; -import java.util.Collections; - import org.citrusframework.UnitTestSupport; import org.citrusframework.exceptions.InvalidFunctionUsageException; import org.citrusframework.exceptions.NoSuchFunctionException; import org.citrusframework.exceptions.NoSuchFunctionLibraryException; import org.citrusframework.functions.core.CurrentDateFunction; -import org.testng.Assert; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.citrusframework.functions.FunctionUtils.resolveFunction; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; + /** * @author Christoph Deppisch */ @@ -33,9 +41,9 @@ public class FunctionUtilsTest extends UnitTestSupport { @Test public void testResolveFunction() { - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat('Hello', ' TestFramework!')", context), "Hello TestFramework!"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat('citrus', ':citrus')", context), "citrus:citrus"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat('citrus:citrus')", context), "citrus:citrus"); + assertEquals(resolveFunction("citrus:concat('Hello',' TestFramework!')", context), "Hello TestFramework!"); + assertEquals(resolveFunction("citrus:concat('citrus', ':citrus')", context), "citrus:citrus"); + assertEquals(resolveFunction("citrus:concat('citrus:citrus')", context), "citrus:citrus"); } @Test @@ -43,15 +51,15 @@ public void testWithVariables() { context.setVariable("greeting", "Hello"); context.setVariable("text", "TestFramework!"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat('Hello', ' ', ${text})", context), "Hello TestFramework!"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat(${greeting}, ' ', ${text})", context), "Hello TestFramework!"); + assertEquals(resolveFunction("citrus:concat('Hello', ' ', ${text})", context), "Hello TestFramework!"); + assertEquals(resolveFunction("citrus:concat(${greeting}, ' ', ${text})", context), "Hello TestFramework!"); } @Test public void testWithNestedFunctions() { - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat(citrus:currentDate('yyyy-mm-dd'))", context), new CurrentDateFunction().execute(Collections.singletonList("yyyy-mm-dd"), context)); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat('Now is: ', citrus:currentDate('yyyy-mm-dd'))", context), "Now is: " + new CurrentDateFunction().execute(Collections.singletonList("yyyy-mm-dd"), context)); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat(citrus:currentDate('yyyy-mm-dd'), ' ', citrus:concat('Hello', ' TestFramework!'))", context), new CurrentDateFunction().execute(Collections.singletonList("yyyy-mm-dd"), context) + " Hello TestFramework!"); + assertEquals(resolveFunction("citrus:concat(citrus:currentDate('yyyy-mm-dd'))", context), new CurrentDateFunction().execute(Collections.singletonList("yyyy-mm-dd"), context)); + assertEquals(resolveFunction("citrus:concat('Now is: ', citrus:currentDate('yyyy-mm-dd'))", context), "Now is: " + new CurrentDateFunction().execute(Collections.singletonList("yyyy-mm-dd"), context)); + assertEquals(resolveFunction("citrus:concat(citrus:currentDate('yyyy-mm-dd'), ' ', citrus:concat('Hello', ' TestFramework!'))", context), new CurrentDateFunction().execute(Collections.singletonList("yyyy-mm-dd"), context) + " Hello TestFramework!"); } @Test @@ -59,41 +67,93 @@ public void testWithNestedFunctionsAndVariables() { context.setVariable("greeting", "Hello"); context.setVariable("dateFormat", "yyyy-mm-dd"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat(citrus:currentDate('${dateFormat}'), ' ', citrus:concat(${greeting}, ' TestFramework!'))", context), new CurrentDateFunction().execute(Collections.singletonList("yyyy-mm-dd"), context) + " Hello TestFramework!"); + assertEquals(resolveFunction("citrus:concat(citrus:currentDate('${dateFormat}'), ' ', citrus:concat(${greeting}, ' TestFramework!'))", context), new CurrentDateFunction().execute(Collections.singletonList("yyyy-mm-dd"), context) + " Hello TestFramework!"); } @Test public void testWithCommaValue() { - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat(citrus:upperCase(Yes), ' ', citrus:upperCase(I like Citrus!))", context), "YES I LIKE CITRUS!"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:upperCase('Monday, Tuesday, wednesday')", context), "MONDAY, TUESDAY, WEDNESDAY"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat('Monday, Tuesday', ' Wednesday')", context), "Monday, Tuesday Wednesday"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:upperCase('Yes, I like Citrus!)", context), "'YES, I LIKE CITRUS!"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:upperCase(''Yes, I like Citrus!)", context), "''YES, I LIKE CITRUS!"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:upperCase(Yes I like Citrus!')", context), "YES I LIKE CITRUS!'"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:upperCase('Yes, I like Citrus!')", context), "YES, I LIKE CITRUS!"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:upperCase('Yes, I like Citrus, and this is great!')", context), "YES, I LIKE CITRUS, AND THIS IS GREAT!"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:upperCase('Yes,I like Citrus!')", context), "YES,I LIKE CITRUS!"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:upperCase('Yes', 'I like Citrus!')", context), "YES"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat('Hello Yes, I like Citrus!')", context), "Hello Yes, I like Citrus!"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat('Hello Yes,I like Citrus!')", context), "Hello Yes,I like Citrus!"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat('Hello Yes,I like Citrus, and this is great!')", context), "Hello Yes,I like Citrus, and this is great!"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat('Hello Yes , I like Citrus!')", context), "Hello Yes , I like Citrus!"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat('Hello Yes, I like Citrus!', 'Hello Yes,we like Citrus!')", context), "Hello Yes, I like Citrus!Hello Yes,we like Citrus!"); - Assert.assertEquals(FunctionUtils.resolveFunction("citrus:concat('Hello Yes, I like Citrus, and this is great!', 'Hello Yes,we like Citrus, and this is great!')", context), "Hello Yes, I like Citrus, and this is great!Hello Yes,we like Citrus, and this is great!"); + assertEquals(resolveFunction("citrus:concat(citrus:upperCase(Yes), ' ', citrus:upperCase(I like Citrus!))", context), "YES I LIKE CITRUS!"); + assertEquals(resolveFunction("citrus:upperCase('Monday, Tuesday, wednesday')", context), "MONDAY, TUESDAY, WEDNESDAY"); + assertEquals(resolveFunction("citrus:concat('Monday, Tuesday', ' Wednesday')", context), "Monday, Tuesday Wednesday"); + assertEquals(resolveFunction("citrus:upperCase('Yes, I like Citrus!')", context), "YES, I LIKE CITRUS!"); + assertEquals(resolveFunction("citrus:upperCase('Yes, I like Citrus, and this is great!')", context), "YES, I LIKE CITRUS, AND THIS IS GREAT!"); + assertEquals(resolveFunction("citrus:upperCase('Yes,I like Citrus!')", context), "YES,I LIKE CITRUS!"); + assertEquals(resolveFunction("citrus:upperCase('Yes', 'I like Citrus!')", context), "YES"); + assertEquals(resolveFunction("citrus:concat('Hello Yes, I like Citrus!')", context), "Hello Yes, I like Citrus!"); + assertEquals(resolveFunction("citrus:concat('Hello Yes,I like Citrus!')", context), "Hello Yes,I like Citrus!"); + assertEquals(resolveFunction("citrus:concat('Hello Yes,I like Citrus, and this is great!')", context), "Hello Yes,I like Citrus, and this is great!"); + assertEquals(resolveFunction("citrus:concat('Hello Yes , I like Citrus!')", context), "Hello Yes , I like Citrus!"); + assertEquals(resolveFunction("citrus:concat('Hello Yes, I like Citrus!', 'Hello Yes,we like Citrus!')", context), "Hello Yes, I like Citrus!Hello Yes,we like Citrus!"); + assertEquals(resolveFunction("citrus:concat('Hello Yes, I like Citrus, and this is great!', 'Hello Yes,we like Citrus, and this is great!')", context), "Hello Yes, I like Citrus, and this is great!Hello Yes,we like Citrus, and this is great!"); + +// assertEquals(resolveFunction("citrus:upperCase(''Yes, I like Citrus!)", context), "''YES, I LIKE CITRUS!"); +// assertEquals(resolveFunction("citrus:upperCase('Yes, I like Citrus!)", context), "'YES, I LIKE CITRUS!"); +// assertEquals(resolveFunction("citrus:upperCase(Yes I like Citrus!')", context), "YES I LIKE CITRUS!'"); } @Test(expectedExceptions = {InvalidFunctionUsageException.class}) public void testInvalidFunction() { - FunctionUtils.resolveFunction("citrus:citrus", context); + resolveFunction("citrus:citrus", context); } @Test(expectedExceptions = {NoSuchFunctionException.class}) public void testUnknownFunction() { - FunctionUtils.resolveFunction("citrus:functiondoesnotexist()", context); + resolveFunction("citrus:functiondoesnotexist()", context); } @Test(expectedExceptions = {NoSuchFunctionLibraryException.class}) public void testUnknownFunctionLibrary() { - FunctionUtils.resolveFunction("doesnotexist:concat('Hello', ' TestFramework!')", context); + resolveFunction("doesnotexist:concat('Hello', ' TestFramework!')", context); + } + + @DataProvider + public static String[][] validParameterLists() { + return new String[][]{ + { + "citrus:concat('{\"lorem\": [\"ipsum\", \"other\"]}')", + "{\"lorem\": [\"ipsum\", \"other\"]}" + }, + { + // has two spaces here ----------------\/ + "citrus:concat('{\"lorem\": [\"ipsum\", \"other\"]}')", + "{\"lorem\": [\"ipsum\", \"other\"]}" + }, + { + // has no space here ----------------\/ + "citrus:concat('{\"lorem\": [\"ipsum\",\"other\"]}')", + "{\"lorem\": [\"ipsum\",\"other\"]}" + }, + { + // with linebreak after comma + """ + citrus:upperCase('{ + "myValues": [ + "O15o3a8", + "PhDjdSruZgG" + ] + }') + """, + """ + { + "MYVALUES": [ + "O15O3A8", + "PHDJDSRUZGG" + ] + } + """ + } + }; + } + + @Test(dataProvider = "validParameterLists") + void shouldReplaceWithCommasInValue(String given, String expected) { + var contextSpy = spy(context); + when(contextSpy.getFunctionRegistry()).thenReturn(spy(context.getFunctionRegistry())); + List functionLibraries = List.of(new DefaultFunctionLibrary()); + when(contextSpy.getFunctionRegistry().getFunctionLibraries()).thenReturn(functionLibraries); + + var result = FunctionUtils.replaceFunctionsInString(given, context, false); + + assertThat(result).isEqualTo(expected); } }