Browse Source

Merge pull request #15127 from AndriiLandiak/rest-api-node-body-template

Added request body template support for REST API call node
pull/14719/merge
Viacheslav Klimov 6 months ago
committed by GitHub
parent
commit
1ddcafcadb
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 32
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java
  2. 44
      rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java
  3. 33
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java
  4. 11
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java
  5. 1
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java
  6. 4
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java
  7. 229
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java
  8. 3
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNodeTest.java
  9. 13
      ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html
  10. 3
      ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss
  11. 1
      ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts
  12. 4
      ui-ngx/src/assets/locale/locale.constant-en_US.json

32
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.rule.engine.api.util;
import com.fasterxml.jackson.core.io.JsonStringEncoder;
import com.fasterxml.jackson.databind.JsonNode;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
@ -57,8 +58,12 @@ public final class TbNodeUtils {
}
public static String processPattern(String pattern, TbMsg tbMsg) {
return processPattern(pattern, tbMsg, false);
}
public static String processPattern(String pattern, TbMsg tbMsg, boolean escapeJsonValues) {
try {
String result = processPattern(pattern, tbMsg.getMetaData());
String result = processPattern(pattern, tbMsg.getMetaData(), escapeJsonValues);
JsonNode json = JacksonUtil.toJsonNode(tbMsg.getData());
result = result.replace(ALL_DATA_TEMPLATE, JacksonUtil.toString(json));
@ -79,7 +84,11 @@ public final class TbNodeUtils {
}
if (jsonNode != null && jsonNode.isValueNode()) {
result = result.replace(formatDataVarTemplate(group), jsonNode.asText());
String value = jsonNode.asText();
if (escapeJsonValues) {
value = escapeJsonValue(value);
}
result = result.replace(formatDataVarTemplate(group), value);
}
}
}
@ -89,22 +98,31 @@ public final class TbNodeUtils {
}
}
private static String processPattern(String pattern, TbMsgMetaData metaData) {
private static String processPattern(String pattern, TbMsgMetaData metaData, boolean escapeJsonValues) {
String replacement = metaData.isEmpty() ? "{}" : JacksonUtil.toString(metaData.getData());
pattern = pattern.replace(ALL_METADATA_TEMPLATE, replacement);
return processTemplate(pattern, metaData.values());
return processTemplate(pattern, metaData.values(), escapeJsonValues);
}
private static String processPattern(String pattern, TbMsgMetaData metaData) {
return processPattern(pattern, metaData, false);
}
public static String processTemplate(String template, Map<String, String> data) {
return processTemplate(template, data, false);
}
private static String processTemplate(String template, Map<String, String> data, boolean escapeJsonValues) {
String result = template;
for (Map.Entry<String, String> kv : data.entrySet()) {
result = processVar(result, kv.getKey(), kv.getValue());
String value = escapeJsonValues ? escapeJsonValue(kv.getValue()) : kv.getValue();
result = result.replace(formatMetadataVarTemplate(kv.getKey()), value);
}
return result;
}
private static String processVar(String pattern, String key, String val) {
return pattern.replace(formatMetadataVarTemplate(key), val);
private static String escapeJsonValue(String value) {
return new String(JsonStringEncoder.getInstance().quoteAsString(value));
}
static String formatDataVarTemplate(String key) {

44
rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java

@ -305,6 +305,50 @@ public class TbNodeUtilsTest {
assertThat(actual, is(expected));
}
@Test
public void testProcessPatternWithJsonEscaping() {
String pattern = "{\"name\":\"${user}\",\"desc\":\"$[description]\"}";
TbMsgMetaData md = new TbMsgMetaData();
md.putValue("user", "John \"Doe\"");
ObjectNode node = JacksonUtil.newObjectNode();
node.put("description", "line1\nline2");
TbMsg msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(TenantId.SYS_TENANT_ID)
.copyMetaData(md)
.data(JacksonUtil.toString(node))
.build();
String result = TbNodeUtils.processPattern(pattern, msg, true);
Assertions.assertEquals("{\"name\":\"John \\\"Doe\\\"\",\"desc\":\"line1\\nline2\"}", result);
// Verify the result is valid JSON
Assertions.assertDoesNotThrow(() -> JacksonUtil.toJsonNode(result));
}
@Test
public void testProcessPatternWithoutJsonEscaping() {
String pattern = "Hello ${user}, desc: $[description]";
TbMsgMetaData md = new TbMsgMetaData();
md.putValue("user", "John \"Doe\"");
ObjectNode node = JacksonUtil.newObjectNode();
node.put("description", "line1\nline2");
TbMsg msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(TenantId.SYS_TENANT_ID)
.copyMetaData(md)
.data(JacksonUtil.toString(node))
.build();
// Without escaping, raw values are substituted as-is
String result = TbNodeUtils.processPattern(pattern, msg, false);
Assertions.assertEquals("Hello John \"Doe\", desc: line1\nline2", result);
}
@Test
public void testMixedAllDataMetadataAndNormalTemplates() {
// GIVEN

33
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java

@ -55,6 +55,7 @@ import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
@ -182,10 +183,7 @@ public class TbHttpClient {
}
EventLoopGroup getSharedOrCreateEventLoopGroup(EventLoopGroup eventLoopGroupShared) {
if (eventLoopGroupShared != null) {
return eventLoopGroupShared;
}
return this.eventLoopGroup = new NioEventLoopGroup();
return Objects.requireNonNullElseGet(eventLoopGroupShared, () -> this.eventLoopGroup = new NioEventLoopGroup());
}
private void checkSystemProxyProperties() throws TbNodeException {
@ -243,7 +241,7 @@ public class TbHttpClient {
if ((HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method) ||
HttpMethod.PATCH.equals(method) || HttpMethod.DELETE.equals(method)) &&
!config.isIgnoreRequestBody()) {
request.body(BodyInserters.fromValue(getData(msg, config.isParseToPlainText())));
request.body(BodyInserters.fromValue(getRequestBody(msg)));
}
request
@ -268,6 +266,11 @@ public class TbHttpClient {
});
} catch (InterruptedException e) {
log.warn("Timeout during waiting for reply!", e);
} catch (Exception e) {
if (semaphore != null) {
semaphore.release();
}
onFailure.accept(msg, e);
}
}
@ -275,7 +278,7 @@ public class TbHttpClient {
if (origin instanceof WebClientResponseException restClientResponseException
&& restClientResponseException.getStatusCode().is2xxSuccessful()) {
// return cause instead of original exception in case 2xx status code
// this will provide meaningful error message to the user
// this will provide a meaningful error message to the user
return new RuntimeException(restClientResponseException.getCause());
}
return origin;
@ -340,6 +343,22 @@ public class TbHttpClient {
return UriComponentsBuilder.fromUriString(endpointUrl).build().encode().toUri();
}
private Object getRequestBody(TbMsg msg) {
if (StringUtils.isNotBlank(config.getRequestBodyTemplate())) {
boolean escapeJson = !config.isParseToPlainText();
String processedTemplate = TbNodeUtils.processPattern(config.getRequestBodyTemplate(), msg, escapeJson);
if (config.isParseToPlainText()) {
return processedTemplate;
}
try {
return JacksonUtil.toJsonNode(processedTemplate);
} catch (Exception e) {
throw new RuntimeException("Request body template produced invalid JSON: " + processedTemplate, e);
}
}
return getData(msg, config.isParseToPlainText());
}
private Object getData(TbMsg tbMsg, boolean parseToPlainText) {
String data = tbMsg.getData();
return parseToPlainText ? JacksonUtil.toPlainText(data) : JacksonUtil.toJsonNode(data);
@ -363,7 +382,7 @@ public class TbHttpClient {
headers.forEach((key, values) -> {
if (values != null && !values.isEmpty()) {
if (values.size() == 1) {
consumer.accept(key, values.get(0));
consumer.accept(key, values.getFirst());
} else {
consumer.accept(key, JacksonUtil.toString(values));
}

11
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java

@ -41,7 +41,9 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie
version = 4,
nodeDescription = "Invoke REST API calls to external REST server",
nodeDetails = "Will invoke REST API call <code>GET | POST | PUT | DELETE</code> to external REST server. " +
"Message payload added into Request body. Configured attributes can be added into Headers from Message Metadata." +
"Message payload is used as the request body by default. " +
"Optionally, a request body template can be configured with <code>${metadataKey}</code> and <code>$[messageKey]</code> placeholders. " +
"Configured attributes can be added into Headers from Message Metadata." +
" Outbound message will contain response fields " +
"(<code>status</code>, <code>statusCode</code>, <code>statusReason</code> and response <code>headers</code>) in the Message Metadata." +
" Response body saved in outbound Message payload. " +
@ -56,6 +58,7 @@ public class TbRestApiCallNode extends TbAbstractExternalNode {
static final String PARSE_TO_PLAIN_TEXT = "parseToPlainText";
static final String MAX_IN_MEMORY_BUFFER_SIZE_IN_KB = "maxInMemoryBufferSizeInKb";
static final String REQUEST_BODY_TEMPLATE = "requestBodyTemplate";
static final String TRIM_DOUBLE_QUOTES = "trimDoubleQuotes";
protected TbHttpClient httpClient;
@ -110,12 +113,16 @@ public class TbRestApiCallNode extends TbAbstractExternalNode {
((ObjectNode) oldConfiguration).put(MAX_IN_MEMORY_BUFFER_SIZE_IN_KB, 256);
}
case 3:
if (!oldConfiguration.has(REQUEST_BODY_TEMPLATE)) {
hasChanges = true;
((ObjectNode) oldConfiguration).putNull(REQUEST_BODY_TEMPLATE);
}
Set<String> knownProperties = Set.of(
"restEndpointUrlPattern", "requestMethod", "headers",
"readTimeoutMs", "maxParallelRequestsCount", "parseToPlainText",
"enableProxy", "useSystemProxyProperties", "proxyHost", "proxyPort",
"proxyUser", "proxyPassword", "credentials", "ignoreRequestBody",
"maxInMemoryBufferSizeInKb"
"requestBodyTemplate", "maxInMemoryBufferSizeInKb"
);
Iterator<String> fieldNames = oldConfiguration.fieldNames();
while (fieldNames.hasNext()) {

1
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java

@ -51,6 +51,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestA
private String proxyPassword;
private ClientCredentials credentials;
private boolean ignoreRequestBody;
private String requestBodyTemplate;
private int maxInMemoryBufferSizeInKb;
@JsonIgnore

4
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java

@ -569,8 +569,8 @@ public class TbHttpClientTest {
Map<String, String> data = metaData.getData();
Assertions.assertEquals(2, data.size());
Assertions.assertEquals(data.get("Content-Type"), "binary");
Assertions.assertEquals(data.get("Set-Cookie"), "[\"sap-context=sap-client=075; path=/\",\"sap-token=sap-client=075; path=/\"]");
Assertions.assertEquals("binary", data.get("Content-Type"));
Assertions.assertEquals("[\"sap-context=sap-client=075; path=/\",\"sap-token=sap-client=075; path=/\"]", data.get("Set-Cookie"));
}
}

229
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java

@ -16,13 +16,9 @@
package org.thingsboard.rule.engine.rest;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import org.apache.http.HttpException;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.config.SocketConfig;
import org.apache.http.impl.bootstrap.HttpServer;
import org.apache.http.impl.bootstrap.ServerBootstrap;
import org.apache.http.protocol.HttpContext;
import org.apache.http.protocol.HttpRequestHandler;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@ -52,11 +48,14 @@ import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.exception.DataValidationException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ -71,19 +70,17 @@ import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
private RuleNode ruleNode;
@Spy
private TbRestApiCallNode restNode;
@Mock
private TbContext ctx;
private EntityId originator = new DeviceId(Uuids.timeBased());
private TbMsgMetaData metaData = new TbMsgMetaData();
private final EntityId originator = new DeviceId(Uuids.timeBased());
private final TbMsgMetaData metaData = new TbMsgMetaData();
private RuleChainId ruleChainId = new RuleChainId(Uuids.timeBased());
private RuleNodeId ruleNodeId = new RuleNodeId(Uuids.timeBased());
private final RuleChainId ruleChainId = new RuleChainId(Uuids.timeBased());
private final RuleNodeId ruleNodeId = new RuleNodeId(Uuids.timeBased());
private HttpServer server;
@ -108,7 +105,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
@BeforeEach
public void setup() {
ruleNode = new RuleNode();
RuleNode ruleNode = new RuleNode();
ruleNode.setId(ruleNodeId);
ruleNode.setName("Test REST API call node");
lenient().when(ctx.getSelf()).thenReturn(ruleNode);
@ -217,22 +214,17 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
public void deleteRequestWithoutBody() throws IOException, InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
final String path = "/path/to/delete";
setupServer("*", new HttpRequestHandler() {
@Override
public void handle(HttpRequest request, HttpResponse response, HttpContext context)
throws HttpException, IOException {
try {
assertEquals(request.getRequestLine().getUri(), path, "Request path matches");
assertTrue(request.containsHeader("Foo"), "Custom header included");
assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value");
response.setStatusCode(200);
latch.countDown();
} catch (Exception e) {
System.out.println("Exception handling request: " + e.toString());
e.printStackTrace();
latch.countDown();
}
setupServer("*", (request, response, _) -> {
try {
assertEquals(path, request.getRequestLine().getUri(), "Request path matches");
assertTrue(request.containsHeader("Foo"), "Custom header included");
assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value");
response.setStatusCode(200);
latch.countDown();
} catch (Exception e) {
System.out.println("Exception handling request: " + e);
e.printStackTrace();
latch.countDown();
}
});
@ -269,28 +261,23 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
public void deleteRequestWithBody() throws IOException, InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
final String path = "/path/to/delete";
setupServer("*", new HttpRequestHandler() {
@Override
public void handle(HttpRequest request, HttpResponse response, HttpContext context)
throws HttpException, IOException {
try {
assertEquals(path, request.getRequestLine().getUri(), "Request path matches");
assertTrue(request.containsHeader("Content-Type"), "Content-Type included");
assertEquals("application/json",
request.getFirstHeader("Content-Type").getValue(), "Content-Type value");
assertTrue(request.containsHeader("Content-Length"), "Content-Length included");
assertEquals("2",
request.getFirstHeader("Content-Length").getValue(), "Content-Length value");
assertTrue(request.containsHeader("Foo"), "Custom header included");
assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value");
response.setStatusCode(200);
latch.countDown();
} catch (Exception e) {
System.out.println("Exception handling request: " + e.toString());
e.printStackTrace();
latch.countDown();
}
setupServer("*", (request, response, _) -> {
try {
assertEquals(path, request.getRequestLine().getUri(), "Request path matches");
assertTrue(request.containsHeader("Content-Type"), "Content-Type included");
assertEquals("application/json",
request.getFirstHeader("Content-Type").getValue(), "Content-Type value");
assertTrue(request.containsHeader("Content-Length"), "Content-Length included");
assertEquals("2",
request.getFirstHeader("Content-Length").getValue(), "Content-Length value");
assertTrue(request.containsHeader("Foo"), "Custom header included");
assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value");
response.setStatusCode(200);
latch.countDown();
} catch (Exception e) {
System.out.println("Exception handling request: " + e);
e.printStackTrace();
latch.countDown();
}
});
@ -323,6 +310,143 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue());
}
@Test
public void postRequestWithBodyTemplate() throws IOException, InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
final String path = "/api/token";
final AtomicReference<String> capturedBody = new AtomicReference<>();
setupServerWithBodyCapture(capturedBody, latch);
TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setRequestMethod("POST");
config.setRequestBodyTemplate("{\"grant_type\":\"client_credentials\",\"client_id\":\"${clientId}\",\"value\":\"$[token]\"}");
config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path));
initWithConfig(config);
metaData.putValue("clientId", "my-client-123");
TbMsg msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(originator)
.copyMetaData(metaData)
.dataType(TbMsgDataType.JSON)
.data("{\"token\":\"abc-xyz\"}")
.ruleChainId(ruleChainId)
.ruleNodeId(ruleNodeId)
.build();
restNode.onMsg(ctx, msg);
assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request");
assertEquals("{\"grant_type\":\"client_credentials\",\"client_id\":\"my-client-123\",\"value\":\"abc-xyz\"}", capturedBody.get());
}
@Test
public void postRequestWithBodyTemplateAndParseToPlainText() throws IOException, InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
final String path = "/api/text";
final AtomicReference<String> capturedBody = new AtomicReference<>();
setupServerWithBodyCapture(capturedBody, latch);
TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setRequestMethod("POST");
config.setParseToPlainText(true);
config.setRequestBodyTemplate("Hello ${name}, your token is $[token]!");
config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path));
initWithConfig(config);
metaData.putValue("name", "World");
TbMsg msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(originator)
.copyMetaData(metaData)
.dataType(TbMsgDataType.JSON)
.data("{\"token\":\"abc-xyz\"}")
.ruleChainId(ruleChainId)
.ruleNodeId(ruleNodeId)
.build();
restNode.onMsg(ctx, msg);
assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request");
assertEquals("Hello World, your token is abc-xyz!", capturedBody.get());
}
@Test
public void postRequestWithBodyTemplateEscapesJsonSpecialChars() throws IOException, InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
final String path = "/api/token";
final AtomicReference<String> capturedBody = new AtomicReference<>();
setupServerWithBodyCapture(capturedBody, latch);
TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setRequestMethod("POST");
config.setRequestBodyTemplate("{\"name\":\"${userName}\",\"desc\":\"$[description]\"}");
config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path));
initWithConfig(config);
metaData.putValue("userName", "John \"Doe\"");
TbMsg msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(originator)
.copyMetaData(metaData)
.dataType(TbMsgDataType.JSON)
.data("{\"description\":\"line1\\nline2\"}")
.ruleChainId(ruleChainId)
.ruleNodeId(ruleNodeId)
.build();
restNode.onMsg(ctx, msg);
assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request");
assertEquals("{\"name\":\"John \\\"Doe\\\"\",\"desc\":\"line1\\nline2\"}", capturedBody.get());
}
@Test
public void postRequestWithEmptyBodyTemplateUsesMessageData() throws IOException, InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
final String path = "/api/data";
final AtomicReference<String> capturedBody = new AtomicReference<>();
setupServerWithBodyCapture(capturedBody, latch);
TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setRequestMethod("POST");
// requestBodyTemplate is null by default — should use msg.getData()
config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path));
initWithConfig(config);
TbMsg msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(originator)
.copyMetaData(metaData)
.dataType(TbMsgDataType.JSON)
.data("{\"temperature\":25}")
.ruleChainId(ruleChainId)
.ruleNodeId(ruleNodeId)
.build();
restNode.onMsg(ctx, msg);
assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request");
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class);
ArgumentCaptor<TbMsgMetaData> metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class);
ArgumentCaptor<String> dataCaptor = ArgumentCaptor.forClass(String.class);
verify(ctx, timeout(10_000)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture());
assertEquals("{\"temperature\":25}", capturedBody.get());
}
private void setupServerWithBodyCapture(AtomicReference<String> capturedBody, CountDownLatch latch) throws IOException {
setupServer("*", (request, response, _) -> {
try {
if (request instanceof org.apache.http.HttpEntityEnclosingRequest entityRequest) {
InputStream is = entityRequest.getEntity().getContent();
capturedBody.set(new String(is.readAllBytes(), StandardCharsets.UTF_8));
}
response.setStatusCode(200);
latch.countDown();
} catch (Exception e) {
e.printStackTrace();
latch.countDown();
}
});
}
private static Stream<Arguments> givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() {
return Stream.of(
Arguments.of(0,
@ -339,7 +463,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," +
"\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," +
"\"credentials\": {\"type\": \"anonymous\"}," +
"\"maxInMemoryBufferSizeInKb\": 256}"),
"\"maxInMemoryBufferSizeInKb\": 256,\"requestBodyTemplate\": null}"),
Arguments.of(1,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
@ -355,7 +479,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," +
"\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," +
"\"credentials\": {\"type\": \"anonymous\"}," +
"\"maxInMemoryBufferSizeInKb\": 256}"),
"\"maxInMemoryBufferSizeInKb\": 256,\"requestBodyTemplate\": null}"),
Arguments.of(2,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
@ -370,7 +494,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," +
"\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," +
"\"credentials\": {\"type\": \"anonymous\"}," +
"\"maxInMemoryBufferSizeInKb\": 256}"),
"\"maxInMemoryBufferSizeInKb\": 256,\"requestBodyTemplate\": null}"),
Arguments.of(3, """
{
"restEndpointUrlPattern": "http://localhost/api",
@ -419,6 +543,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"credentials": {
"type": "anonymous"
},
"requestBodyTemplate": null,
"maxInMemoryBufferSizeInKb": 256
}""")
);

