Browse Source

Merge pull request #14540 from dskarzh/fix/rest-api-node/url-encoding

Fixed query parameter encoding in REST API call rule node
pull/15269/head
Viacheslav Klimov 7 months ago
committed by GitHub
parent
commit
3f7833f225
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 18
      common/data/src/main/java/org/thingsboard/server/common/data/util/KeyValueEntry.java
  2. 4
      rule-engine/rule-engine-components/pom.xml
  3. 62
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java
  4. 32
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java
  5. 38
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java
  6. 502
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java
  7. 178
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java
  8. 3
      ui-ngx/src/app/modules/home/components/rule-node/common/common-rule-node-config.module.ts
  9. 114
      ui-ngx/src/app/modules/home/components/rule-node/common/credentials-config.component.html
  10. 84
      ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts
  11. 78
      ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html
  12. 10
      ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.ts
  13. 226
      ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html
  14. 29
      ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss
  15. 33
      ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts
  16. 13
      ui-ngx/src/assets/locale/locale.constant-en_US.json

18
common/data/src/main/java/org/thingsboard/server/common/data/util/KeyValueEntry.java

@ -0,0 +1,18 @@
/**
* Copyright © 2016-2026 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.server.common.data.util;
public record KeyValueEntry<K, V>(K key, V value) {}

4
rule-engine/rule-engine-components/pom.xml

@ -161,6 +161,10 @@
<groupId>org.thingsboard.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.core5</groupId>
<artifactId>httpcore5</artifactId>
</dependency>
</dependencies>
<build>

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

@ -21,6 +21,7 @@ import io.netty.handler.ssl.SslContext;
import io.netty.handler.timeout.ReadTimeoutHandler;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.hc.core5.net.URIBuilder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
@ -40,6 +41,7 @@ import org.thingsboard.rule.engine.credentials.BasicCredentials;
import org.thingsboard.rule.engine.credentials.ClientCredentials;
import org.thingsboard.rule.engine.credentials.CredentialsType;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.util.KeyValueEntry;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import reactor.netty.http.client.HttpClient;
@ -48,6 +50,7 @@ import reactor.netty.transport.ProxyProvider;
import javax.net.ssl.SSLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
@ -130,10 +133,6 @@ public class TbHttpClient {
SslContext sslContext = config.getCredentials().initSslContext();
httpClient = httpClient.secure(t -> t.sslContext(sslContext));
}
} else if (config.isUseSimpleClientHttpFactory()) {
if (CredentialsType.CERT_PEM == config.getCredentials().getType()) {
throw new TbNodeException("Simple HTTP Factory does not support CERT PEM credentials!");
}
} else {
SslContext sslContext = config.getCredentials().initSslContext();
httpClient = httpClient.secure(t -> t.sslContext(sslContext));
@ -219,8 +218,22 @@ public class TbHttpClient {
}
String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), msg);
List<KeyValueEntry<String, String>> processedQueryParams;
if (config.getQueryParams() != null) {
processedQueryParams = config.getQueryParams().stream()
.map(param -> {
var processedParamName = TbNodeUtils.processPattern(param.key(), msg);
var processedParamValue = TbNodeUtils.processPattern(param.value(), msg);
return new KeyValueEntry<>(processedParamName, processedParamValue);
})
.toList();
} else {
processedQueryParams = null;
}
HttpMethod method = HttpMethod.valueOf(config.getRequestMethod());
URI uri = buildEncodedUri(endpointUrl);
URI uri = buildEncodedUri(endpointUrl, processedQueryParams);
RequestBodySpec request = webClient
.method(method)
@ -268,7 +281,7 @@ public class TbHttpClient {
return origin;
}
public URI buildEncodedUri(String endpointUrl) {
public URI buildEncodedUri(String endpointUrl, List<KeyValueEntry<String, String>> queryParams) {
if (endpointUrl == null) {
throw new RuntimeException("Url string cannot be null!");
}
@ -276,7 +289,13 @@ public class TbHttpClient {
throw new RuntimeException("Url string cannot be empty!");
}
URI uri = UriComponentsBuilder.fromUriString(endpointUrl).build().encode().toUri();
URI uri;
if (queryParams != null) {
uri = buildEncodedUriNew(endpointUrl, queryParams);
} else {
uri = buildEncodedUriLegacy(endpointUrl);
}
if (uri.getScheme() == null || uri.getScheme().isEmpty()) {
throw new RuntimeException("Transport scheme(protocol) must be provided!");
}
@ -292,6 +311,35 @@ public class TbHttpClient {
return uri;
}
private URI buildEncodedUriNew(String endpointUrl, List<KeyValueEntry<String, String>> queryParams) {
try {
URIBuilder builder = new URIBuilder(endpointUrl);
queryParams.forEach(param -> builder.addParameter(param.key(), param.value()));
return builder.build();
} catch (URISyntaxException e) {
throw new IllegalArgumentException(
"Invalid request URL: '" + endpointUrl + "'. The URL must be valid and properly encoded. " +
"If URL contains special characters (e.g., spaces), they must be percent-encoded (e.g., '/my device/' should be '/my%20device/').", e);
}
}
/**
* @deprecated Query params embedded in the URL string are not encoded correctly.
* <p>
* In query strings, {@code +} is interpreted as a space (URL form encoding),
* so {@code email=user+tag@test.com} becomes {@code email=user tag@test.com} on the server.
* <p>
* Pre-encoding the URL (e.g., {@code email=user%2Btag@test.com}) doesn't help
* because {@code .encode()} will double-encode it to {@code email=user%252Btag@test.com}.
* <p>
* Use {@link #buildEncodedUriNew} with a separate query params list,
* where values are encoded exactly once: {@code +} → {@code %2B}, {@code @} → {@code %40}.
*/
@Deprecated(forRemoval = true)
private URI buildEncodedUriLegacy(String endpointUrl) {
return UriComponentsBuilder.fromUriString(endpointUrl).build().encode().toUri();
}
private Object getData(TbMsg tbMsg, boolean parseToPlainText) {
String data = tbMsg.getData();
return parseToPlainText ? JacksonUtil.toPlainText(data) : JacksonUtil.toJsonNode(data);

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

@ -26,14 +26,19 @@ import org.thingsboard.rule.engine.external.TbAbstractExternalNode;
import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.data.util.TbPair;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.exception.DataValidationException;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields;
@RuleNode(
type = ComponentType.EXTERNAL,
name = "rest api call",
configClazz = TbRestApiCallNodeConfiguration.class,
version = 3,
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." +
@ -57,7 +62,15 @@ public class TbRestApiCallNode extends TbAbstractExternalNode {
@Override
public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException {
super.init(ctx);
TbRestApiCallNodeConfiguration config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class);
var config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class);
String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: ";
try {
validateFields(config, errorPrefix);
} catch (DataValidationException e) {
throw new TbNodeException(e, true);
}
httpClient = new TbHttpClient(config, ctx.getSharedEventLoop());
}
@ -96,6 +109,21 @@ public class TbRestApiCallNode extends TbAbstractExternalNode {
hasChanges = true;
((ObjectNode) oldConfiguration).put(MAX_IN_MEMORY_BUFFER_SIZE_IN_KB, 256);
}
case 3:
Set<String> knownProperties = Set.of(
"restEndpointUrlPattern", "requestMethod", "headers",
"readTimeoutMs", "maxParallelRequestsCount", "parseToPlainText",
"enableProxy", "useSystemProxyProperties", "proxyHost", "proxyPort",
"proxyUser", "proxyPassword", "credentials", "ignoreRequestBody",
"maxInMemoryBufferSizeInKb"
);
Iterator<String> fieldNames = oldConfiguration.fieldNames();
while (fieldNames.hasNext()) {
if (!knownProperties.contains(fieldNames.next())) {
hasChanges = true;
fieldNames.remove();
}
}
break;
default:
break;

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

@ -15,25 +15,31 @@
*/
package org.thingsboard.rule.engine.rest;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.thingsboard.rule.engine.api.NodeConfiguration;
import org.thingsboard.rule.engine.credentials.AnonymousCredentials;
import org.thingsboard.rule.engine.credentials.ClientCredentials;
import org.thingsboard.server.common.data.util.KeyValueEntry;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@JsonIgnoreProperties(ignoreUnknown = true)
@Data
public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestApiCallNodeConfiguration> {
private String restEndpointUrlPattern;
private String requestMethod;
// null for legacy configs - triggers different URL encoding logic in TbHttpClient to preserve backward compatibility
// all new/modified configs since introduction of this property are forced to have non-null value by UI (and use new encoding logic)
private List<@NotNull KeyValueEntry<String, String>> queryParams;
private Map<String, String> headers;
private boolean useSimpleClientHttpFactory;
private int readTimeoutMs;
private int maxParallelRequestsCount;
private boolean parseToPlainText;
@ -43,18 +49,31 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestA
private int proxyPort;
private String proxyUser;
private String proxyPassword;
private String proxyScheme;
private ClientCredentials credentials;
private boolean ignoreRequestBody;
private int maxInMemoryBufferSizeInKb;
@JsonIgnore
@AssertTrue(message = "query parameter names and values must be non-null")
public boolean isValid() {
if (queryParams == null) {
return true; // null indicates legacy configuration; keep using old URL encoding logic
}
for (KeyValueEntry<String, String> queryParam : queryParams) {
if (queryParam == null || queryParam.key() == null || queryParam.value() == null) {
return false;
}
}
return true;
}
@Override
public TbRestApiCallNodeConfiguration defaultConfiguration() {
TbRestApiCallNodeConfiguration configuration = new TbRestApiCallNodeConfiguration();
var configuration = new TbRestApiCallNodeConfiguration();
configuration.setRestEndpointUrlPattern("http://localhost/api");
configuration.setRequestMethod("POST");
configuration.setHeaders(Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE));
configuration.setUseSimpleClientHttpFactory(false);
configuration.setQueryParams(Collections.emptyList());
configuration.setReadTimeoutMs(0);
configuration.setMaxParallelRequestsCount(0);
configuration.setParseToPlainText(false);
@ -66,10 +85,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestA
}
public ClientCredentials getCredentials() {
if (this.credentials == null) {
return new AnonymousCredentials();
} else {
return this.credentials;
}
return Objects.requireNonNullElseGet(credentials, AnonymousCredentials::new);
}
}

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

