From 3a098ef815bf55709ee8712ba3c1e3001a3e4c50 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 8 Dec 2025 16:56:35 +0200 Subject: [PATCH 01/15] REST API call node: better URL encoding # Conflicts: # common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java --- .../common/data/util/CollectionsUtil.java | 10 + rule-engine/rule-engine-components/pom.xml | 4 + .../rule/engine/rest/TbHttpClient.java | 58 +- .../rule/engine/rest/TbRestApiCallNode.java | 13 +- .../rest/TbRestApiCallNodeConfiguration.java | 26 + .../rule/engine/rest/TbHttpClientTest.java | 498 +++++++++++++++--- .../engine/rest/TbRestApiCallNodeTest.java | 114 ++++ 7 files changed, 641 insertions(+), 82 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java index 082be9b71f..bdc4430387 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.util; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -72,6 +73,15 @@ public class CollectionsUtil { return map; } + @SafeVarargs + public static LinkedHashMap orderedMapOf(Map.Entry... entries) { + LinkedHashMap map = new LinkedHashMap<>(); + for (Map.Entry entry : entries) { + map.put(entry.getKey(), entry.getValue()); + } + return map; + } + public static boolean emptyOrContains(Collection collection, V element) { return isEmpty(collection) || collection.contains(element); } diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 90e6a87e52..45e37d03f9 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 63b4b70303..a16b3f5fb5 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 @@ -15,12 +15,14 @@ */ package org.thingsboard.rule.engine.rest; +import com.google.common.collect.Maps; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; 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; @@ -47,6 +49,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; @@ -213,8 +216,21 @@ public class TbHttpClient { } String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), msg); + + Map processedQueryParams; + if (config.isUseNewEncoding()) { + processedQueryParams = Maps.newHashMapWithExpectedSize(config.getQueryParams().size()); + config.getQueryParams().forEach((name, value) -> { + var processedParamName = TbNodeUtils.processPattern(name, msg); + var processedParamValue = TbNodeUtils.processPattern(value, msg); + processedQueryParams.put(processedParamName, processedParamValue); + }); + } else { + processedQueryParams = null; + } + HttpMethod method = HttpMethod.valueOf(config.getRequestMethod()); - URI uri = buildEncodedUri(endpointUrl); + URI uri = buildEncodedUri(endpointUrl, processedQueryParams); RequestBodySpec request = webClient .method(method) @@ -262,7 +278,7 @@ public class TbHttpClient { return origin; } - public URI buildEncodedUri(String endpointUrl) { + public URI buildEncodedUri(String endpointUrl, Map queryParams) { if (endpointUrl == null) { throw new RuntimeException("Url string cannot be null!"); } @@ -270,7 +286,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!"); } @@ -284,6 +306,36 @@ public class TbHttpClient { return uri; } + private URI buildEncodedUriNew(String endpointUrl, Map queryParams) { + try { + URIBuilder builder = new URIBuilder(endpointUrl); + queryParams.forEach(builder::addParameter); + return builder.build(); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(""" + Invalid REST endpoint URL: '%s'. The URL must be valid and properly encoded. + If the path contains special characters (e.g., spaces, braces), they must be percent-encoded (e.g., '/my file/' should be '/my%%20file/', '/path/{id}/' should be '/path/%%7Bid%%7D/'). + Query parameters should be configured separately and will be encoded automatically.""".formatted(endpointUrl), 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 map, + * where values are encoded exactly once: {@code +} → {@code %2B}, {@code @} → {@code %40}. + */ + @Deprecated(since = "4.2.1.2", forRemoval = true) // fixme: remove in major 5.0 + 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 a2290a4eda..3e9b034693 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,9 +26,12 @@ 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.dao.exception.DataValidationException; import java.util.List; +import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; + @RuleNode( type = ComponentType.EXTERNAL, name = "rest api call", @@ -57,7 +60,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()); } 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 43ed0c597e..c896dea324 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,7 +15,10 @@ */ package org.thingsboard.rule.engine.rest; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -32,6 +35,8 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration queryParams; private Map headers; private boolean useSimpleClientHttpFactory; private int readTimeoutMs; @@ -48,12 +53,32 @@ 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.setUseSimpleClientHttpFactory(true); + config.setUseNewEncoding(true); + config.setQueryParams(Map.of( + "email", "${userEmail}", // ${} from metadata + "device", "${deviceName}", // ${} from metadata + "${dynamicParam}", "${dynamicValue}", // ${} in both key and value + "temp", "$[temperature]", // $[] from data + "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.setUseNewEncoding(false); + 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, Map 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 testQueryParamsEncoding() { + return Stream.of( + Arguments.of( + Named.named("ISO 8601 date-time in value", "http://somecompany/api/data/fetch"), + Map.of("ts", "2016-08-01T09:06:06.0+02:00"), + "http://somecompany/api/data/fetch?ts=2016-08-01T09%3A06%3A06.0%2B02%3A00" + ), + Arguments.of( + Named.named("email with plus sign in value", "http://localhost:8080/api/user/sendActivationMail"), + Map.of("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"), + Map.of("q", "a + b"), + "http://url/api?q=a%20%2B%20b" + ), + Arguments.of( + Named.named("colon in value", "http://url/api"), + Map.of("time", "12:00"), + "http://url/api?time=12%3A00" + ), + Arguments.of( + Named.named("slash in value", "http://url"), + Map.of("ref", "/home/user"), + "http://url?ref=%2Fhome%2Fuser" + ), + Arguments.of( + Named.named("comma and semicolon in value", "http://url"), + Map.of("l", "a,b;c"), + "http://url?l=a%2Cb%3Bc" + ), + Arguments.of( + Named.named("ampersand and equals in value", "http://url"), + Map.of("q", "key1=value1&key2=value2"), + "http://url?q=key1%3Dvalue1%26key2%3Dvalue2" + ), + Arguments.of( + Named.named("JSON in value", "http://url"), + Map.of("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"), + orderedMapOf( + entry("nom", "Le Goût Moderne"), + entry("назва", "У Миколи \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"), + Map.of("name", ""), + "http://url/empty?name=" + ), + Arguments.of( + Named.named("blank value (spaces)", "http://url/empty"), + Map.of("name", " "), + "http://url/empty?name=%20%20" + ), + Arguments.of( + Named.named("empty key", "http://url/empty"), + Map.of("", "value"), + "http://url/empty?=value" + ), + Arguments.of( + Named.named("blank key (spaces)", "http://url/empty"), + Map.of(" ", "value"), + "http://url/empty?%20%20=value" + ), + Arguments.of( + Named.named("blank key with value that needs to be encoded", "http://url/empty"), + Map.of(" ", "value1+value2"), + "http://url/empty?%20%20=value1%2Bvalue2" + ), + Arguments.of( + Named.named("blank key and value", "http://url/empty"), + Map.of(" ", " "), + "http://url/empty?%20%20=%20%20" + ), + Arguments.of( + Named.named("fragment with query params", "http://url#frag"), + Map.of("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.emptyMap(), + "http://url#frag" + ), + Arguments.of( + Named.named("multiple query params (ordered)", "http://url/api"), + orderedMapOf( + entry("param1", "value1"), + entry("param2", "value2"), + entry("param3", "value3") + ), + "http://url/api?param1=value1¶m2=value2¶m3=value3" + ), + Arguments.of( + Named.named("hash (#) in value", "http://url/api"), + Map.of("color", "#ff0000"), + "http://url/api?color=%23ff0000" + ), + Arguments.of( + Named.named("question mark (?) in value", "http://url/api"), + Map.of("query", "what?"), + "http://url/api?query=what%3F" + ), + Arguments.of( + Named.named("percent sign (%) in value", "http://url/api"), + Map.of("discount", "50%"), + "http://url/api?discount=50%25" + ), + Arguments.of( + Named.named("already encoded string - double encoding", "http://url/api"), + Map.of("encoded", "%20"), + "http://url/api?encoded=%2520" + ), + Arguments.of( + Named.named("URL with port number", "http://localhost:8080/api/v1/data"), + Map.of("key", "value"), + "http://localhost:8080/api/v1/data?key=value" + ), + Arguments.of( + Named.named("URL with userinfo (auth)", "http://user:password@hostname/path"), + Map.of("secure", "true"), + "http://user:password@hostname/path?secure=true" + ), + Arguments.of( + Named.named("IPv4 address in URL", "http://192.168.1.100:9090/endpoint"), + Map.of("ip", "test"), + "http://192.168.1.100:9090/endpoint?ip=test" + ), + Arguments.of( + Named.named("IPv6 address in URL", "http://[::1]:8080/api"), + Map.of("ipv6", "true"), + "http://[::1]:8080/api?ipv6=true" + ), + Arguments.of( + Named.named("HTTPS protocol", "https://secure.example.com/api"), + Map.of("token", "abc123"), + "https://secure.example.com/api?token=abc123" + ), + Arguments.of( + Named.named("pipe (|) in value", "http://url/api"), + Map.of("filter", "a|b|c"), + "http://url/api?filter=a%7Cb%7Cc" + ), + Arguments.of( + Named.named("caret (^) and backtick (`) in value", "http://url/api"), + Map.of("special", "a^b`c"), + "http://url/api?special=a%5Eb%60c" + ), + Arguments.of( + Named.named("tab character in value", "http://url/api"), + Map.of("data", "col1\tcol2\tcol3"), + "http://url/api?data=col1%09col2%09col3" + ), + Arguments.of( + Named.named("CRLF in value", "http://url/api"), + Map.of("text", "line1\r\nline2"), + "http://url/api?text=line1%0D%0Aline2" + ), + Arguments.of( + Named.named("square brackets in value", "http://url/api"), + Map.of("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"), + Map.of("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"), + Map.of("html", ""), + "http://url/api?html=%3Cscript%3Ealert%28%27xss%27%29%3C%2Fscript%3E" + ), + Arguments.of( + Named.named("backslash in value", "http://url/api"), + Map.of("path", "C:\\Users\\test"), + "http://url/api?path=C%3A%5CUsers%5Ctest" + ), + Arguments.of( + Named.named("at sign (@) in value", "http://url/api"), + Map.of("contact", "user@domain.com"), + "http://url/api?contact=user%40domain.com" + ), + Arguments.of( + Named.named("URL as value", "http://url/api"), + Map.of("redirect", "https://example.com/path?foo=bar"), + "http://url/api?redirect=https%3A%2F%2Fexample.com%2Fpath%3Ffoo%3Dbar" + ), + Arguments.of( + Named.named("empty map (not null)", "http://url/api"), + Collections.emptyMap(), + "http://url/api" + ), + Arguments.of( + Named.named("underscore and hyphen in key/value", "http://url/api"), + Map.of("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"), + Map.of("version.major", "1.2.3"), + "http://url/api?version.major=1.2.3" + ), + Arguments.of( + Named.named("multiple reserved chars combined", "http://url/api"), + Map.of("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/"), + Map.of("param", "value"), + "http://url/api/?param=value" + ), + Arguments.of( + Named.named("long value (1000 chars)", "http://url/api"), + Map.of("long", "a".repeat(1000)), + "http://url/api?long=" + "a".repeat(1000) + ), + Arguments.of( + Named.named("Chinese characters in value", "http://url/api"), + Map.of("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"), + Map.of("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"), + Map.of("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"), + Map.of("additional", "data"), + "http://url/api?existing=param&additional=data#section" + ), + Arguments.of( + Named.named("null byte in value", "http://url/api"), + Map.of("data", "before\u0000after"), + "http://url/api?data=before%00after" + ), + Arguments.of( + Named.named("form feed and vertical tab in value", "http://url/api"), + Map.of("whitespace", "a\fb\u000Bc"), + "http://url/api?whitespace=a%0Cb%0Bc" + ), + Arguments.of( + Named.named("tilde (unreserved, not encoded)", "http://url/api"), + Map.of("pattern", "~user"), + "http://url/api?pattern=~user" + ), + Arguments.of( + Named.named("asterisk in value", "http://url/api"), + Map.of("wildcard", "*.txt"), + "http://url/api?wildcard=%2A.txt" + ), + Arguments.of( + Named.named("curly braces in key", "http://url/api"), + Map.of("{key}", "value"), + "http://url/api?%7Bkey%7D=value" + ), + Arguments.of( + Named.named("curly braces in value", "http://url/api"), + Map.of("data", "{value}"), + "http://url/api?data=%7Bvalue%7D" + ), + Arguments.of( + Named.named("curly braces in both key and value", "http://url/api"), + Map.of("{param}", "{data}"), + "http://url/api?%7Bparam%7D=%7Bdata%7D" + ) ); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java index 9c3121f790..8c3654bdc5 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java @@ -25,6 +25,7 @@ import org.apache.http.impl.bootstrap.ServerBootstrap; import org.apache.http.protocol.HttpContext; import org.apache.http.protocol.HttpRequestHandler; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.provider.Arguments; @@ -43,24 +44,34 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.exception.DataValidationException; import java.io.IOException; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { + private RuleNode ruleNode; + @Spy private TbRestApiCallNode restNode; @@ -94,6 +105,14 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { } } + @BeforeEach + public void setup() { + ruleNode = new RuleNode(); + ruleNode.setId(ruleNodeId); + ruleNode.setName("Test REST API call node"); + lenient().when(ctx.getSelf()).thenReturn(ruleNode); + } + @AfterEach public void teardown() { if (server != null) { @@ -101,6 +120,101 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { } } + @Test + public void shouldNotAllowNullQueryParamValues() { + // GIVEN + var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setUseNewEncoding(true); + var params = new HashMap(); + params.put("key", null); + config.setQueryParams(params); + + // 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 values must be non-null"); + } + + @Test + public void shouldAllowOnlyNullQueryParamsIfOldEncodingIsUsed() { + // GIVEN + var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setUseNewEncoding(false); + config.setQueryParams(Map.of("key", "value")); + + // WHEN-THEN + assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasRootCauseInstanceOf(DataValidationException.class) + .hasRootCauseMessage("'" + ruleNode.getName() + "' node configuration is invalid: query parameters must be null if old encoding is used") + .matches(e -> ((TbNodeException) e).isUnrecoverable()); + } + + @Test + public void shouldNotAllowNullQueryParamsIfNewEncodingIsUsed() { + // GIVEN + var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setUseNewEncoding(true); + config.setQueryParams(null); + + // WHEN-THEN + assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasRootCauseInstanceOf(DataValidationException.class) + .hasRootCauseMessage("'" + ruleNode.getName() + "' node configuration is invalid: query parameters must be non-null if new encoding is used") + .matches(e -> ((TbNodeException) e).isUnrecoverable()); + } + + @Test + public void shouldUseNewEncodingForNewNodesByDefault() { + // GIVEN-WHEN + var defaultConfig = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + + // THEN + assertTrue(defaultConfig.isUseNewEncoding()); + assertEquals(Collections.emptyMap(), defaultConfig.getQueryParams()); + } + + @Test + public void shouldDefaultToOldEncodingForLegacyConfigs() { + // GIVEN + String configJson = """ + { + "restEndpointUrlPattern": "http://url?param=value", + "requestMethod": "GET", + "useSimpleClientHttpFactory": false, + "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", + "X-Authorization": "Bearer eyJhbGciOi...." + }, + "credentials": { + "type": "anonymous" + }, + "maxInMemoryBufferSizeInKb": 256 + }"""; + + // WHEN + var config = JacksonUtil.fromString(configJson, TbRestApiCallNodeConfiguration.class); + + // THEN + assertFalse(config.isUseNewEncoding()); + assertNull(config.getQueryParams()); + } + @Test public void deleteRequestWithoutBody() throws IOException, InterruptedException { final CountDownLatch latch = new CountDownLatch(1); From e0fbbf6b4e32ded0cf8f57cd1b389d32e75da748 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 6 Jan 2026 15:10:16 +0200 Subject: [PATCH 02/15] REST API call node: support duplicate query param names --- .../common/data/util/CollectionsUtil.java | 10 -- .../rule/engine/rest/QueryParam.java | 23 +++ .../rule/engine/rest/TbHttpClient.java | 27 ++-- .../rest/TbRestApiCallNodeConfiguration.java | 6 +- .../rule/engine/rest/TbHttpClientTest.java | 146 +++++++++--------- .../engine/rest/TbRestApiCallNodeTest.java | 27 +++- 6 files changed, 136 insertions(+), 103 deletions(-) create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/QueryParam.java diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java index bdc4430387..082be9b71f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java @@ -18,7 +18,6 @@ package org.thingsboard.server.common.data.util; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -73,15 +72,6 @@ public class CollectionsUtil { return map; } - @SafeVarargs - public static LinkedHashMap orderedMapOf(Map.Entry... entries) { - LinkedHashMap map = new LinkedHashMap<>(); - for (Map.Entry entry : entries) { - map.put(entry.getKey(), entry.getValue()); - } - return map; - } - public static boolean emptyOrContains(Collection collection, V element) { return isEmpty(collection) || collection.contains(element); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/QueryParam.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/QueryParam.java new file mode 100644 index 0000000000..2a72b3a88a --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/QueryParam.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2025 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.rule.engine.rest; + +import jakarta.validation.constraints.NotNull; + +record QueryParam( + @NotNull(message = "query parameter names must be non-null") String name, + @NotNull(message = "query parameter values must be non-null") String value +) {} 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 a16b3f5fb5..0d5a78810b 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 @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.rest; -import com.google.common.collect.Maps; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.handler.ssl.SslContext; @@ -217,14 +216,15 @@ public class TbHttpClient { String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), msg); - Map processedQueryParams; + List processedQueryParams; if (config.isUseNewEncoding()) { - processedQueryParams = Maps.newHashMapWithExpectedSize(config.getQueryParams().size()); - config.getQueryParams().forEach((name, value) -> { - var processedParamName = TbNodeUtils.processPattern(name, msg); - var processedParamValue = TbNodeUtils.processPattern(value, msg); - processedQueryParams.put(processedParamName, processedParamValue); - }); + processedQueryParams = config.getQueryParams().stream() + .map(param -> { + var processedParamName = TbNodeUtils.processPattern(param.name(), msg); + var processedParamValue = TbNodeUtils.processPattern(param.value(), msg); + return new QueryParam(processedParamName, processedParamValue); + }) + .toList(); } else { processedQueryParams = null; } @@ -278,7 +278,7 @@ public class TbHttpClient { return origin; } - public URI buildEncodedUri(String endpointUrl, Map queryParams) { + public URI buildEncodedUri(String endpointUrl, List queryParams) { if (endpointUrl == null) { throw new RuntimeException("Url string cannot be null!"); } @@ -306,16 +306,15 @@ public class TbHttpClient { return uri; } - private URI buildEncodedUriNew(String endpointUrl, Map queryParams) { + private URI buildEncodedUriNew(String endpointUrl, List queryParams) { try { URIBuilder builder = new URIBuilder(endpointUrl); - queryParams.forEach(builder::addParameter); + queryParams.forEach(param -> builder.addParameter(param.name(), param.value())); return builder.build(); } catch (URISyntaxException e) { throw new IllegalArgumentException(""" - Invalid REST endpoint URL: '%s'. The URL must be valid and properly encoded. - If the path contains special characters (e.g., spaces, braces), they must be percent-encoded (e.g., '/my file/' should be '/my%%20file/', '/path/{id}/' should be '/path/%%7Bid%%7D/'). - Query parameters should be configured separately and will be encoded automatically.""".formatted(endpointUrl), e); + Invalid request URL: '%s'. The URL must be valid and properly encoded. + If URL contains special characters (e.g., spaces), they must be percent-encoded (e.g., '/my file/' should be '/my%%20file/').""".formatted(endpointUrl), e); } } 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 c896dea324..cd12eb5211 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 @@ -17,6 +17,7 @@ package org.thingsboard.rule.engine.rest; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.Valid; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -27,6 +28,7 @@ import org.thingsboard.rule.engine.credentials.AnonymousCredentials; import org.thingsboard.rule.engine.credentials.ClientCredentials; import java.util.Collections; +import java.util.List; import java.util.Map; @JsonIgnoreProperties(ignoreUnknown = true) @@ -36,7 +38,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration queryParams; + private List<@NotNull @Valid QueryParam> queryParams; private Map headers; private boolean useSimpleClientHttpFactory; private int readTimeoutMs; @@ -78,7 +80,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration queryParams, String expectedEncodedUrl) { + public void testQueryParamsEncoding(String endpointUrl, List queryParams, String expectedEncodedUrl) { // GIVEN Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod(); @@ -253,42 +251,42 @@ public class TbHttpClientTest { return Stream.of( Arguments.of( Named.named("ISO 8601 date-time in value", "http://somecompany/api/data/fetch"), - Map.of("ts", "2016-08-01T09:06:06.0+02:00"), + List.of(new QueryParam("ts", "2016-08-01T09:06:06.0+02:00")), "http://somecompany/api/data/fetch?ts=2016-08-01T09%3A06%3A06.0%2B02%3A00" ), Arguments.of( Named.named("email with plus sign in value", "http://localhost:8080/api/user/sendActivationMail"), - Map.of("email", "someperson+test1289@thingsboard.io"), + List.of(new QueryParam("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"), - Map.of("q", "a + b"), + List.of(new QueryParam("q", "a + b")), "http://url/api?q=a%20%2B%20b" ), Arguments.of( Named.named("colon in value", "http://url/api"), - Map.of("time", "12:00"), + List.of(new QueryParam("time", "12:00")), "http://url/api?time=12%3A00" ), Arguments.of( Named.named("slash in value", "http://url"), - Map.of("ref", "/home/user"), + List.of(new QueryParam("ref", "/home/user")), "http://url?ref=%2Fhome%2Fuser" ), Arguments.of( Named.named("comma and semicolon in value", "http://url"), - Map.of("l", "a,b;c"), + List.of(new QueryParam("l", "a,b;c")), "http://url?l=a%2Cb%3Bc" ), Arguments.of( Named.named("ampersand and equals in value", "http://url"), - Map.of("q", "key1=value1&key2=value2"), + List.of(new QueryParam("q", "key1=value1&key2=value2")), "http://url?q=key1%3Dvalue1%26key2%3Dvalue2" ), Arguments.of( Named.named("JSON in value", "http://url"), - Map.of("json", """ + List.of(new QueryParam("json", """ { "string": "hello", "integer": 42, @@ -305,245 +303,253 @@ public class TbHttpClientTest { "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"), - orderedMapOf( - entry("nom", "Le Goût Moderne"), - entry("назва", "У Миколи \uD83D\uDE0B") + List.of( + new QueryParam("nom", "Le Goût Moderne"), + new QueryParam("назва", "У Миколи \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"), - Map.of("name", ""), + List.of(new QueryParam("name", "")), "http://url/empty?name=" ), Arguments.of( Named.named("blank value (spaces)", "http://url/empty"), - Map.of("name", " "), + List.of(new QueryParam("name", " ")), "http://url/empty?name=%20%20" ), Arguments.of( Named.named("empty key", "http://url/empty"), - Map.of("", "value"), + List.of(new QueryParam("", "value")), "http://url/empty?=value" ), Arguments.of( Named.named("blank key (spaces)", "http://url/empty"), - Map.of(" ", "value"), + List.of(new QueryParam(" ", "value")), "http://url/empty?%20%20=value" ), Arguments.of( Named.named("blank key with value that needs to be encoded", "http://url/empty"), - Map.of(" ", "value1+value2"), + List.of(new QueryParam(" ", "value1+value2")), "http://url/empty?%20%20=value1%2Bvalue2" ), Arguments.of( Named.named("blank key and value", "http://url/empty"), - Map.of(" ", " "), + List.of(new QueryParam(" ", " ")), "http://url/empty?%20%20=%20%20" ), Arguments.of( Named.named("fragment with query params", "http://url#frag"), - Map.of("docs", "مستندات عقدة القاعدة\n"), + List.of(new QueryParam("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.emptyMap(), + Collections.emptyList(), "http://url#frag" ), Arguments.of( Named.named("multiple query params (ordered)", "http://url/api"), - orderedMapOf( - entry("param1", "value1"), - entry("param2", "value2"), - entry("param3", "value3") + List.of( + new QueryParam("param1", "value1"), + new QueryParam("param2", "value2"), + new QueryParam("param3", "value3") ), "http://url/api?param1=value1¶m2=value2¶m3=value3" ), Arguments.of( Named.named("hash (#) in value", "http://url/api"), - Map.of("color", "#ff0000"), + List.of(new QueryParam("color", "#ff0000")), "http://url/api?color=%23ff0000" ), Arguments.of( Named.named("question mark (?) in value", "http://url/api"), - Map.of("query", "what?"), + List.of(new QueryParam("query", "what?")), "http://url/api?query=what%3F" ), Arguments.of( Named.named("percent sign (%) in value", "http://url/api"), - Map.of("discount", "50%"), + List.of(new QueryParam("discount", "50%")), "http://url/api?discount=50%25" ), Arguments.of( Named.named("already encoded string - double encoding", "http://url/api"), - Map.of("encoded", "%20"), + List.of(new QueryParam("encoded", "%20")), "http://url/api?encoded=%2520" ), Arguments.of( Named.named("URL with port number", "http://localhost:8080/api/v1/data"), - Map.of("key", "value"), + List.of(new QueryParam("key", "value")), "http://localhost:8080/api/v1/data?key=value" ), Arguments.of( Named.named("URL with userinfo (auth)", "http://user:password@hostname/path"), - Map.of("secure", "true"), + List.of(new QueryParam("secure", "true")), "http://user:password@hostname/path?secure=true" ), Arguments.of( Named.named("IPv4 address in URL", "http://192.168.1.100:9090/endpoint"), - Map.of("ip", "test"), + List.of(new QueryParam("ip", "test")), "http://192.168.1.100:9090/endpoint?ip=test" ), Arguments.of( Named.named("IPv6 address in URL", "http://[::1]:8080/api"), - Map.of("ipv6", "true"), + List.of(new QueryParam("ipv6", "true")), "http://[::1]:8080/api?ipv6=true" ), Arguments.of( Named.named("HTTPS protocol", "https://secure.example.com/api"), - Map.of("token", "abc123"), + List.of(new QueryParam("token", "abc123")), "https://secure.example.com/api?token=abc123" ), Arguments.of( Named.named("pipe (|) in value", "http://url/api"), - Map.of("filter", "a|b|c"), + List.of(new QueryParam("filter", "a|b|c")), "http://url/api?filter=a%7Cb%7Cc" ), Arguments.of( Named.named("caret (^) and backtick (`) in value", "http://url/api"), - Map.of("special", "a^b`c"), + List.of(new QueryParam("special", "a^b`c")), "http://url/api?special=a%5Eb%60c" ), Arguments.of( Named.named("tab character in value", "http://url/api"), - Map.of("data", "col1\tcol2\tcol3"), + List.of(new QueryParam("data", "col1\tcol2\tcol3")), "http://url/api?data=col1%09col2%09col3" ), Arguments.of( Named.named("CRLF in value", "http://url/api"), - Map.of("text", "line1\r\nline2"), + List.of(new QueryParam("text", "line1\r\nline2")), "http://url/api?text=line1%0D%0Aline2" ), Arguments.of( Named.named("square brackets in value", "http://url/api"), - Map.of("array", "[1,2,3]"), + List.of(new QueryParam("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"), - Map.of("quoted", "He said \"Hello\" and 'Hi'"), + List.of(new QueryParam("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"), - Map.of("html", ""), + List.of(new QueryParam("html", "")), "http://url/api?html=%3Cscript%3Ealert%28%27xss%27%29%3C%2Fscript%3E" ), Arguments.of( Named.named("backslash in value", "http://url/api"), - Map.of("path", "C:\\Users\\test"), + List.of(new QueryParam("path", "C:\\Users\\test")), "http://url/api?path=C%3A%5CUsers%5Ctest" ), Arguments.of( Named.named("at sign (@) in value", "http://url/api"), - Map.of("contact", "user@domain.com"), + List.of(new QueryParam("contact", "user@domain.com")), "http://url/api?contact=user%40domain.com" ), Arguments.of( Named.named("URL as value", "http://url/api"), - Map.of("redirect", "https://example.com/path?foo=bar"), + List.of(new QueryParam("redirect", "https://example.com/path?foo=bar")), "http://url/api?redirect=https%3A%2F%2Fexample.com%2Fpath%3Ffoo%3Dbar" ), Arguments.of( - Named.named("empty map (not null)", "http://url/api"), - Collections.emptyMap(), + 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"), - Map.of("my-key_name", "my-value_data"), + List.of(new QueryParam("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"), - Map.of("version.major", "1.2.3"), + List.of(new QueryParam("version.major", "1.2.3")), "http://url/api?version.major=1.2.3" ), Arguments.of( Named.named("multiple reserved chars combined", "http://url/api"), - Map.of("complex", "a=1&b=2#section?query"), + List.of(new QueryParam("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/"), - Map.of("param", "value"), + List.of(new QueryParam("param", "value")), "http://url/api/?param=value" ), Arguments.of( Named.named("long value (1000 chars)", "http://url/api"), - Map.of("long", "a".repeat(1000)), + List.of(new QueryParam("long", "a".repeat(1000))), "http://url/api?long=" + "a".repeat(1000) ), Arguments.of( Named.named("Chinese characters in value", "http://url/api"), - Map.of("greeting", "你好世界"), + List.of(new QueryParam("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"), - Map.of("text", "こんにちは"), + List.of(new QueryParam("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"), - Map.of("new", "value"), + List.of(new QueryParam("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"), - Map.of("additional", "data"), + List.of(new QueryParam("additional", "data")), "http://url/api?existing=param&additional=data#section" ), Arguments.of( Named.named("null byte in value", "http://url/api"), - Map.of("data", "before\u0000after"), + List.of(new QueryParam("data", "before\u0000after")), "http://url/api?data=before%00after" ), Arguments.of( Named.named("form feed and vertical tab in value", "http://url/api"), - Map.of("whitespace", "a\fb\u000Bc"), + List.of(new QueryParam("whitespace", "a\fb\u000Bc")), "http://url/api?whitespace=a%0Cb%0Bc" ), Arguments.of( Named.named("tilde (unreserved, not encoded)", "http://url/api"), - Map.of("pattern", "~user"), + List.of(new QueryParam("pattern", "~user")), "http://url/api?pattern=~user" ), Arguments.of( Named.named("asterisk in value", "http://url/api"), - Map.of("wildcard", "*.txt"), + List.of(new QueryParam("wildcard", "*.txt")), "http://url/api?wildcard=%2A.txt" ), Arguments.of( Named.named("curly braces in key", "http://url/api"), - Map.of("{key}", "value"), + List.of(new QueryParam("{key}", "value")), "http://url/api?%7Bkey%7D=value" ), Arguments.of( Named.named("curly braces in value", "http://url/api"), - Map.of("data", "{value}"), + List.of(new QueryParam("data", "{value}")), "http://url/api?data=%7Bvalue%7D" ), Arguments.of( Named.named("curly braces in both key and value", "http://url/api"), - Map.of("{param}", "{data}"), + List.of(new QueryParam("{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 QueryParam("test", "a+b"), + new QueryParam("test", "b c") + ), + "http://url/api?test=a%2Bb&test=b%20c" ) ); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java index 8c3654bdc5..39d9e0d585 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java @@ -52,8 +52,7 @@ import org.thingsboard.server.dao.exception.DataValidationException; import java.io.IOException; import java.util.Collections; -import java.util.HashMap; -import java.util.Map; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; @@ -120,14 +119,28 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { } } + @Test + public void shouldNotAllowNullQueryParamNames() { + // GIVEN + var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setUseNewEncoding(true); + config.setQueryParams(List.of(new QueryParam(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 must be non-null"); + } + @Test public void shouldNotAllowNullQueryParamValues() { // GIVEN var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); config.setUseNewEncoding(true); - var params = new HashMap(); - params.put("key", null); - config.setQueryParams(params); + config.setQueryParams(List.of(new QueryParam("key", null))); // WHEN-THEN assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) @@ -143,7 +156,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { // GIVEN var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); config.setUseNewEncoding(false); - config.setQueryParams(Map.of("key", "value")); + config.setQueryParams(List.of(new QueryParam("key", "value"))); // WHEN-THEN assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) @@ -175,7 +188,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { // THEN assertTrue(defaultConfig.isUseNewEncoding()); - assertEquals(Collections.emptyMap(), defaultConfig.getQueryParams()); + assertEquals(Collections.emptyList(), defaultConfig.getQueryParams()); } @Test From df795e252b7d0bac6948a062240c7205139a9bb1 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Wed, 7 Jan 2026 11:59:46 +0200 Subject: [PATCH 03/15] REST API call node: refactor to avoid boolean flag --- .../rule/engine/rest/TbHttpClient.java | 12 +++---- .../rest/TbRestApiCallNodeConfiguration.java | 22 ------------ .../rule/engine/rest/TbHttpClientTest.java | 2 -- .../engine/rest/TbRestApiCallNodeTest.java | 35 ++++++------------- 4 files changed, 16 insertions(+), 55 deletions(-) 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 0d5a78810b..e9476157aa 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 @@ -217,7 +217,7 @@ public class TbHttpClient { String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), msg); List processedQueryParams; - if (config.isUseNewEncoding()) { + if (config.getQueryParams() != null) { processedQueryParams = config.getQueryParams().stream() .map(param -> { var processedParamName = TbNodeUtils.processPattern(param.name(), msg); @@ -312,9 +312,9 @@ public class TbHttpClient { queryParams.forEach(param -> builder.addParameter(param.name(), param.value())); return builder.build(); } catch (URISyntaxException e) { - throw new IllegalArgumentException(""" - Invalid request URL: '%s'. The URL must be valid and properly encoded. - If URL contains special characters (e.g., spaces), they must be percent-encoded (e.g., '/my file/' should be '/my%%20file/').""".formatted(endpointUrl), 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); } } @@ -327,10 +327,10 @@ public class TbHttpClient { * 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 map, + * Use {@link #buildEncodedUriNew} with a separate query params list, * where values are encoded exactly once: {@code +} → {@code %2B}, {@code @} → {@code %40}. */ - @Deprecated(since = "4.2.1.2", forRemoval = true) // fixme: remove in major 5.0 + @Deprecated(since = "4.2.1.2", forRemoval = true) private URI buildEncodedUriLegacy(String endpointUrl) { return UriComponentsBuilder.fromUriString(endpointUrl).build().encode().toUri(); } 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 cd12eb5211..d319d15157 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,10 +15,8 @@ */ package org.thingsboard.rule.engine.rest; -import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.validation.Valid; -import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotNull; import lombok.Data; import org.springframework.http.HttpHeaders; @@ -37,7 +35,6 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration queryParams; private Map headers; private boolean useSimpleClientHttpFactory; @@ -55,31 +52,12 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) - .isInstanceOf(TbNodeException.class) - .hasRootCauseInstanceOf(DataValidationException.class) - .hasRootCauseMessage("'" + ruleNode.getName() + "' node configuration is invalid: query parameters must be null if old encoding is used") - .matches(e -> ((TbNodeException) e).isUnrecoverable()); - } - - @Test - public void shouldNotAllowNullQueryParamsIfNewEncodingIsUsed() { - // GIVEN - var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); - config.setUseNewEncoding(true); - config.setQueryParams(null); + var queryParams = new ArrayList(); + queryParams.add(new QueryParam("key", "value")); + queryParams.add(null); + config.setQueryParams(queryParams); // WHEN-THEN assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) .isInstanceOf(TbNodeException.class) - .hasRootCauseInstanceOf(DataValidationException.class) - .hasRootCauseMessage("'" + ruleNode.getName() + "' node configuration is invalid: query parameters must be non-null if new encoding is used") - .matches(e -> ((TbNodeException) e).isUnrecoverable()); + .matches(e -> ((TbNodeException) e).isUnrecoverable()) + .rootCause() + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("must not be null"); } @Test @@ -187,7 +174,6 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { var defaultConfig = new TbRestApiCallNodeConfiguration().defaultConfiguration(); // THEN - assertTrue(defaultConfig.isUseNewEncoding()); assertEquals(Collections.emptyList(), defaultConfig.getQueryParams()); } @@ -224,7 +210,6 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { var config = JacksonUtil.fromString(configJson, TbRestApiCallNodeConfiguration.class); // THEN - assertFalse(config.isUseNewEncoding()); assertNull(config.getQueryParams()); } From bc322a966d30997b38b9fd9d836d9b52e501151e Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 9 Jan 2026 12:23:48 +0200 Subject: [PATCH 04/15] REST API call node: cleanup properties in node config --- .../rule/engine/rest/TbHttpClient.java | 4 -- .../rule/engine/rest/TbRestApiCallNode.java | 20 ++++++- .../rest/TbRestApiCallNodeConfiguration.java | 7 +-- .../rule/engine/rest/TbHttpClientTest.java | 1 - .../engine/rest/TbRestApiCallNodeTest.java | 55 ++++++++++++++++++- 5 files changed, 73 insertions(+), 14 deletions(-) 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 e9476157aa..05f5d547a8 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 @@ -130,10 +130,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)); 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 3e9b034693..bccbc9a08f 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 @@ -28,7 +28,9 @@ import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.dao.exception.DataValidationException; +import java.util.Iterator; import java.util.List; +import java.util.Set; import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; @@ -36,7 +38,7 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie 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." + @@ -108,6 +110,22 @@ public class TbRestApiCallNode extends TbAbstractExternalNode { ((ObjectNode) oldConfiguration).put(MAX_IN_MEMORY_BUFFER_SIZE_IN_KB, 256); } break; + 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 d319d15157..fbff14f640 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,7 +15,6 @@ */ package org.thingsboard.rule.engine.rest; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -29,15 +28,15 @@ import java.util.Collections; import java.util.List; import java.util.Map; -@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 preserver 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 @Valid QueryParam> queryParams; private Map headers; - private boolean useSimpleClientHttpFactory; private int readTimeoutMs; private int maxParallelRequestsCount; private boolean parseToPlainText; @@ -47,7 +46,6 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration Date: Fri, 9 Jan 2026 14:34:25 +0200 Subject: [PATCH 05/15] Move REST API call rule node to new UI --- .../common/credentials-config.component.html | 114 ++++----- .../common/kv-map-config-old.component.html | 78 +++--- .../common/kv-map-config-old.component.ts | 4 + .../rest-api-call-config.component.html | 227 ++++++++++-------- .../rest-api-call-config.component.scss | 30 +++ .../rest-api-call-config.component.ts | 28 ++- .../assets/locale/locale.constant-en_US.json | 9 + 7 files changed, 290 insertions(+), 200 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/credentials-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/credentials-config.component.html index 59bfa7ce9f..9a42bfda14 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/credentials-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/credentials-config.component.html @@ -15,8 +15,8 @@ limitations under the License. --> -