3
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNodeTest.java

@ -51,7 +51,7 @@ public class TbSendRestApiCallReplyNodeTest {
private TbSendRestApiCallReplyNode node;
private TbSendRestApiCallReplyNodeConfiguration config;
@Mock
private TbContext ctxMock;
@Mock
@ -140,4 +140,5 @@ public class TbSendRestApiCallReplyNodeTest {
TbMsg.EMPTY_STRING, "Request body is empty!")
);
}
}

13
ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html

@ -138,10 +138,21 @@
</div>
</mat-slide-toggle>
</div>
<div class="tb-form-row">
<div class="tb-form-panel stroked column">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="ignoreRequestBody">
{{ 'rule-node-config.ignore-request-body' | translate }}
</mat-slide-toggle>
@if(!restApiCallConfigForm.get('ignoreRequestBody').value) {
<div class="tb-form-hint tb-primary-fill mb-4" [innerHTML]="'rule-node-config.request-body-template-hint' | translate | safe: 'html'"></div>
<mat-form-field class="mat-block" subscriptSizing="dynamic" appearance="outline" floatLabel="always">
<mat-label translate>rule-node-config.request-body-template</mat-label>
<textarea matInput
formControlName="requestBodyTemplate"
class="tb-enable-vertical-resize"
cdkTextareaAutosize
cdkAutosizeMinRows="3"></textarea>
</mat-form-field>
}
</div>
<mat-form-field class="mat-block" subscriptSizing="dynamic" appearance="outline">
<mat-label translate>rule-node-config.read-timeout</mat-label>

