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; package org.thingsboard.rule.engine.api.util;
import com.fasterxml.jackson.core.io.JsonStringEncoder;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeConfiguration;
@ -57,8 +58,12 @@ public final class TbNodeUtils {
} }
public static String processPattern(String pattern, TbMsg tbMsg) { public static String processPattern(String pattern, TbMsg tbMsg) {
return processPattern(pattern, tbMsg, false);
}
public static String processPattern(String pattern, TbMsg tbMsg, boolean escapeJsonValues) {
try { try {
String result = processPattern(pattern, tbMsg.getMetaData()); String result = processPattern(pattern, tbMsg.getMetaData(), escapeJsonValues);
JsonNode json = JacksonUtil.toJsonNode(tbMsg.getData()); JsonNode json = JacksonUtil.toJsonNode(tbMsg.getData());
result = result.replace(ALL_DATA_TEMPLATE, JacksonUtil.toString(json)); result = result.replace(ALL_DATA_TEMPLATE, JacksonUtil.toString(json));
@ -79,7 +84,11 @@ public final class TbNodeUtils {
} }
if (jsonNode != null && jsonNode.isValueNode()) { 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()); String replacement = metaData.isEmpty() ? "{}" : JacksonUtil.toString(metaData.getData());
pattern = pattern.replace(ALL_METADATA_TEMPLATE, replacement); 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) { 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; String result = template;
for (Map.Entry<String, String> kv : data.entrySet()) { 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; return result;
} }
private static String processVar(String pattern, String key, String val) { private static String escapeJsonValue(String value) {
return pattern.replace(formatMetadataVarTemplate(key), val); return new String(JsonStringEncoder.getInstance().quoteAsString(value));
} }
static String formatDataVarTemplate(String key) { 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)); 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 @Test
public void testMixedAllDataMetadataAndNormalTemplates() { public void testMixedAllDataMetadataAndNormalTemplates() {
// GIVEN // 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.Base64;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Properties; import java.util.Properties;
import java.util.concurrent.Semaphore; import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -182,10 +183,7 @@ public class TbHttpClient {
} }
EventLoopGroup getSharedOrCreateEventLoopGroup(EventLoopGroup eventLoopGroupShared) { EventLoopGroup getSharedOrCreateEventLoopGroup(EventLoopGroup eventLoopGroupShared) {
if (eventLoopGroupShared != null) { return Objects.requireNonNullElseGet(eventLoopGroupShared, () -> this.eventLoopGroup = new NioEventLoopGroup());
return eventLoopGroupShared;
}
return this.eventLoopGroup = new NioEventLoopGroup();
} }
private void checkSystemProxyProperties() throws TbNodeException { private void checkSystemProxyProperties() throws TbNodeException {
@ -243,7 +241,7 @@ public class TbHttpClient {
if ((HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method) || if ((HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method) ||
HttpMethod.PATCH.equals(method) || HttpMethod.DELETE.equals(method)) && HttpMethod.PATCH.equals(method) || HttpMethod.DELETE.equals(method)) &&
!config.isIgnoreRequestBody()) { !config.isIgnoreRequestBody()) {
request.body(BodyInserters.fromValue(getData(msg, config.isParseToPlainText()))); request.body(BodyInserters.fromValue(getRequestBody(msg)));
} }
request request
@ -268,6 +266,11 @@ public class TbHttpClient {
}); });
} catch (InterruptedException e) { } catch (InterruptedException e) {
log.warn("Timeout during waiting for reply!", 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 if (origin instanceof WebClientResponseException restClientResponseException
&& restClientResponseException.getStatusCode().is2xxSuccessful()) { && restClientResponseException.getStatusCode().is2xxSuccessful()) {
// return cause instead of original exception in case 2xx status code // 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 new RuntimeException(restClientResponseException.getCause());
} }
return origin; return origin;
@ -340,6 +343,22 @@ public class TbHttpClient {
return UriComponentsBuilder.fromUriString(endpointUrl).build().encode().toUri(); 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) { private Object getData(TbMsg tbMsg, boolean parseToPlainText) {
String data = tbMsg.getData(); String data = tbMsg.getData();
return parseToPlainText ? JacksonUtil.toPlainText(data) : JacksonUtil.toJsonNode(data); return parseToPlainText ? JacksonUtil.toPlainText(data) : JacksonUtil.toJsonNode(data);
@ -363,7 +382,7 @@ public class TbHttpClient {
headers.forEach((key, values) -> { headers.forEach((key, values) -> {
if (values != null && !values.isEmpty()) { if (values != null && !values.isEmpty()) {
if (values.size() == 1) { if (values.size() == 1) {
consumer.accept(key, values.get(0)); consumer.accept(key, values.getFirst());
} else { } else {
consumer.accept(key, JacksonUtil.toString(values)); 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, version = 4,
nodeDescription = "Invoke REST API calls to external REST server", 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. " + 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 " + " Outbound message will contain response fields " +
"(<code>status</code>, <code>statusCode</code>, <code>statusReason</code> and response <code>headers</code>) in the Message Metadata." + "(<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. " + " 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 PARSE_TO_PLAIN_TEXT = "parseToPlainText";
static final String MAX_IN_MEMORY_BUFFER_SIZE_IN_KB = "maxInMemoryBufferSizeInKb"; 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"; static final String TRIM_DOUBLE_QUOTES = "trimDoubleQuotes";
protected TbHttpClient httpClient; protected TbHttpClient httpClient;
@ -110,12 +113,16 @@ public class TbRestApiCallNode extends TbAbstractExternalNode {
((ObjectNode) oldConfiguration).put(MAX_IN_MEMORY_BUFFER_SIZE_IN_KB, 256); ((ObjectNode) oldConfiguration).put(MAX_IN_MEMORY_BUFFER_SIZE_IN_KB, 256);
} }
case 3: case 3:
if (!oldConfiguration.has(REQUEST_BODY_TEMPLATE)) {
hasChanges = true;
((ObjectNode) oldConfiguration).putNull(REQUEST_BODY_TEMPLATE);
}
Set<String> knownProperties = Set.of( Set<String> knownProperties = Set.of(
"restEndpointUrlPattern", "requestMethod", "headers", "restEndpointUrlPattern", "requestMethod", "headers",
"readTimeoutMs", "maxParallelRequestsCount", "parseToPlainText", "readTimeoutMs", "maxParallelRequestsCount", "parseToPlainText",
"enableProxy", "useSystemProxyProperties", "proxyHost", "proxyPort", "enableProxy", "useSystemProxyProperties", "proxyHost", "proxyPort",
"proxyUser", "proxyPassword", "credentials", "ignoreRequestBody", "proxyUser", "proxyPassword", "credentials", "ignoreRequestBody",
"maxInMemoryBufferSizeInKb" "requestBodyTemplate", "maxInMemoryBufferSizeInKb"
); );
Iterator<String> fieldNames = oldConfiguration.fieldNames(); Iterator<String> fieldNames = oldConfiguration.fieldNames();
while (fieldNames.hasNext()) { 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 String proxyPassword;
private ClientCredentials credentials; private ClientCredentials credentials;
private boolean ignoreRequestBody; private boolean ignoreRequestBody;
private String requestBodyTemplate;
private int maxInMemoryBufferSizeInKb; private int maxInMemoryBufferSizeInKb;
@JsonIgnore @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(); Map<String, String> data = metaData.getData();
Assertions.assertEquals(2, data.size()); Assertions.assertEquals(2, data.size());
Assertions.assertEquals(data.get("Content-Type"), "binary"); Assertions.assertEquals("binary", data.get("Content-Type"));
Assertions.assertEquals(data.get("Set-Cookie"), "[\"sap-context=sap-client=075; path=/\",\"sap-token=sap-client=075; path=/\"]"); 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; package org.thingsboard.rule.engine.rest;
import com.datastax.oss.driver.api.core.uuid.Uuids; 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.config.SocketConfig;
import org.apache.http.impl.bootstrap.HttpServer; import org.apache.http.impl.bootstrap.HttpServer;
import org.apache.http.impl.bootstrap.ServerBootstrap; import org.apache.http.impl.bootstrap.ServerBootstrap;
import org.apache.http.protocol.HttpContext;
import org.apache.http.protocol.HttpRequestHandler; import org.apache.http.protocol.HttpRequestHandler;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@ -52,11 +48,14 @@ import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.exception.DataValidationException;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream; import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ -71,19 +70,17 @@ import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
private RuleNode ruleNode;
@Spy @Spy
private TbRestApiCallNode restNode; private TbRestApiCallNode restNode;
@Mock @Mock
private TbContext ctx; private TbContext ctx;
private EntityId originator = new DeviceId(Uuids.timeBased()); private final EntityId originator = new DeviceId(Uuids.timeBased());
private TbMsgMetaData metaData = new TbMsgMetaData(); private final TbMsgMetaData metaData = new TbMsgMetaData();
private RuleChainId ruleChainId = new RuleChainId(Uuids.timeBased()); private final RuleChainId ruleChainId = new RuleChainId(Uuids.timeBased());
private RuleNodeId ruleNodeId = new RuleNodeId(Uuids.timeBased()); private final RuleNodeId ruleNodeId = new RuleNodeId(Uuids.timeBased());
private HttpServer server; private HttpServer server;
@ -108,7 +105,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
@BeforeEach @BeforeEach
public void setup() { public void setup() {
ruleNode = new RuleNode(); RuleNode ruleNode = new RuleNode();
ruleNode.setId(ruleNodeId); ruleNode.setId(ruleNodeId);
ruleNode.setName("Test REST API call node"); ruleNode.setName("Test REST API call node");
lenient().when(ctx.getSelf()).thenReturn(ruleNode); lenient().when(ctx.getSelf()).thenReturn(ruleNode);
@ -217,22 +214,17 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
public void deleteRequestWithoutBody() throws IOException, InterruptedException { public void deleteRequestWithoutBody() throws IOException, InterruptedException {
final CountDownLatch latch = new CountDownLatch(1); final CountDownLatch latch = new CountDownLatch(1);
final String path = "/path/to/delete"; final String path = "/path/to/delete";
setupServer("*", new HttpRequestHandler() { setupServer("*", (request, response, _) -> {
try {
@Override assertEquals(path, request.getRequestLine().getUri(), "Request path matches");
public void handle(HttpRequest request, HttpResponse response, HttpContext context) assertTrue(request.containsHeader("Foo"), "Custom header included");
throws HttpException, IOException { assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value");
try { response.setStatusCode(200);
assertEquals(request.getRequestLine().getUri(), path, "Request path matches"); latch.countDown();
assertTrue(request.containsHeader("Foo"), "Custom header included"); } catch (Exception e) {
assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value"); System.out.println("Exception handling request: " + e);
response.setStatusCode(200); e.printStackTrace();
latch.countDown(); latch.countDown();
} catch (Exception e) {
System.out.println("Exception handling request: " + e.toString());
e.printStackTrace();
latch.countDown();
}
} }
}); });
@ -269,28 +261,23 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
public void deleteRequestWithBody() throws IOException, InterruptedException { public void deleteRequestWithBody() throws IOException, InterruptedException {
final CountDownLatch latch = new CountDownLatch(1); final CountDownLatch latch = new CountDownLatch(1);
final String path = "/path/to/delete"; final String path = "/path/to/delete";
setupServer("*", new HttpRequestHandler() { setupServer("*", (request, response, _) -> {
try {
@Override assertEquals(path, request.getRequestLine().getUri(), "Request path matches");
public void handle(HttpRequest request, HttpResponse response, HttpContext context) assertTrue(request.containsHeader("Content-Type"), "Content-Type included");
throws HttpException, IOException { assertEquals("application/json",
try { request.getFirstHeader("Content-Type").getValue(), "Content-Type value");
assertEquals(path, request.getRequestLine().getUri(), "Request path matches"); assertTrue(request.containsHeader("Content-Length"), "Content-Length included");
assertTrue(request.containsHeader("Content-Type"), "Content-Type included"); assertEquals("2",
assertEquals("application/json", request.getFirstHeader("Content-Length").getValue(), "Content-Length value");
request.getFirstHeader("Content-Type").getValue(), "Content-Type value"); assertTrue(request.containsHeader("Foo"), "Custom header included");
assertTrue(request.containsHeader("Content-Length"), "Content-Length included"); assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value");
assertEquals("2", response.setStatusCode(200);
request.getFirstHeader("Content-Length").getValue(), "Content-Length value"); latch.countDown();
assertTrue(request.containsHeader("Foo"), "Custom header included"); } catch (Exception e) {
assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value"); System.out.println("Exception handling request: " + e);
response.setStatusCode(200); e.printStackTrace();
latch.countDown(); latch.countDown();
} catch (Exception e) {
System.out.println("Exception handling request: " + e.toString());
e.printStackTrace();
latch.countDown();
}
} }
}); });
@ -323,6 +310,143 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue()); 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() { private static Stream<Arguments> givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() {
return Stream.of( return Stream.of(
Arguments.of(0, Arguments.of(0,
@ -339,7 +463,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," +
"\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," +
"\"credentials\": {\"type\": \"anonymous\"}," + "\"credentials\": {\"type\": \"anonymous\"}," +
"\"maxInMemoryBufferSizeInKb\": 256}"), "\"maxInMemoryBufferSizeInKb\": 256,\"requestBodyTemplate\": null}"),
Arguments.of(1, Arguments.of(1,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
@ -355,7 +479,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," +
"\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," +
"\"credentials\": {\"type\": \"anonymous\"}," + "\"credentials\": {\"type\": \"anonymous\"}," +
"\"maxInMemoryBufferSizeInKb\": 256}"), "\"maxInMemoryBufferSizeInKb\": 256,\"requestBodyTemplate\": null}"),
Arguments.of(2, Arguments.of(2,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
@ -370,7 +494,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," +
"\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," +
"\"credentials\": {\"type\": \"anonymous\"}," + "\"credentials\": {\"type\": \"anonymous\"}," +
"\"maxInMemoryBufferSizeInKb\": 256}"), "\"maxInMemoryBufferSizeInKb\": 256,\"requestBodyTemplate\": null}"),
Arguments.of(3, """ Arguments.of(3, """
{ {
"restEndpointUrlPattern": "http://localhost/api", "restEndpointUrlPattern": "http://localhost/api",
@ -419,6 +543,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"credentials": { "credentials": {
"type": "anonymous" "type": "anonymous"
}, },
"requestBodyTemplate": null,
"maxInMemoryBufferSizeInKb": 256 "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 TbSendRestApiCallReplyNode node;
private TbSendRestApiCallReplyNodeConfiguration config; private TbSendRestApiCallReplyNodeConfiguration config;
@Mock @Mock
private TbContext ctxMock; private TbContext ctxMock;
@Mock @Mock
@ -140,4 +140,5 @@ public class TbSendRestApiCallReplyNodeTest {
TbMsg.EMPTY_STRING, "Request body is empty!") 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> </div>
</mat-slide-toggle> </mat-slide-toggle>
</div> </div>
<div class="tb-form-row"> <div class="tb-form-panel stroked column">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="ignoreRequestBody"> <mat-slide-toggle class="mat-slide fixed-title-width" formControlName="ignoreRequestBody">
{{ 'rule-node-config.ignore-request-body' | translate }} {{ 'rule-node-config.ignore-request-body' | translate }}
</mat-slide-toggle> </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> </div>
<mat-form-field class="mat-block" subscriptSizing="dynamic" appearance="outline"> <mat-form-field class="mat-block" subscriptSizing="dynamic" appearance="outline">
<mat-label translate>rule-node-config.read-timeout</mat-label> <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]], requestMethod: [configuration ? configuration.requestMethod : null, [Validators.required]],
parseToPlainText: [configuration ? configuration.parseToPlainText : false, []], parseToPlainText: [configuration ? configuration.parseToPlainText : false, []],
ignoreRequestBody: [configuration ? configuration.ignoreRequestBody : false, []], ignoreRequestBody: [configuration ? configuration.ignoreRequestBody : false, []],
requestBodyTemplate: [configuration ? configuration.requestBodyTemplate : null, []],
enableProxy: [configuration ? configuration.enableProxy : false, []], enableProxy: [configuration ? configuration.enableProxy : false, []],
useSystemProxyProperties: [configuration ? configuration.enableProxy : false, []], useSystemProxyProperties: [configuration ? configuration.enableProxy : false, []],
proxyHost: [configuration ? configuration.proxyHost : null, []], 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", "endpoint-url-pattern-required": "Endpoint URL pattern is required",
"request-method": "Request method", "request-method": "Request method",
"ignore-request-body": "Without request body", "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": "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": "Read timeout in millis",
"read-timeout-hint": "The value of 0 means an infinite timeout", "read-timeout-hint": "The value of 0 means an infinite timeout",
"max-parallel-requests-count": "Max number of parallel requests", "max-parallel-requests-count": "Max number of parallel requests",

Loading…
Cancel
Save