bundleDetails = client.getBundleWidgetTypesDetails(
+ savedBundle.getId().getId().toString(), false);
+ assertEquals(5, bundleDetails.size());
+
+ // get bundle widget types infos (paginated)
+ PageDataWidgetTypeInfo bundleInfos = client.getBundleWidgetTypesInfos(
+ savedBundle.getId().getId().toString(), 100, 0,
+ null, null, null, null, null, null);
+ assertEquals(5, bundleInfos.getData().size());
+
+ // update widget type
+ WidgetTypeDetails widgetToUpdate = client.getWidgetTypeById(
+ createdWidgetTypes.get(3).getId().getId().toString(), true);
+ widgetToUpdate.setDescription("Updated description");
+ widgetToUpdate.setDeprecated(true);
+ widgetToUpdate.setTags(List.of("test", "updated"));
+ WidgetTypeDetails updatedWidget = client.saveWidgetType(widgetToUpdate, false);
+ assertEquals("Updated description", updatedWidget.getDescription());
+ assertEquals(true, updatedWidget.getDeprecated());
+
+ // delete widget type
+ String widgetToDeleteId = createdWidgetTypes.get(0).getId().getId().toString();
+ client.deleteWidgetType(widgetToDeleteId);
+
+ // verify deletion
+ assertReturns404(() ->
+ client.getWidgetTypeById(widgetToDeleteId, false)
+ );
+
+ PageDataWidgetTypeInfo typesAfterDelete = client.getWidgetTypes(100, 0,
+ TEST_PREFIX + "Widget_" + timestamp, null, null,
+ true, false, null, null, null);
+ assertEquals(4, typesAfterDelete.getData().size());
+
+ // delete widgets bundle
+ client.deleteWidgetsBundle(savedBundle.getId().getId().toString());
+
+ assertReturns404(() ->
+ client.getWidgetsBundleById(savedBundle.getId().getId().toString(), false)
+ );
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java
index 04a1b87833..7614bdd310 100644
--- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java
@@ -32,7 +32,6 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.test.util.ReflectionTestUtils;
-import org.thingsboard.server.controller.AbstractWebTest;
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc;
import java.io.ByteArrayInputStream;
@@ -56,7 +55,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.awaitility.Awaitility.await;
/**
- * Tests for Edge gRPC SSL setup using the production {@link EdgeGrpcService#setupSsl} method.
+ * Tests for Edge gRPC SSL setup using the production {@link GrpcServer#setupSsl} method.
*
* Covers:
* 1. Separate cert and key PEM inputs
@@ -68,6 +67,8 @@ import static org.awaitility.Awaitility.await;
*/
class EdgeGrpcSslTest {
+ private static final int TIMEOUT = 30;
+
static {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
@@ -170,13 +171,14 @@ class EdgeGrpcSslTest {
Path certOnlyFile = writeTempPem("cert-only", cert);
assertThatThrownBy(() -> startServer(certOnlyFile.toString(), "", null))
- .isInstanceOf(IllegalArgumentException.class);
+ .isInstanceOf(RuntimeException.class)
+ .hasCauseInstanceOf(IllegalArgumentException.class);
}
// --- Server startup using production EdgeGrpcService.setupSsl() ---
private Server startServer(String certFileResource, String privateKeyResource, String keyPassword) throws Exception {
- EdgeGrpcService edgeGrpcService = new EdgeGrpcService();
+ GrpcServer edgeGrpcService = new GrpcServer(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {});
ReflectionTestUtils.setField(edgeGrpcService, "certFileResource", certFileResource);
ReflectionTestUtils.setField(edgeGrpcService, "privateKeyResource", privateKeyResource);
ReflectionTestUtils.setField(edgeGrpcService, "keyPassword", keyPassword != null ? keyPassword : "");
@@ -200,7 +202,7 @@ class EdgeGrpcSslTest {
.build();
channel.getState(true); // trigger connection attempt
- await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS)
+ await().atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(50, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
var state = channel.getState(false);
diff --git a/application/src/test/resources/logback-test.xml b/application/src/test/resources/logback-test.xml
index 6faaf97536..9409eed7bd 100644
--- a/application/src/test/resources/logback-test.xml
+++ b/application/src/test/resources/logback-test.xml
@@ -18,9 +18,6 @@
-
-
-
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/KeyValueEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/KeyValueEntry.java
new file mode 100644
index 0000000000..a4ce7c77e4
--- /dev/null
+++ b/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 key, V value) {}
diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java
index e5f81dafc4..34cc1151c4 100644
--- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java
+++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java
@@ -30,6 +30,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import java.util.List;
+import java.util.function.BiConsumer;
import java.util.function.Consumer;
@Component
@@ -88,6 +89,11 @@ public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustom
public void addBundleUpdateHandler(String name, Consumer handler) {
// no-op
}
+
+ @Override
+ public void addBundleRegisterHandler(BiConsumer handler) {
+ // no-op
+ }
};
}
diff --git a/common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java b/common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java
index d2d1be80c9..77f915c09c 100644
--- a/common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java
+++ b/common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java
@@ -29,7 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
-@ResourceLock("SsrfProtectionValidatorTest") // some tests mutate static additional-blocked-hosts
+@ResourceLock("SsrfProtectionValidator") // to avoid race conditions when modifying SsrfProtectionValidator's static configuration
public class SsrfProtectionValidatorTest {
@ParameterizedTest
diff --git a/pom.xml b/pom.xml
index 4dcc3929fd..47902751f4 100755
--- a/pom.xml
+++ b/pom.xml
@@ -38,11 +38,9 @@
${project.name}
/var/log/${pkg.name}
/usr/share/${pkg.name}
- 3.4.13
- 10.1.52
- 2.18.6
+ 4.4.0-SNAPSHOT
+ 3.5.12
2.4.0-b180830.0359
- 5.1.5
0.12.5
0.10
4.17.0
@@ -67,7 +65,7 @@
3.25.5
1.76.0
1.2.9
- 1.18.40
+ 1.18.44
1.2.5
1.2.5
1.7.1
@@ -91,7 +89,6 @@
1.10.1
8.10.1
3.5.3
- 2.2
1.12.701
1.128.1
@@ -122,7 +119,6 @@
0.27.0
1.7.0
- 3.27.7
2.7.3
1.5.6
5.15.0
@@ -921,43 +917,6 @@
-
-
- org.apache.tomcat.embed
- tomcat-embed-core
- ${tomcat.version}
-
-
- org.apache.tomcat.embed
- tomcat-embed-el
- ${tomcat.version}
-
-
- org.apache.tomcat.embed
- tomcat-embed-websocket
- ${tomcat.version}
-
-
-
-
-
- com.fasterxml.jackson
- jackson-bom
- ${jackson.version}
- pom
- import
-
-
-
-
-
- org.assertj
- assertj-core
- ${assertj.version}
- test
-
-
-
org.springframework.boot
spring-boot-dependencies
@@ -1134,6 +1093,12 @@
${project.version}
test
+
+ org.thingsboard.client
+ thingsboard-ce-client
+ ${thingsboard.client.version}
+ test
+
org.thingsboard.msa
js-executor
@@ -1241,11 +1206,6 @@
jjwt
${jjwt.version}
-
- org.yaml
- snakeyaml
- ${snakeyaml.version}
-
antlr
antlr
@@ -1546,12 +1506,6 @@
${dbunit.version}
test
-
- org.projectlombok
- lombok
- ${lombok.version}
- provided
-
org.eclipse.paho
org.eclipse.paho.client.mqttv3
@@ -1597,11 +1551,6 @@
bcprov-ext-jdk18on
${bouncycastle.version}
-
- redis.clients
- jedis
- ${jedis.version}
-
com.sun.winsw
winsw
diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml
index 4e092211b8..5e7cb48970 100644
--- a/rule-engine/rule-engine-components/pom.xml
+++ b/rule-engine/rule-engine-components/pom.xml
@@ -161,6 +161,10 @@
org.thingsboard.langchain4j
langchain4j
+
+ org.apache.httpcomponents.core5
+ httpcore5
+
diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java
index df9ce0194b..8db6a3ccaf 100644
--- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java
+++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java
@@ -21,6 +21,7 @@ import io.netty.handler.ssl.SslContext;
import io.netty.handler.timeout.ReadTimeoutHandler;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
+import org.apache.hc.core5.net.URIBuilder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
@@ -40,6 +41,7 @@ import org.thingsboard.rule.engine.credentials.BasicCredentials;
import org.thingsboard.rule.engine.credentials.ClientCredentials;
import org.thingsboard.rule.engine.credentials.CredentialsType;
import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.util.KeyValueEntry;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import reactor.netty.http.client.HttpClient;
@@ -48,6 +50,7 @@ import reactor.netty.transport.ProxyProvider;
import javax.net.ssl.SSLException;
import java.net.URI;
+import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
@@ -130,10 +133,6 @@ public class TbHttpClient {
SslContext sslContext = config.getCredentials().initSslContext();
httpClient = httpClient.secure(t -> t.sslContext(sslContext));
}
- } else if (config.isUseSimpleClientHttpFactory()) {
- if (CredentialsType.CERT_PEM == config.getCredentials().getType()) {
- throw new TbNodeException("Simple HTTP Factory does not support CERT PEM credentials!");
- }
} else {
SslContext sslContext = config.getCredentials().initSslContext();
httpClient = httpClient.secure(t -> t.sslContext(sslContext));
@@ -219,8 +218,22 @@ public class TbHttpClient {
}
String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), msg);
+
+ List> processedQueryParams;
+ if (config.getQueryParams() != null) {
+ processedQueryParams = config.getQueryParams().stream()
+ .map(param -> {
+ var processedParamName = TbNodeUtils.processPattern(param.key(), msg);
+ var processedParamValue = TbNodeUtils.processPattern(param.value(), msg);
+ return new KeyValueEntry<>(processedParamName, processedParamValue);
+ })
+ .toList();
+ } else {
+ processedQueryParams = null;
+ }
+
HttpMethod method = HttpMethod.valueOf(config.getRequestMethod());
- URI uri = buildEncodedUri(endpointUrl);
+ URI uri = buildEncodedUri(endpointUrl, processedQueryParams);
RequestBodySpec request = webClient
.method(method)
@@ -268,7 +281,7 @@ public class TbHttpClient {
return origin;
}
- public URI buildEncodedUri(String endpointUrl) {
+ public URI buildEncodedUri(String endpointUrl, List> queryParams) {
if (endpointUrl == null) {
throw new RuntimeException("Url string cannot be null!");
}
@@ -276,7 +289,13 @@ public class TbHttpClient {
throw new RuntimeException("Url string cannot be empty!");
}
- URI uri = UriComponentsBuilder.fromUriString(endpointUrl).build().encode().toUri();
+ URI uri;
+ if (queryParams != null) {
+ uri = buildEncodedUriNew(endpointUrl, queryParams);
+ } else {
+ uri = buildEncodedUriLegacy(endpointUrl);
+ }
+
if (uri.getScheme() == null || uri.getScheme().isEmpty()) {
throw new RuntimeException("Transport scheme(protocol) must be provided!");
}
@@ -292,6 +311,35 @@ public class TbHttpClient {
return uri;
}
+ private URI buildEncodedUriNew(String endpointUrl, List> 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.
+ *
+ * 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.
+ *
+ * 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}.
+ *
+ * Use {@link #buildEncodedUriNew} with a separate query params list,
+ * where values are encoded exactly once: {@code +} → {@code %2B}, {@code @} → {@code %40}.
+ */
+ @Deprecated(forRemoval = true)
+ private URI buildEncodedUriLegacy(String endpointUrl) {
+ return UriComponentsBuilder.fromUriString(endpointUrl).build().encode().toUri();
+ }
+
private Object getData(TbMsg tbMsg, boolean parseToPlainText) {
String data = tbMsg.getData();
return parseToPlainText ? JacksonUtil.toPlainText(data) : JacksonUtil.toJsonNode(data);
diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java
index 0217edf5e0..5ffe37b102 100644
--- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java
+++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java
@@ -26,14 +26,19 @@ import org.thingsboard.rule.engine.external.TbAbstractExternalNode;
import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.data.util.TbPair;
import org.thingsboard.server.common.msg.TbMsg;
+import org.thingsboard.server.exception.DataValidationException;
+import java.util.Iterator;
import java.util.List;
+import java.util.Set;
+
+import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields;
@RuleNode(
type = ComponentType.EXTERNAL,
name = "rest api call",
configClazz = TbRestApiCallNodeConfiguration.class,
- version = 3,
+ version = 4,
nodeDescription = "Invoke REST API calls to external REST server",
nodeDetails = "Will invoke REST API call GET | POST | PUT | DELETE to external REST server. " +
"Message payload added into Request body. Configured attributes can be added into Headers from Message Metadata." +
@@ -57,7 +62,15 @@ public class TbRestApiCallNode extends TbAbstractExternalNode {
@Override
public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException {
super.init(ctx);
- TbRestApiCallNodeConfiguration config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class);
+
+ var config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class);
+ String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: ";
+ try {
+ validateFields(config, errorPrefix);
+ } catch (DataValidationException e) {
+ throw new TbNodeException(e, true);
+ }
+
httpClient = new TbHttpClient(config, ctx.getSharedEventLoop());
}
@@ -96,6 +109,21 @@ public class TbRestApiCallNode extends TbAbstractExternalNode {
hasChanges = true;
((ObjectNode) oldConfiguration).put(MAX_IN_MEMORY_BUFFER_SIZE_IN_KB, 256);
}
+ case 3:
+ Set knownProperties = Set.of(
+ "restEndpointUrlPattern", "requestMethod", "headers",
+ "readTimeoutMs", "maxParallelRequestsCount", "parseToPlainText",
+ "enableProxy", "useSystemProxyProperties", "proxyHost", "proxyPort",
+ "proxyUser", "proxyPassword", "credentials", "ignoreRequestBody",
+ "maxInMemoryBufferSizeInKb"
+ );
+ Iterator fieldNames = oldConfiguration.fieldNames();
+ while (fieldNames.hasNext()) {
+ if (!knownProperties.contains(fieldNames.next())) {
+ hasChanges = true;
+ fieldNames.remove();
+ }
+ }
break;
default:
break;
diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java
index 8413c49c7d..9377d83949 100644
--- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java
+++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java
@@ -15,25 +15,31 @@
*/
package org.thingsboard.rule.engine.rest;
-import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import jakarta.validation.constraints.AssertTrue;
+import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.thingsboard.rule.engine.api.NodeConfiguration;
import org.thingsboard.rule.engine.credentials.AnonymousCredentials;
import org.thingsboard.rule.engine.credentials.ClientCredentials;
+import org.thingsboard.server.common.data.util.KeyValueEntry;
import java.util.Collections;
+import java.util.List;
import java.util.Map;
+import java.util.Objects;
-@JsonIgnoreProperties(ignoreUnknown = true)
@Data
public class TbRestApiCallNodeConfiguration implements NodeConfiguration {
private String restEndpointUrlPattern;
private String requestMethod;
+ // null for legacy configs - triggers different URL encoding logic in TbHttpClient to preserve backward compatibility
+ // all new/modified configs since introduction of this property are forced to have non-null value by UI (and use new encoding logic)
+ private List<@NotNull KeyValueEntry> queryParams;
private Map headers;
- private boolean useSimpleClientHttpFactory;
private int readTimeoutMs;
private int maxParallelRequestsCount;
private boolean parseToPlainText;
@@ -43,18 +49,31 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration queryParam : queryParams) {
+ if (queryParam == null || queryParam.key() == null || queryParam.value() == null) {
+ return false;
+ }
+ }
+ return true;
+ }
+
@Override
public TbRestApiCallNodeConfiguration defaultConfiguration() {
- TbRestApiCallNodeConfiguration configuration = new TbRestApiCallNodeConfiguration();
+ var configuration = new TbRestApiCallNodeConfiguration();
configuration.setRestEndpointUrlPattern("http://localhost/api");
configuration.setRequestMethod("POST");
configuration.setHeaders(Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE));
- configuration.setUseSimpleClientHttpFactory(false);
+ configuration.setQueryParams(Collections.emptyList());
configuration.setReadTimeoutMs(0);
configuration.setMaxParallelRequestsCount(0);
configuration.setParseToPlainText(false);
@@ -66,10 +85,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration client.buildEncodedUri(url));
+ assertThatThrownBy(() -> client.buildEncodedUri(url, null));
}
@Test
public void testBuildInvalidUri() {
- Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod();
+ Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
String url = "aaa";
- assertThatThrownBy(() -> client.buildEncodedUri(url));
+ assertThatThrownBy(() -> client.buildEncodedUri(url, null));
}
@Test
public void testBuildUriWithSpecialSymbols() {
- Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod();
+ Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
String url = "http://192.168.1.1/data?d={\"a\": 12}";
String expected = "http://192.168.1.1/data?d=%7B%22a%22:%2012%7D";
- URI uri = client.buildEncodedUri(url);
+ URI uri = client.buildEncodedUri(url, null);
Assertions.assertEquals(expected, uri.toString());
}
+ @Test
+ public void testProcessMessageWithQueryParamsPatternProcessing() throws Exception {
+ // GIVEN
+ String path = "/api/notify";
+ try (var server = startClientAndServer("localhost", 0)) {
+ server.when(
+ request()
+ .withMethod("GET")
+ .withPath(path)
+ .withQueryStringParameter("email", "user+tag@test.com")
+ .withQueryStringParameter("device", "sensor-1")
+ .withQueryStringParameter("custom-header", "header-value")
+ .withQueryStringParameter("temp", "25.5")
+ .withQueryStringParameter("location", "room-1/zone-A")
+ ).respond(
+ response().withStatusCode(200)
+ );
+
+ var config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
+ config.setRestEndpointUrlPattern("http://localhost:" + server.getPort() + path);
+ config.setRequestMethod("GET");
+ config.setQueryParams(List.of(
+ new KeyValueEntry<>("email", "${userEmail}"), // ${} from metadata
+ new KeyValueEntry<>("device", "${deviceName}"), // ${} from metadata
+ new KeyValueEntry<>("${dynamicParam}", "${dynamicValue}"), // ${} in both key and value
+ new KeyValueEntry<>("temp", "$[temperature]"), // $[] from data
+ new KeyValueEntry<>("location", "$[sensor.location]") // $[] from nested data
+ ));
+
+ var metaData = new TbMsgMetaData();
+ metaData.putValue("userEmail", "user+tag@test.com");
+ metaData.putValue("deviceName", "sensor-1");
+ metaData.putValue("dynamicParam", "custom-header");
+ metaData.putValue("dynamicValue", "header-value");
+
+ var msg = TbMsg.newMsg()
+ .type(TbMsgType.POST_TELEMETRY_REQUEST)
+ .originator(new DeviceId(EntityId.NULL_UUID))
+ .metaData(metaData)
+ .data("""
+ {"temperature": 25.5, "sensor": {"location": "room-1/zone-A"}}
+ """)
+ .build();
+
+ // WHEN-THEN
+ processMessageAndWait(config, msg);
+ }
+ }
+
@Test
public void testProcessMessageWithJsonInUrlVariable() throws Exception {
- String host = "localhost";
+ // GIVEN
String path = "/api";
- String paramKey = "data";
- String paramVal = "[{\"test\":\"test\"}]";
- String successResponseBody = "SUCCESS";
-
- var server = setUpDummyServer(host, path, paramKey, paramVal, successResponseBody);
-
- String endpointUrl = String.format(
- "http://%s:%d%s?%s=%s",
- host, server.getPort(), path, paramKey, paramVal
- );
- String method = "GET";
-
-
- var config = new TbRestApiCallNodeConfiguration()
- .defaultConfiguration();
- config.setRequestMethod(method);
- config.setRestEndpointUrlPattern(endpointUrl);
- config.setUseSimpleClientHttpFactory(true);
+ String paramValue = "[{\"test\":\"test\"}]";
+
+ try (var server = startClientAndServer("localhost", 0)) {
+ server.when(
+ request()
+ .withMethod("GET")
+ .withPath(path)
+ .withQueryStringParameter("data", paramValue)
+ ).respond(
+ response().withStatusCode(200)
+ );
+
+ var config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
+ config.setRestEndpointUrlPattern("http://localhost:" + server.getPort() + path + "?data=" + paramValue);
+ config.setRequestMethod("GET");
+ config.setQueryParams(null);
+
+ var msg = TbMsg.newMsg()
+ .type(TbMsgType.POST_TELEMETRY_REQUEST)
+ .originator(new DeviceId(UUID.randomUUID()))
+ .metaData(new TbMsgMetaData())
+ .data(TbMsg.EMPTY_JSON_OBJECT)
+ .build();
+
+ // WHEN-THEN
+ processMessageAndWait(config, msg);
+ }
+ }
+ private void processMessageAndWait(TbRestApiCallNodeConfiguration config, TbMsg msg) throws Exception {
var httpClient = new TbHttpClient(config, eventLoop);
-
- var msg = TbMsg.newMsg()
- .type(TbMsgType.POST_TELEMETRY_REQUEST)
- .originator(new DeviceId(EntityId.NULL_UUID))
- .copyMetaData(TbMsgMetaData.EMPTY)
- .data(TbMsg.EMPTY_JSON_OBJECT)
- .build();
- var successMsg = TbMsg.newMsg()
- .type(TbMsgType.POST_TELEMETRY_REQUEST)
- .originator(msg.getOriginator())
- .copyMetaData(msg.getMetaData())
- .data(msg.getData())
- .build();
-
var ctx = mock(TbContext.class);
- when(ctx.transformMsg(
- eq(msg),
- eq(msg.getMetaData()),
- eq(msg.getData())
- )).thenReturn(successMsg);
-
- var capturedData = ArgumentCaptor.forClass(String.class);
+ when(ctx.transformMsg(eq(msg), any(), any())).thenReturn(msg);
- when(ctx.transformMsg(
- eq(msg),
- any(),
- capturedData.capture()
- )).thenReturn(successMsg);
-
- CountDownLatch latch = new CountDownLatch(1);
+ var latch = new CountDownLatch(1);
+ var error = new AtomicReference();
httpClient.processMessage(ctx, msg,
m -> {
- ctx.tellSuccess(msg);
+ ctx.tellSuccess(m);
latch.countDown();
},
(m, t) -> {
ctx.tellFailure(m, t);
+ error.set(t);
latch.countDown();
});
- latch.await(5, TimeUnit.SECONDS);
+ assertTrue(latch.await(5, TimeUnit.SECONDS), "Request should complete within timeout");
+ assertNull(error.get(), "Request should succeed, but got: " + error.get());
- verify(ctx, times(1)).tellSuccess(any());
- verify(ctx, times(0)).tellFailure(any(), any());
- Assertions.assertEquals(successResponseBody, capturedData.getValue());
+ verify(ctx).tellSuccess(any());
+ verify(ctx, never()).tellFailure(any(), any());
}
- private ClientAndServer setUpDummyServer(String host, String path, String paramKey, String paramVal, String successResponseBody) {
- var server = startClientAndServer(host, 1080);
- createGetMethodExpectations(server, path, paramKey, paramVal, successResponseBody);
- return server;
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource
+ public void testQueryParamsEncoding(String endpointUrl, List> queryParams, String expectedEncodedUrl) {
+ // GIVEN
+ Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
+
+ // WHEN
+ URI uri = client.buildEncodedUri(endpointUrl, queryParams);
+
+ // THEN
+ Assertions.assertEquals(expectedEncodedUrl, uri.toASCIIString());
}
- private void createGetMethodExpectations(ClientAndServer server, String path, String paramKey, String paramVal, String successResponseBody) {
- server.when(
- request()
- .withMethod("GET")
- .withPath(path)
- .withQueryStringParameter(paramKey, paramVal)
- ).respond(
- response()
- .withStatusCode(200)
- .withBody(successResponseBody)
+ private static Stream