3
ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss

@ -26,4 +26,7 @@
}
}
}
textarea.tb-enable-vertical-resize {
resize: vertical;
}
}

1
ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts

@ -51,6 +51,7 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent {
requestMethod: [configuration ? configuration.requestMethod : null, [Validators.required]],
parseToPlainText: [configuration ? configuration.parseToPlainText : false, []],
ignoreRequestBody: [configuration ? configuration.ignoreRequestBody : false, []],
requestBodyTemplate: [configuration ? configuration.requestBodyTemplate : null, []],
enableProxy: [configuration ? configuration.enableProxy : false, []],
useSystemProxyProperties: [configuration ? configuration.enableProxy : false, []],
proxyHost: [configuration ? configuration.proxyHost : null, []],

4
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -5521,8 +5521,10 @@
"endpoint-url-pattern-required": "Endpoint URL pattern is required",
"request-method": "Request method",
"ignore-request-body": "Without request body",
"request-body-template": "Request body template",
"request-body-template-hint": "Use <code><span style=\"color: #000;\">$&#123;</span>metadataKey<span style=\"color: #000;\">&#125;</span></code> for metadata, <code><span style=\"color: #000;\">$[</span>messageKey<span style=\"color: #000;\">]</span></code> for message values. If empty, the message payload is used as the request body. For non-JSON payloads, enable 'Parse to plain text'.",
"parse-to-plain-text": "Parse to plain text",
"parse-to-plain-text-hint": "If selected, request body message payload will be transformed from JSON string to plain text, e.g. msg = \"Hello,\\t\"world\"\" will be parsed to Hello, \"world\"",
"parse-to-plain-text-hint": "If selected, the request body (message payload or body template result) will be sent as a plain text string instead of being parsed as JSON, e.g. msg = \"Hello,\\t\"world\"\" will be parsed to Hello, \"world\"",
"read-timeout": "Read timeout in millis",
"read-timeout-hint": "The value of 0 means an infinite timeout",
"max-parallel-requests-count": "Max number of parallel requests",

Loading…
Cancel
Save