This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 72882e7734 add volume tracking to http actions, transforms and extra 
tests, fixes #6819 (#6820)
72882e7734 is described below

commit 72882e7734a99117ed6be5ebc965b93be42710b4
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Wed Mar 18 20:23:29 2026 +0100

    add volume tracking to http actions, transforms and extra tests, fixes 
#6819 (#6820)
---
 .../hop/workflow/actions/http/ActionHttp.java      |  48 ++-
 .../actions/http/ActionHttpExecutionTest.java      | 298 +++++++++++++++++
 .../apache/hop/pipeline/transforms/http/Http.java  |  23 +-
 .../transforms/http/HttpExecutionTest.java         | 330 +++++++++++++++++++
 .../hop/pipeline/transforms/http/HttpTest.java     | 139 ++++++++
 .../hop/pipeline/transforms/httppost/HttpPost.java |  72 ++++-
 .../transforms/httppost/HttpPostExecutionTest.java | 356 +++++++++++++++++++++
 .../pipeline/transforms/httppost/HttpPostTest.java | 175 +++++++++-
 .../apache/hop/pipeline/transforms/rest/Rest.java  |  62 ++++
 .../hop/pipeline/transforms/rest/RestTest.java     |  88 +++++
 10 files changed, 1559 insertions(+), 32 deletions(-)

diff --git 
a/plugins/actions/http/src/main/java/org/apache/hop/workflow/actions/http/ActionHttp.java
 
b/plugins/actions/http/src/main/java/org/apache/hop/workflow/actions/http/ActionHttp.java
index 9f55a72b94..51773ce24c 100644
--- 
a/plugins/actions/http/src/main/java/org/apache/hop/workflow/actions/http/ActionHttp.java
+++ 
b/plugins/actions/http/src/main/java/org/apache/hop/workflow/actions/http/ActionHttp.java
@@ -43,6 +43,8 @@ import org.apache.hop.core.RowMetaAndData;
 import org.apache.hop.core.annotations.Action;
 import org.apache.hop.core.encryption.Encr;
 import org.apache.hop.core.exception.HopXmlException;
+import org.apache.hop.core.io.CountingInputStream;
+import org.apache.hop.core.io.CountingOutputStream;
 import org.apache.hop.core.row.value.ValueMetaString;
 import org.apache.hop.core.util.HttpClientManager;
 import org.apache.hop.core.util.Utils;
