Browse Source

Merge branch 'master' into feature/tb-client-tests

pull/15264/head
Viacheslav Klimov 7 months ago
committed by GitHub
parent
commit
1039cfc079
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
  17. 9445
      ui-ngx/src/assets/locale/locale.constant-ko_KR.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.",

9445
ui-ngx/src/assets/locale/locale.constant-ko_KR.json

File diff suppressed because it is too large
Loading…
Cancel
Save