- +
+ rule-node-config.credentials @@ -24,7 +24,7 @@ - + rule-node-config.credentials-type -
- - - - - rule-node-config.username - - - {{ 'rule-node-config.username-required' | translate }} - - - - rule-node-config.password - - - - {{ 'rule-node-config.password-required' | translate }} - - - - -
{{ 'rule-node-config.credentials-pem-hint' | translate }}
- - - - - - - - rule-node-config.private-key-password - - - -
-
+ @if (credentialsConfigFormGroup.get('type').value !== 'anonymous') { +
+ + + rule-node-config.username + + + {{ 'rule-node-config.username-required' | translate }} + + + + rule-node-config.password + + + + {{ 'rule-node-config.password-required' | translate }} + + + + +
{{ 'rule-node-config.credentials-pem-hint' | translate }}
+ + + + + + + + rule-node-config.private-key-password + + + +
+
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html index df0d61fead..c3a1981ebc 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html @@ -16,40 +16,52 @@ -->
-
- {{ keyText | translate }} - {{ valText | translate }} - -
-
-
- - - - {{ keyRequiredText | translate }} - - - - - - {{ valRequiredText | translate }} - - - + @if (description) { +
+ } + @if (keyValsFormArray().controls.length || !emptyText) { +
+ {{ keyText | translate }} + {{ valText | translate }} +
-
+ } +
+ @for (keyValControl of keyValsFormArray().controls; track $index) { +
+ + + + {{ keyRequiredText | translate }} + + + + + + {{ valRequiredText | translate }} + + + +
+ } @empty { + @if (emptyText) { +
{{emptyText | translate}}
+ } + } + @if (hintText) { +
+ }
- - rule-node-config.endpoint-url-pattern - - - {{ 'rule-node-config.endpoint-url-pattern-required' | translate }} - - rule-node-config.general-pattern-hint - +
+
+
Request URL
+ + rule-node-config.endpoint-url-pattern + + + {{ 'rule-node-config.endpoint-url-pattern-required' | translate }} + + rule-node-config.url-hint + + + + {{ 'rule-node-config.query-params' | translate }} +
+
+ + > + + +
+
rule-node-config.request-method @@ -32,99 +51,113 @@ - - {{ 'rule-node-config.enable-proxy' | translate }} - - - {{ 'rule-node-config.use-simple-client-http-factory' | translate }} - - - {{ 'rule-node-config.parse-to-plain-text' | translate }} - -
rule-node-config.parse-to-plain-text-hint
- - {{ 'rule-node-config.ignore-request-body' | translate }} - -
- - {{ 'rule-node-config.use-system-proxy-properties' | translate }} - -
-
- - rule-node-config.proxy-scheme - - - {{ proxyScheme }} - - - - - rule-node-config.proxy-host - - - {{ 'rule-node-config.proxy-host-required' | translate }} +
+ + + {{ 'rule-node-config.headers' | translate }} + + + + + + +
+ +
+ + + {{ 'rule-node-config.advanced-settings' | translate }} + + +
+ + {{ 'rule-node-config.enable-proxy' | translate }} + +
+ + {{ 'rule-node-config.use-system-proxy-properties' | translate }} + +
+
+ + rule-node-config.proxy-host + + + {{ 'rule-node-config.proxy-host-required' | translate }} + + + + rule-node-config.proxy-port + + + {{ 'rule-node-config.proxy-port-required' | translate }} + + + {{ 'rule-node-config.proxy-port-range' | translate }} + + +
+ + rule-node-config.proxy-user + + + + rule-node-config.proxy-password + + + +
+
+
+
+ +
+ {{ 'rule-node-config.parse-to-plain-text' | translate }} +
+
+
+
+ + {{ 'rule-node-config.ignore-request-body' | translate }} + +
+ + rule-node-config.read-timeout + + rule-node-config.read-timeout-hint + + {{ 'rule-node-config.int-range' | translate }} - - rule-node-config.proxy-port - - - {{ 'rule-node-config.proxy-port-required' | translate }} + + rule-node-config.max-parallel-requests-count + + rule-node-config.max-parallel-requests-count-hint + + {{ 'rule-node-config.int-range' | translate }} + + + rule-node-config.max-response-size + + rule-node-config.max-response-size-hint - {{ 'rule-node-config.proxy-port-range' | translate }} + *ngIf="restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('min') || restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('max')"> + {{ 'rule-node-config.memory-buffer-size-range' | translate: { max: MemoryBufferSizeInKbLimit } }} -
- - rule-node-config.proxy-user - - - - rule-node-config.proxy-password - - -
+ +
- - rule-node-config.read-timeout - - rule-node-config.read-timeout-hint - - {{ 'rule-node-config.int-range' | translate }} - - - - rule-node-config.max-parallel-requests-count - - rule-node-config.max-parallel-requests-count-hint - - {{ 'rule-node-config.int-range' | translate }} - - - - rule-node-config.max-response-size - - rule-node-config.max-response-size-hint - - {{ 'rule-node-config.memory-buffer-size-range' | translate: { max: MemoryBufferSizeInKbLimit } }} - - - -
- - -
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss new file mode 100644 index 0000000000..79b42e8eaa --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2025 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; + } + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts index 483b6318d6..d08b97f7de 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts @@ -18,21 +18,24 @@ import { Component } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shared/models/rule-node.models'; import { HttpRequestType, IntLimit } from '../rule-node-config.models'; +import { isDefinedAndNotNull } from '@core/utils'; @Component({ selector: 'tb-external-node-rest-api-call-config', templateUrl: './rest-api-call-config.component.html', - styleUrls: [] + styleUrls: ['./rest-api-call-config.component.scss'] }) export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent { restApiCallConfigForm: UntypedFormGroup; - readonly proxySchemes: string[] = ['http', 'https']; readonly httpRequestTypes = Object.keys(HttpRequestType); readonly MemoryBufferSizeInKbLimit = 25000; readonly IntLimit = IntLimit; + isHeadersExpanded = false; + isQueryParamsExpanded = false; + constructor(private fb: UntypedFormBuilder) { super(); } @@ -45,12 +48,10 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent { this.restApiCallConfigForm = this.fb.group({ restEndpointUrlPattern: [configuration ? configuration.restEndpointUrlPattern : null, [Validators.required]], requestMethod: [configuration ? configuration.requestMethod : null, [Validators.required]], - useSimpleClientHttpFactory: [configuration ? configuration.useSimpleClientHttpFactory : false, []], parseToPlainText: [configuration ? configuration.parseToPlainText : false, []], ignoreRequestBody: [configuration ? configuration.ignoreRequestBody : false, []], enableProxy: [configuration ? configuration.enableProxy : false, []], useSystemProxyProperties: [configuration ? configuration.enableProxy : false, []], - proxyScheme: [configuration ? configuration.proxyHost : null, []], proxyHost: [configuration ? configuration.proxyHost : null, []], proxyPort: [configuration ? configuration.proxyPort : null, []], proxyUser: [configuration ? configuration.proxyUser :null, []], @@ -59,16 +60,22 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent { maxParallelRequestsCount: [configuration ? configuration.maxParallelRequestsCount : null, [Validators.min(0), Validators.max(IntLimit)]], headers: [configuration ? configuration.headers : null, []], credentials: [configuration ? configuration.credentials : null, []], - maxInMemoryBufferSizeInKb: [configuration ? configuration.maxInMemoryBufferSizeInKb : null, [Validators.min(1), Validators.max(this.MemoryBufferSizeInKbLimit)]] + maxInMemoryBufferSizeInKb: [configuration ? configuration.maxInMemoryBufferSizeInKb : null, [Validators.min(1), Validators.max(this.MemoryBufferSizeInKbLimit)]], + queryParams: [configuration ? configuration.queryParams : [], []], }); } protected validatorTriggers(): string[] { - return ['useSimpleClientHttpFactory', 'enableProxy', 'useSystemProxyProperties']; + return ['enableProxy', 'useSystemProxyProperties']; + } + + protected prepareInputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration { + this.isQueryParamsExpanded = configuration && isDefinedAndNotNull(configuration.queryParams) && !!Object.keys(configuration.queryParams).length; + this.isHeadersExpanded = configuration && isDefinedAndNotNull(configuration.headers) && !!Object.keys(configuration.headers).length; + return super.prepareInputConfig(configuration); } protected updateValidators(emitEvent: boolean) { - const useSimpleClientHttpFactory: boolean = this.restApiCallConfigForm.get('useSimpleClientHttpFactory').value; const enableProxy: boolean = this.restApiCallConfigForm.get('enableProxy').value; const useSystemProxyProperties: boolean = this.restApiCallConfigForm.get('useSystemProxyProperties').value; @@ -79,12 +86,7 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent { } else { this.restApiCallConfigForm.get('proxyHost').setValidators([]); this.restApiCallConfigForm.get('proxyPort').setValidators([]); - - if (useSimpleClientHttpFactory) { - this.restApiCallConfigForm.get('readTimeoutMs').setValidators([]); - } else { - this.restApiCallConfigForm.get('readTimeoutMs').setValidators([Validators.min(0), Validators.max(IntLimit)]); - } + this.restApiCallConfigForm.get('readTimeoutMs').setValidators([Validators.min(0), Validators.max(IntLimit)]); } this.restApiCallConfigForm.get('readTimeoutMs').updateValueAndValidity({emitEvent}); diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 54ea5737f7..5e30e42e97 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -5404,6 +5404,15 @@ "tell-failure-aws-lambda-hint": "Rule node forces failure of message processing if AWS Lambda function execution raises exception.", "basic-mode": "Basic", "advanced-mode": "Advanced", + "url-hint" : "Enter the request URL. Ensure special characters are percent-encoded.", + "query-params": "Query parameters", + "query-params-hint": "Additional query parameters to include in the request URL. Both names and values support templatization and are strictly (RFC 3986) encoded after template resolution.", + "query-params-empty": "No query parameters configured", + "query-params-name": "Name", + "query-params-value": "Value", + "query-params-name-required": "Name is required", + "query-params-value-required": "Value is required", + "headers-empty": "No headers configured", "save-time-series": { "processing-settings": "Processing settings", "processing-settings-hint": "Define how incoming messages are processed. Basic processing settings allow you to select preconfigured strategies, while Advanced settings allow you to select individual processing strategies for each action.", From 44a283a5a2455330624e6ca0579a6ec1b0175ff9 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Fri, 9 Jan 2026 18:00:58 +0200 Subject: [PATCH 06/15] Translation cleanup --- ui-ngx/src/assets/locale/locale.constant-en_US.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 5e30e42e97..419eaf50ac 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -4964,7 +4964,6 @@ "endpoint-url-pattern": "Endpoint URL pattern", "endpoint-url-pattern-required": "Endpoint URL pattern is required", "request-method": "Request method", - "use-simple-client-http-factory": "Use simple client HTTP factory", "ignore-request-body": "Without request body", "parse-to-plain-text": "Parse to plain text", "parse-to-plain-text-hint": "If selected, request body message payload will be transformed from JSON string to plain text, e.g. msg = \"Hello,\\t\"world\"\" will be parsed to Hello, \"world\"", @@ -5120,7 +5119,6 @@ "proxy-port-range": "Proxy port should be in a range from 1 to 65535.", "proxy-user": "Proxy user", "proxy-password": "Proxy password", - "proxy-scheme": "Proxy scheme", "numbers-to-template": "Phone Numbers To Template", "numbers-to-template-required": "Phone Numbers To Template is required", "numbers-to-template-hint": "Comma separated Phone Numbers, use ${metadataKey} for value from metadata, $[messageKey] for value from message body", From af3ef1d5cecb7859a0f0fc3349071471b7b9866e Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Mon, 12 Jan 2026 13:52:09 +0200 Subject: [PATCH 07/15] Fixed set config logic --- .../rule-node/external/rest-api-call-config.component.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts index d08b97f7de..42685f2ca7 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts @@ -75,6 +75,11 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent { return super.prepareInputConfig(configuration); } + protected prepareOutputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration { + configuration.queryParams = isDefinedAndNotNull(configuration.queryParams) ? configuration.queryParams : []; + return super.prepareOutputConfig(configuration); + } + protected updateValidators(emitEvent: boolean) { const enableProxy: boolean = this.restApiCallConfigForm.get('enableProxy').value; const useSystemProxyProperties: boolean = this.restApiCallConfigForm.get('useSystemProxyProperties').value; From 1bd9b8d287a237ab35397aa329b082556424382c Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Fri, 16 Jan 2026 15:01:18 +0200 Subject: [PATCH 08/15] Added component for query params --- .../common/common-rule-node-config.module.ts | 7 +- .../common/kv-list-config.component.ts | 89 +++++++++++++++++++ .../common/kv-map-config-old.component.ts | 6 +- .../rest-api-call-config.component.html | 9 +- .../assets/locale/locale.constant-en_US.json | 1 + 5 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/common-rule-node-config.module.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/common-rule-node-config.module.ts index c7d8ce2723..e21f0aa689 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/common-rule-node-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/common-rule-node-config.module.ts @@ -34,6 +34,7 @@ import { SelectAttributesComponent } from './select-attributes.component'; import { AlarmStatusSelectComponent } from './alarm-status-select.component'; import { ExampleHintComponent } from './example-hint.component'; import { TimeUnitInputComponent } from './time-unit-input.component'; +import { KvListConfigComponent } from '@home/components/rule-node/common/kv-list-config.component'; @NgModule({ declarations: [ @@ -52,7 +53,8 @@ import { TimeUnitInputComponent } from './time-unit-input.component'; SelectAttributesComponent, AlarmStatusSelectComponent, ExampleHintComponent, - TimeUnitInputComponent + TimeUnitInputComponent, + KvListConfigComponent ], imports: [ CommonModule, @@ -75,7 +77,8 @@ import { TimeUnitInputComponent } from './time-unit-input.component'; SelectAttributesComponent, AlarmStatusSelectComponent, ExampleHintComponent, - TimeUnitInputComponent + TimeUnitInputComponent, + KvListConfigComponent ] }) diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts new file mode 100644 index 0000000000..5284b88bce --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts @@ -0,0 +1,89 @@ +/// +/// Copyright © 2016-2025 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, Input, 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'], + 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{ + + @Input() keyProperty: string = 'key'; + + override writeValue(kvList: any): void { + const keyValsControls: Array = []; + if (Array.isArray(kvList) && kvList.length > 0) { + for (const property of kvList) { + keyValsControls.push(this.fb.group({ + key: [property[this.keyProperty], [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 { + const result = kvList.map(entry => ({ + [this.keyProperty]: entry.key, + value: entry.value + })); + this.propagateChange(result); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.ts index bd56212d4f..69fe1820eb 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.ts @@ -86,11 +86,11 @@ export class KvMapConfigOldComponent extends PageComponent implements ControlVal ngControl: NgControl; - private propagateChange = null; + public propagateChange = null; constructor(public translate: TranslateService, private injector: Injector, - private fb: FormBuilder, + protected fb: FormBuilder, private destroyRef: DestroyRef) { super(); } @@ -182,7 +182,7 @@ export class KvMapConfigOldComponent extends PageComponent implements ControlVal return null; } - private updateModel() { + protected updateModel() { const kvList: { key: string; value: string }[] = this.kvListFormGroup.get('keyVals').value; if (this.required && !kvList.length || !this.kvListFormGroup.valid) { this.propagateChange(null); diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html index b265b997ac..7a30723f9e 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html @@ -19,7 +19,7 @@
Request URL
- rule-node-config.endpoint-url-pattern + rule-node-config.url {{ 'rule-node-config.endpoint-url-pattern-required' | translate }} @@ -32,14 +32,15 @@
- > - + emptyText="rule-node-config.query-params-empty" + keyProperty="name"> +
diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 419eaf50ac..2ae1b4ff48 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -5411,6 +5411,7 @@ "query-params-name-required": "Name is required", "query-params-value-required": "Value is required", "headers-empty": "No headers configured", + "url": "URL", "save-time-series": { "processing-settings": "Processing settings", "processing-settings-hint": "Define how incoming messages are processed. Basic processing settings allow you to select preconfigured strategies, while Advanced settings allow you to select individual processing strategies for each action.", From 07fd49c080944acdea5888b75d1cccb94eb72d41 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Fri, 16 Jan 2026 17:35:32 +0200 Subject: [PATCH 09/15] Moved to key name prop in Query params --- .../rule-node/common/kv-list-config.component.ts | 12 +++--------- .../external/rest-api-call-config.component.html | 3 +-- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts index 5284b88bce..fe913815cd 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { Component, forwardRef, OnInit } from '@angular/core'; import { AbstractControl, ControlValueAccessor, @@ -44,14 +44,12 @@ import { KvMapConfigOldComponent } from '@home/components/rule-node/common/kv-ma }) export class KvListConfigComponent extends KvMapConfigOldComponent implements ControlValueAccessor, OnInit, Validator{ - @Input() keyProperty: string = 'key'; - override writeValue(kvList: any): void { const keyValsControls: Array = []; if (Array.isArray(kvList) && kvList.length > 0) { for (const property of kvList) { keyValsControls.push(this.fb.group({ - key: [property[this.keyProperty], [Validators.required]], + key: [property.key, [Validators.required]], value: [property.value, [Validators.required]] })) } @@ -79,11 +77,7 @@ export class KvListConfigComponent extends KvMapConfigOldComponent implements Co if (this.required && !kvList.length || !this.kvListFormGroup.valid) { this.propagateChange(null); } else { - const result = kvList.map(entry => ({ - [this.keyProperty]: entry.key, - value: entry.value - })); - this.propagateChange(result); + this.propagateChange(kvList); } } } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html index 7a30723f9e..2f6840639c 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html @@ -38,8 +38,7 @@ 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" - keyProperty="name"> + emptyText="rule-node-config.query-params-empty"> From 38d35f1ca8366aa728d0eefd98c280c0e15df986 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 19 Jan 2026 10:37:00 +0200 Subject: [PATCH 10/15] Fix upgrade script --- .../rule/engine/rest/TbRestApiCallNode.java | 1 - .../rule/engine/rest/TbRestApiCallNodeTest.java | 16 ++++++---------- 2 files changed, 6 insertions(+), 11 deletions(-) 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 bccbc9a08f..86dd5e576e 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 @@ -109,7 +109,6 @@ public class TbRestApiCallNode extends TbAbstractExternalNode { hasChanges = true; ((ObjectNode) oldConfiguration).put(MAX_IN_MEMORY_BUFFER_SIZE_IN_KB, 256); } - break; case 3: Set knownProperties = Set.of( "restEndpointUrlPattern", "requestMethod", "headers", diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java index 011b035e87..c51c044e18 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java @@ -345,7 +345,6 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { return Stream.of( - // config for version 2 with upgrade from version 0 Arguments.of(0, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\":\"POST\"," + "\"useSimpleClientHttpFactory\":false,\"ignoreRequestBody\":false,\"enableProxy\":false," + @@ -355,13 +354,12 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { "\"trimQueue\":null,\"maxQueueSize\":null,\"credentials\":{\"type\":\"anonymous\"},\"trimDoubleQuotes\":false}", true, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + - "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + - "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," + + "\"parseToPlainText\": false,\"ignoreRequestBody\": false," + + "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," + "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"credentials\": {\"type\": \"anonymous\"}," + "\"maxInMemoryBufferSizeInKb\": 256}"), - // config for version 2 with upgrade from version 1 Arguments.of(1, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + @@ -372,13 +370,12 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { "\"credentials\": {\"type\": \"anonymous\"}}", true, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + - "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + - "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," + + "\"parseToPlainText\": false,\"ignoreRequestBody\": false," + + "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," + "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"credentials\": {\"type\": \"anonymous\"}," + "\"maxInMemoryBufferSizeInKb\": 256}"), - // config for version 3 with upgrade from version 2 Arguments.of(2, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + @@ -388,13 +385,12 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { "\"credentials\": {\"type\": \"anonymous\"}}", true, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + - "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + - "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," + + "\"parseToPlainText\": false,\"ignoreRequestBody\": false," + + "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," + "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"credentials\": {\"type\": \"anonymous\"}," + "\"maxInMemoryBufferSizeInKb\": 256}"), - // config for version 4 with upgrade from version 3 Arguments.of(3, """ { "restEndpointUrlPattern": "http://localhost/api", From 208cbb9566e25d63dc1f9eb46a9aba4fe96381b8 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 19 Jan 2026 10:37:38 +0200 Subject: [PATCH 11/15] Remove version from deprecation annotation --- .../java/org/thingsboard/rule/engine/rest/TbHttpClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 05f5d547a8..9fb6e451e8 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 @@ -326,7 +326,7 @@ public class TbHttpClient { * Use {@link #buildEncodedUriNew} with a separate query params list, * where values are encoded exactly once: {@code +} → {@code %2B}, {@code @} → {@code %40}. */ - @Deprecated(since = "4.2.1.2", forRemoval = true) + @Deprecated(forRemoval = true) private URI buildEncodedUriLegacy(String endpointUrl) { return UriComponentsBuilder.fromUriString(endpointUrl).build().encode().toUri(); } From e3f1f7f7625f3bdeb0289650fd9ede53a91eace1 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 19 Jan 2026 10:40:25 +0200 Subject: [PATCH 12/15] Fix type --- .../rule/engine/rest/TbRestApiCallNodeConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fbff14f640..ac4d493819 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 @@ -33,7 +33,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration queryParams; private Map headers; From 0f288a137996d59408a840bb3446d0738ef85c83 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 19 Jan 2026 11:15:33 +0200 Subject: [PATCH 13/15] Refactor `QueryParam` into general-purpose `KeyValueEntry` --- .../common/data/util/KeyValueEntry.java | 18 ++++++++++++ .../rule/engine/rest/TbHttpClient.java | 2 +- .../rest/TbRestApiCallNodeConfiguration.java | 29 ++++++++++++++----- .../rule/engine/rest/TbHttpClientTest.java | 11 +++---- .../engine/rest/TbRestApiCallNodeTest.java | 13 +++++---- 5 files changed, 53 insertions(+), 20 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/util/KeyValueEntry.java 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..3e758f69aa --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/util/KeyValueEntry.java @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2025 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/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 9fb6e451e8..64b4f70b64 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 @@ -216,7 +216,7 @@ public class TbHttpClient { if (config.getQueryParams() != null) { processedQueryParams = config.getQueryParams().stream() .map(param -> { - var processedParamName = TbNodeUtils.processPattern(param.name(), msg); + var processedParamName = TbNodeUtils.processPattern(param.key(), msg); var processedParamValue = TbNodeUtils.processPattern(param.value(), msg); return new QueryParam(processedParamName, processedParamValue); }) 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 ac4d493819..bbef69ff31 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,7 +15,8 @@ */ package org.thingsboard.rule.engine.rest; -import jakarta.validation.Valid; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotNull; import lombok.Data; import org.springframework.http.HttpHeaders; @@ -23,10 +24,12 @@ 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; @Data public class TbRestApiCallNodeConfiguration implements NodeConfiguration { @@ -35,7 +38,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration queryParams; + private List<@NotNull KeyValueEntry> queryParams; private Map headers; private int readTimeoutMs; private int maxParallelRequestsCount; @@ -50,9 +53,23 @@ 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)); @@ -68,11 +85,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration("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(); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java index c51c044e18..90d4cdc47d 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java @@ -45,6 +45,7 @@ import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.util.KeyValueEntry; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; @@ -123,7 +124,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { public void shouldNotAllowNullQueryParamNames() { // GIVEN var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); - config.setQueryParams(List.of(new QueryParam(null, "value"))); + config.setQueryParams(List.of(new KeyValueEntry<>(null, "value"))); // WHEN-THEN assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) @@ -131,14 +132,14 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { .matches(e -> ((TbNodeException) e).isUnrecoverable()) .rootCause() .isInstanceOf(DataValidationException.class) - .hasMessageContaining("query parameter names must be non-null"); + .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 QueryParam("key", null))); + config.setQueryParams(List.of(new KeyValueEntry<>("key", null))); // WHEN-THEN assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) @@ -146,7 +147,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { .matches(e -> ((TbNodeException) e).isUnrecoverable()) .rootCause() .isInstanceOf(DataValidationException.class) - .hasMessageContaining("query parameter values must be non-null"); + .hasMessageContaining("query parameter names and values must be non-null"); } @Test @@ -154,8 +155,8 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { // GIVEN var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); - var queryParams = new ArrayList(); - queryParams.add(new QueryParam("key", "value")); + var queryParams = new ArrayList>(); + queryParams.add(new KeyValueEntry<>("key", "value")); queryParams.add(null); config.setQueryParams(queryParams); From 0ea01d61af763404da95cd0927283488a9a42a79 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 19 Jan 2026 12:45:54 +0200 Subject: [PATCH 14/15] Cleanup remnants of `QueryParam` --- .../rule/engine/rest/QueryParam.java | 23 ---- .../rule/engine/rest/TbHttpClient.java | 11 +- .../rule/engine/rest/TbHttpClientTest.java | 116 +++++++++--------- 3 files changed, 64 insertions(+), 86 deletions(-) delete mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/QueryParam.java diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/QueryParam.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/QueryParam.java deleted file mode 100644 index 3eeecc0560..0000000000 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/QueryParam.java +++ /dev/null @@ -1,23 +0,0 @@ -/** - * 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.rule.engine.rest; - -import jakarta.validation.constraints.NotNull; - -record QueryParam( - @NotNull(message = "query parameter names must be non-null") String name, - @NotNull(message = "query parameter values must be non-null") String value -) {} 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 177c6811eb..fb0fbd9f35 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 @@ -40,6 +40,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; @@ -212,13 +213,13 @@ public class TbHttpClient { String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), msg); - List processedQueryParams; + 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 QueryParam(processedParamName, processedParamValue); + return new KeyValueEntry<>(processedParamName, processedParamValue); }) .toList(); } else { @@ -274,7 +275,7 @@ public class TbHttpClient { return origin; } - public URI buildEncodedUri(String endpointUrl, List queryParams) { + public URI buildEncodedUri(String endpointUrl, List> queryParams) { if (endpointUrl == null) { throw new RuntimeException("Url string cannot be null!"); } @@ -302,10 +303,10 @@ public class TbHttpClient { return uri; } - private URI buildEncodedUriNew(String endpointUrl, List queryParams) { + private URI buildEncodedUriNew(String endpointUrl, List> queryParams) { try { URIBuilder builder = new URIBuilder(endpointUrl); - queryParams.forEach(param -> builder.addParameter(param.name(), param.value())); + queryParams.forEach(param -> builder.addParameter(param.key(), param.value())); return builder.build(); } catch (URISyntaxException e) { throw new IllegalArgumentException( diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java index eb3b9802aa..9abe3d28a9 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java @@ -234,7 +234,7 @@ public class TbHttpClientTest { @ParameterizedTest(name = "{0}") @MethodSource - public void testQueryParamsEncoding(String endpointUrl, List queryParams, String expectedEncodedUrl) { + public void testQueryParamsEncoding(String endpointUrl, List> queryParams, String expectedEncodedUrl) { // GIVEN Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod(); @@ -249,42 +249,42 @@ public class TbHttpClientTest { return Stream.of( Arguments.of( Named.named("ISO 8601 date-time in value", "http://somecompany/api/data/fetch"), - List.of(new QueryParam("ts", "2016-08-01T09:06:06.0+02:00")), + List.of(new KeyValueEntry<>("ts", "2016-08-01T09:06:06.0+02:00")), "http://somecompany/api/data/fetch?ts=2016-08-01T09%3A06%3A06.0%2B02%3A00" ), Arguments.of( Named.named("email with plus sign in value", "http://localhost:8080/api/user/sendActivationMail"), - List.of(new QueryParam("email", "someperson+test1289@thingsboard.io")), + 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 QueryParam("q", "a + b")), + 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 QueryParam("time", "12:00")), + 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 QueryParam("ref", "/home/user")), + 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 QueryParam("l", "a,b;c")), + 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 QueryParam("q", "key1=value1&key2=value2")), + 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 QueryParam("json", """ + List.of(new KeyValueEntry<>("json", """ { "string": "hello", "integer": 42, @@ -307,44 +307,44 @@ public class TbHttpClientTest { Arguments.of( Named.named("UTF-8 in query", "http://url/cafes"), List.of( - new QueryParam("nom", "Le Goût Moderne"), - new QueryParam("назва", "У Миколи \uD83D\uDE0B") + 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 QueryParam("name", "")), + List.of(new KeyValueEntry<>("name", "")), "http://url/empty?name=" ), Arguments.of( Named.named("blank value (spaces)", "http://url/empty"), - List.of(new QueryParam("name", " ")), + List.of(new KeyValueEntry<>("name", " ")), "http://url/empty?name=%20%20" ), Arguments.of( Named.named("empty key", "http://url/empty"), - List.of(new QueryParam("", "value")), + List.of(new KeyValueEntry<>("", "value")), "http://url/empty?=value" ), Arguments.of( Named.named("blank key (spaces)", "http://url/empty"), - List.of(new QueryParam(" ", "value")), + 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 QueryParam(" ", "value1+value2")), + 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 QueryParam(" ", " ")), + 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 QueryParam("docs", "مستندات عقدة القاعدة\n")), + 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( @@ -355,105 +355,105 @@ public class TbHttpClientTest { Arguments.of( Named.named("multiple query params (ordered)", "http://url/api"), List.of( - new QueryParam("param1", "value1"), - new QueryParam("param2", "value2"), - new QueryParam("param3", "value3") + new KeyValueEntry<>("param1", "value1"), + new KeyValueEntry<>("param2", "value2"), + new KeyValueEntry<>("param3", "value3") ), "http://url/api?param1=value1¶m2=value2¶m3=value3" ), Arguments.of( Named.named("hash (#) in value", "http://url/api"), - List.of(new QueryParam("color", "#ff0000")), + 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 QueryParam("query", "what?")), + 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 QueryParam("discount", "50%")), + 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 QueryParam("encoded", "%20")), + 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 QueryParam("key", "value")), + 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 QueryParam("secure", "true")), + 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 QueryParam("ip", "test")), + 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 QueryParam("ipv6", "true")), + 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 QueryParam("token", "abc123")), + 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 QueryParam("filter", "a|b|c")), + 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 QueryParam("special", "a^b`c")), + 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 QueryParam("data", "col1\tcol2\tcol3")), + 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 QueryParam("text", "line1\r\nline2")), + 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 QueryParam("array", "[1,2,3]")), + 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 QueryParam("quoted", "He said \"Hello\" and 'Hi'")), + 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 QueryParam("html", "")), + List.of(new KeyValueEntry<>("html", "")), "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 QueryParam("path", "C:\\Users\\test")), + 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 QueryParam("contact", "user@domain.com")), + 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 QueryParam("redirect", "https://example.com/path?foo=bar")), + 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( @@ -463,89 +463,89 @@ public class TbHttpClientTest { ), Arguments.of( Named.named("underscore and hyphen in key/value", "http://url/api"), - List.of(new QueryParam("my-key_name", "my-value_data")), + 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 QueryParam("version.major", "1.2.3")), + 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 QueryParam("complex", "a=1&b=2#section?query")), + 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 QueryParam("param", "value")), + 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 QueryParam("long", "a".repeat(1000))), + 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 QueryParam("greeting", "你好世界")), + 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 QueryParam("text", "こんにちは")), + 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 QueryParam("new", "value")), + 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 QueryParam("additional", "data")), + 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 QueryParam("data", "before\u0000after")), + 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 QueryParam("whitespace", "a\fb\u000Bc")), + 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 QueryParam("pattern", "~user")), + List.of(new KeyValueEntry<>("pattern", "~user")), "http://url/api?pattern=~user" ), Arguments.of( Named.named("asterisk in value", "http://url/api"), - List.of(new QueryParam("wildcard", "*.txt")), + 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 QueryParam("{key}", "value")), + 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 QueryParam("data", "{value}")), + 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 QueryParam("{param}", "{data}")), + 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 QueryParam("test", "a+b"), - new QueryParam("test", "b c") + new KeyValueEntry<>("test", "a+b"), + new KeyValueEntry<>("test", "b c") ), "http://url/api?test=a%2Bb&test=b%20c" ) From 2bf4ac32708a2efa527fc1587e9a49fc554fa0d7 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 9 Feb 2026 15:27:15 +0200 Subject: [PATCH 15/15] fix: address PR review comments for REST API call node - Fix JSON formatting inconsistency in locale file (extra space before colon) - Add i18n for hard-coded "Request URL" panel title - Fix missing semicolon and space in KvListConfigComponent - Add standalone: false to KvListConfigComponent for NgModule compatibility - Fix misleading comment in TbRestApiCallNodeConfiguration.isValid() - Always show readTimeoutMs field regardless of proxy settings Co-Authored-By: Claude Opus 4.6 --- .../rule/engine/rest/TbRestApiCallNodeConfiguration.java | 2 +- .../components/rule-node/common/kv-list-config.component.ts | 5 +++-- .../rule-node/external/rest-api-call-config.component.html | 5 ++--- ui-ngx/src/assets/locale/locale.constant-en_US.json | 3 ++- 4 files changed, 8 insertions(+), 7 deletions(-) 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 1194947c98..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 @@ -57,7 +57,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration queryParam : queryParams) { if (queryParam == null || queryParam.key() == null || queryParam.value() == null) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts index 850cbb0fac..98a5140de4 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts @@ -29,6 +29,7 @@ import { KvMapConfigOldComponent } from '@home/components/rule-node/common/kv-ma 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, @@ -42,7 +43,7 @@ import { KvMapConfigOldComponent } from '@home/components/rule-node/common/kv-ma } ] }) -export class KvListConfigComponent extends KvMapConfigOldComponent implements ControlValueAccessor, OnInit, Validator{ +export class KvListConfigComponent extends KvMapConfigOldComponent implements ControlValueAccessor, OnInit, Validator { override writeValue(kvList: any): void { const keyValsControls: Array = []; @@ -51,7 +52,7 @@ export class KvListConfigComponent extends KvMapConfigOldComponent implements Co 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}); diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html index f8dfc3b1f2..b2fdb3a14c 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html @@ -17,7 +17,7 @@ -->
-
Request URL
+
rule-node-config.request-url
rule-node-config.url @@ -129,8 +129,7 @@ {{ 'rule-node-config.ignore-request-body' | translate }}
- + rule-node-config.read-timeout rule-node-config.read-timeout-hint diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 8c53605a37..7097380bb1 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -5947,7 +5947,7 @@ "tell-failure-aws-lambda-hint": "Rule node forces failure of message processing if AWS Lambda function execution raises exception.", "basic-mode": "Basic", "advanced-mode": "Advanced", - "url-hint" : "Enter the request URL. Ensure special characters are percent-encoded.", + "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", @@ -5956,6 +5956,7 @@ "query-params-name-required": "Name is required", "query-params-value-required": "Value is required", "headers-empty": "No headers configured", + "request-url": "Request URL", "url": "URL", "save-time-series": { "processing-settings": "Processing settings",