@@ -251,8 +253,10 @@ public class ActionHttp extends ActionBase {
 
       OutputStream outputFile = null;
       OutputStream uploadStream = null;
-      BufferedInputStream fileStream = null;
+      InputStream fileStream = null;
       InputStream input = null;
+      long bytesReadThisRow = 0L;
+      long bytesWrittenThisRow = 0L;
 
       try {
         String urlToUse = resolve(row.getString(urlFieldnameToUse, ""));
@@ -301,7 +305,7 @@ public class ActionHttp extends ActionBase {
         }
 
         // Create the output File...
-        outputFile = HopVfs.getOutputStream(realTargetFile, fileAppended);
+        outputFile = new 
CountingOutputStream(HopVfs.getOutputStream(realTargetFile, fileAppended));
 
         // Get a stream for the specified URL
         server = new URL(urlToUse);
@@ -345,14 +349,23 @@ public class ActionHttp extends ActionBase {
           }
 
           // Grab an output stream to upload data to web server
-          uploadStream = connection.getOutputStream();
-          fileStream = new BufferedInputStream(new FileInputStream(new 
File(realUploadFile)));
+          uploadStream = new 
CountingOutputStream(connection.getOutputStream());
+          fileStream =
+              new CountingInputStream(
+                  new BufferedInputStream(new FileInputStream(new 
File(realUploadFile))));
           try {
-            int c;
-            while ((c = fileStream.read()) >= 0) {
-              uploadStream.write(c);
+            byte[] buffer = new byte[8192];
+            int bytesRead;
+            while ((bytesRead = fileStream.read(buffer)) >= 0) {
+              uploadStream.write(buffer, 0, bytesRead);
             }
           } finally {
+            if (fileStream instanceof CountingInputStream countingInputStream) 
{
+              bytesReadThisRow += countingInputStream.getCount();
+            }
+            if (uploadStream instanceof CountingOutputStream 
countingOutputStream) {
+              bytesWrittenThisRow += countingOutputStream.getCount();
+            }
             // Close upload and file
             if (uploadStream != null) {
               uploadStream.close();
@@ -373,7 +386,7 @@ public class ActionHttp extends ActionBase {
         }
 
         // Read the result from the server...
-        input = connection.getInputStream();
+        input = new CountingInputStream(connection.getInputStream());
         Date date = new Date(connection.getLastModified());
         if (isBasic()) {
           logBasic(
@@ -381,17 +394,21 @@ public class ActionHttp extends ActionBase {
                   PKG, "ActionHTTP.Log.ReplayInfo", 
connection.getContentType(), date));
         }
 
-        int oneChar;
-        long bytesRead = 0L;
-        while ((oneChar = input.read()) != -1) {
-          outputFile.write(oneChar);
-          bytesRead++;
+        byte[] buffer = new byte[8192];
+        int bytesRead;
+        while ((bytesRead = input.read(buffer)) != -1) {
+          outputFile.write(buffer, 0, bytesRead);
         }
+        bytesReadThisRow += ((CountingInputStream) input).getCount();
+        bytesWrittenThisRow += ((CountingOutputStream) outputFile).getCount();
 
         if (isBasic()) {
           logBasic(
               BaseMessages.getString(
-                  PKG, "ActionHTTP.Log.FinisedWritingReply", bytesRead, 
realTargetFile));
+                  PKG,
+                  "ActionHTTP.Log.FinisedWritingReply",
+                  ((CountingInputStream) input).getCount(),
+                  realTargetFile));
         }
 
         if (addFilenameResult) {
@@ -449,6 +466,9 @@ public class ActionHttp extends ActionBase {
         System.setProperty(CONST_HTTPS_PROXY_PORT, 
Const.NVL(beforeHttpsProxyPort, ""));
         System.setProperty(CONST_HTTP_NON_PROXY_HOSTS, 
Const.NVL(beforeNonProxyHosts, ""));
       }
+
+      result.setBytesReadThisAction(result.getBytesReadThisAction() + 
bytesReadThisRow);
+      result.setBytesWrittenThisAction(result.getBytesWrittenThisAction() + 
bytesWrittenThisRow);
     }
 
     return result;
diff --git 
a/plugins/actions/http/src/test/java/org/apache/hop/workflow/actions/http/ActionHttpExecutionTest.java
 
b/plugins/actions/http/src/test/java/org/apache/hop/workflow/actions/http/ActionHttpExecutionTest.java
new file mode 100644
index 0000000000..7c64cf3101
--- /dev/null
+++ 
b/plugins/actions/http/src/test/java/org/apache/hop/workflow/actions/http/ActionHttpExecutionTest.java
@@ -0,0 +1,298 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.hop.workflow.actions.http;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.sun.net.httpserver.Headers;
+import com.sun.net.httpserver.HttpServer;
+import com.sun.net.httpserver.HttpsConfigurator;
+import com.sun.net.httpserver.HttpsServer;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyStore;
+import java.security.SecureRandom;
+import java.util.List;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import org.apache.commons.io.FileUtils;
+import org.apache.hop.core.HopClientEnvironment;
+import org.apache.hop.core.Result;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.workflow.engines.local.LocalWorkflowEngine;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+class ActionHttpExecutionTest {
+  private static final String HTTP_HOST = "localhost";
+  private static final String HTTPS_HOST = "localhost";
+  private static final String HTTPS_KEYSTORE_PASSWORD = "changeit";
+  private static final String HEADER_NAME = "X-Test-Header";
+  private static final String HEADER_VALUE = "header-value";
+
+  @RegisterExtension
+  static RestoreHopEngineEnvironmentExtension env = new 
RestoreHopEngineEnvironmentExtension();
+
+  private static HttpServer httpServer;
+  private static HttpsServer httpsServer;
+  private static Path httpsKeyStorePath;
+
+  @BeforeAll
+  static void setupBeforeClass() throws Exception {
+    HopClientEnvironment.init();
+    ActionHttpExecutionTest.startHttpServer();
+    ActionHttpExecutionTest.startHttpsServer();
+  }
+
+  @AfterAll
+  static void tearDown() throws IOException {
+    ActionHttpExecutionTest.stopHttpServer();
+    ActionHttpExecutionTest.stopHttpsServer();
+  }
+
+  @Test
+  void testHttpConfiguredSingleRunTracksDownloadAndAddsResultFile() throws 
Exception {
+    String payload = "download only payload";
+    File tempFileForDownload = File.createTempFile("downloadedFile3", ".tmp");
+    tempFileForDownload.deleteOnExit();
+
+    ActionHttp http = new ActionHttp();
+    http.setParentWorkflow(new LocalWorkflowEngine());
+    http.setRunForEveryRow(false);
+    http.setAddFilenameToResult(true);
+    http.setUrl(httpBaseUrl() + "/downloadFile");
+    http.setTargetFilename(tempFileForDownload.getCanonicalPath());
+
+    Result result = http.execute(new Result(), 0);
+
+    assertTrue(result.getResult());
+    assertEquals(0, result.getNrErrors());
+    assertFalse(result.getResultFiles().isEmpty());
+    assertEquals(payload, FileUtils.readFileToString(tempFileForDownload, 
UTF_8));
+    assertEquals(payload.getBytes(UTF_8).length, 
result.getBytesReadThisAction());
+    assertEquals(payload.getBytes(UTF_8).length, 
result.getBytesWrittenThisAction());
+  }
+
+  @Test
+  void testHttpConfiguredSingleRunSetsHeaders() throws Exception {
+    String payload = "header ok";
+    File tempFileForDownload = File.createTempFile("downloadedHeader", ".tmp");
+    tempFileForDownload.deleteOnExit();
+
+    ActionHttp.Header header = new ActionHttp.Header();
+    header.setHeaderName(HEADER_NAME);
+    header.setHeaderValue(HEADER_VALUE);
+
+    ActionHttp http = new ActionHttp();
+    http.setParentWorkflow(new LocalWorkflowEngine());
+    http.setRunForEveryRow(false);
+    http.setAddFilenameToResult(false);
+    http.setHeaders(List.of(header));
+    http.setUrl(httpBaseUrl() + "/headerCheck");
+    http.setTargetFilename(tempFileForDownload.getCanonicalPath());
+
+    Result result = http.execute(new Result(), 0);
+
+    assertTrue(result.getResult());
+    assertEquals(0, result.getNrErrors());
+    assertEquals(payload, FileUtils.readFileToString(tempFileForDownload, 
UTF_8));
+  }
+
+  @Test
+  void testHttpConfiguredSingleRunWithMalformedUrlSetsError() throws Exception 
{
+    File tempFileForDownload = File.createTempFile("downloadedMalformed", 
".tmp");
+    tempFileForDownload.deleteOnExit();
+
+    ActionHttp http = new ActionHttp();
+    http.setParentWorkflow(new LocalWorkflowEngine());
+    http.setRunForEveryRow(false);
+    http.setUrl("ht!tp://bad-url");
+    http.setTargetFilename(tempFileForDownload.getCanonicalPath());
+
+    Result result = http.execute(new Result(), 0);
+
+    assertFalse(result.getResult());
+    assertEquals(1, result.getNrErrors());
+    assertEquals(0, result.getBytesReadThisAction());
+    assertEquals(0, result.getBytesWrittenThisAction());
+  }
+
+  @Test
+  void testHttpConfiguredSingleRunWithIgnoreSslAcceptsSelfSignedCertificate() 
throws Exception {
+    String payload = "https payload";
+    File tempFileForDownload = File.createTempFile("downloadedHttps", ".tmp");
+    tempFileForDownload.deleteOnExit();
+
+    ActionHttp http = new ActionHttp();
+    http.setParentWorkflow(new LocalWorkflowEngine());
+    http.setRunForEveryRow(false);
+    http.setAddFilenameToResult(false);
+    http.setIgnoreSsl(true);
+    http.setUrl(httpsBaseUrl() + "/downloadFile");
+    http.setTargetFilename(tempFileForDownload.getCanonicalPath());
+
+    Result result = http.execute(new Result(), 0);
+
+    assertTrue(result.getResult());
+    assertEquals(0, result.getNrErrors());
+    assertEquals(payload, FileUtils.readFileToString(tempFileForDownload, 
UTF_8));
+    assertEquals(payload.getBytes(UTF_8).length, 
result.getBytesReadThisAction());
+    assertEquals(payload.getBytes(UTF_8).length, 
result.getBytesWrittenThisAction());
+  }
+
+  private static void startHttpServer() throws IOException {
+    httpServer = HttpServer.create(new 
InetSocketAddress(ActionHttpExecutionTest.HTTP_HOST, 0), 10);
+    httpServer.createContext(
+        "/downloadFile",
+        httpExchange -> {
+          byte[] response = "download only payload".getBytes(UTF_8);
+          Headers h = httpExchange.getResponseHeaders();
+          h.add("Content-Type", "application/octet-stream");
+          httpExchange.sendResponseHeaders(200, response.length);
+          try (OutputStream os = httpExchange.getResponseBody()) {
+            os.write(response);
+            os.flush();
+          }
+          httpExchange.close();
+        });
+    httpServer.createContext(
+        "/headerCheck",
+        httpExchange -> {
+          String headerValue = 
httpExchange.getRequestHeaders().getFirst(HEADER_NAME);
+          byte[] response =
+              (HEADER_VALUE.equals(headerValue) ? "header ok" : "missing 
header").getBytes(UTF_8);
+          Headers h = httpExchange.getResponseHeaders();
+          h.add("Content-Type", "text/plain");
+          httpExchange.sendResponseHeaders(
+              HEADER_VALUE.equals(headerValue) ? 200 : 400, response.length);
+          try (OutputStream os = httpExchange.getResponseBody()) {
+            os.write(response);
+            os.flush();
+          }
+          httpExchange.close();
+        });
+    httpServer.start();
+  }
+
+  private static void startHttpsServer() throws Exception {
+    httpsKeyStorePath = Files.createTempFile("action-http-execution-test", 
".p12");
+    Files.deleteIfExists(httpsKeyStorePath);
+    generateHttpsKeyStore(httpsKeyStorePath);
+
+    KeyStore keyStore = KeyStore.getInstance("PKCS12");
+    try (InputStream inputStream = Files.newInputStream(httpsKeyStorePath)) {
+      keyStore.load(inputStream, HTTPS_KEYSTORE_PASSWORD.toCharArray());
+    }
+
+    KeyManagerFactory keyManagerFactory =
+        KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+    keyManagerFactory.init(keyStore, HTTPS_KEYSTORE_PASSWORD.toCharArray());
+
+    SSLContext sslContext = SSLContext.getInstance("TLS");
+    sslContext.init(keyManagerFactory.getKeyManagers(), null, new 
SecureRandom());
+
+    httpsServer = HttpsServer.create(new InetSocketAddress(HTTPS_HOST, 0), 10);
+    httpsServer.setHttpsConfigurator(new HttpsConfigurator(sslContext));
+    httpsServer.createContext(
+        "/downloadFile",
+        httpExchange -> {
+          byte[] response = "https payload".getBytes(UTF_8);
+          Headers h = httpExchange.getResponseHeaders();
+          h.add("Content-Type", "application/octet-stream");
+          httpExchange.sendResponseHeaders(200, response.length);
+          try (OutputStream os = httpExchange.getResponseBody()) {
+            os.write(response);
+            os.flush();
+          }
+          httpExchange.close();
+        });
+    httpsServer.start();
+  }
+
+  private static void generateHttpsKeyStore(Path keyStorePath)
+      throws IOException, InterruptedException {
+    Process process =
+        new ProcessBuilder(
+                keytoolExecutable(),
+                "-genkeypair",
+                "-alias",
+                "hop-test",
+                "-keyalg",
+                "RSA",
+                "-keysize",
+                "2048",
+                "-validity",
+                "1",
+                "-storetype",
+                "PKCS12",
+                "-keystore",
+                keyStorePath.toString(),
+                "-storepass",
+                HTTPS_KEYSTORE_PASSWORD,
+                "-keypass",
+                HTTPS_KEYSTORE_PASSWORD,
+                "-dname",
+                "CN=localhost, OU=Hop, O=Apache, L=Test, S=Test, C=US",
+                "-noprompt")
+            .redirectErrorStream(true)
+            .start();
+
+    int exitCode = process.waitFor();
+    if (exitCode != 0) {
+      String output = new String(process.getInputStream().readAllBytes(), 
UTF_8);
+      throw new IOException("Unable to generate HTTPS test keystore: " + 
output);
+    }
+  }
+
+  private static String keytoolExecutable() {
+    return Path.of(System.getProperty("java.home"), "bin", 
"keytool").toString();
+  }
+
+  private static String httpBaseUrl() {
+    return "http://"; + HTTP_HOST + ":" + httpServer.getAddress().getPort();
+  }
+
+  private static String httpsBaseUrl() {
+    return "https://"; + HTTPS_HOST + ":" + httpsServer.getAddress().getPort();
+  }
+
+  private static void stopHttpServer() {
+    if (httpServer != null) {
+      httpServer.stop(2);
+    }
+  }
+
+  private static void stopHttpsServer() throws IOException {
+    if (httpsServer != null) {
+      httpsServer.stop(2);
+    }
+    if (httpsKeyStorePath != null) {
+      Files.deleteIfExists(httpsKeyStorePath);
+    }
+  }
+}
diff --git 
a/plugins/transforms/http/src/main/java/org/apache/hop/pipeline/transforms/http/Http.java
 
b/plugins/transforms/http/src/main/java/org/apache/hop/pipeline/transforms/http/Http.java
index 0f961084eb..c2b37d6494 100644
--- 
a/plugins/transforms/http/src/main/java/org/apache/hop/pipeline/transforms/http/Http.java
+++ 
b/plugins/transforms/http/src/main/java/org/apache/hop/pipeline/transforms/http/Http.java
@@ -49,6 +49,7 @@ import org.apache.http.client.methods.CloseableHttpResponse;
 import org.apache.http.client.methods.HttpGet;
 import org.apache.http.client.protocol.HttpClientContext;
 import org.apache.http.client.utils.URIBuilder;
+import org.apache.http.entity.ByteArrayEntity;
 import org.apache.http.impl.auth.BasicScheme;
 import org.apache.http.impl.client.BasicAuthCache;
 import org.apache.http.impl.client.CloseableHttpClient;
@@ -217,10 +218,7 @@ public class Http extends BaseTransform<HttpMeta, 
HttpData> {
       default:
         HttpEntity entity = httpResponse.getEntity();
         if (entity != null) {
-          body =
-              StringUtils.isEmpty(meta.getEncoding())
-                  ? EntityUtils.toString(entity)
-                  : EntityUtils.toString(entity, meta.getEncoding());
+          body = readResponseBody(entity);
         } else {
           body = "";
         }
@@ -229,6 +227,23 @@ public class Http extends BaseTransform<HttpMeta, 
HttpData> {
     return body;
   }
 
+  private String readResponseBody(HttpEntity entity) throws IOException {
+    byte[] bodyBytes = EntityUtils.toByteArray(entity);
+    dataVolumeIn = (dataVolumeIn != null ? dataVolumeIn : 0L) + 
bodyBytes.length;
+
+    ByteArrayEntity countedEntity = new ByteArrayEntity(bodyBytes);
+    if (entity.getContentType() != null) {
+      countedEntity.setContentType(entity.getContentType());
+    }
+    if (entity.getContentEncoding() != null) {
+      countedEntity.setContentEncoding(entity.getContentEncoding());
+    }
+
+    return StringUtils.isEmpty(meta.getEncoding())
+        ? EntityUtils.toString(countedEntity)
+        : EntityUtils.toString(countedEntity, meta.getEncoding());
+  }
+
   private static @NotNull HttpClientContext buildtHttpClientContext(HttpHost 
target) {
     // Create AuthCache instance
     AuthCache authCache = new BasicAuthCache();
diff --git 
a/plugins/transforms/http/src/test/java/org/apache/hop/pipeline/transforms/http/HttpExecutionTest.java
 
b/plugins/transforms/http/src/test/java/org/apache/hop/pipeline/transforms/http/HttpExecutionTest.java
new file mode 100644
index 0000000000..773f7d6fb5
--- /dev/null
+++ 
b/plugins/transforms/http/src/test/java/org/apache/hop/pipeline/transforms/http/HttpExecutionTest.java
@@ -0,0 +1,330 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.hop.pipeline.transforms.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.sun.net.httpserver.HttpServer;
+import java.io.OutputStream;
+import java.lang.reflect.Field;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.core.HopClientEnvironment;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.engines.local.LocalPipelineEngine;
+import org.apache.hop.pipeline.transform.BaseTransform;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.http.message.BasicNameValuePair;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** Integration-style tests for {@link Http#callHttpService} using an embedded 
HTTP server. */
+class HttpExecutionTest {
+
+  private static HttpServer server;
+  private static int port;
+
+  // Capture values written by server handlers so assertions can read them
+  private static final AtomicReference<String> lastRequestUri = new 
AtomicReference<>();
+  private static final AtomicReference<String> lastRequestHeader = new 
AtomicReference<>();
+
+  @BeforeAll
+  static void startServer() throws Exception {
+    HopClientEnvironment.init();
+
+    server = HttpServer.create(new InetSocketAddress("localhost", 0), 10);
+    port = server.getAddress().getPort();
+
+    // Returns a fixed payload
+    server.createContext(
+        "/body",
+        exchange -> {
+          byte[] response = "hello world".getBytes(StandardCharsets.UTF_8);
+          exchange.getResponseHeaders().add("Content-Type", "text/plain; 
charset=UTF-8");
+          exchange.sendResponseHeaders(200, response.length);
+          try (OutputStream os = exchange.getResponseBody()) {
+            os.write(response);
+          }
+          exchange.close();
+        });
+
+    // Records query string so tests can assert on it
+    server.createContext(
+        "/query",
+        exchange -> {
+          lastRequestUri.set(exchange.getRequestURI().toString());
+          byte[] response = "ok".getBytes(StandardCharsets.UTF_8);
+          exchange.sendResponseHeaders(200, response.length);
+          try (OutputStream os = exchange.getResponseBody()) {
+            os.write(response);
+          }
+          exchange.close();
+        });
+
+    // Echoes back the custom request header
+    server.createContext(
+        "/headercheck",
+        exchange -> {
+          
lastRequestHeader.set(exchange.getRequestHeaders().getFirst("X-Custom-Header"));
+          byte[] response = "header ok".getBytes(StandardCharsets.UTF_8);
+          exchange.sendResponseHeaders(200, response.length);
+          try (OutputStream os = exchange.getResponseBody()) {
+            os.write(response);
+          }
+          exchange.close();
+        });
+
+    // Returns a custom response header so tests can verify header capture
+    server.createContext(
+        "/withheader",
+        exchange -> {
+          exchange.getResponseHeaders().add("X-Custom-Response", "my-value");
+          byte[] response = "ok".getBytes(StandardCharsets.UTF_8);
+          exchange.sendResponseHeaders(200, response.length);
+          try (OutputStream os = exchange.getResponseBody()) {
+            os.write(response);
+          }
+          exchange.close();
+        });
+
+    // Returns 200 but no entity
+    server.createContext(
+        "/nobody",
+        exchange -> {
+          exchange.sendResponseHeaders(200, 0);
+          exchange.close();
+        });
+
+    // Returns 204 No Content
+    server.createContext(
+        "/nocontent",
+        exchange -> {
+          exchange.sendResponseHeaders(204, -1);
+          exchange.close();
+        });
+
+    // Returns 401 Unauthorized
+    server.createContext(
+        "/unauthorized",
+        exchange -> {
+          exchange.sendResponseHeaders(401, -1);
+          exchange.close();
+        });
+
+    server.start();
+  }
+
+  @AfterAll
+  static void stopServer() {
+    if (server != null) {
+      server.stop(0);
+    }
+  }
+
+  @BeforeEach
+  void resetCaptures() {
+    lastRequestUri.set(null);
+    lastRequestHeader.set(null);
+  }
+
+  // ---- helper factories ----
+
+  private String baseUrl() {
+    return "http://localhost:"; + port;
+  }
+
+  private Http newHttp(HttpMeta meta, HttpData data) {
+    TransformMeta transformMeta = new TransformMeta();
+    transformMeta.setName("HttpExecTest");
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.setName("HttpExecTest");
+    pipelineMeta.addTransform(transformMeta);
+    return new Http(transformMeta, meta, data, 0, pipelineMeta, new 
LocalPipelineEngine());
+  }
+
+  /** Minimal HttpMeta + HttpData for a no-params GET returning just the body 
field. */
+  private Http minimalGet(String url) {
+    HttpMeta meta = new HttpMeta(); // resultFields.fieldName = "result" by 
default
+    HttpData data = new HttpData();
+    data.realUrl = url;
+    data.argNrs = new int[0];
+    return newHttp(meta, data);
+  }
+
+  // ---- tests ----
+
+  @Test
+  void callHttpServiceReturnsBodyAndTracksDataVolumeIn() throws Exception {
+    Http http = minimalGet(baseUrl() + "/body");
+
+    Object[] result = http.callHttpService(new RowMeta(), new Object[0]);
+
+    assertEquals("hello world", result[0]);
+    assertEquals(
+        (long) "hello world".getBytes(StandardCharsets.UTF_8).length, 
getDataVolumeIn(http));
+  }
+
+  @Test
+  void callHttpServiceWith204ReturnsEmptyBody() throws Exception {
+    Http http = minimalGet(baseUrl() + "/nocontent");
+
+    Object[] result = http.callHttpService(new RowMeta(), new Object[0]);
+
+    assertEquals("", result[0]);
+    assertNull(getDataVolumeIn(http)); // nothing was read
+  }
+
+  @Test
+  void callHttpServiceWithEmptyEntityReturnsEmptyBody() throws Exception {
+    Http http = minimalGet(baseUrl() + "/nobody");
+
+    Object[] result = http.callHttpService(new RowMeta(), new Object[0]);
+
+    assertEquals("", result[0]);
+  }
+
+  @Test
+  void callHttpServiceWith401ThrowsHopException() {
+    Http http = minimalGet(baseUrl() + "/unauthorized");
+
+    assertThrows(HopException.class, () -> http.callHttpService(new RowMeta(), 
new Object[0]));
+  }
+
+  @Test
+  void callHttpServiceWithQueryParameterAppendsToUrl() throws Exception {
+    HttpMeta meta = new HttpMeta();
+    meta.getLookupParameters().getQueryParameters().add(new 
HttpMeta.QueryParameter("qField", "q"));
+
+    HttpData data = new HttpData();
+    data.realUrl = baseUrl() + "/query";
+    data.argNrs = new int[] {0};
+
+    RowMeta rowMeta = new RowMeta();
+    rowMeta.addValueMeta(new ValueMetaString("qField"));
+
+    Http http = newHttp(meta, data);
+    http.callHttpService(rowMeta, new Object[] {"searchterm"});
+
+    assertNotNull(lastRequestUri.get());
+    assertTrue(lastRequestUri.get().contains("q=searchterm"));
+  }
+
+  @Test
+  void callHttpServiceSendsRequestHeaderFromRow() throws Exception {
+    HttpMeta meta = new HttpMeta();
+    HttpData data = new HttpData();
+    data.realUrl = baseUrl() + "/headercheck";
+    data.argNrs = new int[0];
+    data.useHeaderParameters = true;
+    data.headerParameters =
+        new org.apache.http.NameValuePair[] {
+          new BasicNameValuePair("X-Custom-Header", "" /* value comes from row 
*/)
+        };
+    data.headerParametersNrs = new int[] {0};
+
+    RowMeta rowMeta = new RowMeta();
+    rowMeta.addValueMeta(new ValueMetaString("headerValueField"));
+    data.inputRowMeta = rowMeta;
+
+    Http http = newHttp(meta, data);
+    http.callHttpService(rowMeta, new Object[] {"testvalue"});
+
+    assertEquals("testvalue", lastRequestHeader.get());
+  }
+
+  @Test
+  void callHttpServiceStatusCodeAppearsInResultWhenConfigured() throws 
Exception {
+    HttpMeta meta = new HttpMeta();
+    meta.getResultFields().setResultCodeFieldName("statusCode");
+
+    HttpData data = new HttpData();
+    data.realUrl = baseUrl() + "/body";
+    data.argNrs = new int[0];
+
+    Http http = newHttp(meta, data);
+    Object[] result = http.callHttpService(new RowMeta(), new Object[0]);
+
+    // result[0] = body (fieldName="result" by default), result[1] = status 
code
+    assertEquals("hello world", result[0]);
+    assertEquals(200L, result[1]);
+  }
+
+  @Test
+  void callHttpServiceResponseHeadersIncludedInResult() throws Exception {
+    HttpMeta meta = new HttpMeta();
+    meta.getResultFields().setFieldName(""); // no body field
+    meta.getResultFields().setResponseHeaderFieldName("responseHeaders");
+
+    HttpData data = new HttpData();
+    data.realUrl = baseUrl() + "/withheader";
+    data.argNrs = new int[0];
+
+    Http http = newHttp(meta, data);
+    Object[] result = http.callHttpService(new RowMeta(), new Object[0]);
+
+    // result[0] = header JSON (body field empty → skipped)
+    String headerJson = (String) result[0];
+    assertNotNull(headerJson);
+    // Header JSON must contain our custom value (header name casing may vary 
by HTTP stack)
+    assertTrue(
+        headerJson.contains("my-value"),
+        "Expected headers JSON to contain 'my-value' but got: " + headerJson);
+  }
+
+  @Test
+  void callHttpServiceWithUnknownHostThrowsHopException() {
+    Http http = minimalGet("http://this.host.does.not.exist.invalid";);
+
+    assertThrows(HopException.class, () -> http.callHttpService(new RowMeta(), 
new Object[0]));
+  }
+
+  @Test
+  void callHttpServiceResponseTimeAppearsInResultWhenConfigured() throws 
Exception {
+    HttpMeta meta = new HttpMeta();
+    meta.getResultFields().setResponseTimeFieldName("responseTime");
+
+    HttpData data = new HttpData();
+    data.realUrl = baseUrl() + "/body";
+    data.argNrs = new int[0];
+
+    Http http = newHttp(meta, data);
+    Object[] result = http.callHttpService(new RowMeta(), new Object[0]);
+
+    // result[0] = body, result[1] = responseTime (Long >= 0)
+    assertTrue(result[1] instanceof Long);
+    assertTrue((Long) result[1] >= 0L);
+  }
+
+  // ---- reflection helpers ----
+
+  private static Long getDataVolumeIn(Http http) throws Exception {
+    Field field = BaseTransform.class.getDeclaredField("dataVolumeIn");
+    field.setAccessible(true);
+    return (Long) field.get(http);
+  }
+}
diff --git 
a/plugins/transforms/http/src/test/java/org/apache/hop/pipeline/transforms/http/HttpTest.java
 
b/plugins/transforms/http/src/test/java/org/apache/hop/pipeline/transforms/http/HttpTest.java
new file mode 100644
index 0000000000..e34f75ad59
--- /dev/null
+++ 
b/plugins/transforms/http/src/test/java/org/apache/hop/pipeline/transforms/http/HttpTest.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.hop.pipeline.transforms.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.net.HttpURLConnection;
+import java.nio.charset.StandardCharsets;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.engines.local.LocalPipelineEngine;
+import org.apache.hop.pipeline.transform.BaseTransform;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.http.Header;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.message.BasicHeader;
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.json.simple.parser.JSONParser;
+import org.junit.jupiter.api.Test;
+
+class HttpTest {
+
+  private Http newTransform(String encoding) {
+    TransformMeta transformMeta = new TransformMeta();
+    transformMeta.setName("HttpTest");
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.setName("HttpTest");
+    pipelineMeta.addTransform(transformMeta);
+
+    HttpMeta meta = new HttpMeta();
+    meta.setEncoding(encoding);
+
+    HttpData data = new HttpData();
+    data.realUrl = "http://localhost/test";;
+
+    return new Http(transformMeta, meta, data, 0, pipelineMeta, new 
LocalPipelineEngine());
+  }
+
+  @Test
+  void extractHeaderStringAggregatesDuplicateHeaders() throws Exception {
+    Header[] headers = {
+      new BasicHeader("X-Test", "first"),
+      new BasicHeader("X-Test", "second"),
+      new BasicHeader("Content-Type", "application/json")
+    };
+
+    JSONObject headerJson = (JSONObject) new 
JSONParser().parse(invokeExtractHeaderString(headers));
+
+    assertEquals("application/json", headerJson.get("Content-Type"));
+    assertTrue(headerJson.get("X-Test") instanceof JSONArray);
+    assertEquals("first", ((JSONArray) headerJson.get("X-Test")).get(0));
+    assertEquals("second", ((JSONArray) headerJson.get("X-Test")).get(1));
+  }
+
+  @Test
+  void handResponseReadsBodyAndTracksDataVolumeIn() throws Exception {
+    Http http = newTransform("UTF-8");
+    CloseableHttpResponse response = mock(CloseableHttpResponse.class);
+    String payload = "hello http";
+    doReturn(new StringEntity(payload, 
StandardCharsets.UTF_8)).when(response).getEntity();
+
+    String body = invokeHandResponse(http, HttpURLConnection.HTTP_OK, 
response);
+
+    assertEquals(payload, body);
+    assertEquals(
+        (long) payload.getBytes(StandardCharsets.UTF_8).length, 
getLongField(http, "dataVolumeIn"));
+  }
+
+  @Test
+  void handResponseReturnsEmptyStringForNoContent() throws Exception {
+    Http http = newTransform("UTF-8");
+    CloseableHttpResponse response = mock(CloseableHttpResponse.class);
+
+    String body = invokeHandResponse(http, HttpURLConnection.HTTP_NO_CONTENT, 
response);
+
+    assertEquals("", body);
+  }
+
+  @Test
+  void handResponseThrowsForUnauthorizedStatus() {
+    Http http = newTransform("UTF-8");
+    CloseableHttpResponse response = mock(CloseableHttpResponse.class);
+
+    assertThrows(
+        HopTransformException.class,
+        () -> invokeHandResponse(http, HttpURLConnection.HTTP_UNAUTHORIZED, 
response));
+  }
+
+  private static String invokeHandResponse(
+      Http http, int statusCode, CloseableHttpResponse response) throws 
Exception {
+    Method method =
+        http.getClass().getDeclaredMethod("handResponse", int.class, 
CloseableHttpResponse.class);
+    method.setAccessible(true);
+    try {
+      return (String) method.invoke(http, statusCode, response);
+    } catch (InvocationTargetException e) {
+      if (e.getCause() instanceof Exception exception) {
+        throw exception;
+      }
+      throw e;
+    }
+  }
+
+  private static String invokeExtractHeaderString(Header[] headers) throws 
Exception {
+    Method method = Http.class.getDeclaredMethod("extractHeaderString", 
Header[].class);
+    method.setAccessible(true);
+    return (String) method.invoke(null, (Object) headers);
+  }
+
+  private static Long getLongField(Object target, String fieldName) throws 
Exception {
+    Field field = BaseTransform.class.getDeclaredField(fieldName);
+    field.setAccessible(true);
+    return (Long) field.get(target);
+  }
+}
diff --git 
a/plugins/transforms/httppost/src/main/java/org/apache/hop/pipeline/transforms/httppost/HttpPost.java
 
b/plugins/transforms/httppost/src/main/java/org/apache/hop/pipeline/transforms/httppost/HttpPost.java
index 3a8b759d59..4c2b3a6341 100644
--- 
a/plugins/transforms/httppost/src/main/java/org/apache/hop/pipeline/transforms/httppost/HttpPost.java
+++ 
b/plugins/transforms/httppost/src/main/java/org/apache/hop/pipeline/transforms/httppost/HttpPost.java
@@ -21,6 +21,7 @@ import static 
org.apache.hop.pipeline.transforms.httppost.HttpPostMeta.DEFAULT_E
 
 import com.google.common.annotations.VisibleForTesting;
 import java.io.InputStreamReader;
+import java.io.OutputStream;
 import java.io.UnsupportedEncodingException;
 import java.net.HttpURLConnection;
 import java.net.URI;
@@ -35,6 +36,7 @@ import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.exception.HopFileException;
 import org.apache.hop.core.exception.HopTransformException;
 import org.apache.hop.core.exception.HopValueException;
+import org.apache.hop.core.io.CountingOutputStream;
 import org.apache.hop.core.row.RowDataUtil;
 import org.apache.hop.core.util.HttpClientManager;
 import org.apache.hop.core.util.StringUtil;
@@ -56,6 +58,7 @@ import org.apache.http.client.methods.CloseableHttpResponse;
 import org.apache.http.client.protocol.HttpClientContext;
 import org.apache.http.client.utils.URIBuilder;
 import org.apache.http.client.utils.URLEncodedUtils;
+import org.apache.http.entity.ByteArrayEntity;
 import org.apache.http.entity.ContentType;
 import org.apache.http.entity.StringEntity;
 import org.apache.http.entity.mime.MultipartEntityBuilder;
@@ -86,7 +89,8 @@ public class HttpPost extends BaseTransform<HttpPostMeta, 
HttpPostData> {
     super(transformMeta, meta, data, copyNr, pipelineMeta, pipeline);
   }
 
-  private Object[] callHttpPOST(Object[] rowData) throws HopException {
+  @VisibleForTesting
+  Object[] callHttpPOST(Object[] rowData) throws HopException {
     HttpClientManager.HttpClientBuilderFacade clientBuilder =
         HttpClientManager.getInstance().createBuilder();
 
@@ -135,8 +139,9 @@ public class HttpPost extends BaseTransform<HttpPostMeta, 
HttpPostData> {
       // BODY PARAMETERS
       addBodyParams(post, rowData, multipart);
       // Set request entity?
-      addBodyFileParam(rowData, multipart);
+      addBodyFileParam(post, rowData, multipart);
       addBodyParamsAfter(post, multipart);
+      collectRequestBytes(post.getEntity());
 
       // Execute request
       Object[] newRow = null;
@@ -196,7 +201,7 @@ public class HttpPost extends BaseTransform<HttpPostMeta, 
HttpPostData> {
           default:
             HttpEntity entity = httpResponse.getEntity();
             if (entity != null) {
-              body = EntityUtils.toString(entity);
+              body = readResponseBody(entity);
             } else {
               body = "";
             }
@@ -259,6 +264,45 @@ public class HttpPost extends BaseTransform<HttpPostMeta, 
HttpPostData> {
     }
   }
 
+  void collectRequestBytes(HttpEntity entity) {
+    if (entity == null) {
+      return;
+    }
+
+    long contentLength = entity.getContentLength();
+    if (contentLength < 0) {
+      try (CountingOutputStream counting =
+          new CountingOutputStream(OutputStream.nullOutputStream())) {
+        entity.writeTo(counting);
+        contentLength = counting.getCount();
+      } catch (Exception e) {
+        if (isDebug()) {
+          logDebug("Unable to determine HTTP POST request size", e);
+        }
+        return;
+      }
+    }
+
+    if (contentLength > 0) {
+      dataVolumeOut = (dataVolumeOut != null ? dataVolumeOut : 0L) + 
contentLength;
+    }
+  }
+
+  private String readResponseBody(HttpEntity entity) throws Exception {
+    byte[] bodyBytes = EntityUtils.toByteArray(entity);
+    dataVolumeIn = (dataVolumeIn != null ? dataVolumeIn : 0L) + 
bodyBytes.length;
+
+    ByteArrayEntity countedEntity = new ByteArrayEntity(bodyBytes);
+    if (entity.getContentType() != null) {
+      countedEntity.setContentType(entity.getContentType());
+    }
+    if (entity.getContentEncoding() != null) {
+      countedEntity.setContentEncoding(entity.getContentEncoding());
+    }
+
+    return EntityUtils.toString(countedEntity);
+  }
+
   protected int requestStatusCode(HttpResponse httpResponse) {
     return httpResponse.getStatusLine().getStatusCode();
   }
@@ -604,7 +648,10 @@ public class HttpPost extends BaseTransform<HttpPostMeta, 
HttpPostData> {
    *
    * @param rowData row data
    */
-  private void addBodyFileParam(Object[] rowData, MultipartEntityBuilder 
multipart)
+  private void addBodyFileParam(
+      org.apache.http.client.methods.HttpPost post,
+      Object[] rowData,
+      MultipartEntityBuilder multipart)
       throws UnsupportedEncodingException, HopFileException, HopValueException 
{
     if (data.indexOfRequestEntity < 0) {
       return;
@@ -635,7 +682,24 @@ public class HttpPost extends BaseTransform<HttpPostMeta, 
HttpPostData> {
 
     if (meta.isMultipartupload()) {
       multipart.addBinaryBody("file", bytes);
+      return;
     }
+
+    attachRawRequestEntityIfNeeded(post, bytes);
+  }
+
+  @VisibleForTesting
+  void attachRawRequestEntityIfNeeded(org.apache.http.client.methods.HttpPost 
post, byte[] bytes) {
+    // In the non-multipart case the request entity field is the raw POST body 
when no other body
+    // has been configured.
+    if (post.getEntity() == null) {
+      post.setEntity(new ByteArrayEntity(bytes));
+    }
+  }
+
+  @VisibleForTesting
+  Long getTrackedDataVolumeOut() {
+    return dataVolumeOut;
   }
 
   /**
diff --git 
a/plugins/transforms/httppost/src/test/java/org/apache/hop/pipeline/transforms/httppost/HttpPostExecutionTest.java
 
b/plugins/transforms/httppost/src/test/java/org/apache/hop/pipeline/transforms/httppost/HttpPostExecutionTest.java
new file mode 100644
index 0000000000..0a608c686d
--- /dev/null
+++ 
b/plugins/transforms/httppost/src/test/java/org/apache/hop/pipeline/transforms/httppost/HttpPostExecutionTest.java
@@ -0,0 +1,356 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.hop.pipeline.transforms.httppost;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.sun.net.httpserver.HttpServer;
+import java.io.OutputStream;
+import java.lang.reflect.Field;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.core.HopClientEnvironment;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.engines.local.LocalPipelineEngine;
+import org.apache.hop.pipeline.transform.BaseTransform;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.http.message.BasicNameValuePair;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** Integration-style tests for {@link HttpPost#callHttpPOST} using an 
embedded HTTP server. */
+class HttpPostExecutionTest {
+
+  private static HttpServer server;
+  private static int port;
+
+  private static final AtomicReference<String> lastRequestBody = new 
AtomicReference<>();
+  private static final AtomicReference<String> lastRequestContentType = new 
AtomicReference<>();
+
+  @BeforeAll
+  static void startServer() throws Exception {
+    HopClientEnvironment.init();
+
+    server = HttpServer.create(new InetSocketAddress("localhost", 0), 10);
+    port = server.getAddress().getPort();
+
+    // Captures request body and echoes it back
+    server.createContext(
+        "/echo",
+        exchange -> {
+          byte[] body = exchange.getRequestBody().readAllBytes();
+          lastRequestBody.set(new String(body, StandardCharsets.UTF_8));
+          
lastRequestContentType.set(exchange.getRequestHeaders().getFirst("Content-type"));
+          exchange.sendResponseHeaders(200, body.length);
+          try (OutputStream os = exchange.getResponseBody()) {
+            os.write(body);
+          }
+          exchange.close();
+        });
+
+    // Returns a fixed body payload
+    server.createContext(
+        "/body",
+        exchange -> {
+          byte[] response = "post response".getBytes(StandardCharsets.UTF_8);
+          exchange.sendResponseHeaders(200, response.length);
+          try (OutputStream os = exchange.getResponseBody()) {
+            os.write(response);
+          }
+          exchange.close();
+        });
+
+    // Returns two values for the same response header
+    server.createContext(
+        "/multiheader",
+        exchange -> {
+          exchange.getResponseHeaders().add("X-Resp", "val1");
+          exchange.getResponseHeaders().add("X-Resp", "val2");
+          byte[] response = "ok".getBytes(StandardCharsets.UTF_8);
+          exchange.sendResponseHeaders(200, response.length);
+          try (OutputStream os = exchange.getResponseBody()) {
+            os.write(response);
+          }
+          exchange.close();
+        });
+
+    // Returns 204 No Content
+    server.createContext(
+        "/nocontent",
+        exchange -> {
+          exchange.sendResponseHeaders(204, -1);
+          exchange.close();
+        });
+
+    // Returns 401 Unauthorized
+    server.createContext(
+        "/unauthorized",
+        exchange -> {
+          exchange.sendResponseHeaders(401, -1);
+          exchange.close();
+        });
+
+    server.start();
+  }
+
+  @AfterAll
+  static void stopServer() {
+    if (server != null) {
+      server.stop(0);
+    }
+  }
+
+  @BeforeEach
+  void resetCaptures() {
+    lastRequestBody.set(null);
+    lastRequestContentType.set(null);
+  }
+
+  // ---- helpers ----
+
+  private String baseUrl() {
+    return "http://localhost:"; + port;
+  }
+
+  private HttpPost newPost(HttpPostMeta meta, HttpPostData data) {
+    TransformMeta transformMeta = new TransformMeta();
+    transformMeta.setName("HttpPostExecTest");
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.setName("HttpPostExecTest");
+    pipelineMeta.addTransform(transformMeta);
+    return new HttpPost(transformMeta, meta, data, 0, pipelineMeta, new 
LocalPipelineEngine());
+  }
+
+  /**
+   * Minimal meta with a named body result field and no status/time/header 
fields. The default
+   * constructor sets code="result", so we clear that to avoid an extra index 
in the output.
+   */
+  private HttpPostMeta bodyOnlyMeta() {
+    HttpPostMeta meta = new HttpPostMeta();
+    meta.setDefault();
+    meta.getResultFields().get(0).setName("response");
+    meta.getResultFields().get(0).setCode(null);
+    return meta;
+  }
+
+  /** Minimal data pointing at the given URL with an empty input row. */
+  private HttpPostData minimalData(String url) {
+    HttpPostData data = new HttpPostData();
+    data.realUrl = url;
+    data.inputRowMeta = new RowMeta();
+    data.outputRowMeta = new RowMeta();
+    return data;
+  }
+
+  // ---- tests ----
+
+  @Test
+  void callHttpPOSTReturnsBodyAndTracksDataVolumeIn() throws Exception {
+    HttpPost http = newPost(bodyOnlyMeta(), minimalData(baseUrl() + "/body"));
+
+    Object[] result = http.callHttpPOST(new Object[0]);
+
+    assertEquals("post response", result[0]);
+    assertEquals(
+        (long) "post response".getBytes(StandardCharsets.UTF_8).length, 
getDataVolumeIn(http));
+  }
+
+  @Test
+  void callHttpPOSTWith204ReturnsEmptyBody() throws Exception {
+    HttpPost http = newPost(bodyOnlyMeta(), minimalData(baseUrl() + 
"/nocontent"));
+
+    Object[] result = http.callHttpPOST(new Object[0]);
+
+    assertEquals("", result[0]);
+    assertNull(getDataVolumeIn(http)); // nothing was read
+  }
+
+  @Test
+  void callHttpPOSTWith401ThrowsHopException() {
+    HttpPost http = newPost(bodyOnlyMeta(), minimalData(baseUrl() + 
"/unauthorized"));
+
+    assertThrows(HopException.class, () -> http.callHttpPOST(new Object[0]));
+  }
+
+  @Test
+  void callHttpPOSTWithBodyParamsSendsFormEncodedPayload() throws Exception {
+    HttpPostMeta meta = bodyOnlyMeta();
+    HttpPostData data = minimalData(baseUrl() + "/echo");
+    data.useBodyParameters = true;
+    data.body_parameters_nrs = new int[] {0};
+    data.bodyParameters =
+        new org.apache.http.NameValuePair[] {
+          new BasicNameValuePair("city", "") // name fixed; value comes from 
row
+        };
+    RowMeta rowMeta = new RowMeta();
+    rowMeta.addValueMeta(new ValueMetaString("cityField"));
+    data.inputRowMeta = rowMeta;
+    data.outputRowMeta = rowMeta;
+
+    HttpPost http = newPost(meta, data);
+    http.callHttpPOST(new Object[] {"Berlin"});
+
+    assertNotNull(lastRequestBody.get());
+    assertTrue(lastRequestBody.get().contains("city=Berlin"));
+  }
+
+  @Test
+  void callHttpPOSTWithQueryParamsSendsFormEncodedPayload() throws Exception {
+    // In HttpPost "query params" are sent as URL-encoded form body 
(UrlEncodedFormEntity)
+    HttpPostMeta meta = bodyOnlyMeta();
+    HttpPostData data = minimalData(baseUrl() + "/echo");
+    data.useQueryParameters = true;
+    data.query_parameters_nrs = new int[] {0};
+    data.queryParameters = new org.apache.http.NameValuePair[] {new 
BasicNameValuePair("q", "")};
+    RowMeta rowMeta = new RowMeta();
+    rowMeta.addValueMeta(new ValueMetaString("qField"));
+    data.inputRowMeta = rowMeta;
+    data.outputRowMeta = rowMeta;
+
+    HttpPost http = newPost(meta, data);
+    http.callHttpPOST(new Object[] {"test"});
+
+    assertNotNull(lastRequestBody.get());
+    assertTrue(lastRequestBody.get().contains("q=test"));
+  }
+
+  @Test
+  void callHttpPOSTWithRawRequestEntitySendsBytes() throws Exception {
+    HttpPostMeta meta = bodyOnlyMeta();
+    HttpPostData data = minimalData(baseUrl() + "/echo");
+    data.indexOfRequestEntity = 0;
+    RowMeta rowMeta = new RowMeta();
+    rowMeta.addValueMeta(new ValueMetaString("bodyField"));
+    data.inputRowMeta = rowMeta;
+    data.outputRowMeta = rowMeta;
+
+    HttpPost http = newPost(meta, data);
+    http.callHttpPOST(new Object[] {"raw body content"});
+
+    assertEquals("raw body content", lastRequestBody.get());
+    assertEquals(
+        (long) "raw body content".getBytes(StandardCharsets.UTF_8).length, 
getDataVolumeOut(http));
+  }
+
+  @Test
+  void callHttpPOSTStatusCodeAppearsInResultWhenConfigured() throws Exception {
+    HttpPostMeta meta = new HttpPostMeta();
+    meta.setDefault();
+    meta.getResultFields().get(0).setName("response");
+    meta.getResultFields().get(0).setCode("status");
+
+    HttpPost http = newPost(meta, minimalData(baseUrl() + "/body"));
+    Object[] result = http.callHttpPOST(new Object[0]);
+
+    assertEquals("post response", result[0]);
+    assertEquals(200L, result[1]);
+  }
+
+  @Test
+  void callHttpPOSTWithDuplicateResponseHeadersAggregatesIntoArray() throws 
Exception {
+    HttpPostMeta meta = new HttpPostMeta();
+    meta.setDefault();
+    meta.getResultFields().get(0).setName(null);
+    meta.getResultFields().get(0).setCode(null);
+    meta.getResultFields().get(0).setResponseHeaderFieldName("headers");
+
+    HttpPost http = newPost(meta, minimalData(baseUrl() + "/multiheader"));
+    Object[] result = http.callHttpPOST(new Object[0]);
+
+    // result[0] = headers JSON (name/code both empty → skipped)
+    String headersJson = (String) result[0];
+    assertNotNull(headersJson);
+    // Both header values must appear (name casing/folding varies by HTTP 
stack)
+    assertTrue(
+        headersJson.contains("val1"),
+        "Expected headers JSON to contain 'val1' but got: " + headersJson);
+    assertTrue(
+        headersJson.contains("val2"),
+        "Expected headers JSON to contain 'val2' but got: " + headersJson);
+  }
+
+  @Test
+  void callHttpPOSTWithUnknownHostThrowsHopException() {
+    HttpPost http = newPost(bodyOnlyMeta(), 
minimalData("http://this.host.does.not.exist.invalid";));
+
+    assertThrows(HopException.class, () -> http.callHttpPOST(new Object[0]));
+  }
+
+  @Test
+  void callHttpPOSTResponseTimeAppearsInResultWhenConfigured() throws 
Exception {
+    HttpPostMeta meta = new HttpPostMeta();
+    meta.setDefault();
+    meta.getResultFields().get(0).setName("response");
+    meta.getResultFields().get(0).setCode(null);
+    meta.getResultFields().get(0).setResponseTimeFieldName("time");
+
+    HttpPost http = newPost(meta, minimalData(baseUrl() + "/body"));
+    Object[] result = http.callHttpPOST(new Object[0]);
+
+    // result[0] = body, result[1] = response time
+    assertTrue(result[1] instanceof Long);
+    assertTrue((Long) result[1] >= 0L);
+  }
+
+  @Test
+  void callHttpPOSTSetsDefaultContentTypeHeader() throws Exception {
+    HttpPost http = newPost(bodyOnlyMeta(), minimalData(baseUrl() + "/echo"));
+    http.callHttpPOST(new Object[0]);
+
+    // addHeadersContentType sets text/xml when no encoding and no override
+    assertNotNull(lastRequestContentType.get());
+    assertTrue(lastRequestContentType.get().contains("text/xml"));
+  }
+
+  @Test
+  void callHttpPOSTWithEncodingSetsContentTypeWithCharset() throws Exception {
+    HttpPostMeta meta = bodyOnlyMeta();
+    HttpPostData data = minimalData(baseUrl() + "/echo");
+    data.realEncoding = "UTF-8";
+
+    HttpPost http = newPost(meta, data);
+    http.callHttpPOST(new Object[0]);
+
+    assertNotNull(lastRequestContentType.get());
+    assertTrue(lastRequestContentType.get().contains("UTF-8"));
+  }
+
+  // ---- reflection helpers ----
+
+  private static Long getDataVolumeIn(HttpPost http) throws Exception {
+    Field field = BaseTransform.class.getDeclaredField("dataVolumeIn");
+    field.setAccessible(true);
+    return (Long) field.get(http);
+  }
+
+  private static Long getDataVolumeOut(HttpPost http) throws Exception {
+    Field field = BaseTransform.class.getDeclaredField("dataVolumeOut");
+    field.setAccessible(true);
+    return (Long) field.get(http);
+  }
+}
diff --git 
a/plugins/transforms/httppost/src/test/java/org/apache/hop/pipeline/transforms/httppost/HttpPostTest.java
 
b/plugins/transforms/httppost/src/test/java/org/apache/hop/pipeline/transforms/httppost/HttpPostTest.java
index a18b5f5582..06e648db1b 100644
--- 
a/plugins/transforms/httppost/src/test/java/org/apache/hop/pipeline/transforms/httppost/HttpPostTest.java
+++ 
b/plugins/transforms/httppost/src/test/java/org/apache/hop/pipeline/transforms/httppost/HttpPostTest.java
@@ -18,24 +18,48 @@
 package org.apache.hop.pipeline.transforms.httppost;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.nullable;
-import static org.mockito.Mockito.doCallRealMethod;
-import static org.mockito.Mockito.mock;
+import static org.junit.jupiter.api.Assertions.assertSame;
 
-import org.apache.hop.core.exception.HopException;
+import java.io.ByteArrayInputStream;
+import java.io.InputStreamReader;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.engines.local.LocalPipelineEngine;
+import org.apache.hop.pipeline.transform.BaseTransform;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.HttpVersion;
 import org.apache.http.NameValuePair;
+import org.apache.http.entity.InputStreamEntity;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.message.BasicHttpResponse;
 import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.message.BasicStatusLine;
 import org.junit.jupiter.api.Test;
 
 class HttpPostTest {
 
+  private HttpPost newTransform() {
+    TransformMeta transformMeta = new TransformMeta();
+    transformMeta.setName("HttpPostTest");
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.setName("HttpPostTest");
+    pipelineMeta.addTransform(transformMeta);
+    return new HttpPost(
+        transformMeta,
+        new HttpPostMeta(),
+        new HttpPostData(),
+        0,
+        pipelineMeta,
+        new LocalPipelineEngine());
+  }
+
   @Test
-  void getRequestBodyParametersAsStringWithNullEncoding() throws HopException {
-    HttpPost http = mock(HttpPost.class);
-    doCallRealMethod()
-        .when(http)
-        .getRequestBodyParamsAsStr(any(NameValuePair[].class), 
nullable(String.class));
+  void getRequestBodyParametersAsStringWithNullEncoding() {
+    HttpPost http = newTransform();
 
     NameValuePair[] pairs =
         new NameValuePair[] {
@@ -44,4 +68,135 @@ class HttpPostTest {
 
     assertEquals("u=usr&p=pass", http.getRequestBodyParamsAsStr(pairs, null));
   }
+
+  @Test
+  void getRequestBodyParametersAsStringWithEncodingEscapesContent() {
+    HttpPost http = newTransform();
+
+    NameValuePair[] pairs = {
+      new BasicNameValuePair("city", "Sao Paulo"), new BasicNameValuePair("q", 
"a+b")
+    };
+
+    assertEquals("city=Sao+Paulo&q=a%2Bb", 
http.getRequestBodyParamsAsStr(pairs, "UTF-8"));
+  }
+
+  @Test
+  void attachRawRequestEntityIfNeededSetsEntityWhenMissing() {
+    HttpPost http = newTransform();
+
+    org.apache.http.client.methods.HttpPost post =
+        new org.apache.http.client.methods.HttpPost("http://localhost";);
+
+    http.attachRawRequestEntityIfNeeded(post, "payload".getBytes());
+
+    assertEquals(7, post.getEntity().getContentLength());
+  }
+
+  @Test
+  void attachRawRequestEntityIfNeededDoesNotOverrideExistingEntity() throws 
Exception {
+    HttpPost http = newTransform();
+
+    org.apache.http.client.methods.HttpPost post =
+        new org.apache.http.client.methods.HttpPost("http://localhost";);
+    HttpEntity existing = new StringEntity("existing");
+    post.setEntity(existing);
+
+    http.attachRawRequestEntityIfNeeded(post, "payload".getBytes());
+
+    assertSame(existing, post.getEntity());
+  }
+
+  @Test
+  void collectRequestBytesCountsUnknownLengthEntity() {
+    HttpPost http = newTransform();
+
+    HttpEntity entity = new InputStreamEntity(new 
ByteArrayInputStream("payload".getBytes()), -1);
+
+    http.collectRequestBytes(entity);
+
+    assertEquals(7L, http.getTrackedDataVolumeOut());
+  }
+
+  @Test
+  void collectRequestBytesCountsKnownLengthEntity() {
+    HttpPost http = newTransform();
+
+    HttpEntity entity = new StringEntity("payload", StandardCharsets.UTF_8);
+
+    http.collectRequestBytes(entity);
+
+    assertEquals(7L, http.getTrackedDataVolumeOut());
+  }
+
+  @Test
+  void readResponseBodyTracksDataVolumeIn() throws Exception {
+    HttpPost http = newTransform();
+
+    String body =
+        (String)
+            invokePrivate(
+                http, "readResponseBody", new StringEntity("response", 
StandardCharsets.UTF_8));
+
+    assertEquals("response", body);
+    assertEquals(8L, getLongField(http, "dataVolumeIn"));
+  }
+
+  @Test
+  void openStreamWithEncodingReturnsReaderDecodedInThatEncoding() throws 
Exception {
+    HttpPost http = newTransform();
+    HttpResponse response = responseWithBody("hello");
+
+    try (InputStreamReader reader = http.openStream("UTF-8", response)) {
+      char[] buf = new char[5];
+      int read = reader.read(buf);
+      assertEquals(5, read);
+      assertEquals("hello", new String(buf));
+    }
+  }
+
+  @Test
+  void openStreamWithoutEncodingReturnsDefaultReader() throws Exception {
+    HttpPost http = newTransform();
+    HttpResponse response = responseWithBody("world");
+
+    try (InputStreamReader reader = http.openStream(null, response)) {
+      char[] buf = new char[5];
+      int read = reader.read(buf);
+      assertEquals(5, read);
+      assertEquals("world", new String(buf));
+    }
+  }
+
+  @Test
+  void openStreamWithEmptyEncodingReturnsDefaultReader() throws Exception {
+    HttpPost http = newTransform();
+    HttpResponse response = responseWithBody("hop");
+
+    try (InputStreamReader reader = http.openStream("", response)) {
+      char[] buf = new char[3];
+      int read = reader.read(buf);
+      assertEquals(3, read);
+      assertEquals("hop", new String(buf));
+    }
+  }
+
+  private static HttpResponse responseWithBody(String body) {
+    BasicHttpResponse response =
+        new BasicHttpResponse(new BasicStatusLine(HttpVersion.HTTP_1_1, 200, 
"OK"));
+    response.setEntity(new StringEntity(body, StandardCharsets.UTF_8));
+    return response;
+  }
+
+  private static Object invokePrivate(Object target, String methodName, 
Object... args)
+      throws Exception {
+    Method method = target.getClass().getDeclaredMethod(methodName, 
HttpEntity.class);
+    method.setAccessible(true);
+    return method.invoke(target, args);
+  }
+
+  private static Long getLongField(Object target, String fieldName) throws 
Exception {
+    Field field = BaseTransform.class.getDeclaredField(fieldName);
+    field.setAccessible(true);
+    return (Long) field.get(target);
+  }
 }
diff --git 
a/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/Rest.java
 
b/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/Rest.java
index 7948ed22d2..709dd4d40c 100644
--- 
a/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/Rest.java
+++ 
b/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/Rest.java
@@ -34,12 +34,14 @@ import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
 import java.security.KeyManagementException;
 import java.security.KeyStoreException;
 import java.security.NoSuchAlgorithmException;
 import java.security.cert.CertificateException;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.ThreadLocalRandom;
 import java.util.function.Supplier;
 import javax.net.ssl.SSLContext;
@@ -333,6 +335,7 @@ public class Rest extends BaseTransform<RestMeta, RestData> 
{
           }
         }
       }
+      trackResponseBytes(response, body);
       // get Header
       MultivaluedMap<String, Object> headers = searchForHeaders(response);
       JSONObject json = new JSONObject();
@@ -491,6 +494,9 @@ public class Rest extends BaseTransform<RestMeta, RestData> 
{
           return invocationBuilder.get(Response.class);
         }
         case RestMeta.HTTP_METHOD_POST -> {
+          trackRequestBytes(
+              entityString,
+              contentType != null ? resolveCharset(contentType) : 
resolveCharset(data.mediaType));
           if (null != contentType) {
             return invocationBuilder.post(Entity.entity(entityString, 
contentType));
           } else {
@@ -498,6 +504,9 @@ public class Rest extends BaseTransform<RestMeta, RestData> 
{
           }
         }
         case RestMeta.HTTP_METHOD_PUT -> {
+          trackRequestBytes(
+              entityString,
+              contentType != null ? resolveCharset(contentType) : 
resolveCharset(data.mediaType));
           if (null != contentType) {
             return invocationBuilder.put(Entity.entity(entityString, 
contentType));
           } else {
@@ -505,6 +514,9 @@ public class Rest extends BaseTransform<RestMeta, RestData> 
{
           }
         }
         case RestMeta.HTTP_METHOD_DELETE -> {
+          trackRequestBytes(
+              entityString,
+              contentType != null ? resolveCharset(contentType) : 
resolveCharset(data.mediaType));
           Invocation invocation =
               invocationBuilder.build("DELETE", Entity.entity(entityString, 
data.mediaType));
           return invocation.invoke();
@@ -516,6 +528,9 @@ public class Rest extends BaseTransform<RestMeta, RestData> 
{
           return invocationBuilder.options();
         }
         case RestMeta.HTTP_METHOD_PATCH -> {
+          trackRequestBytes(
+              entityString,
+              contentType != null ? resolveCharset(contentType) : 
resolveCharset(data.mediaType));
           if (null != contentType) {
             return invocationBuilder.method(
                 RestMeta.HTTP_METHOD_PATCH, Entity.entity(entityString, 
contentType));
@@ -533,6 +548,53 @@ public class Rest extends BaseTransform<RestMeta, 
RestData> {
     }
   }
 
+  private void trackRequestBytes(String entityString, Charset charset) {
+    if (entityString == null) {
+      return;
+    }
+
+    byte[] requestBytes = entityString.getBytes(charset);
+    if (requestBytes.length > 0) {
+      dataVolumeOut = (dataVolumeOut != null ? dataVolumeOut : 0L) + 
requestBytes.length;
+    }
+  }
+
+  private void trackResponseBytes(Response response, String body) {
+    long responseBytes = response.getLength();
+    if (responseBytes < 0 && body != null) {
+      responseBytes = 
body.getBytes(resolveCharset(response.getMediaType())).length;
+    }
+    if (responseBytes > 0) {
+      dataVolumeIn = (dataVolumeIn != null ? dataVolumeIn : 0L) + 
responseBytes;
+    }
+  }
+
+  private Charset resolveCharset(String mediaTypeValue) {
+    if (!Utils.isEmpty(mediaTypeValue)) {
+      try {
+        return resolveCharset(MediaType.valueOf(mediaTypeValue));
+      } catch (Exception ignored) {
+        // Fall back to UTF-8 below if the header value is malformed.
+      }
+    }
+    return StandardCharsets.UTF_8;
+  }
+
+  private Charset resolveCharset(MediaType mediaType) {
+    if (mediaType != null) {
+      Map<String, String> parameters = mediaType.getParameters();
+      String charsetName = parameters.get("charset");
+      if (!Utils.isEmpty(charsetName)) {
+        try {
+          return Charset.forName(charsetName);
+        } catch (Exception ignored) {
+          // Fall back to UTF-8 below if the charset is unknown.
+        }
+      }
+    }
+    return StandardCharsets.UTF_8;
+  }
+
   /**
    * set the Authentication/Authorization header from the connection first, if 
available. this
    * transform's headers will override this value if available.
diff --git 
a/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestTest.java
 
b/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestTest.java
index fe3b697c91..98ad1e2036 100644
--- 
a/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestTest.java
+++ 
b/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestTest.java
@@ -27,11 +27,17 @@ import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.spy;
 
 import jakarta.ws.rs.client.Client;
+import jakarta.ws.rs.core.MediaType;
 import jakarta.ws.rs.core.MultivaluedHashMap;
 import jakarta.ws.rs.core.MultivaluedMap;
 import jakarta.ws.rs.core.Response;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import org.apache.hop.metadata.rest.RestConnection;
 import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.engines.local.LocalPipelineEngine;
+import org.apache.hop.pipeline.transform.BaseTransform;
 import org.apache.hop.pipeline.transform.TransformMeta;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
@@ -135,4 +141,86 @@ class RestTest {
     assertNull(data.indexOfHeaderFields);
     assertNull(data.paramNames);
   }
+
+  @Test
+  void testTrackRequestBytesAddsBytesForCharset() throws Exception {
+    Rest rest = newRest();
+
+    invokePrivate(rest, "trackRequestBytes", "hello", 
StandardCharsets.UTF_16LE);
+
+    assertEquals(10L, getLongField(rest, "dataVolumeOut"));
+  }
+
+  @Test
+  void testTrackResponseBytesFallsBackToBodyLength() throws Exception {
+    Rest rest = newRest();
+    Response response = mock(Response.class);
+    doReturn(-1).when(response).getLength();
+    doReturn(MediaType.valueOf("text/plain; 
charset=UTF-16LE")).when(response).getMediaType();
+
+    invokePrivate(rest, "trackResponseBytes", response, "ok");
+
+    assertEquals(4L, getLongField(rest, "dataVolumeIn"));
+  }
+
+  @Test
+  void testAddApiKeyHeaderIfAbsentAddsPrefixedHeaderWithoutOverriding() throws 
Exception {
+    Rest rest = newRest();
+    RestConnection connection = mock(RestConnection.class);
+    doReturn("API Key").when(connection).getAuthType();
+    doReturn("Authorization").when(connection).getAuthorizationHeaderName();
+    doReturn("secret").when(connection).getAuthorizationHeaderValue();
+    doReturn("Bearer").when(connection).getAuthorizationPrefix();
+
+    Field connectionField = Rest.class.getDeclaredField("connection");
+    connectionField.setAccessible(true);
+    connectionField.set(rest, connection);
+
+    MultivaluedMap<String, Object> headers = new MultivaluedHashMap<>();
+    invokePrivate(rest, "addApiKeyHeaderIfAbsent", headers);
+    assertEquals("Bearer secret", headers.getFirst("Authorization"));
+
+    headers.putSingle("Authorization", "existing");
+    invokePrivate(rest, "addApiKeyHeaderIfAbsent", headers);
+    assertEquals("existing", headers.getFirst("Authorization"));
+  }
+
+  private Rest newRest() {
+    TransformMeta transformMeta = new TransformMeta();
+    transformMeta.setName("TestRest");
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.setName("TestRest");
+    pipelineMeta.addTransform(transformMeta);
+    return new Rest(
+        transformMeta,
+        mock(RestMeta.class),
+        new RestData(),
+        1,
+        pipelineMeta,
+        spy(new LocalPipelineEngine()));
+  }
+
+  private static Object invokePrivate(Object target, String methodName, 
Object... args)
+      throws Exception {
+    Method method =
+        switch (methodName) {
+          case "trackRequestBytes" ->
+              target
+                  .getClass()
+                  .getDeclaredMethod(methodName, String.class, 
java.nio.charset.Charset.class);
+          case "trackResponseBytes" ->
+              target.getClass().getDeclaredMethod(methodName, Response.class, 
String.class);
+          case "addApiKeyHeaderIfAbsent" ->
+              target.getClass().getDeclaredMethod(methodName, 
MultivaluedMap.class);
+          default -> throw new NoSuchMethodException(methodName);
+        };
+    method.setAccessible(true);
+    return method.invoke(target, args);
+  }
+
+  private static Long getLongField(Object target, String fieldName) throws 
Exception {
+    Field field = BaseTransform.class.getDeclaredField(fieldName);
+    field.setAccessible(true);
+    return (Long) field.get(target);
+  }
 }

Reply via email to