From 96890d5b9965118ec20bf6cdeb35caca381d3c3f Mon Sep 17 00:00:00 2001 From: Oleksii Ivanov <108869886+Oleksiivanov@users.noreply.github.com> Date: Mon, 22 May 2023 14:00:12 +0300 Subject: [PATCH] fix(inbound): add FEEL expression parser for properties in inbound connectors (#470) --- .../runtime/util/feel/FeelParserWrapper.java | 112 +++++++ .../inbound/InboundConnectorContextImpl.java | 30 +- .../InboundConnectorContextImplTest.java | 279 ++++++++++++++++++ 3 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 runtime-util/src/main/java/io/camunda/connector/runtime/util/feel/FeelParserWrapper.java create mode 100644 runtime-util/src/test/java/io/camunda/connector/runtime/util/inbound/InboundConnectorContextImplTest.java diff --git a/runtime-util/src/main/java/io/camunda/connector/runtime/util/feel/FeelParserWrapper.java b/runtime-util/src/main/java/io/camunda/connector/runtime/util/feel/FeelParserWrapper.java new file mode 100644 index 0000000000..f99df468f1 --- /dev/null +++ b/runtime-util/src/main/java/io/camunda/connector/runtime/util/feel/FeelParserWrapper.java @@ -0,0 +1,112 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. Camunda licenses this file to you under the Apache License, + * Version 2.0; 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 io.camunda.connector.runtime.util.feel; + +import fastparse.Parsed; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; +import org.camunda.feel.impl.parser.FeelParser; +import org.camunda.feel.syntaxtree.ArithmeticNegation; +import org.camunda.feel.syntaxtree.ConstBool; +import org.camunda.feel.syntaxtree.ConstContext; +import org.camunda.feel.syntaxtree.ConstDate; +import org.camunda.feel.syntaxtree.ConstDateTime; +import org.camunda.feel.syntaxtree.ConstList; +import org.camunda.feel.syntaxtree.ConstLocalDateTime; +import org.camunda.feel.syntaxtree.ConstNull; +import org.camunda.feel.syntaxtree.ConstNumber; +import org.camunda.feel.syntaxtree.ConstString; +import org.camunda.feel.syntaxtree.Exp; +import scala.Tuple2; +import scala.collection.immutable.List; +import scala.math.BigDecimal; + +public class FeelParserWrapper { + + @SuppressWarnings("unchecked") + public static Object parseIfIsFeelExpressionOrGetOriginal(final Object value) { + if (value instanceof Map) { + return ((Map) value) + .entrySet().stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, + entry -> parseIfIsFeelExpressionOrGetOriginal(entry.getValue()))); + } + if (value instanceof String && isFeelExpression((String) value)) { + return parseExpression((String) value); + } else { + return value; + } + } + + private static boolean isFeelExpression(String value) { + return value.startsWith("="); + } + + private static Object parseExpression(String expressionStr) { + String feelExpression = expressionStr.substring(1); + Parsed parsedExp = FeelParser.parseExpression(feelExpression); + if (!parsedExp.isSuccess()) { + throw new RuntimeException(parsedExp.toString()); + } + Exp expression = parsedExp.get().value(); + return parseExpression(expression); + } + + private static Object parseExpression(final Exp expression) { + if (ConstNull.canEqual(expression)) { + return null; + } else if (expression instanceof ConstContext) { + ConstContext constContext = (ConstContext) expression; + Map map = new HashMap<>(); + for (int i = 0; i < constContext.entries().size(); i++) { + Tuple2 apply = constContext.entries().apply(i); + String s = apply._1; + Exp exp = apply._2; + map.put(s, parseExpression(exp)); + } + return map; + } else if (expression instanceof ConstList) { + ConstList value1 = (ConstList) expression; + List items = value1.items(); + java.util.List result = new java.util.ArrayList<>(); + for (int i = 0; i < items.size(); i++) { + result.add(parseExpression(items.apply(i))); + } + return result; + } else if (expression instanceof ArithmeticNegation) { + Object parsedValue = parseExpression(((ArithmeticNegation) expression).x()); + return parsedValue != null ? ((BigDecimal) parsedValue).bigDecimal().negate() : null; + } else if (expression instanceof ConstString) { + return ((ConstString) expression).value(); + } else if (expression instanceof ConstBool) { + return ((ConstBool) expression).value(); + } else if (expression instanceof ConstNumber) { + return ((ConstNumber) expression).value(); + } else if (expression instanceof ConstDate) { + return ((ConstDate) expression).value(); + } else if (expression instanceof ConstDateTime) { + return ((ConstDateTime) expression).value(); + } else if (expression instanceof ConstLocalDateTime) { + return ((ConstLocalDateTime) expression).value(); + } else { + throw new RuntimeException("Failed to parse expression " + expression.toString()); + } + } +} diff --git a/runtime-util/src/main/java/io/camunda/connector/runtime/util/inbound/InboundConnectorContextImpl.java b/runtime-util/src/main/java/io/camunda/connector/runtime/util/inbound/InboundConnectorContextImpl.java index 968ba89a67..1076586e1e 100644 --- a/runtime-util/src/main/java/io/camunda/connector/runtime/util/inbound/InboundConnectorContextImpl.java +++ b/runtime-util/src/main/java/io/camunda/connector/runtime/util/inbound/InboundConnectorContextImpl.java @@ -25,17 +25,31 @@ import io.camunda.connector.api.inbound.InboundConnectorContext; import io.camunda.connector.api.inbound.InboundConnectorResult; import io.camunda.connector.api.secret.SecretProvider; +import io.camunda.connector.impl.Constants; import io.camunda.connector.impl.context.AbstractConnectorContext; import io.camunda.connector.impl.inbound.InboundConnectorProperties; +import io.camunda.connector.runtime.util.feel.FeelParserWrapper; import io.camunda.connector.runtime.util.inbound.correlation.InboundCorrelationHandler; +import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.function.Consumer; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class InboundConnectorContextImpl extends AbstractConnectorContext implements InboundConnectorContext { private final Logger LOG = LoggerFactory.getLogger(InboundConnectorContextImpl.class); + private static final Set reservedKeys = + Set.of( + Constants.ACTIVATION_CONDITION_KEYWORD, + Constants.LEGACY_VARIABLE_MAPPING_KEYWORD, + Constants.INBOUND_TYPE_KEYWORD, + Constants.RESULT_VARIABLE_KEYWORD, + Constants.RESULT_EXPRESSION_KEYWORD, + Constants.ERROR_EXPRESSION_KEYWORD, + Constants.CORRELATION_KEY_EXPRESSION_KEYWORD); private final InboundConnectorProperties properties; private final InboundCorrelationHandler correlationHandler; @@ -97,7 +111,21 @@ public InboundConnectorProperties getProperties() { @Override public T getPropertiesAsType(Class cls) { - return objectMapper.convertValue(properties.getPropertiesAsObjectMap(), cls); + Map result = + properties.getPropertiesAsObjectMap().entrySet().stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, + entry -> + isReservedKey(entry.getKey()) + ? entry.getValue() + : FeelParserWrapper.parseIfIsFeelExpressionOrGetOriginal( + entry.getValue()))); + return objectMapper.convertValue(result, cls); + } + + private static boolean isReservedKey(final String value) { + return reservedKeys.contains(value); } @Override diff --git a/runtime-util/src/test/java/io/camunda/connector/runtime/util/inbound/InboundConnectorContextImplTest.java b/runtime-util/src/test/java/io/camunda/connector/runtime/util/inbound/InboundConnectorContextImplTest.java new file mode 100644 index 0000000000..a3b535fae7 --- /dev/null +++ b/runtime-util/src/test/java/io/camunda/connector/runtime/util/inbound/InboundConnectorContextImplTest.java @@ -0,0 +1,279 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. Camunda licenses this file to you under the Apache License, + * Version 2.0; 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 io.camunda.connector.runtime.util.inbound; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.camunda.connector.api.secret.SecretProvider; +import io.camunda.connector.impl.inbound.InboundConnectorProperties; +import io.camunda.connector.impl.inbound.correlation.MessageCorrelationPoint; +import io.camunda.connector.runtime.util.outbound.TestSecretProvider; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.junit.jupiter.api.Test; + +class InboundConnectorContextImplTest { + private final SecretProvider secretProvider = new TestSecretProvider(); + + @Test + void getPropertiesAsType_shouldThrowExceptionWhenWrongFormat() { + // given + InboundConnectorProperties properties = + new InboundConnectorProperties( + new MessageCorrelationPoint(""), + Map.of("stringMap", "={{\"key\":\"value\"}"), + "bool", + 0, + 0, + "id"); + InboundConnectorContextImpl inboundConnectorContext = + new InboundConnectorContextImpl(secretProvider, properties, null, (e) -> {}); + // when and then + RuntimeException exception = + assertThrows( + RuntimeException.class, + () -> inboundConnectorContext.getPropertiesAsType(TestPropertiesClass.class)); + assertThat(exception.getMessage()).contains("Parsed.Failure(Position 1:1"); + } + + @Test + void getPropertiesAsType_shouldParseNullValue() { + // given + InboundConnectorProperties properties = + new InboundConnectorProperties( + new MessageCorrelationPoint(""), + Map.of("stringMap", "={\"keyString\":null}"), + "bool", + 0, + 0, + "id"); + InboundConnectorContextImpl inboundConnectorContext = + new InboundConnectorContextImpl(secretProvider, properties, null, (e) -> {}); + // when + TestPropertiesClass propertiesAsType = + inboundConnectorContext.getPropertiesAsType(TestPropertiesClass.class); + // then + assertThat(propertiesAsType.getStringMap().containsKey("keyString")).isTrue(); + assertThat(propertiesAsType.getStringMap().get("keyString")).isNull(); + System.out.println(propertiesAsType.getMapWithStringListWithNumbers()); + } + + @Test + void getPropertiesAsType_shouldParseStringAsString() { + // given + InboundConnectorProperties properties = + new InboundConnectorProperties( + new MessageCorrelationPoint(""), + Map.of( + "mapWithStringListWithNumbers", + "={\"key\":[\"34\", \"45\", \"890\",\"0\",\"16785\"]}"), + "bool", + 0, + 0, + "id"); + InboundConnectorContextImpl inboundConnectorContext = + new InboundConnectorContextImpl(secretProvider, properties, null, (e) -> {}); + // when + TestPropertiesClass propertiesAsType = + inboundConnectorContext.getPropertiesAsType(TestPropertiesClass.class); + // then + assertThat(propertiesAsType.getMapWithStringListWithNumbers().get("key").get(0)) + .isInstanceOf(String.class); + } + + @Test + void getPropertiesAsTypeShouldParseAllObject() { + // Given + InboundConnectorProperties properties = + new InboundConnectorProperties( + new MessageCorrelationPoint(""), + Map.of( + "stringMap", + "={\"keyString\":\"valueString\"}", + "stringMapMap", + "={\"keyString\":{\"innerKeyString\":\"innerValueString\"}}", + "stringList", + "=[\"value1\", \"value2\", \"value3\"]", + "numberList", + "=[34, -45, 890, 0, -16785]", + "str", + "foo ", + "bool", + "true", + "mapWithNumberList", + "={\"key\":[43, 0, -123]}", + "mapWithStringListWithNumbers", + "={\"key\":[\"34\", \"45\", \"890\",\"0\",\"16785\"]}", + "stringNumberList", + "=[\"34\", \"-45\", \"890\", \"0\", \"-16785\"]", + "stringObjectMap", + "={\"innerObject\":{\"stringList\":[\"innerList\"], \"bool\":false}}"), + "bool", + 0, + 0, + "id"); + InboundConnectorContextImpl inboundConnectorContext = + new InboundConnectorContextImpl(secretProvider, properties, null, (e) -> {}); + // when + TestPropertiesClass propertiesAsType = + inboundConnectorContext.getPropertiesAsType(TestPropertiesClass.class); + // then + assertThat(propertiesAsType).isEqualTo(createTestClass()); + } + + private TestPropertiesClass createTestClass() { + TestPropertiesClass testClass = new TestPropertiesClass(); + testClass.setStringMap(Map.of("keyString", "valueString")); + testClass.setStringMapMap(Map.of("keyString", Map.of("innerKeyString", "innerValueString"))); + testClass.setStringList(List.of("value1", "value2", "value3")); + testClass.setNumberList(List.of(34, -45, 890, 0, -16785)); + testClass.setStringNumberList(List.of("34", "-45", "890", "0", "-16785")); + testClass.setStr("foo "); + testClass.setBool(true); + testClass.setMapWithNumberList(Map.of("key", List.of(43L, 0L, -123L))); + var innerObject = new TestPropertiesClass(); + innerObject.setBool(false); + innerObject.setStringList(List.of("innerList")); + testClass.setStringObjectMap(Map.of("innerObject", innerObject)); + testClass.setMapWithStringListWithNumbers( + Map.of("key", List.of("34", "45", "890", "0", "16785"))); + return testClass; + } + + public static class TestPropertiesClass { + private Map stringMap; + private Map> stringMapMap; + private Map stringObjectMap; + private List stringList; + private List numberList; + private List stringNumberList; + private Map> mapWithNumberList; + private Map> mapWithStringListWithNumbers; + private String str; + private boolean bool; + + public Map getStringMap() { + return stringMap; + } + + public void setStringMap(final Map stringMap) { + this.stringMap = stringMap; + } + + public void setStringMapMap(final Map> stringMapMap) { + this.stringMapMap = stringMapMap; + } + + public void setStringObjectMap(final Map stringObjectMap) { + this.stringObjectMap = stringObjectMap; + } + + public void setStringList(final List stringList) { + this.stringList = stringList; + } + + public void setNumberList(final List numberList) { + this.numberList = numberList; + } + + public void setStringNumberList(final List stringNumberList) { + this.stringNumberList = stringNumberList; + } + + public void setMapWithNumberList(final Map> mapWithNumberList) { + this.mapWithNumberList = mapWithNumberList; + } + + public Map> getMapWithStringListWithNumbers() { + return mapWithStringListWithNumbers; + } + + public void setMapWithStringListWithNumbers( + final Map> mapWithStringListWithNumbers) { + this.mapWithStringListWithNumbers = mapWithStringListWithNumbers; + } + + public void setStr(final String str) { + this.str = str; + } + + public void setBool(final boolean bool) { + this.bool = bool; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final TestPropertiesClass that = (TestPropertiesClass) o; + return bool == that.bool + && Objects.equals(stringMap, that.stringMap) + && Objects.equals(stringMapMap, that.stringMapMap) + && Objects.equals(stringObjectMap, that.stringObjectMap) + && Objects.equals(stringList, that.stringList) + && Objects.equals(numberList, that.numberList) + && Objects.equals(stringNumberList, that.stringNumberList) + && Objects.equals(mapWithNumberList, that.mapWithNumberList) + && Objects.equals(mapWithStringListWithNumbers, that.mapWithStringListWithNumbers) + && Objects.equals(str, that.str); + } + + @Override + public int hashCode() { + return Objects.hash( + stringMap, + stringMapMap, + stringObjectMap, + stringList, + numberList, + stringNumberList, + mapWithNumberList, + mapWithStringListWithNumbers, + str, + bool); + } + + @Override + public String toString() { + return "TestPropertiesClass{" + + "stringMap=" + + stringMap + + ", stringMapMap=" + + stringMapMap + + ", stringObjectMap=" + + stringObjectMap + + ", stringList=" + + stringList + + ", numberList=" + + numberList + + ", stringNumberList=" + + stringNumberList + + ", mapWithNumberList=" + + mapWithNumberList + + ", mapWithStringListWithNumbers=" + + mapWithStringListWithNumbers + + ", str='" + + str + + "'" + + ", bool=" + + bool + + "}"; + } + } +}