@ -15,39 +15,47 @@
*/
package org.thingsboard.rule.engine.rest;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Named;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mockito;
import org.mockserver.integration.ClientAndServer;
import org.springframework.util.LinkedMultiValueMap;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.util.KeyValueEntry;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import java.net.URI;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
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;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.willCallRealMethod;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockserver.integration.ClientAndServer.startClientAndServer;
@ -86,123 +94,461 @@ public class TbHttpClientTest {
@Test
public void testBuildSimpleUri() {
Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod();
Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
String url = "http://localhost:8080/";
URI uri = client.buildEncodedUri(url);
URI uri = client.buildEncodedUri(url, null);
Assertions.assertEquals(url, uri.toString());
}
@Test
public void testBuildUriWithoutProtocol() {
Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod();
Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
String url = "localhost:8080/";
assertThatThrownBy(() -> client.buildEncodedUri(url));
assertThatThrownBy(() -> client.buildEncodedUri(url, null));
}
@Test
public void testBuildInvalidUri() {
Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod();
Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
String url = "aaa";
assertThatThrownBy(() -> client.buildEncodedUri(url));
assertThatThrownBy(() -> client.buildEncodedUri(url, null));
}
@Test
public void testBuildUriWithSpecialSymbols() {
Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod();
Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
String url = "http://192.168.1.1/data?d={\"a\": 12}";
String expected = "http://192.168.1.1/data?d=%7B%22a%22:%2012%7D";
URI uri = client.buildEncodedUri(url);
URI uri = client.buildEncodedUri(url, null);
Assertions.assertEquals(expected, uri.toString());
}
@Test
public void testProcessMessageWithQueryParamsPatternProcessing() throws Exception {
// GIVEN
String path = "/api/notify";
try (var server = startClientAndServer("localhost", 0)) {
server.when(
request()
.withMethod("GET")
.withPath(path)
.withQueryStringParameter("email", "user+tag@test.com")
.withQueryStringParameter("device", "sensor-1")
.withQueryStringParameter("custom-header", "header-value")
.withQueryStringParameter("temp", "25.5")
.withQueryStringParameter("location", "room-1/zone-A")
).respond(
response().withStatusCode(200)
);
var config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setRestEndpointUrlPattern("http://localhost:" + server.getPort() + path);
config.setRequestMethod("GET");
config.setQueryParams(List.of(
new KeyValueEntry<>("email", "${userEmail}"), // ${} from metadata
new KeyValueEntry<>("device", "${deviceName}"), // ${} from metadata
new KeyValueEntry<>("${dynamicParam}", "${dynamicValue}"), // ${} in both key and value
new KeyValueEntry<>("temp", "$[temperature]"), // $[] from data
new KeyValueEntry<>("location", "$[sensor.location]") // $[] from nested data
));
var metaData = new TbMsgMetaData();
metaData.putValue("userEmail", "user+tag@test.com");
metaData.putValue("deviceName", "sensor-1");
metaData.putValue("dynamicParam", "custom-header");
metaData.putValue("dynamicValue", "header-value");
var msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(new DeviceId(EntityId.NULL_UUID))
.metaData(metaData)
.data("""
{"temperature": 25.5, "sensor": {"location": "room-1/zone-A"}}
""")
.build();
// WHEN-THEN
processMessageAndWait(config, msg);
}
}
@Test
public void testProcessMessageWithJsonInUrlVariable() throws Exception {
String host = "localhost";
// GIVEN
String path = "/api";
String paramKey = "data";
String paramVal = "[{\"test\":\"test\"}]";
String successResponseBody = "SUCCESS";
var server = setUpDummyServer(host, path, paramKey, paramVal, successResponseBody);
String endpointUrl = String.format(
"http://%s:%d%s?%s=%s",
host, server.getPort(), path, paramKey, paramVal
);
String method = "GET";
var config = new TbRestApiCallNodeConfiguration()
.defaultConfiguration();
config.setRequestMethod(method);
config.setRestEndpointUrlPattern(endpointUrl);
config.setUseSimpleClientHttpFactory(true);
String paramValue = "[{\"test\":\"test\"}]";
try (var server = startClientAndServer("localhost", 0)) {
server.when(
request()
.withMethod("GET")
.withPath(path)
.withQueryStringParameter("data", paramValue)
).respond(
response().withStatusCode(200)
);
var config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setRestEndpointUrlPattern("http://localhost:" + server.getPort() + path + "?data=" + paramValue);
config.setRequestMethod("GET");
config.setQueryParams(null);
var msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(new DeviceId(UUID.randomUUID()))
.metaData(new TbMsgMetaData())
.data(TbMsg.EMPTY_JSON_OBJECT)
.build();
// WHEN-THEN
processMessageAndWait(config, msg);
}
}
private void processMessageAndWait(TbRestApiCallNodeConfiguration config, TbMsg msg) throws Exception {
var httpClient = new TbHttpClient(config, eventLoop);
var msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(new DeviceId(EntityId.NULL_UUID))
.copyMetaData(TbMsgMetaData.EMPTY)
.data(TbMsg.EMPTY_JSON_OBJECT)
.build();
var successMsg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(msg.getOriginator())
.copyMetaData(msg.getMetaData())
.data(msg.getData())
.build();
var ctx = mock(TbContext.class);
when(ctx.transformMsg(
eq(msg),
eq(msg.getMetaData()),
eq(msg.getData())
)).thenReturn(successMsg);
var capturedData = ArgumentCaptor.forClass(String.class);
when(ctx.transformMsg(eq(msg), any(), any())).thenReturn(msg);
when(ctx.transformMsg(
eq(msg),
any(),
capturedData.capture()
)).thenReturn(successMsg);
CountDownLatch latch = new CountDownLatch(1);
var latch = new CountDownLatch(1);
var error = new AtomicReference<Throwable>();
httpClient.processMessage(ctx, msg,
m -> {
ctx.tellSuccess(msg);
ctx.tellSuccess(m);
latch.countDown();
},
(m, t) -> {
ctx.tellFailure(m, t);
error.set(t);
latch.countDown();
});
latch.await(5, TimeUnit.SECONDS);
assertTrue(latch.await(5, TimeUnit.SECONDS), "Request should complete within timeout");
assertNull(error.get(), "Request should succeed, but got: " + error.get());
verify(ctx, times(1)).tellSuccess(any());
verify(ctx, times(0)).tellFailure(any(), any());
Assertions.assertEquals(successResponseBody, capturedData.getValue());
verify(ctx).tellSuccess(any());
verify(ctx, never()).tellFailure(any(), any());
}
private ClientAndServer setUpDummyServer(String host, String path, String paramKey, String paramVal, String successResponseBody) {
var server = startClientAndServer(host, 1080);
createGetMethodExpectations(server, path, paramKey, paramVal, successResponseBody);
return server;
@ParameterizedTest(name = "{0}")
@MethodSource
public void testQueryParamsEncoding(String endpointUrl, List<KeyValueEntry<String, String>> queryParams, String expectedEncodedUrl) {
// GIVEN
Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
// WHEN
URI uri = client.buildEncodedUri(endpointUrl, queryParams);
// THEN
Assertions.assertEquals(expectedEncodedUrl, uri.toASCIIString());
}
private void createGetMethodExpectations(ClientAndServer server, String path, String paramKey, String paramVal, String successResponseBody) {
server.when(
request()
.withMethod("GET")
.withPath(path)
.withQueryStringParameter(paramKey, paramVal)
).respond(
response()
.withStatusCode(200)
.withBody(successResponseBody)
private static Stream<Arguments> testQueryParamsEncoding() {
return Stream.of(
Arguments.of(
Named.named("ISO 8601 date-time in value", "http://somecompany/api/data/fetch"),
List.of(new KeyValueEntry<>("ts", "2016-08-01T09:06:06.0+02:00")),
"http://somecompany/api/data/fetch?ts=2016-08-01T09%3A06%3A06.0%2B02%3A00"
),
Arguments.of(
Named.named("email with plus sign in value", "http://localhost:8080/api/user/sendActivationMail"),
List.of(new KeyValueEntry<>("email", "someperson+test1289@thingsboard.io")),
"http://localhost:8080/api/user/sendActivationMail?email=someperson%2Btest1289%40thingsboard.io"
),
Arguments.of(
Named.named("plus mixed with spaces in value", "http://url/api"),
List.of(new KeyValueEntry<>("q", "a + b")),
"http://url/api?q=a%20%2B%20b"
),
Arguments.of(
Named.named("colon in value", "http://url/api"),
List.of(new KeyValueEntry<>("time", "12:00")),
"http://url/api?time=12%3A00"
),
Arguments.of(
Named.named("slash in value", "http://url"),
List.of(new KeyValueEntry<>("ref", "/home/user")),
"http://url?ref=%2Fhome%2Fuser"
),
Arguments.of(
Named.named("comma and semicolon in value", "http://url"),
List.of(new KeyValueEntry<>("l", "a,b;c")),
"http://url?l=a%2Cb%3Bc"
),
Arguments.of(
Named.named("ampersand and equals in value", "http://url"),
List.of(new KeyValueEntry<>("q", "key1=value1&key2=value2")),
"http://url?q=key1%3Dvalue1%26key2%3Dvalue2"
),
Arguments.of(
Named.named("JSON in value", "http://url"),
List.of(new KeyValueEntry<>("json", """
{
"string": "hello",
"integer": 42,
"float": 3.14,
"boolTrue": true,
"boolFalse": false,
"null": null,
"array": [
1,
"two",
true,
null
],
"object": {
"nested": "value"
}
}""")),
"http://url?json=%7B%0A%20%20%20%20%22string%22%3A%20%22hello%22%2C%0A%20%20%20%20%22integer%22%3A%2042%2C%0A%20%20%20%20%22float%22%3A%203.14%2C%0A%20%20%20%20%22boolTrue%22%3A%20true%2C%0A%20%20%20%20%22boolFalse%22%3A%20false%2C%0A%20%20%20%20%22null%22%3A%20null%2C%0A%20%20%20%20%22array%22%3A%20%5B%0A%20%20%20%20%20%20%20%201%2C%0A%20%20%20%20%20%20%20%20%22two%22%2C%0A%20%20%20%20%20%20%20%20true%2C%0A%20%20%20%20%20%20%20%20null%0A%20%20%20%20%5D%2C%0A%20%20%20%20%22object%22%3A%20%7B%0A%20%20%20%20%20%20%20%20%22nested%22%3A%20%22value%22%0A%20%20%20%20%7D%0A%7D"
),
Arguments.of(
Named.named("UTF-8 in query", "http://url/cafes"),
List.of(
new KeyValueEntry<>("nom", "Le Goût Moderne"),
new KeyValueEntry<>("назва", "У Миколи \uD83D\uDE0B")
),
"http://url/cafes?nom=Le%20Go%C3%BBt%20Moderne&%D0%BD%D0%B0%D0%B7%D0%B2%D0%B0=%D0%A3%20%D0%9C%D0%B8%D0%BA%D0%BE%D0%BB%D0%B8%20%F0%9F%98%8B"
),
Arguments.of(
Named.named("empty value", "http://url/empty"),
List.of(new KeyValueEntry<>("name", "")),
"http://url/empty?name="
),
Arguments.of(
Named.named("blank value (spaces)", "http://url/empty"),
List.of(new KeyValueEntry<>("name", " ")),
"http://url/empty?name=%20%20"
),
Arguments.of(
Named.named("empty key", "http://url/empty"),
List.of(new KeyValueEntry<>("", "value")),
"http://url/empty?=value"
),
Arguments.of(
Named.named("blank key (spaces)", "http://url/empty"),
List.of(new KeyValueEntry<>(" ", "value")),
"http://url/empty?%20%20=value"
),
Arguments.of(
Named.named("blank key with value that needs to be encoded", "http://url/empty"),
List.of(new KeyValueEntry<>(" ", "value1+value2")),
"http://url/empty?%20%20=value1%2Bvalue2"
),
Arguments.of(
Named.named("blank key and value", "http://url/empty"),
List.of(new KeyValueEntry<>(" ", " ")),
"http://url/empty?%20%20=%20%20"
),
Arguments.of(
Named.named("fragment with query params", "http://url#frag"),
List.of(new KeyValueEntry<>("docs", "مستندات عقدة القاعدة\n")),
"http://url?docs=%D9%85%D8%B3%D8%AA%D9%86%D8%AF%D8%A7%D8%AA%20%D8%B9%D9%82%D8%AF%D8%A9%20%D8%A7%D9%84%D9%82%D8%A7%D8%B9%D8%AF%D8%A9%0A#frag"
),
Arguments.of(
Named.named("fragment only", "http://url#frag"),
Collections.emptyList(),
"http://url#frag"
),
Arguments.of(
Named.named("multiple query params (ordered)", "http://url/api"),
List.of(
new KeyValueEntry<>("param1", "value1"),
new KeyValueEntry<>("param2", "value2"),
new KeyValueEntry<>("param3", "value3")
),
"http://url/api?param1=value1&param2=value2&param3=value3"
),
Arguments.of(
Named.named("hash (#) in value", "http://url/api"),
List.of(new KeyValueEntry<>("color", "#ff0000")),
"http://url/api?color=%23ff0000"
),
Arguments.of(
Named.named("question mark (?) in value", "http://url/api"),
List.of(new KeyValueEntry<>("query", "what?")),
"http://url/api?query=what%3F"
),
Arguments.of(
Named.named("percent sign (%) in value", "http://url/api"),
List.of(new KeyValueEntry<>("discount", "50%")),
"http://url/api?discount=50%25"
),
Arguments.of(
Named.named("already encoded string - double encoding", "http://url/api"),
List.of(new KeyValueEntry<>("encoded", "%20")),
"http://url/api?encoded=%2520"
),
Arguments.of(
Named.named("URL with port number", "http://localhost:8080/api/v1/data"),
List.of(new KeyValueEntry<>("key", "value")),
"http://localhost:8080/api/v1/data?key=value"
),
Arguments.of(
Named.named("URL with userinfo (auth)", "http://user:password@hostname/path"),
List.of(new KeyValueEntry<>("secure", "true")),
"http://user:password@hostname/path?secure=true"
),
Arguments.of(
Named.named("IPv4 address in URL", "http://192.168.1.100:9090/endpoint"),
List.of(new KeyValueEntry<>("ip", "test")),
"http://192.168.1.100:9090/endpoint?ip=test"
),
Arguments.of(
Named.named("IPv6 address in URL", "http://[::1]:8080/api"),
List.of(new KeyValueEntry<>("ipv6", "true")),
"http://[::1]:8080/api?ipv6=true"
),
Arguments.of(
Named.named("HTTPS protocol", "https://secure.example.com/api"),
List.of(new KeyValueEntry<>("token", "abc123")),
"https://secure.example.com/api?token=abc123"
),
Arguments.of(
Named.named("pipe (|) in value", "http://url/api"),
List.of(new KeyValueEntry<>("filter", "a|b|c")),
"http://url/api?filter=a%7Cb%7Cc"
),
Arguments.of(
Named.named("caret (^) and backtick (`) in value", "http://url/api"),
List.of(new KeyValueEntry<>("special", "a^b`c")),
"http://url/api?special=a%5Eb%60c"
),
Arguments.of(
Named.named("tab character in value", "http://url/api"),
List.of(new KeyValueEntry<>("data", "col1\tcol2\tcol3")),
"http://url/api?data=col1%09col2%09col3"
),
Arguments.of(
Named.named("CRLF in value", "http://url/api"),
List.of(new KeyValueEntry<>("text", "line1\r\nline2")),
"http://url/api?text=line1%0D%0Aline2"
),
Arguments.of(
Named.named("square brackets in value", "http://url/api"),
List.of(new KeyValueEntry<>("array", "[1,2,3]")),
"http://url/api?array=%5B1%2C2%2C3%5D"
),
Arguments.of(
Named.named("single and double quotes in value", "http://url/api"),
List.of(new KeyValueEntry<>("quoted", "He said \"Hello\" and 'Hi'")),
"http://url/api?quoted=He%20said%20%22Hello%22%20and%20%27Hi%27"
),
Arguments.of(
Named.named("angle brackets in value (XSS test)", "http://url/api"),
List.of(new KeyValueEntry<>("html", "<script>alert('xss')</script>")),
"http://url/api?html=%3Cscript%3Ealert%28%27xss%27%29%3C%2Fscript%3E"
),
Arguments.of(
Named.named("backslash in value", "http://url/api"),
List.of(new KeyValueEntry<>("path", "C:\\Users\\test")),
"http://url/api?path=C%3A%5CUsers%5Ctest"
),
Arguments.of(
Named.named("at sign (@) in value", "http://url/api"),
List.of(new KeyValueEntry<>("contact", "user@domain.com")),
"http://url/api?contact=user%40domain.com"
),
Arguments.of(
Named.named("URL as value", "http://url/api"),
List.of(new KeyValueEntry<>("redirect", "https://example.com/path?foo=bar")),
"http://url/api?redirect=https%3A%2F%2Fexample.com%2Fpath%3Ffoo%3Dbar"
),
Arguments.of(
Named.named("empty list (not null)", "http://url/api"),
Collections.emptyList(),
"http://url/api"
),
Arguments.of(
Named.named("underscore and hyphen in key/value", "http://url/api"),
List.of(new KeyValueEntry<>("my-key_name", "my-value_data")),
"http://url/api?my-key_name=my-value_data"
),
Arguments.of(
Named.named("dots in key and value", "http://url/api"),
List.of(new KeyValueEntry<>("version.major", "1.2.3")),
"http://url/api?version.major=1.2.3"
),
Arguments.of(
Named.named("multiple reserved chars combined", "http://url/api"),
List.of(new KeyValueEntry<>("complex", "a=1&b=2#section?query")),
"http://url/api?complex=a%3D1%26b%3D2%23section%3Fquery"
),
Arguments.of(
Named.named("trailing slash in URL", "http://url/api/"),
List.of(new KeyValueEntry<>("param", "value")),
"http://url/api/?param=value"
),
Arguments.of(
Named.named("long value (1000 chars)", "http://url/api"),
List.of(new KeyValueEntry<>("long", "a".repeat(1000))),
"http://url/api?long=" + "a".repeat(1000)
),
Arguments.of(
Named.named("Chinese characters in value", "http://url/api"),
List.of(new KeyValueEntry<>("greeting", "你好世界")),
"http://url/api?greeting=%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C"
),
Arguments.of(
Named.named("Japanese characters in value", "http://url/api"),
List.of(new KeyValueEntry<>("text", "こんにちは")),
"http://url/api?text=%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF"
),
Arguments.of(
Named.named("existing query params in URL", "http://url/api?existing=param"),
List.of(new KeyValueEntry<>("new", "value")),
"http://url/api?existing=param&new=value"
),
Arguments.of(
Named.named("existing query and fragment in URL", "http://url/api?existing=param#section"),
List.of(new KeyValueEntry<>("additional", "data")),
"http://url/api?existing=param&additional=data#section"
),
Arguments.of(
Named.named("null byte in value", "http://url/api"),
List.of(new KeyValueEntry<>("data", "before\u0000after")),
"http://url/api?data=before%00after"
),
Arguments.of(
Named.named("form feed and vertical tab in value", "http://url/api"),
List.of(new KeyValueEntry<>("whitespace", "a\fb\u000Bc")),
"http://url/api?whitespace=a%0Cb%0Bc"
),
Arguments.of(
Named.named("tilde (unreserved, not encoded)", "http://url/api"),
List.of(new KeyValueEntry<>("pattern", "~user")),
"http://url/api?pattern=~user"
),
Arguments.of(
Named.named("asterisk in value", "http://url/api"),
List.of(new KeyValueEntry<>("wildcard", "*.txt")),
"http://url/api?wildcard=%2A.txt"
),
Arguments.of(
Named.named("curly braces in key", "http://url/api"),
List.of(new KeyValueEntry<>("{key}", "value")),
"http://url/api?%7Bkey%7D=value"
),
Arguments.of(
Named.named("curly braces in value", "http://url/api"),
List.of(new KeyValueEntry<>("data", "{value}")),
"http://url/api?data=%7Bvalue%7D"
),
Arguments.of(
Named.named("curly braces in both key and value", "http://url/api"),
List.of(new KeyValueEntry<>("{param}", "{data}")),
"http://url/api?%7Bparam%7D=%7Bdata%7D"
),
Arguments.of(
Named.named("duplicate param names with different values", "http://url/api"),
List.of(
new KeyValueEntry<>("test", "a+b"),
new KeyValueEntry<>("test", "b c")
),
"http://url/api?test=a%2Bb&test=b%20c"
)
);
}

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

@ -25,6 +25,7 @@ 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;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.provider.Arguments;
@ -43,25 +44,35 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.common.data.util.KeyValueEntry;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgDataType;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.exception.DataValidationException;
import java.io.IOException;
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.stream.Stream;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
private RuleNode ruleNode;
@Spy
private TbRestApiCallNode restNode;
@ -95,6 +106,14 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
}
}
@BeforeEach
public void setup() {
ruleNode = new RuleNode();
ruleNode.setId(ruleNodeId);
ruleNode.setName("Test REST API call node");
lenient().when(ctx.getSelf()).thenReturn(ruleNode);
}
@AfterEach
public void teardown() {
if (server != null) {
@ -102,6 +121,98 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
}
}
@Test
public void shouldNotAllowNullQueryParamNames() {
// GIVEN
var config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setQueryParams(List.of(new KeyValueEntry<>(null, "value")));
// WHEN-THEN
assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
.isInstanceOf(TbNodeException.class)
.matches(e -> ((TbNodeException) e).isUnrecoverable())
.rootCause()
.isInstanceOf(DataValidationException.class)
.hasMessageContaining("query parameter names and values must be non-null");
}
@Test
public void shouldNotAllowNullQueryParamValues() {
// GIVEN
var config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setQueryParams(List.of(new KeyValueEntry<>("key", null)));
// WHEN-THEN
assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
.isInstanceOf(TbNodeException.class)
.matches(e -> ((TbNodeException) e).isUnrecoverable())
.rootCause()
.isInstanceOf(DataValidationException.class)
.hasMessageContaining("query parameter names and values must be non-null");
}
@Test
public void shouldNotAllowNullQueryParamEntries() {
// GIVEN
var config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
var queryParams = new ArrayList<KeyValueEntry<String, String>>();
queryParams.add(new KeyValueEntry<>("key", "value"));
queryParams.add(null);
config.setQueryParams(queryParams);
// WHEN-THEN
assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
.isInstanceOf(TbNodeException.class)
.matches(e -> ((TbNodeException) e).isUnrecoverable())
.rootCause()
.isInstanceOf(DataValidationException.class)
.hasMessageContaining("must not be null");
}
@Test
public void shouldUseNewEncodingForNewNodesByDefault() {
// GIVEN-WHEN
var defaultConfig = new TbRestApiCallNodeConfiguration().defaultConfiguration();
// THEN
assertEquals(Collections.emptyList(), defaultConfig.getQueryParams());
}
@Test
public void shouldDefaultToOldEncodingForLegacyConfigs() {
// GIVEN
String configJson = """
{
"restEndpointUrlPattern": "http://url?param=value",
"requestMethod": "GET",
"parseToPlainText": false,
"ignoreRequestBody": false,
"enableProxy": false,
"useSystemProxyProperties": false,
"proxyHost": null,
"proxyPort": 0,
"proxyUser": null,
"proxyPassword": null,
"readTimeoutMs": 0,
"maxParallelRequestsCount": 0,
"headers": {
"Content-Type": "application/json",
"X-Authorization": "Bearer eyJhbGciOi...."
},
"credentials": {
"type": "anonymous"
},
"maxInMemoryBufferSizeInKb": 256
}""";
// WHEN
var config = JacksonUtil.fromString(configJson, TbRestApiCallNodeConfiguration.class);
// THEN
assertNull(config.getQueryParams());
}
@Test
public void deleteRequestWithoutBody() throws IOException, InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
@ -214,7 +325,6 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
private static Stream<Arguments> givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() {
return Stream.of(
// config for version 2 with upgrade from version 0
Arguments.of(0,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\":\"POST\"," +
"\"useSimpleClientHttpFactory\":false,\"ignoreRequestBody\":false,\"enableProxy\":false," +
@ -224,13 +334,12 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"\"trimQueue\":null,\"maxQueueSize\":null,\"credentials\":{\"type\":\"anonymous\"},\"trimDoubleQuotes\":false}",
true,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
"\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," +
"\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
"\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," +
"\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," +
"\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," +
"\"credentials\": {\"type\": \"anonymous\"}," +
"\"maxInMemoryBufferSizeInKb\": 256}"),
// config for version 2 with upgrade from version 1
Arguments.of(1,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
@ -241,13 +350,12 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"\"credentials\": {\"type\": \"anonymous\"}}",
true,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
"\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," +
"\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
"\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," +
"\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," +
"\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," +
"\"credentials\": {\"type\": \"anonymous\"}," +
"\"maxInMemoryBufferSizeInKb\": 256}"),
// config for version 3 with upgrade from version 2
Arguments.of(2,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
@ -257,12 +365,62 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"\"credentials\": {\"type\": \"anonymous\"}}",
true,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
"\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," +
"\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
"\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," +
"\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," +
"\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," +
"\"credentials\": {\"type\": \"anonymous\"}," +
"\"maxInMemoryBufferSizeInKb\": 256}")
"\"maxInMemoryBufferSizeInKb\": 256}"),
Arguments.of(3, """
{
"restEndpointUrlPattern": "http://localhost/api",
"requestMethod": "POST",
"useSimpleClientHttpFactory": true,
"parseToPlainText": false,
"ignoreRequestBody": false,
"enableProxy": false,
"useSystemProxyProperties": false,
"proxyScheme": null,
"proxyHost": null,
"proxyPort": 0,
"proxyUser": null,
"proxyPassword": null,
"readTimeoutMs": 0,
"maxParallelRequestsCount": 0,
"headers": {
"Content-Type": "application/json"
},
"credentials": {
"type": "anonymous"
},
"maxInMemoryBufferSizeInKb": 256,
"trimQueue": true,
"maxQueueSize": 100,
"trimDoubleQuotes": false,
"useRedisQueueForMsgPersistence": false
}""",
true, """
{
"restEndpointUrlPattern": "http://localhost/api",
"requestMethod": "POST",
"parseToPlainText": false,
"ignoreRequestBody": false,
"enableProxy": false,
"useSystemProxyProperties": false,
"proxyHost": null,
"proxyPort": 0,
"proxyUser": null,
"proxyPassword": null,
"readTimeoutMs": 0,
"maxParallelRequestsCount": 0,
"headers": {
"Content-Type": "application/json"
},
"credentials": {
"type": "anonymous"
},
"maxInMemoryBufferSizeInKb": 256
}""")
);
}

3
ui-ngx/src/app/modules/home/components/rule-node/common/common-rule-node-config.module.ts

@ -33,6 +33,7 @@ import { RelationsQueryConfigOldComponent } from './relations-query-config-old.c
import { SelectAttributesComponent } from './select-attributes.component';
import { AlarmStatusSelectComponent } from './alarm-status-select.component';
import { ExampleHintComponent } from './example-hint.component';
import { KvListConfigComponent } from '@home/components/rule-node/common/kv-list-config.component';
@NgModule({
declarations: [
@ -51,6 +52,7 @@ import { ExampleHintComponent } from './example-hint.component';
SelectAttributesComponent,
AlarmStatusSelectComponent,
ExampleHintComponent,
KvListConfigComponent
],
imports: [
CommonModule,
@ -73,6 +75,7 @@ import { ExampleHintComponent } from './example-hint.component';
SelectAttributesComponent,
AlarmStatusSelectComponent,
ExampleHintComponent,
KvListConfigComponent
]
})

114
ui-ngx/src/app/modules/home/components/rule-node/common/credentials-config.component.html

@ -15,8 +15,8 @@
limitations under the License.
-->
<section [formGroup]="credentialsConfigFormGroup" class="flex flex-col">
<mat-expansion-panel class="tb-credentials-config-panel-group" expanded="true">
<section [formGroup]="credentialsConfigFormGroup" class="tb-form-panel stroked">
<mat-expansion-panel class="tb-credentials-config-panel-group tb-settings" expanded="true">
<mat-expansion-panel-header>
<mat-panel-title translate>rule-node-config.credentials</mat-panel-title>
<mat-panel-description>
@ -24,7 +24,7 @@
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.credentials-type</mat-label>
<mat-select formControlName="type" required>
<mat-option *ngFor="let credentialsType of allCredentialsTypes" [value]="credentialsType"
@ -36,60 +36,60 @@
{{ 'rule-node-config.credentials-type-required' | translate }}
</mat-error>
</mat-form-field>
<section class="flex flex-col" [ngSwitch]="credentialsConfigFormGroup.get('type').value">
<ng-template ngSwitchCase="anonymous">
</ng-template>
<ng-template ngSwitchCase="basic">
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.username</mat-label>
<input required matInput formControlName="username">
<mat-error *ngIf="credentialsConfigFormGroup.get('username').hasError('required')">
{{ 'rule-node-config.username-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.password</mat-label>
<input type="password" [required]="passwordFieldRequired" matInput formControlName="password" autocomplete="new-password">
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-error *ngIf="credentialsConfigFormGroup.get('password').hasError('required')">
{{ 'rule-node-config.password-required' | translate }}
</mat-error>
</mat-form-field>
</ng-template>
<ng-template ngSwitchCase="cert.PEM">
<div class="tb-hint">{{ 'rule-node-config.credentials-pem-hint' | translate }}</div>
<tb-file-input formControlName="caCert"
inputId="caCertSelect"
[existingFileName]="credentialsConfigFormGroup.get('caCertFileName').value"
(fileNameChanged)="credentialsConfigFormGroup.get('caCertFileName').setValue($event)"
label="{{'rule-node-config.ca-cert' | translate}}"
noFileText="rule-node-config.no-file"
dropLabel="{{'rule-node-config.drop-file' | translate}}">
</tb-file-input>
<tb-file-input formControlName="cert"
inputId="CertSelect"
[existingFileName]="credentialsConfigFormGroup.get('certFileName').value"
(fileNameChanged)="credentialsConfigFormGroup.get('certFileName').setValue($event)"
label="{{'rule-node-config.cert' | translate}}"
noFileText="rule-node-config.no-file"
dropLabel="{{'rule-node-config.drop-file' | translate}}">
</tb-file-input>
<tb-file-input style="padding-bottom: 8px;"
formControlName="privateKey"
inputId="privateKeySelect"
[existingFileName]="credentialsConfigFormGroup.get('privateKeyFileName').value"
(fileNameChanged)="credentialsConfigFormGroup.get('privateKeyFileName').setValue($event)"
label="{{'rule-node-config.private-key' | translate}}"
noFileText="rule-node-config.no-file"
dropLabel="{{'rule-node-config.drop-file' | translate}}">
</tb-file-input>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.private-key-password</mat-label>
<input type="password" matInput formControlName="password" autocomplete="new-password">
<tb-toggle-password matSuffix></tb-toggle-password>
</mat-form-field>
</ng-template>
</section>
@if (credentialsConfigFormGroup.get('type').value !== 'anonymous') {
<section class="flex flex-col" [ngSwitch]="credentialsConfigFormGroup.get('type').value">
<ng-template ngSwitchCase="basic">
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.username</mat-label>
<input required matInput formControlName="username">
<mat-error *ngIf="credentialsConfigFormGroup.get('username').hasError('required')">
{{ 'rule-node-config.username-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.password</mat-label>
<input type="password" [required]="passwordFieldRequired" matInput formControlName="password" autocomplete="new-password">
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-error *ngIf="credentialsConfigFormGroup.get('password').hasError('required')">
{{ 'rule-node-config.password-required' | translate }}
</mat-error>
</mat-form-field>
</ng-template>
<ng-template ngSwitchCase="cert.PEM">
<div class="tb-form-hint tb-primary-fill mb-4">{{ 'rule-node-config.credentials-pem-hint' | translate }}</div>
<tb-file-input formControlName="caCert"
inputId="caCertSelect"
[existingFileName]="credentialsConfigFormGroup.get('caCertFileName').value"
(fileNameChanged)="credentialsConfigFormGroup.get('caCertFileName').setValue($event)"
label="{{'rule-node-config.ca-cert' | translate}}"
noFileText="rule-node-config.no-file"
dropLabel="{{'rule-node-config.drop-file' | translate}}">
</tb-file-input>
<tb-file-input formControlName="cert"
inputId="CertSelect"
[existingFileName]="credentialsConfigFormGroup.get('certFileName').value"
(fileNameChanged)="credentialsConfigFormGroup.get('certFileName').setValue($event)"
label="{{'rule-node-config.cert' | translate}}"
noFileText="rule-node-config.no-file"
dropLabel="{{'rule-node-config.drop-file' | translate}}">
</tb-file-input>
<tb-file-input style="padding-bottom: 8px;"
formControlName="privateKey"
inputId="privateKeySelect"
[existingFileName]="credentialsConfigFormGroup.get('privateKeyFileName').value"
(fileNameChanged)="credentialsConfigFormGroup.get('privateKeyFileName').setValue($event)"
label="{{'rule-node-config.private-key' | translate}}"
noFileText="rule-node-config.no-file"
dropLabel="{{'rule-node-config.drop-file' | translate}}">
</tb-file-input>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.private-key-password</mat-label>
<input type="password" matInput formControlName="password" autocomplete="new-password">
<tb-toggle-password matSuffix></tb-toggle-password>
</mat-form-field>
</ng-template>
</section>
}
</ng-template>
</mat-expansion-panel>
</section>

84
ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts

@ -0,0 +1,84 @@
///
/// Copyright © 2016-2026 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.
///
import { Component, forwardRef, OnInit } from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
Validator,
Validators
} from '@angular/forms';
import { KvMapConfigOldComponent } from '@home/components/rule-node/common/kv-map-config-old.component';
@Component({
selector: 'tb-kv-list-config',
templateUrl: './kv-map-config-old.component.html',
styleUrls: ['./kv-map-config-old.component.scss'],
standalone: false,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => KvListConfigComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => KvListConfigComponent),
multi: true,
}
]
})
export class KvListConfigComponent extends KvMapConfigOldComponent implements ControlValueAccessor, OnInit, Validator {
override writeValue(kvList: any): void {
const keyValsControls: Array<AbstractControl> = [];
if (Array.isArray(kvList) && kvList.length > 0) {
for (const property of kvList) {
keyValsControls.push(this.fb.group({
key: [property.key, [Validators.required]],
value: [property.value, [Validators.required]]
}));
}
}
this.kvListFormGroup.setControl('keyVals', this.fb.array(keyValsControls), {emitEvent: false});
}
public override validate() {
const kvList: { key: string; value: string }[] = this.kvListFormGroup.get('keyVals').value;
if (!kvList.length && this.required) {
return {
kvMapRequired: true
};
}
if (!this.kvListFormGroup.valid) {
return {
kvFieldsRequired: true
};
}
return null;
}
protected override updateModel() {
const kvList: { key: string; value: string }[] = this.kvListFormGroup.get('keyVals').value;
if (this.required && !kvList.length || !this.kvListFormGroup.valid) {
this.propagateChange(null);
} else {
this.propagateChange(kvList);
}
}
}

78
ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html

@ -16,40 +16,52 @@
-->
<section class="tb-kv-map-config flex flex-col" [formGroup]="kvListFormGroup">
<div class="header flex flex-1 flex-row gap-2">
<span class="cell tb-required flex-1">{{ keyText | translate }}</span>
<span class="cell tb-required flex-1">{{ valText | translate }}</span>
<span [class.!hidden]="disabled" style="width: 52px;" innerHTML="&nbsp"></span>
</div>
<div class="body">
<div class="row flex flex-row items-center justify-start gap-2"
formArrayName="keyVals"
*ngFor="let keyValControl of keyValsFormArray().controls; let $index = index">
<mat-form-field class="cell mat-block flex-1">
<input [formControl]="keyValControl.get('key')" matInput required
placeholder="{{ keyText | translate }}"/>
<mat-error *ngIf="keyValControl.get('key').hasError('required')">
{{ keyRequiredText | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="cell mat-block flex-1">
<input [formControl]="keyValControl.get('value')" matInput required
placeholder="{{ valText | translate }}"/>
<mat-error *ngIf="keyValControl.get('value').hasError('required')">
{{ valRequiredText | translate }}
</mat-error>
</mat-form-field>
<button mat-icon-button color="primary"
[class.!hidden]="disabled"
type="button"
(click)="removeKeyVal($index)"
[disabled]="isLoading$ | async"
matTooltip="{{ 'rule-node-config.key-val.remove-entry' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
@if (description) {
<div class="tb-form-hint tb-primary-fill mb-4" [innerHTML]="description | translate | safe: 'html'"></div>
}
@if (keyValsFormArray().controls.length || !emptyText) {
<div class="header flex flex-1 flex-row gap-2" >
<span class="cell tb-required flex-1">{{ keyText | translate }}</span>
<span class="cell tb-required flex-1">{{ valText | translate }}</span>
<span [class.!hidden]="disabled" style="width: 52px;" innerHTML="&nbsp"></span>
</div>
<div *ngIf="hintText" class="tb-hint" [innerHTML]="hintText | translate | safe: 'html'"></div>
}
<div class="body">
@for (keyValControl of keyValsFormArray().controls; track $index) {
<div class="row flex flex-row items-center justify-start gap-2"
formArrayName="keyVals">
<mat-form-field class="cell mat-block flex-1">
<input [formControl]="keyValControl.get('key')" matInput required
placeholder="{{ keyText | translate }}"/>
<mat-error *ngIf="keyValControl.get('key').hasError('required')">
{{ keyRequiredText | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="cell mat-block flex-1">
<input [formControl]="keyValControl.get('value')" matInput required
placeholder="{{ valText | translate }}"/>
<mat-error *ngIf="keyValControl.get('value').hasError('required')">
{{ valRequiredText | translate }}
</mat-error>
</mat-form-field>
<button mat-icon-button color="primary"
[class.!hidden]="disabled"
type="button"
(click)="removeKeyVal($index)"
[disabled]="isLoading$ | async"
matTooltip="{{ 'rule-node-config.key-val.remove-entry' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
} @empty {
@if (emptyText) {
<div class="tb-prompt h-10 flex items-center justify-center">{{emptyText | translate}}</div>
}
}
@if (hintText) {
<div class="tb-hint" [innerHTML]="hintText | translate | safe: 'html'"></div>
}
</div>
<tb-error [error]="ngControl.hasError('kvMapRequired') || ngControl.hasError('uniqueKeyValuePair') ?
(ngControl.hasError('kvMapRequired') ? translate.instant(requiredText) :

10
ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.ts

@ -68,6 +68,10 @@ export class KvMapConfigOldComponent extends PageComponent implements ControlVal
@Input() hintText: string;
@Input() description: string;
@Input() emptyText: string;
private requiredValue: boolean;
get required(): boolean {
@ -83,11 +87,11 @@ export class KvMapConfigOldComponent extends PageComponent implements ControlVal
ngControl: NgControl;
private propagateChange = null;
public propagateChange = null;
constructor(public translate: TranslateService,
private injector: Injector,
private fb: FormBuilder,
protected fb: FormBuilder,
private destroyRef: DestroyRef) {
super();
}
@ -179,7 +183,7 @@ export class KvMapConfigOldComponent extends PageComponent implements ControlVal
return null;
}
private updateModel() {
protected updateModel() {
const kvList: { key: string; value: string }[] = this.kvListFormGroup.get('keyVals').value;
if (this.required && !kvList.length || !this.kvListFormGroup.valid) {
this.propagateChange(null);

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

@ -15,15 +15,34 @@
limitations under the License.
-->
<section [formGroup]="restApiCallConfigForm" class="flex flex-col">
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.endpoint-url-pattern</mat-label>
<input required matInput formControlName="restEndpointUrlPattern">
<mat-error *ngIf="restApiCallConfigForm.get('restEndpointUrlPattern').hasError('required')">
{{ 'rule-node-config.endpoint-url-pattern-required' | translate }}
</mat-error>
<mat-hint translate>rule-node-config.general-pattern-hint</mat-hint>
</mat-form-field>
<section [formGroup]="restApiCallConfigForm" class="flex flex-col gap-4">
<div class="tb-form-panel stroked">
<div class="tb-form-panel-title" translate>rule-node-config.request-url</div>
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.url</mat-label>
<input required matInput formControlName="restEndpointUrlPattern">
<mat-error *ngIf="restApiCallConfigForm.get('restEndpointUrlPattern').hasError('required')">
{{ 'rule-node-config.endpoint-url-pattern-required' | translate }}
</mat-error>
<mat-hint translate>rule-node-config.url-hint</mat-hint>
</mat-form-field>
<mat-expansion-panel class="tb-settings tb-form-panel stroked no-gap" [expanded]="isQueryParamsExpanded">
<mat-expansion-panel-header>
{{ 'rule-node-config.query-params' | translate }}
<div [tb-hint-tooltip-icon]="'rule-node-config.query-params-hint' | translate" tooltipPosition="above"></div>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-kv-list-config
formControlName="queryParams"
keyText="rule-node-config.query-params-name"
keyRequiredText="rule-node-config.query-params-name-required"
valText="rule-node-config.query-params-value"
valRequiredText="rule-node-config.query-params-value-required"
emptyText="rule-node-config.query-params-empty">
</tb-kv-list-config>
</ng-template>
</mat-expansion-panel>
</div>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.request-method</mat-label>
<mat-select formControlName="requestMethod">
@ -32,99 +51,112 @@
</mat-option>
</mat-select>
</mat-form-field>
<mat-checkbox formControlName="enableProxy">
{{ 'rule-node-config.enable-proxy' | translate }}
</mat-checkbox>
<mat-checkbox *ngIf="!restApiCallConfigForm.get('enableProxy').value" formControlName="useSimpleClientHttpFactory">
{{ 'rule-node-config.use-simple-client-http-factory' | translate }}
</mat-checkbox>
<mat-checkbox formControlName="parseToPlainText">
{{ 'rule-node-config.parse-to-plain-text' | translate }}
</mat-checkbox>
<div class="tb-hint" style="padding-bottom: 5px;" translate>rule-node-config.parse-to-plain-text-hint</div>
<mat-checkbox formControlName="ignoreRequestBody">
{{ 'rule-node-config.ignore-request-body' | translate }}
</mat-checkbox>
<div *ngIf="restApiCallConfigForm.get('enableProxy').value">
<mat-checkbox formControlName="useSystemProxyProperties">
{{ 'rule-node-config.use-system-proxy-properties' | translate }}
</mat-checkbox>
<div *ngIf="!restApiCallConfigForm.get('useSystemProxyProperties').value">
<div class="gt-sm:flex gt-sm:flex-row gt-sm:gap-2">
<mat-form-field class="mat-block gt-sm:max-w-10% gt-sm:flex-full">
<mat-label translate>rule-node-config.proxy-scheme</mat-label>
<mat-select formControlName="proxyScheme">
<mat-option *ngFor="let proxyScheme of proxySchemes" [value]="proxyScheme">
{{ proxyScheme }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="md-block gt-sm:max-w-50% gt-sm:flex-full">
<mat-label translate>rule-node-config.proxy-host</mat-label>
<input matInput required formControlName="proxyHost">
<mat-error *ngIf="restApiCallConfigForm.get('proxyHost').hasError('required')">
{{ 'rule-node-config.proxy-host-required' | translate }}
<div class="tb-form-panel stroked">
<mat-expansion-panel class="tb-settings" [expanded]="isHeadersExpanded">
<mat-expansion-panel-header>
{{ 'rule-node-config.headers' | translate }}
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-kv-map-config-old
formControlName="headers"
keyText="rule-node-config.header"
keyRequiredText="rule-node-config.header-required"
valText="rule-node-config.value"
valRequiredText="rule-node-config.value-required"
description="rule-node-config.headers-hint"
emptyText="rule-node-config.headers-empty">
</tb-kv-map-config-old>
</ng-template>
</mat-expansion-panel>
</div>
<tb-credentials-config formControlName="credentials"></tb-credentials-config>
<div class="tb-form-panel stroked">
<mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header>
{{ 'rule-node-config.advanced-settings' | translate }}
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-panel stroked column">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="enableProxy">
{{ 'rule-node-config.enable-proxy' | translate }}
</mat-slide-toggle>
<div *ngIf="restApiCallConfigForm.get('enableProxy').value" class="flex flex-col gap-4">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="useSystemProxyProperties">
{{ 'rule-node-config.use-system-proxy-properties' | translate }}
</mat-slide-toggle>
<div *ngIf="!restApiCallConfigForm.get('useSystemProxyProperties').value">
<div class="gt-sm:flex gt-sm:flex-row gt-sm:gap-2">
<mat-form-field class="md-block gt-sm:max-w-50% gt-sm:flex-full">
<mat-label translate>rule-node-config.proxy-host</mat-label>
<input matInput required formControlName="proxyHost">
<mat-error *ngIf="restApiCallConfigForm.get('proxyHost').hasError('required')">
{{ 'rule-node-config.proxy-host-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block gt-sm:max-w-50% gt-sm:flex-full">
<mat-label translate>rule-node-config.proxy-port</mat-label>
<input matInput required formControlName="proxyPort" type="number" step="1">
<mat-error *ngIf="restApiCallConfigForm.get('proxyPort').hasError('required')">
{{ 'rule-node-config.proxy-port-required' | translate }}
</mat-error>
<mat-error
*ngIf="restApiCallConfigForm.get('proxyPort').hasError('min') || restApiCallConfigForm.get('proxyPort').hasError('max')">
{{ 'rule-node-config.proxy-port-range' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.proxy-user</mat-label>
<input matInput formControlName="proxyUser">
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.proxy-password</mat-label>
<input matInput type="password" formControlName="proxyPassword">
<tb-toggle-password matSuffix></tb-toggle-password>
</mat-form-field>
</div>
</div>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="parseToPlainText">
<div [tb-hint-tooltip-icon]="'rule-node-config.parse-to-plain-text-hint' | translate" tooltipPosition="above">
{{ 'rule-node-config.parse-to-plain-text' | translate }}
</div>
</mat-slide-toggle>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="ignoreRequestBody">
{{ 'rule-node-config.ignore-request-body' | translate }}
</mat-slide-toggle>
</div>
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.read-timeout</mat-label>
<input type="text" min="0" [max]="IntLimit" inputmode="numeric" pattern="[0-9]*" matInput formControlName="readTimeoutMs">
<mat-hint translate>rule-node-config.read-timeout-hint</mat-hint>
<mat-error
*ngIf="restApiCallConfigForm.get('readTimeoutMs').hasError('max')">
{{ 'rule-node-config.int-range' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block gt-sm:max-w-40% gt-sm:flex-full">
<mat-label translate>rule-node-config.proxy-port</mat-label>
<input matInput required formControlName="proxyPort" type="number" step="1">
<mat-error *ngIf="restApiCallConfigForm.get('proxyPort').hasError('required')">
{{ 'rule-node-config.proxy-port-required' | translate }}
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.max-parallel-requests-count</mat-label>
<input type="text" min="0" [max]="IntLimit" inputmode="numeric" pattern="[0-9]*" matInput formControlName="maxParallelRequestsCount">
<mat-hint translate>rule-node-config.max-parallel-requests-count-hint</mat-hint>
<mat-error
*ngIf="restApiCallConfigForm.get('maxParallelRequestsCount').hasError('max')">
{{ 'rule-node-config.int-range' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.max-response-size</mat-label>
<input type="text" min="1" [max]="MemoryBufferSizeInKbLimit" inputmode="numeric" pattern="[0-9]*" matInput formControlName="maxInMemoryBufferSizeInKb">
<mat-hint translate>rule-node-config.max-response-size-hint</mat-hint>
<mat-error
*ngIf="restApiCallConfigForm.get('proxyPort').hasError('min') || restApiCallConfigForm.get('proxyPort').hasError('max')">
{{ 'rule-node-config.proxy-port-range' | translate }}
*ngIf="restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('min') || restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('max')">
{{ 'rule-node-config.memory-buffer-size-range' | translate: { max: MemoryBufferSizeInKbLimit } }}
</mat-error>
</mat-form-field>
</div>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.proxy-user</mat-label>
<input matInput formControlName="proxyUser">
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.proxy-password</mat-label>
<input matInput formControlName="proxyPassword">
</mat-form-field>
</div>
</ng-template>
</mat-expansion-panel>
</div>
<mat-form-field *ngIf="!restApiCallConfigForm.get('useSimpleClientHttpFactory').value || restApiCallConfigForm.get('enableProxy').value" class="mat-block"
subscriptSizing="dynamic">
<mat-label translate>rule-node-config.read-timeout</mat-label>
<input type="text" min="0" [max]="IntLimit" inputmode="numeric" pattern="[0-9]*" matInput formControlName="readTimeoutMs">
<mat-hint translate>rule-node-config.read-timeout-hint</mat-hint>
<mat-error
*ngIf="restApiCallConfigForm.get('readTimeoutMs').hasError('max')">
{{ 'rule-node-config.int-range' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.max-parallel-requests-count</mat-label>
<input type="text" min="0" [max]="IntLimit" inputmode="numeric" pattern="[0-9]*" matInput formControlName="maxParallelRequestsCount">
<mat-hint translate>rule-node-config.max-parallel-requests-count-hint</mat-hint>
<mat-error
*ngIf="restApiCallConfigForm.get('maxParallelRequestsCount').hasError('max')">
{{ 'rule-node-config.int-range' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.max-response-size</mat-label>
<input type="text" min="1" [max]="MemoryBufferSizeInKbLimit" inputmode="numeric" pattern="[0-9]*" matInput formControlName="maxInMemoryBufferSizeInKb">
<mat-hint translate>rule-node-config.max-response-size-hint</mat-hint>
<mat-error
*ngIf="restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('min') || restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('max')">
{{ 'rule-node-config.memory-buffer-size-range' | translate: { max: MemoryBufferSizeInKbLimit } }}
</mat-error>
</mat-form-field>
<label translate class="tb-title">rule-node-config.headers</label>
<div class="tb-hint" [innerHTML]="'rule-node-config.headers-hint' | translate | safe: 'html'"> </div>
<tb-kv-map-config-old
required="false"
formControlName="headers"
keyText="rule-node-config.header"
keyRequiredText="rule-node-config.header-required"
valText="rule-node-config.value"
valRequiredText="rule-node-config.value-required">
</tb-kv-map-config-old>
<tb-credentials-config formControlName="credentials" [disableCertPemCredentials]="restApiCallConfigForm.get('useSimpleClientHttpFactory').value"></tb-credentials-config>
</section>

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

@ -0,0 +1,29 @@
/**
* Copyright © 2016-2026 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.
*/
:host ::ng-deep {
.mat-expansion-panel-content {
.mat-expansion-panel-body {
.tb-kv-map-config {
margin-bottom: 0;
.body {
.mdc-icon-button {
margin-bottom: 22px;
}
}
}
}
}
}

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

@ -18,22 +18,25 @@ import { Component } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shared/models/rule-node.models';
import { HttpRequestType, IntLimit } from '../rule-node-config.models';
import { isDefinedAndNotNull } from '@core/utils';
@Component({
selector: 'tb-external-node-rest-api-call-config',
templateUrl: './rest-api-call-config.component.html',
styleUrls: [],
styleUrls: ['./rest-api-call-config.component.scss'],
standalone: false
})
export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent {
restApiCallConfigForm: UntypedFormGroup;
readonly proxySchemes: string[] = ['http', 'https'];
readonly httpRequestTypes = Object.keys(HttpRequestType);
readonly MemoryBufferSizeInKbLimit = 25000;
readonly IntLimit = IntLimit;
isHeadersExpanded = false;
isQueryParamsExpanded = false;
constructor(private fb: UntypedFormBuilder) {
super();
}
@ -46,12 +49,10 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent {
this.restApiCallConfigForm = this.fb.group({
restEndpointUrlPattern: [configuration ? configuration.restEndpointUrlPattern : null, [Validators.required]],
requestMethod: [configuration ? configuration.requestMethod : null, [Validators.required]],
useSimpleClientHttpFactory: [configuration ? configuration.useSimpleClientHttpFactory : false, []],
parseToPlainText: [configuration ? configuration.parseToPlainText : false, []],
ignoreRequestBody: [configuration ? configuration.ignoreRequestBody : false, []],
enableProxy: [configuration ? configuration.enableProxy : false, []],
useSystemProxyProperties: [configuration ? configuration.enableProxy : false, []],
proxyScheme: [configuration ? configuration.proxyHost : null, []],
proxyHost: [configuration ? configuration.proxyHost : null, []],
proxyPort: [configuration ? configuration.proxyPort : null, []],
proxyUser: [configuration ? configuration.proxyUser :null, []],
@ -60,16 +61,27 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent {
maxParallelRequestsCount: [configuration ? configuration.maxParallelRequestsCount : null, [Validators.min(0), Validators.max(IntLimit)]],
headers: [configuration ? configuration.headers : null, []],
credentials: [configuration ? configuration.credentials : null, []],
maxInMemoryBufferSizeInKb: [configuration ? configuration.maxInMemoryBufferSizeInKb : null, [Validators.min(1), Validators.max(this.MemoryBufferSizeInKbLimit)]]
maxInMemoryBufferSizeInKb: [configuration ? configuration.maxInMemoryBufferSizeInKb : null, [Validators.min(1), Validators.max(this.MemoryBufferSizeInKbLimit)]],
queryParams: [configuration ? configuration.queryParams : [], []],
});
}
protected validatorTriggers(): string[] {
return ['useSimpleClientHttpFactory', 'enableProxy', 'useSystemProxyProperties'];
return ['enableProxy', 'useSystemProxyProperties'];
}
protected prepareInputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration {
this.isQueryParamsExpanded = configuration && isDefinedAndNotNull(configuration.queryParams) && !!Object.keys(configuration.queryParams).length;
this.isHeadersExpanded = configuration && isDefinedAndNotNull(configuration.headers) && !!Object.keys(configuration.headers).length;
return super.prepareInputConfig(configuration);
}
protected prepareOutputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration {
configuration.queryParams = isDefinedAndNotNull(configuration.queryParams) ? configuration.queryParams : [];
return super.prepareOutputConfig(configuration);
}
protected updateValidators(emitEvent: boolean) {
const useSimpleClientHttpFactory: boolean = this.restApiCallConfigForm.get('useSimpleClientHttpFactory').value;
const enableProxy: boolean = this.restApiCallConfigForm.get('enableProxy').value;
const useSystemProxyProperties: boolean = this.restApiCallConfigForm.get('useSystemProxyProperties').value;
@ -80,12 +92,7 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent {
} else {
this.restApiCallConfigForm.get('proxyHost').setValidators([]);
this.restApiCallConfigForm.get('proxyPort').setValidators([]);
if (useSimpleClientHttpFactory) {
this.restApiCallConfigForm.get('readTimeoutMs').setValidators([]);
} else {
this.restApiCallConfigForm.get('readTimeoutMs').setValidators([Validators.min(0), Validators.max(IntLimit)]);
}
this.restApiCallConfigForm.get('readTimeoutMs').setValidators([Validators.min(0), Validators.max(IntLimit)]);
}
this.restApiCallConfigForm.get('readTimeoutMs').updateValueAndValidity({emitEvent});

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

@ -5510,7 +5510,6 @@
"endpoint-url-pattern": "Endpoint URL pattern",
"endpoint-url-pattern-required": "Endpoint URL pattern is required",
"request-method": "Request method",
"use-simple-client-http-factory": "Use simple client HTTP factory",
"ignore-request-body": "Without request body",
"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\"",
@ -5666,7 +5665,6 @@
"proxy-port-range": "Proxy port should be in a range from 1 to 65535.",
"proxy-user": "Proxy user",
"proxy-password": "Proxy password",
"proxy-scheme": "Proxy scheme",
"numbers-to-template": "Phone Numbers To Template",
"numbers-to-template-required": "Phone Numbers To Template is required",
"numbers-to-template-hint": "Comma separated Phone Numbers, use <code><span style=\"color: #000;\">$&#123;</span>metadataKey<span style=\"color: #000;\">&#125;</span></code> for value from metadata, <code><span style=\"color: #000;\">$[</span>messageKey<span style=\"color: #000;\">]</span></code> for value from message body",
@ -5950,6 +5948,17 @@
"tell-failure-aws-lambda-hint": "Rule node forces failure of message processing if AWS Lambda function execution raises exception.",
"basic-mode": "Basic",
"advanced-mode": "Advanced",
"url-hint": "Enter the request URL. Ensure special characters are percent-encoded.",
"query-params": "Query parameters",
"query-params-hint": "Additional query parameters to include in the request URL. Both names and values support templatization and are strictly (RFC 3986) encoded after template resolution.",
"query-params-empty": "No query parameters configured",
"query-params-name": "Name",
"query-params-value": "Value",
"query-params-name-required": "Name is required",
"query-params-value-required": "Value is required",
"headers-empty": "No headers configured",
"request-url": "Request URL",
"url": "URL",
"save-time-series": {
"processing-settings": "Processing settings",
"processing-settings-hint": "Define how incoming messages are processed. Basic processing settings allow you to select preconfigured strategies, while Advanced settings allow you to select individual processing strategies for each action.",

Loading…
Cancel
Save