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> <groupId>org.thingsboard.langchain4j</groupId>
<artifactId>langchain4j</artifactId> <artifactId>langchain4j</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.apache.httpcomponents.core5</groupId>
<artifactId>httpcore5</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <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 io.netty.handler.timeout.ReadTimeoutHandler;
import lombok.Data; import lombok.Data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.hc.core5.net.URIBuilder;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; 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.ClientCredentials;
import org.thingsboard.rule.engine.credentials.CredentialsType; import org.thingsboard.rule.engine.credentials.CredentialsType;
import org.thingsboard.server.common.data.StringUtils; 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.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgMetaData;
import reactor.netty.http.client.HttpClient; import reactor.netty.http.client.HttpClient;
@ -48,6 +50,7 @@ import reactor.netty.transport.ProxyProvider;
import javax.net.ssl.SSLException; import javax.net.ssl.SSLException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
@ -130,10 +133,6 @@ public class TbHttpClient {
SslContext sslContext = config.getCredentials().initSslContext(); SslContext sslContext = config.getCredentials().initSslContext();
httpClient = httpClient.secure(t -> t.sslContext(sslContext)); 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 { } else {
SslContext sslContext = config.getCredentials().initSslContext(); SslContext sslContext = config.getCredentials().initSslContext();
httpClient = httpClient.secure(t -> t.sslContext(sslContext)); httpClient = httpClient.secure(t -> t.sslContext(sslContext));
@ -219,8 +218,22 @@ public class TbHttpClient {
} }
String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), msg); 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()); HttpMethod method = HttpMethod.valueOf(config.getRequestMethod());
URI uri = buildEncodedUri(endpointUrl); URI uri = buildEncodedUri(endpointUrl, processedQueryParams);
RequestBodySpec request = webClient RequestBodySpec request = webClient
.method(method) .method(method)
@ -268,7 +281,7 @@ public class TbHttpClient {
return origin; return origin;
} }
public URI buildEncodedUri(String endpointUrl) { public URI buildEncodedUri(String endpointUrl, List<KeyValueEntry<String, String>> queryParams) {
if (endpointUrl == null) { if (endpointUrl == null) {
throw new RuntimeException("Url string cannot be null!"); throw new RuntimeException("Url string cannot be null!");
} }
@ -276,7 +289,13 @@ public class TbHttpClient {
throw new RuntimeException("Url string cannot be empty!"); 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()) { if (uri.getScheme() == null || uri.getScheme().isEmpty()) {
throw new RuntimeException("Transport scheme(protocol) must be provided!"); throw new RuntimeException("Transport scheme(protocol) must be provided!");
} }
@ -292,6 +311,35 @@ public class TbHttpClient {
return uri; 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) { private Object getData(TbMsg tbMsg, boolean parseToPlainText) {
String data = tbMsg.getData(); String data = tbMsg.getData();
return parseToPlainText ? JacksonUtil.toPlainText(data) : JacksonUtil.toJsonNode(data); return parseToPlainText ? JacksonUtil.toPlainText(data) : JacksonUtil.toJsonNode(data);

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.plugin.ComponentType;
import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.data.util.TbPair;
import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.exception.DataValidationException;
import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Set;
import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields;
@RuleNode( @RuleNode(
type = ComponentType.EXTERNAL, type = ComponentType.EXTERNAL,
name = "rest api call", name = "rest api call",
configClazz = TbRestApiCallNodeConfiguration.class, configClazz = TbRestApiCallNodeConfiguration.class,
version = 3, version = 4,
nodeDescription = "Invoke REST API calls to external REST server", nodeDescription = "Invoke REST API calls to external REST server",
nodeDetails = "Will invoke REST API call <code>GET | POST | PUT | DELETE</code> to external REST server. " + nodeDetails = "Will invoke REST API call <code>GET | POST | PUT | DELETE</code> to external REST server. " +
"Message payload added into Request body. Configured attributes can be added into Headers from Message Metadata." + "Message payload added into Request body. Configured attributes can be added into Headers from Message Metadata." +
@ -57,7 +62,15 @@ public class TbRestApiCallNode extends TbAbstractExternalNode {
@Override @Override
public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException {
super.init(ctx); 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()); httpClient = new TbHttpClient(config, ctx.getSharedEventLoop());
} }
@ -96,6 +109,21 @@ public class TbRestApiCallNode extends TbAbstractExternalNode {
hasChanges = true; hasChanges = true;
((ObjectNode) oldConfiguration).put(MAX_IN_MEMORY_BUFFER_SIZE_IN_KB, 256); ((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; break;
default: default:
break; 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; 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 lombok.Data;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.thingsboard.rule.engine.api.NodeConfiguration; import org.thingsboard.rule.engine.api.NodeConfiguration;
import org.thingsboard.rule.engine.credentials.AnonymousCredentials; import org.thingsboard.rule.engine.credentials.AnonymousCredentials;
import org.thingsboard.rule.engine.credentials.ClientCredentials; import org.thingsboard.rule.engine.credentials.ClientCredentials;
import org.thingsboard.server.common.data.util.KeyValueEntry;
import java.util.Collections; import java.util.Collections;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
@JsonIgnoreProperties(ignoreUnknown = true)
@Data @Data
public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestApiCallNodeConfiguration> { public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestApiCallNodeConfiguration> {
private String restEndpointUrlPattern; private String restEndpointUrlPattern;
private String requestMethod; 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 Map<String, String> headers;
private boolean useSimpleClientHttpFactory;
private int readTimeoutMs; private int readTimeoutMs;
private int maxParallelRequestsCount; private int maxParallelRequestsCount;
private boolean parseToPlainText; private boolean parseToPlainText;
@ -43,18 +49,31 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestA
private int proxyPort; private int proxyPort;
private String proxyUser; private String proxyUser;
private String proxyPassword; private String proxyPassword;
private String proxyScheme;
private ClientCredentials credentials; private ClientCredentials credentials;
private boolean ignoreRequestBody; private boolean ignoreRequestBody;
private int maxInMemoryBufferSizeInKb; 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 @Override
public TbRestApiCallNodeConfiguration defaultConfiguration() { public TbRestApiCallNodeConfiguration defaultConfiguration() {
TbRestApiCallNodeConfiguration configuration = new TbRestApiCallNodeConfiguration(); var configuration = new TbRestApiCallNodeConfiguration();
configuration.setRestEndpointUrlPattern("http://localhost/api"); configuration.setRestEndpointUrlPattern("http://localhost/api");
configuration.setRequestMethod("POST"); configuration.setRequestMethod("POST");
configuration.setHeaders(Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); configuration.setHeaders(Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE));
configuration.setUseSimpleClientHttpFactory(false); configuration.setQueryParams(Collections.emptyList());
configuration.setReadTimeoutMs(0); configuration.setReadTimeoutMs(0);
configuration.setMaxParallelRequestsCount(0); configuration.setMaxParallelRequestsCount(0);
configuration.setParseToPlainText(false); configuration.setParseToPlainText(false);
@ -66,10 +85,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestA
} }
public ClientCredentials getCredentials() { public ClientCredentials getCredentials() {
if (this.credentials == null) { return Objects.requireNonNullElseGet(credentials, AnonymousCredentials::new);
return new AnonymousCredentials();
} else {
return this.credentials;
}
} }
} }

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; package org.thingsboard.rule.engine.rest;
import io.netty.channel.EventLoopGroup; import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Named;
import org.junit.jupiter.api.Test; 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.mockito.Mockito;
import org.mockserver.integration.ClientAndServer;
import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.LinkedMultiValueMap;
import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.msg.TbMsgType; 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.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgMetaData;
import java.net.URI; import java.net.URI;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is; 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.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.willCallRealMethod; import static org.mockito.BDDMockito.willCallRealMethod;
import static org.mockito.Mockito.mock; 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.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockserver.integration.ClientAndServer.startClientAndServer; import static org.mockserver.integration.ClientAndServer.startClientAndServer;
@ -86,123 +94,461 @@ public class TbHttpClientTest {
@Test @Test
public void testBuildSimpleUri() { public void testBuildSimpleUri() {
Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod(); Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
String url = "http://localhost:8080/"; String url = "http://localhost:8080/";
URI uri = client.buildEncodedUri(url); URI uri = client.buildEncodedUri(url, null);
Assertions.assertEquals(url, uri.toString()); Assertions.assertEquals(url, uri.toString());
} }
@Test @Test
public void testBuildUriWithoutProtocol() { public void testBuildUriWithoutProtocol() {
Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod(); Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
String url = "localhost:8080/"; String url = "localhost:8080/";
assertThatThrownBy(() -> client.buildEncodedUri(url)); assertThatThrownBy(() -> client.buildEncodedUri(url, null));
} }
@Test @Test
public void testBuildInvalidUri() { public void testBuildInvalidUri() {
Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod(); Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
String url = "aaa"; String url = "aaa";
assertThatThrownBy(() -> client.buildEncodedUri(url)); assertThatThrownBy(() -> client.buildEncodedUri(url, null));
} }
@Test @Test
public void testBuildUriWithSpecialSymbols() { 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 url = "http://192.168.1.1/data?d={\"a\": 12}";
String expected = "http://192.168.1.1/data?d=%7B%22a%22:%2012%7D"; 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()); 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 @Test
public void testProcessMessageWithJsonInUrlVariable() throws Exception { public void testProcessMessageWithJsonInUrlVariable() throws Exception {
String host = "localhost"; // GIVEN
String path = "/api"; String path = "/api";
String paramKey = "data"; String paramValue = "[{\"test\":\"test\"}]";
String paramVal = "[{\"test\":\"test\"}]";
String successResponseBody = "SUCCESS"; try (var server = startClientAndServer("localhost", 0)) {
server.when(
var server = setUpDummyServer(host, path, paramKey, paramVal, successResponseBody); request()
.withMethod("GET")
String endpointUrl = String.format( .withPath(path)
"http://%s:%d%s?%s=%s", .withQueryStringParameter("data", paramValue)
host, server.getPort(), path, paramKey, paramVal ).respond(
); response().withStatusCode(200)
String method = "GET"; );
var config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
var config = new TbRestApiCallNodeConfiguration() config.setRestEndpointUrlPattern("http://localhost:" + server.getPort() + path + "?data=" + paramValue);
.defaultConfiguration(); config.setRequestMethod("GET");
config.setRequestMethod(method); config.setQueryParams(null);
config.setRestEndpointUrlPattern(endpointUrl);
config.setUseSimpleClientHttpFactory(true); 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 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); var ctx = mock(TbContext.class);
when(ctx.transformMsg( when(ctx.transformMsg(eq(msg), any(), any())).thenReturn(msg);
eq(msg),
eq(msg.getMetaData()),
eq(msg.getData())
)).thenReturn(successMsg);
var capturedData = ArgumentCaptor.forClass(String.class);
when(ctx.transformMsg( var latch = new CountDownLatch(1);
eq(msg), var error = new AtomicReference<Throwable>();
any(),
capturedData.capture()
)).thenReturn(successMsg);
CountDownLatch latch = new CountDownLatch(1);
httpClient.processMessage(ctx, msg, httpClient.processMessage(ctx, msg,
m -> { m -> {
ctx.tellSuccess(msg); ctx.tellSuccess(m);
latch.countDown(); latch.countDown();
}, },
(m, t) -> { (m, t) -> {
ctx.tellFailure(m, t); ctx.tellFailure(m, t);
error.set(t);
latch.countDown(); 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).tellSuccess(any());
verify(ctx, times(0)).tellFailure(any(), any()); verify(ctx, never()).tellFailure(any(), any());
Assertions.assertEquals(successResponseBody, capturedData.getValue());
} }
private ClientAndServer setUpDummyServer(String host, String path, String paramKey, String paramVal, String successResponseBody) {
var server = startClientAndServer(host, 1080); @ParameterizedTest(name = "{0}")
createGetMethodExpectations(server, path, paramKey, paramVal, successResponseBody); @MethodSource
return server; 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) { private static Stream<Arguments> testQueryParamsEncoding() {
server.when( return Stream.of(
request() Arguments.of(
.withMethod("GET") Named.named("ISO 8601 date-time in value", "http://somecompany/api/data/fetch"),
.withPath(path) List.of(new KeyValueEntry<>("ts", "2016-08-01T09:06:06.0+02:00")),
.withQueryStringParameter(paramKey, paramVal) "http://somecompany/api/data/fetch?ts=2016-08-01T09%3A06%3A06.0%2B02%3A00"
).respond( ),
response() Arguments.of(
.withStatusCode(200) Named.named("email with plus sign in value", "http://localhost:8080/api/user/sendActivationMail"),
.withBody(successResponseBody) 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.HttpContext;
import org.apache.http.protocol.HttpRequestHandler; import org.apache.http.protocol.HttpRequestHandler;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.provider.Arguments; 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.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.msg.TbMsgType; 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.TbMsg;
import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgDataType;
import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.exception.DataValidationException;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Stream; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame; 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.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
private RuleNode ruleNode;
@Spy @Spy
private TbRestApiCallNode restNode; private TbRestApiCallNode restNode;
@ -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 @AfterEach
public void teardown() { public void teardown() {
if (server != null) { 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 @Test
public void deleteRequestWithoutBody() throws IOException, InterruptedException { public void deleteRequestWithoutBody() throws IOException, InterruptedException {
final CountDownLatch latch = new CountDownLatch(1); final CountDownLatch latch = new CountDownLatch(1);
@ -214,7 +325,6 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
private static Stream<Arguments> givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { private static Stream<Arguments> givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() {
return Stream.of( return Stream.of(
// config for version 2 with upgrade from version 0
Arguments.of(0, Arguments.of(0,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\":\"POST\"," + "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\":\"POST\"," +
"\"useSimpleClientHttpFactory\":false,\"ignoreRequestBody\":false,\"enableProxy\":false," + "\"useSimpleClientHttpFactory\":false,\"ignoreRequestBody\":false,\"enableProxy\":false," +
@ -224,13 +334,12 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"\"trimQueue\":null,\"maxQueueSize\":null,\"credentials\":{\"type\":\"anonymous\"},\"trimDoubleQuotes\":false}", "\"trimQueue\":null,\"maxQueueSize\":null,\"credentials\":{\"type\":\"anonymous\"},\"trimDoubleQuotes\":false}",
true, true,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + "\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
"\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," + "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," +
"\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," +
"\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," +
"\"credentials\": {\"type\": \"anonymous\"}," + "\"credentials\": {\"type\": \"anonymous\"}," +
"\"maxInMemoryBufferSizeInKb\": 256}"), "\"maxInMemoryBufferSizeInKb\": 256}"),
// config for version 2 with upgrade from version 1
Arguments.of(1, Arguments.of(1,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
@ -241,13 +350,12 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"\"credentials\": {\"type\": \"anonymous\"}}", "\"credentials\": {\"type\": \"anonymous\"}}",
true, true,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + "\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
"\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," + "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," +
"\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," +
"\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," +
"\"credentials\": {\"type\": \"anonymous\"}," + "\"credentials\": {\"type\": \"anonymous\"}," +
"\"maxInMemoryBufferSizeInKb\": 256}"), "\"maxInMemoryBufferSizeInKb\": 256}"),
// config for version 3 with upgrade from version 2
Arguments.of(2, Arguments.of(2,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
@ -257,12 +365,62 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"\"credentials\": {\"type\": \"anonymous\"}}", "\"credentials\": {\"type\": \"anonymous\"}}",
true, true,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + "\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
"\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," + "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," +
"\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," +
"\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," +
"\"credentials\": {\"type\": \"anonymous\"}," + "\"credentials\": {\"type\": \"anonymous\"}," +
"\"maxInMemoryBufferSizeInKb\": 256}") "\"maxInMemoryBufferSizeInKb\": 256}"),
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 { SelectAttributesComponent } from './select-attributes.component';
import { AlarmStatusSelectComponent } from './alarm-status-select.component'; import { AlarmStatusSelectComponent } from './alarm-status-select.component';
import { ExampleHintComponent } from './example-hint.component'; import { ExampleHintComponent } from './example-hint.component';
import { KvListConfigComponent } from '@home/components/rule-node/common/kv-list-config.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -51,6 +52,7 @@ import { ExampleHintComponent } from './example-hint.component';
SelectAttributesComponent, SelectAttributesComponent,
AlarmStatusSelectComponent, AlarmStatusSelectComponent,
ExampleHintComponent, ExampleHintComponent,
KvListConfigComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -73,6 +75,7 @@ import { ExampleHintComponent } from './example-hint.component';
SelectAttributesComponent, SelectAttributesComponent,
AlarmStatusSelectComponent, AlarmStatusSelectComponent,
ExampleHintComponent, 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. limitations under the License.
--> -->
<section [formGroup]="credentialsConfigFormGroup" class="flex flex-col"> <section [formGroup]="credentialsConfigFormGroup" class="tb-form-panel stroked">
<mat-expansion-panel class="tb-credentials-config-panel-group" expanded="true"> <mat-expansion-panel class="tb-credentials-config-panel-group tb-settings" expanded="true">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title translate>rule-node-config.credentials</mat-panel-title> <mat-panel-title translate>rule-node-config.credentials</mat-panel-title>
<mat-panel-description> <mat-panel-description>
@ -24,7 +24,7 @@
</mat-panel-description> </mat-panel-description>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<ng-template matExpansionPanelContent> <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-label translate>rule-node-config.credentials-type</mat-label>
<mat-select formControlName="type" required> <mat-select formControlName="type" required>
<mat-option *ngFor="let credentialsType of allCredentialsTypes" [value]="credentialsType" <mat-option *ngFor="let credentialsType of allCredentialsTypes" [value]="credentialsType"
@ -36,60 +36,60 @@
{{ 'rule-node-config.credentials-type-required' | translate }} {{ 'rule-node-config.credentials-type-required' | translate }}
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
<section class="flex flex-col" [ngSwitch]="credentialsConfigFormGroup.get('type').value"> @if (credentialsConfigFormGroup.get('type').value !== 'anonymous') {
<ng-template ngSwitchCase="anonymous"> <section class="flex flex-col" [ngSwitch]="credentialsConfigFormGroup.get('type').value">
</ng-template> <ng-template ngSwitchCase="basic">
<ng-template ngSwitchCase="basic"> <mat-form-field class="mat-block">
<mat-form-field class="mat-block"> <mat-label translate>rule-node-config.username</mat-label>
<mat-label translate>rule-node-config.username</mat-label> <input required matInput formControlName="username">
<input required matInput formControlName="username"> <mat-error *ngIf="credentialsConfigFormGroup.get('username').hasError('required')">
<mat-error *ngIf="credentialsConfigFormGroup.get('username').hasError('required')"> {{ 'rule-node-config.username-required' | translate }}
{{ 'rule-node-config.username-required' | translate }} </mat-error>
</mat-error> </mat-form-field>
</mat-form-field> <mat-form-field class="mat-block">
<mat-form-field class="mat-block"> <mat-label translate>rule-node-config.password</mat-label>
<mat-label translate>rule-node-config.password</mat-label> <input type="password" [required]="passwordFieldRequired" matInput formControlName="password" autocomplete="new-password">
<input type="password" [required]="passwordFieldRequired" matInput formControlName="password" autocomplete="new-password"> <tb-toggle-password matSuffix></tb-toggle-password>
<tb-toggle-password matSuffix></tb-toggle-password> <mat-error *ngIf="credentialsConfigFormGroup.get('password').hasError('required')">
<mat-error *ngIf="credentialsConfigFormGroup.get('password').hasError('required')"> {{ 'rule-node-config.password-required' | translate }}
{{ 'rule-node-config.password-required' | translate }} </mat-error>
</mat-error> </mat-form-field>
</mat-form-field> </ng-template>
</ng-template> <ng-template ngSwitchCase="cert.PEM">
<ng-template ngSwitchCase="cert.PEM"> <div class="tb-form-hint tb-primary-fill mb-4">{{ 'rule-node-config.credentials-pem-hint' | translate }}</div>
<div class="tb-hint">{{ 'rule-node-config.credentials-pem-hint' | translate }}</div> <tb-file-input formControlName="caCert"
<tb-file-input formControlName="caCert" inputId="caCertSelect"
inputId="caCertSelect" [existingFileName]="credentialsConfigFormGroup.get('caCertFileName').value"
[existingFileName]="credentialsConfigFormGroup.get('caCertFileName').value" (fileNameChanged)="credentialsConfigFormGroup.get('caCertFileName').setValue($event)"
(fileNameChanged)="credentialsConfigFormGroup.get('caCertFileName').setValue($event)" label="{{'rule-node-config.ca-cert' | translate}}"
label="{{'rule-node-config.ca-cert' | translate}}" noFileText="rule-node-config.no-file"
noFileText="rule-node-config.no-file" dropLabel="{{'rule-node-config.drop-file' | translate}}">
dropLabel="{{'rule-node-config.drop-file' | translate}}"> </tb-file-input>
</tb-file-input> <tb-file-input formControlName="cert"
<tb-file-input formControlName="cert" inputId="CertSelect"
inputId="CertSelect" [existingFileName]="credentialsConfigFormGroup.get('certFileName').value"
[existingFileName]="credentialsConfigFormGroup.get('certFileName').value" (fileNameChanged)="credentialsConfigFormGroup.get('certFileName').setValue($event)"
(fileNameChanged)="credentialsConfigFormGroup.get('certFileName').setValue($event)" label="{{'rule-node-config.cert' | translate}}"
label="{{'rule-node-config.cert' | translate}}" noFileText="rule-node-config.no-file"
noFileText="rule-node-config.no-file" dropLabel="{{'rule-node-config.drop-file' | translate}}">
dropLabel="{{'rule-node-config.drop-file' | translate}}"> </tb-file-input>
</tb-file-input> <tb-file-input style="padding-bottom: 8px;"
<tb-file-input style="padding-bottom: 8px;" formControlName="privateKey"
formControlName="privateKey" inputId="privateKeySelect"
inputId="privateKeySelect" [existingFileName]="credentialsConfigFormGroup.get('privateKeyFileName').value"
[existingFileName]="credentialsConfigFormGroup.get('privateKeyFileName').value" (fileNameChanged)="credentialsConfigFormGroup.get('privateKeyFileName').setValue($event)"
(fileNameChanged)="credentialsConfigFormGroup.get('privateKeyFileName').setValue($event)" label="{{'rule-node-config.private-key' | translate}}"
label="{{'rule-node-config.private-key' | translate}}" noFileText="rule-node-config.no-file"
noFileText="rule-node-config.no-file" dropLabel="{{'rule-node-config.drop-file' | translate}}">
dropLabel="{{'rule-node-config.drop-file' | translate}}"> </tb-file-input>
</tb-file-input> <mat-form-field class="mat-block">
<mat-form-field class="mat-block"> <mat-label translate>rule-node-config.private-key-password</mat-label>
<mat-label translate>rule-node-config.private-key-password</mat-label> <input type="password" matInput formControlName="password" autocomplete="new-password">
<input type="password" matInput formControlName="password" autocomplete="new-password"> <tb-toggle-password matSuffix></tb-toggle-password>
<tb-toggle-password matSuffix></tb-toggle-password> </mat-form-field>
</mat-form-field> </ng-template>
</ng-template> </section>
</section> }
</ng-template> </ng-template>
</mat-expansion-panel> </mat-expansion-panel>
</section> </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"> <section class="tb-kv-map-config flex flex-col" [formGroup]="kvListFormGroup">
<div class="header flex flex-1 flex-row gap-2"> @if (description) {
<span class="cell tb-required flex-1">{{ keyText | translate }}</span> <div class="tb-form-hint tb-primary-fill mb-4" [innerHTML]="description | translate | safe: 'html'"></div>
<span class="cell tb-required flex-1">{{ valText | translate }}</span> }
<span [class.!hidden]="disabled" style="width: 52px;" innerHTML="&nbsp"></span> @if (keyValsFormArray().controls.length || !emptyText) {
</div> <div class="header flex flex-1 flex-row gap-2" >
<div class="body"> <span class="cell tb-required flex-1">{{ keyText | translate }}</span>
<div class="row flex flex-row items-center justify-start gap-2" <span class="cell tb-required flex-1">{{ valText | translate }}</span>
formArrayName="keyVals" <span [class.!hidden]="disabled" style="width: 52px;" innerHTML="&nbsp"></span>
*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>
</div> </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> </div>
<tb-error [error]="ngControl.hasError('kvMapRequired') || ngControl.hasError('uniqueKeyValuePair') ? <tb-error [error]="ngControl.hasError('kvMapRequired') || ngControl.hasError('uniqueKeyValuePair') ?
(ngControl.hasError('kvMapRequired') ? translate.instant(requiredText) : (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() hintText: string;
@Input() description: string;
@Input() emptyText: string;
private requiredValue: boolean; private requiredValue: boolean;
get required(): boolean { get required(): boolean {
@ -83,11 +87,11 @@ export class KvMapConfigOldComponent extends PageComponent implements ControlVal
ngControl: NgControl; ngControl: NgControl;
private propagateChange = null; public propagateChange = null;
constructor(public translate: TranslateService, constructor(public translate: TranslateService,
private injector: Injector, private injector: Injector,
private fb: FormBuilder, protected fb: FormBuilder,
private destroyRef: DestroyRef) { private destroyRef: DestroyRef) {
super(); super();
} }
@ -179,7 +183,7 @@ export class KvMapConfigOldComponent extends PageComponent implements ControlVal
return null; return null;
} }
private updateModel() { protected updateModel() {
const kvList: { key: string; value: string }[] = this.kvListFormGroup.get('keyVals').value; const kvList: { key: string; value: string }[] = this.kvListFormGroup.get('keyVals').value;
if (this.required && !kvList.length || !this.kvListFormGroup.valid) { if (this.required && !kvList.length || !this.kvListFormGroup.valid) {
this.propagateChange(null); 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. limitations under the License.
--> -->
<section [formGroup]="restApiCallConfigForm" class="flex flex-col"> <section [formGroup]="restApiCallConfigForm" class="flex flex-col gap-4">
<mat-form-field class="mat-block" subscriptSizing="dynamic"> <div class="tb-form-panel stroked">
<mat-label translate>rule-node-config.endpoint-url-pattern</mat-label> <div class="tb-form-panel-title" translate>rule-node-config.request-url</div>
<input required matInput formControlName="restEndpointUrlPattern"> <mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-error *ngIf="restApiCallConfigForm.get('restEndpointUrlPattern').hasError('required')"> <mat-label translate>rule-node-config.url</mat-label>
{{ 'rule-node-config.endpoint-url-pattern-required' | translate }} <input required matInput formControlName="restEndpointUrlPattern">
</mat-error> <mat-error *ngIf="restApiCallConfigForm.get('restEndpointUrlPattern').hasError('required')">
<mat-hint translate>rule-node-config.general-pattern-hint</mat-hint> {{ 'rule-node-config.endpoint-url-pattern-required' | translate }}
</mat-form-field> </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-form-field class="mat-block">
<mat-label translate>rule-node-config.request-method</mat-label> <mat-label translate>rule-node-config.request-method</mat-label>
<mat-select formControlName="requestMethod"> <mat-select formControlName="requestMethod">
@ -32,99 +51,112 @@
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-checkbox formControlName="enableProxy"> <div class="tb-form-panel stroked">
{{ 'rule-node-config.enable-proxy' | translate }} <mat-expansion-panel class="tb-settings" [expanded]="isHeadersExpanded">
</mat-checkbox> <mat-expansion-panel-header>
<mat-checkbox *ngIf="!restApiCallConfigForm.get('enableProxy').value" formControlName="useSimpleClientHttpFactory"> {{ 'rule-node-config.headers' | translate }}
{{ 'rule-node-config.use-simple-client-http-factory' | translate }} </mat-expansion-panel-header>
</mat-checkbox> <ng-template matExpansionPanelContent>
<mat-checkbox formControlName="parseToPlainText"> <tb-kv-map-config-old
{{ 'rule-node-config.parse-to-plain-text' | translate }} formControlName="headers"
</mat-checkbox> keyText="rule-node-config.header"
<div class="tb-hint" style="padding-bottom: 5px;" translate>rule-node-config.parse-to-plain-text-hint</div> keyRequiredText="rule-node-config.header-required"
<mat-checkbox formControlName="ignoreRequestBody"> valText="rule-node-config.value"
{{ 'rule-node-config.ignore-request-body' | translate }} valRequiredText="rule-node-config.value-required"
</mat-checkbox> description="rule-node-config.headers-hint"
<div *ngIf="restApiCallConfigForm.get('enableProxy').value"> emptyText="rule-node-config.headers-empty">
<mat-checkbox formControlName="useSystemProxyProperties"> </tb-kv-map-config-old>
{{ 'rule-node-config.use-system-proxy-properties' | translate }} </ng-template>
</mat-checkbox> </mat-expansion-panel>
<div *ngIf="!restApiCallConfigForm.get('useSystemProxyProperties').value"> </div>
<div class="gt-sm:flex gt-sm:flex-row gt-sm:gap-2"> <tb-credentials-config formControlName="credentials"></tb-credentials-config>
<mat-form-field class="mat-block gt-sm:max-w-10% gt-sm:flex-full"> <div class="tb-form-panel stroked">
<mat-label translate>rule-node-config.proxy-scheme</mat-label> <mat-expansion-panel class="tb-settings">
<mat-select formControlName="proxyScheme"> <mat-expansion-panel-header>
<mat-option *ngFor="let proxyScheme of proxySchemes" [value]="proxyScheme"> {{ 'rule-node-config.advanced-settings' | translate }}
{{ proxyScheme }} </mat-expansion-panel-header>
</mat-option> <ng-template matExpansionPanelContent>
</mat-select> <div class="tb-form-panel stroked column">
</mat-form-field> <mat-slide-toggle class="mat-slide fixed-title-width" formControlName="enableProxy">
<mat-form-field class="md-block gt-sm:max-w-50% gt-sm:flex-full"> {{ 'rule-node-config.enable-proxy' | translate }}
<mat-label translate>rule-node-config.proxy-host</mat-label> </mat-slide-toggle>
<input matInput required formControlName="proxyHost"> <div *ngIf="restApiCallConfigForm.get('enableProxy').value" class="flex flex-col gap-4">
<mat-error *ngIf="restApiCallConfigForm.get('proxyHost').hasError('required')"> <mat-slide-toggle class="mat-slide fixed-title-width" formControlName="useSystemProxyProperties">
{{ 'rule-node-config.proxy-host-required' | translate }} {{ '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-error>
</mat-form-field> </mat-form-field>
<mat-form-field class="mat-block gt-sm:max-w-40% gt-sm:flex-full"> <mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.proxy-port</mat-label> <mat-label translate>rule-node-config.max-parallel-requests-count</mat-label>
<input matInput required formControlName="proxyPort" type="number" step="1"> <input type="text" min="0" [max]="IntLimit" inputmode="numeric" pattern="[0-9]*" matInput formControlName="maxParallelRequestsCount">
<mat-error *ngIf="restApiCallConfigForm.get('proxyPort').hasError('required')"> <mat-hint translate>rule-node-config.max-parallel-requests-count-hint</mat-hint>
{{ 'rule-node-config.proxy-port-required' | translate }} <mat-error
*ngIf="restApiCallConfigForm.get('maxParallelRequestsCount').hasError('max')">
{{ 'rule-node-config.int-range' | translate }}
</mat-error> </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 <mat-error
*ngIf="restApiCallConfigForm.get('proxyPort').hasError('min') || restApiCallConfigForm.get('proxyPort').hasError('max')"> *ngIf="restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('min') || restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('max')">
{{ 'rule-node-config.proxy-port-range' | translate }} {{ 'rule-node-config.memory-buffer-size-range' | translate: { max: MemoryBufferSizeInKbLimit } }}
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </ng-template>
<mat-form-field class="mat-block"> </mat-expansion-panel>
<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>
</div> </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> </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 { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shared/models/rule-node.models'; import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shared/models/rule-node.models';
import { HttpRequestType, IntLimit } from '../rule-node-config.models'; import { HttpRequestType, IntLimit } from '../rule-node-config.models';
import { isDefinedAndNotNull } from '@core/utils';
@Component({ @Component({
selector: 'tb-external-node-rest-api-call-config', selector: 'tb-external-node-rest-api-call-config',
templateUrl: './rest-api-call-config.component.html', templateUrl: './rest-api-call-config.component.html',
styleUrls: [], styleUrls: ['./rest-api-call-config.component.scss'],
standalone: false standalone: false
}) })
export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent { export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent {
restApiCallConfigForm: UntypedFormGroup; restApiCallConfigForm: UntypedFormGroup;
readonly proxySchemes: string[] = ['http', 'https'];
readonly httpRequestTypes = Object.keys(HttpRequestType); readonly httpRequestTypes = Object.keys(HttpRequestType);
readonly MemoryBufferSizeInKbLimit = 25000; readonly MemoryBufferSizeInKbLimit = 25000;
readonly IntLimit = IntLimit; readonly IntLimit = IntLimit;
isHeadersExpanded = false;
isQueryParamsExpanded = false;
constructor(private fb: UntypedFormBuilder) { constructor(private fb: UntypedFormBuilder) {
super(); super();
} }
@ -46,12 +49,10 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent {
this.restApiCallConfigForm = this.fb.group({ this.restApiCallConfigForm = this.fb.group({
restEndpointUrlPattern: [configuration ? configuration.restEndpointUrlPattern : null, [Validators.required]], restEndpointUrlPattern: [configuration ? configuration.restEndpointUrlPattern : null, [Validators.required]],
requestMethod: [configuration ? configuration.requestMethod : null, [Validators.required]], requestMethod: [configuration ? configuration.requestMethod : null, [Validators.required]],
useSimpleClientHttpFactory: [configuration ? configuration.useSimpleClientHttpFactory : false, []],
parseToPlainText: [configuration ? configuration.parseToPlainText : false, []], parseToPlainText: [configuration ? configuration.parseToPlainText : false, []],
ignoreRequestBody: [configuration ? configuration.ignoreRequestBody : false, []], ignoreRequestBody: [configuration ? configuration.ignoreRequestBody : false, []],
enableProxy: [configuration ? configuration.enableProxy : false, []], enableProxy: [configuration ? configuration.enableProxy : false, []],
useSystemProxyProperties: [configuration ? configuration.enableProxy : false, []], useSystemProxyProperties: [configuration ? configuration.enableProxy : false, []],
proxyScheme: [configuration ? configuration.proxyHost : null, []],
proxyHost: [configuration ? configuration.proxyHost : null, []], proxyHost: [configuration ? configuration.proxyHost : null, []],
proxyPort: [configuration ? configuration.proxyPort : null, []], proxyPort: [configuration ? configuration.proxyPort : null, []],
proxyUser: [configuration ? configuration.proxyUser :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)]], maxParallelRequestsCount: [configuration ? configuration.maxParallelRequestsCount : null, [Validators.min(0), Validators.max(IntLimit)]],
headers: [configuration ? configuration.headers : null, []], headers: [configuration ? configuration.headers : null, []],
credentials: [configuration ? configuration.credentials : 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[] { 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) { protected updateValidators(emitEvent: boolean) {
const useSimpleClientHttpFactory: boolean = this.restApiCallConfigForm.get('useSimpleClientHttpFactory').value;
const enableProxy: boolean = this.restApiCallConfigForm.get('enableProxy').value; const enableProxy: boolean = this.restApiCallConfigForm.get('enableProxy').value;
const useSystemProxyProperties: boolean = this.restApiCallConfigForm.get('useSystemProxyProperties').value; const useSystemProxyProperties: boolean = this.restApiCallConfigForm.get('useSystemProxyProperties').value;
@ -80,12 +92,7 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent {
} else { } else {
this.restApiCallConfigForm.get('proxyHost').setValidators([]); this.restApiCallConfigForm.get('proxyHost').setValidators([]);
this.restApiCallConfigForm.get('proxyPort').setValidators([]); this.restApiCallConfigForm.get('proxyPort').setValidators([]);
this.restApiCallConfigForm.get('readTimeoutMs').setValidators([Validators.min(0), Validators.max(IntLimit)]);
if (useSimpleClientHttpFactory) {
this.restApiCallConfigForm.get('readTimeoutMs').setValidators([]);
} else {
this.restApiCallConfigForm.get('readTimeoutMs').setValidators([Validators.min(0), Validators.max(IntLimit)]);
}
} }
this.restApiCallConfigForm.get('readTimeoutMs').updateValueAndValidity({emitEvent}); 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": "Endpoint URL pattern",
"endpoint-url-pattern-required": "Endpoint URL pattern is required", "endpoint-url-pattern-required": "Endpoint URL pattern is required",
"request-method": "Request method", "request-method": "Request method",
"use-simple-client-http-factory": "Use simple client HTTP factory",
"ignore-request-body": "Without request body", "ignore-request-body": "Without request body",
"parse-to-plain-text": "Parse to plain text", "parse-to-plain-text": "Parse to plain text",
"parse-to-plain-text-hint": "If selected, request body message payload will be transformed from JSON string to plain text, e.g. msg = \"Hello,\\t\"world\"\" will be parsed to Hello, \"world\"", "parse-to-plain-text-hint": "If selected, 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-port-range": "Proxy port should be in a range from 1 to 65535.",
"proxy-user": "Proxy user", "proxy-user": "Proxy user",
"proxy-password": "Proxy password", "proxy-password": "Proxy password",
"proxy-scheme": "Proxy scheme",
"numbers-to-template": "Phone Numbers To Template", "numbers-to-template": "Phone Numbers To Template",
"numbers-to-template-required": "Phone Numbers To Template is required", "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", "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.", "tell-failure-aws-lambda-hint": "Rule node forces failure of message processing if AWS Lambda function execution raises exception.",
"basic-mode": "Basic", "basic-mode": "Basic",
"advanced-mode": "Advanced", "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": { "save-time-series": {
"processing-settings": "Processing settings", "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.", "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