diff --git a/application/pom.xml b/application/pom.xml index 3693fe6dbe..96c9ab7b39 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -286,7 +286,6 @@ com.jayway.jsonpath json-path - test com.jayway.jsonpath diff --git a/pom.xml b/pom.xml index 347874faef..b7ecea21fe 100755 --- a/pom.xml +++ b/pom.xml @@ -1213,7 +1213,6 @@ com.jayway.jsonpath json-path ${json-path.version} - test com.jayway.jsonpath diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 439ed41859..5326d44fd2 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -160,6 +160,10 @@ test + + com.jayway.jsonpath + json-path + diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNode.java new file mode 100644 index 0000000000..ff5b8a1ece --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNode.java @@ -0,0 +1,82 @@ +/** + * Copyright © 2016-2022 The Thingsboard 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.thingsboard.rule.engine.transform; + +import com.fasterxml.jackson.databind.JsonNode; +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.PathNotFoundException; +import com.jayway.jsonpath.spi.json.JacksonJsonNodeJsonProvider; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.RuleNode; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNode; +import org.thingsboard.rule.engine.api.TbNodeConfiguration; +import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.rule.engine.api.util.TbNodeUtils; +import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.common.msg.TbMsg; + +import java.util.concurrent.ExecutionException; + +@Slf4j +@RuleNode( + type = ComponentType.TRANSFORMATION, + name = "json path", + configClazz = TbJsonPathNodeConfiguration.class, + nodeDescription = "Transforms incoming message body using JSONPath expression.", + nodeDetails = "JSONPath expression specifies a path to an element or a set of elements in a JSON structure.
" + + "'$' represents the root object or array.
" + + "If JSONPath expression evaluation failed, incoming message routes via Failure chain, " + + "otherwise Success chain is used.", + uiResources = {"static/rulenode/rulenode-core-config.js"}, + icon = "functions", + configDirective = "tbTransformationNodeJsonPathConfig" +) +public class TbJsonPathNode implements TbNode { + + private TbJsonPathNodeConfiguration config; + private Configuration configurationJsonPath; + private JsonPath jsonPath; + private String jsonPathValue; + + @Override + public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { + this.config = TbNodeUtils.convert(configuration, TbJsonPathNodeConfiguration.class); + this.jsonPathValue = config.getJsonPath(); + if (!TbJsonPathNodeConfiguration.DEFAULT_JSON_PATH.equals(this.jsonPathValue)) { + this.configurationJsonPath = Configuration.builder() + .jsonProvider(new JacksonJsonNodeJsonProvider()) + .build(); + this.jsonPath = JsonPath.compile(config.getJsonPath()); + } + } + + @Override + public void onMsg(TbContext ctx, TbMsg msg) throws ExecutionException, InterruptedException, TbNodeException { + if (!TbJsonPathNodeConfiguration.DEFAULT_JSON_PATH.equals(this.jsonPathValue)) { + try { + JsonNode jsonPathData = jsonPath.read(msg.getData(), this.configurationJsonPath); + ctx.tellSuccess(TbMsg.transformMsg(msg, msg.getType(), msg.getOriginator(), msg.getMetaData(), JacksonUtil.toString(jsonPathData))); + } catch (PathNotFoundException e) { + ctx.tellFailure(msg, e); + } + } else { + ctx.tellSuccess(msg); + } + } +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNodeConfiguration.java new file mode 100644 index 0000000000..ac7ec6b2b2 --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNodeConfiguration.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard 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.thingsboard.rule.engine.transform; + +import lombok.Data; +import org.thingsboard.rule.engine.api.NodeConfiguration; + +@Data +public class TbJsonPathNodeConfiguration implements NodeConfiguration { + + static final String DEFAULT_JSON_PATH = "$"; + private String jsonPath; + + @Override + public TbJsonPathNodeConfiguration defaultConfiguration() { + TbJsonPathNodeConfiguration configuration = new TbJsonPathNodeConfiguration(); + configuration.setJsonPath(DEFAULT_JSON_PATH); + return configuration; + } + +} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbJsonPathNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbJsonPathNodeTest.java new file mode 100644 index 0000000000..ae9a8f9fdb --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbJsonPathNodeTest.java @@ -0,0 +1,167 @@ +/** + * Copyright © 2016-2022 The Thingsboard 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.thingsboard.rule.engine.transform; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.PathNotFoundException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNodeConfiguration; +import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.queue.TbMsgCallback; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public class TbJsonPathNodeTest { + final ObjectMapper mapper = new ObjectMapper(); + + DeviceId deviceId; + TbJsonPathNode node; + TbJsonPathNodeConfiguration config; + TbNodeConfiguration nodeConfiguration; + TbContext ctx; + TbMsgCallback callback; + + @BeforeEach + void setUp() throws TbNodeException { + deviceId = new DeviceId(UUID.randomUUID()); + callback = mock(TbMsgCallback.class); + ctx = mock(TbContext.class); + config = new TbJsonPathNodeConfiguration(); + config.setJsonPath("$.Attribute_2"); + nodeConfiguration = new TbNodeConfiguration(mapper.valueToTree(config)); + node = spy(new TbJsonPathNode()); + node.init(ctx, nodeConfiguration); + } + + @AfterEach + void tearDown() { + node.destroy(); + } + + @Test + void givenDefaultConfig_whenInit_thenFail() { + config.setJsonPath(""); + nodeConfiguration = new TbNodeConfiguration(mapper.valueToTree(config)); + assertThatThrownBy(() -> node.init(ctx, nodeConfiguration)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void givenDefaultConfig_whenVerify_thenOK() { + TbJsonPathNodeConfiguration defaultConfig = new TbJsonPathNodeConfiguration().defaultConfiguration(); + assertThat(defaultConfig.getJsonPath()).isEqualTo(TbJsonPathNodeConfiguration.DEFAULT_JSON_PATH); + } + + @Test + void givenPrimitiveMsg_whenOnMsg_thenVerifyOutput() throws Exception { + String data = "{\"Attribute_1\":22.5,\"Attribute_2\":100}"; + VerifyOutputMsg(data, 1, 100); + + data = "{\"Attribute_1\":22.5,\"Attribute_2\":\"StringValue\"}"; + VerifyOutputMsg(data, 2, "StringValue"); + } + + @Test + void givenJsonArray_whenOnMsg_thenVerifyOutput() throws Exception { + String data = "{\"Attribute_1\":22.5,\"Attribute_2\":[{\"Attribute_3\":22.5,\"Attribute_4\":10.3}, {\"Attribute_5\":22.5,\"Attribute_6\":10.3}]}"; + VerifyOutputMsg(data, 1, JacksonUtil.toJsonNode(data).get("Attribute_2")); + } + + @Test + void givenJsonNode_whenOnMsg_thenVerifyOutput() throws Exception { + String data = "{\"Attribute_1\":22.5,\"Attribute_2\":{\"Attribute_3\":22.5,\"Attribute_4\":10.3}}"; + VerifyOutputMsg(data, 1, JacksonUtil.toJsonNode(data).get("Attribute_2")); + } + + @Test + void givenJsonArrayWithFilter_whenOnMsg_thenVerifyOutput() throws Exception { + config.setJsonPath("$.Attribute_2[?(@.voltage > 200)]"); + nodeConfiguration = new TbNodeConfiguration(mapper.valueToTree(config)); + node.init(ctx, nodeConfiguration); + + String data = "{\"Attribute_1\":22.5,\"Attribute_2\":[{\"voltage\":220}, {\"voltage\":250}, {\"voltage\":110}]}"; + VerifyOutputMsg(data, 1, JacksonUtil.toJsonNode("[{\"voltage\":220}, {\"voltage\":250}]")); + } + + @Test + void givenNoArrayMsg_whenOnMsg_thenTellFailure() throws Exception { + String data = "{\"Attribute_1\":22.5,\"Attribute_5\":10.3}"; + JsonNode dataNode = JacksonUtil.toJsonNode(data); + TbMsg msg = getTbMsg(deviceId, dataNode.toString()); + node.onMsg(ctx, msg); + + ArgumentCaptor newMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + ArgumentCaptor exceptionCaptor = ArgumentCaptor.forClass(Exception.class); + verify(ctx, never()).tellSuccess(any()); + verify(ctx, times(1)).tellFailure(newMsgCaptor.capture(), exceptionCaptor.capture()); + + assertThat(newMsgCaptor.getValue()).isSameAs(msg); + assertThat(exceptionCaptor.getValue()).isInstanceOf(RuntimeException.class); + } + + @Test + void givenNoResultsForPath_whenOnMsg_thenTellFailure() throws Exception { + String data = "{\"Attribute_1\":22.5,\"Attribute_5\":10.3}"; + JsonNode dataNode = JacksonUtil.toJsonNode(data); + TbMsg msg = getTbMsg(deviceId, dataNode.toString()); + node.onMsg(ctx, msg); + + ArgumentCaptor newMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + ArgumentCaptor exceptionCaptor = ArgumentCaptor.forClass(Exception.class); + verify(ctx, never()).tellSuccess(any()); + verify(ctx, times(1)).tellFailure(newMsgCaptor.capture(), exceptionCaptor.capture()); + + assertThat(newMsgCaptor.getValue()).isSameAs(msg); + assertThat(exceptionCaptor.getValue()).isInstanceOf(PathNotFoundException.class); + } + + private void VerifyOutputMsg(String data, int countTellSuccess, Object value) throws Exception { + JsonNode dataNode = JacksonUtil.toJsonNode(data); + node.onMsg(ctx, getTbMsg(deviceId, dataNode.toString())); + + ArgumentCaptor newMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); + verify(ctx, times(countTellSuccess)).tellSuccess(newMsgCaptor.capture()); + verify(ctx, never()).tellFailure(any(), any()); + + assertThat(newMsgCaptor.getValue().getData()).isEqualTo(JacksonUtil.toString(value)); + } + + private TbMsg getTbMsg(EntityId entityId, String data) { + Map mdMap = Map.of("country", "US", + "city", "NY" + ); + return TbMsg.newMsg("POST_ATTRIBUTES_REQUEST", entityId, new TbMsgMetaData(mdMap), data, callback); + } +}