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 49180f65fc Issue #8507 : Do not send a charset on the REST Client 
Content-Type (#8569)
49180f65fc is described below

commit 49180f65fcb52cf2a9c3e0636ff67c06e544f36c
Author: Matt Casters <[email protected]>
AuthorDate: Fri Sep 25 08:10:40 2026 +0200

    Issue #8507 : Do not send a charset on the REST Client Content-Type (#8569)
    
    * Issue #8507 : Do not send a charset on the REST Client Content-Type
    
    HttpClient's JSON content type is application/json; charset=UTF-8. Omie and
    similar gateways answer that parameter with HTTP 500 and a SOAP Sender 
fault.
    Send the mime type alone unless the row sets Content-Type itself. The body
    encoding is unchanged.
    
    * Address review feedback: omit charset on Content-Type for JSON only 
(#8507)
---
 .../apache/hop/pipeline/transforms/rest/Rest.java  |  25 ++--
 .../pipeline/transforms/rest/RestCallRestTest.java | 142 +++++++++++++++++++++
 .../transforms/rest/RestInitAndProcessTest.java    |   7 +-
 .../transforms/rest/RestRequestLoggingTest.java    |   8 +-
 4 files changed, 166 insertions(+), 16 deletions(-)

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 08df19ab32..ab51965bfc 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
@@ -68,7 +68,6 @@ import org.apache.hc.core5.http.Header;
 import org.apache.hc.core5.http.HttpEntity;
 import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
 import org.apache.hc.core5.http.io.entity.EntityUtils;
-import org.apache.hc.core5.http.io.entity.StringEntity;
 import org.apache.hc.core5.net.URIBuilder;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.encryption.Encr;
@@ -1644,11 +1643,10 @@ public class Rest extends BaseTransform<RestMeta, 
RestData> {
 
       if (RestMeta.isActiveBody(data.method)) {
         ContentType type = contentType != null ? 
ContentType.parse(contentType) : data.mediaType;
-        trackRequestBytes(body, resolveCharset(type));
-        request.setEntity(
-            body instanceof byte[] bytes
-                ? new ByteArrayEntity(bytes, type)
-                : new StringEntity((String) body, type));
+        Charset charset = resolveCharset(type);
+        trackRequestBytes(body, charset);
+        byte[] payload = body instanceof byte[] bytes ? bytes : ((String) 
body).getBytes(charset);
+        request.setEntity(new ByteArrayEntity(payload, type));
       }
 
       if (isDetailed()) {
@@ -1701,14 +1699,16 @@ public class Rest extends BaseTransform<RestMeta, 
RestData> {
         .append(CredentialRedactor.redact(request.getRequestUri()))
         .append(Const.CR);
 
-    // Host and Content-Type never appear in getHeaders(): the client derives 
the first from the
-    // route and the second from the entity, both at send time. Leaving them 
out would make this a
-    // misleading picture of the request rather than a faithful one.
+    // Host is filled in from the route at send time, so it is not in 
getHeaders() yet.
+    // Content-Type is already on the request when the row set it. Otherwise 
it lives on
+    // the entity and is copied at send time. Print the one that will go out, 
once.
     if (request.getAuthority() != null) {
       text.append("Host: 
").append(request.getAuthority().toString()).append(Const.CR);
     }
     HttpEntity requestEntity = request.getEntity();
-    if (requestEntity != null && requestEntity.getContentType() != null) {
+    if (!request.containsHeader("Content-Type")
+        && requestEntity != null
+        && requestEntity.getContentType() != null) {
       text.append("Content-Type: 
").append(requestEntity.getContentType()).append(Const.CR);
     }
 
@@ -2264,7 +2264,10 @@ public class Rest extends BaseTransform<RestMeta, 
RestData> {
       String applicationType = NVL(meta.getApplicationType(), "");
       switch (applicationType) {
         case RestMeta.APPLICATION_TYPE_XML -> data.mediaType = 
ContentType.APPLICATION_XML;
-        case RestMeta.APPLICATION_TYPE_JSON -> data.mediaType = 
ContentType.APPLICATION_JSON;
+          // Issue #8507: ContentType.APPLICATION_JSON has charset=UTF-8, 
which gateways such as
+          // Omie reject. JSON is defined as UTF-8 (RFC 8259), so omit the 
charset parameter.
+        case RestMeta.APPLICATION_TYPE_JSON ->
+            data.mediaType = ContentType.create("application/json");
         case RestMeta.APPLICATION_TYPE_OCTET_STREAM ->
             data.mediaType = ContentType.APPLICATION_OCTET_STREAM;
         case RestMeta.APPLICATION_TYPE_XHTML -> data.mediaType = 
ContentType.APPLICATION_XHTML_XML;
diff --git 
a/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestCallRestTest.java
 
b/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestCallRestTest.java
index a53d4fa10e..0958f41b6d 100644
--- 
a/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestCallRestTest.java
+++ 
b/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestCallRestTest.java
@@ -17,8 +17,11 @@
 
 package org.apache.hop.pipeline.transforms.rest;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 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 static org.mockito.Mockito.mock;
 
@@ -214,6 +217,109 @@ class RestCallRestTest {
     assertEquals("", requestBody());
   }
 
+  /**
+   * Issue #8507. {@link ContentType#APPLICATION_JSON} is {@code 
application/json; charset=UTF-8}.
+   * Sending that parameter makes gateways such as Omie answer HTTP 500 with a 
SOAP Sender fault
+   * before the API runs. The body stays UTF-8. The charset is omitted unless 
the row sets
+   * Content-Type itself.
+   */
+  @Test
+  void testJsonPostOmitsCharsetParameter() throws Exception {
+    Rest rest = post();
+    rest.callRest(new Object[] {"value1", "{\"n\":\"caf\u00e9\"}"});
+
+    ClassicHttpRequest request = FakeHttpClient.captured();
+    assertNull(request.getFirstHeader("Content-Type"));
+    assertEquals("application/json", request.getEntity().getContentType());
+    
assertArrayEquals("{\"n\":\"caf\u00e9\"}".getBytes(StandardCharsets.UTF_8), 
requestBytes());
+  }
+
+  @Test
+  void testExplicitContentTypeWithoutCharsetIsKept() throws Exception {
+    Rest rest = post("Content-Type", "application/json");
+    rest.callRest(new Object[] {"value1", "{\"n\":\"caf\u00e9\"}", 
"application/json"});
+
+    ClassicHttpRequest request = FakeHttpClient.captured();
+    assertEquals(1, request.getHeaders("Content-Type").length);
+    assertEquals("application/json", 
request.getFirstHeader("Content-Type").getValue());
+    
assertArrayEquals("{\"n\":\"caf\u00e9\"}".getBytes(StandardCharsets.UTF_8), 
requestBytes());
+  }
+
+  @Test
+  void testExplicitContentTypeCharsetIsHonored() throws Exception {
+    String header = "application/json; charset=ISO-8859-1";
+    Rest rest = post("Content-Type", header);
+    rest.callRest(new Object[] {"value1", "caf\u00e9", header});
+
+    ClassicHttpRequest request = FakeHttpClient.captured();
+    assertEquals(1, request.getHeaders("Content-Type").length);
+    assertEquals(header, request.getFirstHeader("Content-Type").getValue());
+    assertArrayEquals("caf\u00e9".getBytes(StandardCharsets.ISO_8859_1), 
requestBytes());
+  }
+
+  @Test
+  void testFormUrlEncodedPostRetainsCharsetParameter() throws Exception {
+    RestMeta meta = new RestMeta();
+    meta.setMethod(RestMeta.HTTP_METHOD_POST);
+    meta.setUrl("http://example.com/api";);
+    meta.setBodyField("body");
+    meta.setResultField(new ResultField());
+    meta.getResultField().setFieldName("result");
+
+    RestData data = new RestData();
+    data.mediaType = ContentType.APPLICATION_FORM_URLENCODED;
+    data.method = RestMeta.HTTP_METHOD_POST;
+    data.realUrl = "http://example.com/api";;
+    data.resultFieldName = "result";
+    data.useBody = true;
+    data.indexOfBodyField = 1;
+    data.inputRowMeta = rowMeta("field1", "body");
+
+    Rest rest = transform(meta, data, json(200, "{}"));
+    rest.callRest(new Object[] {"value1", "a=caf\u00e9"});
+
+    ClassicHttpRequest request = FakeHttpClient.captured();
+    assertNull(request.getFirstHeader("Content-Type"));
+    assertEquals(
+        ContentType.APPLICATION_FORM_URLENCODED.toString(), 
request.getEntity().getContentType());
+    assertArrayEquals("a=caf\u00e9".getBytes(StandardCharsets.ISO_8859_1), 
requestBytes());
+  }
+
+  @Test
+  void testTextPlainPostRetainsCharsetParameter() throws Exception {
+    RestMeta meta = new RestMeta();
+    meta.setMethod(RestMeta.HTTP_METHOD_POST);
+    meta.setUrl("http://example.com/api";);
+    meta.setBodyField("body");
+    meta.setResultField(new ResultField());
+    meta.getResultField().setFieldName("result");
+
+    RestData data = new RestData();
+    data.mediaType = ContentType.TEXT_PLAIN;
+    data.method = RestMeta.HTTP_METHOD_POST;
+    data.realUrl = "http://example.com/api";;
+    data.resultFieldName = "result";
+    data.useBody = true;
+    data.indexOfBodyField = 1;
+    data.inputRowMeta = rowMeta("field1", "body");
+
+    Rest rest = transform(meta, data, json(200, "{}"));
+    rest.callRest(new Object[] {"value1", "caf\u00e9"});
+
+    ClassicHttpRequest request = FakeHttpClient.captured();
+    assertNull(request.getFirstHeader("Content-Type"));
+    assertEquals(ContentType.TEXT_PLAIN.toString(), 
request.getEntity().getContentType());
+    assertArrayEquals("caf\u00e9".getBytes(StandardCharsets.UTF_8), 
requestBytes());
+  }
+
+  @Test
+  void testMalformedRowContentTypeFails() {
+    Rest rest = post("Content-Type", "application/json; charset=utf-99");
+    assertThrows(
+        Exception.class,
+        () -> rest.callRest(new Object[] {"value1", "{}", "application/json; 
charset=utf-99"}));
+  }
+
   @Test
   void testCallRestWithPutMethod() throws HopException {
     RestMeta meta = new RestMeta();
@@ -576,6 +682,34 @@ class RestCallRestTest {
     return rowMeta;
   }
 
+  /** A JSON POST. When two arguments are given they are a header name and the 
row value for it. */
+  private static Rest post(String... header) {
+    RestMeta meta = new RestMeta();
+    meta.setMethod(RestMeta.HTTP_METHOD_POST);
+    meta.setUrl("http://example.com/api";);
+    meta.setBodyField("body");
+    meta.setResultField(new ResultField());
+    meta.getResultField().setFieldName("result");
+
+    RestData data = new RestData();
+    data.mediaType = ContentType.create("application/json");
+    data.method = RestMeta.HTTP_METHOD_POST;
+    data.realUrl = "http://example.com/api";;
+    data.resultFieldName = "result";
+    data.useBody = true;
+    data.indexOfBodyField = 1;
+    if (header.length == 2) {
+      data.useHeaders = true;
+      data.nrheader = 1;
+      data.headerNames = new String[] {header[0]};
+      data.indexOfHeaderFields = new int[] {2};
+      data.inputRowMeta = rowMeta("field1", "body", "header");
+    } else {
+      data.inputRowMeta = rowMeta("field1", "body");
+    }
+    return transform(meta, data, json(200, "{}"));
+  }
+
   /** The URL the captured request was actually sent to. */
   private static String uri() {
     try {
@@ -592,4 +726,12 @@ class RestCallRestTest {
       throw new IllegalStateException("Unable to read the captured request 
body", e);
     }
   }
+
+  private static byte[] requestBytes() {
+    try {
+      return EntityUtils.toByteArray(FakeHttpClient.captured().getEntity());
+    } catch (Exception e) {
+      throw new IllegalStateException("Unable to read the captured request 
body", e);
+    }
+  }
 }
diff --git 
a/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestInitAndProcessTest.java
 
b/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestInitAndProcessTest.java
index 49e710f249..5be672584c 100644
--- 
a/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestInitAndProcessTest.java
+++ 
b/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestInitAndProcessTest.java
@@ -20,6 +20,7 @@ package org.apache.hop.pipeline.transforms.rest;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.spy;
@@ -102,7 +103,8 @@ class RestInitAndProcessTest {
     assertEquals(5000, data.realConnectionTimeout);
     assertEquals(10000, data.realReadTimeout);
     assertEquals(RestMeta.HTTP_METHOD_GET, data.method);
-    assertEquals(ContentType.APPLICATION_JSON, data.mediaType);
+    assertEquals("application/json", data.mediaType.toString());
+    assertNull(data.mediaType.getCharset());
   }
 
   @Test
@@ -230,7 +232,8 @@ class RestInitAndProcessTest {
           assertEquals(ContentType.APPLICATION_XML, data.mediaType);
           break;
         case RestMeta.APPLICATION_TYPE_JSON:
-          assertEquals(ContentType.APPLICATION_JSON, data.mediaType);
+          assertEquals("application/json", data.mediaType.toString());
+          assertNull(data.mediaType.getCharset());
           break;
         case RestMeta.APPLICATION_TYPE_OCTET_STREAM:
           assertEquals(ContentType.APPLICATION_OCTET_STREAM, data.mediaType);
diff --git 
a/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestRequestLoggingTest.java
 
b/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestRequestLoggingTest.java
index 26f198a383..bd038fb421 100644
--- 
a/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestRequestLoggingTest.java
+++ 
b/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestRequestLoggingTest.java
@@ -64,10 +64,12 @@ class RestRequestLoggingTest {
     assertTrue(
         log.contains("/api/things?q=hop"), "the request line carries the query 
string:\n" + log);
     assertTrue(log.contains("X-Trace: abc123"), "a configured header:\n" + 
log);
-    // Neither of these is a header on the request object: the client derives 
Host from the route
-    // and Content-Type from the entity. Without them the block would 
misrepresent the request.
+    // Host is not on the request object yet: the client derives it from the 
route at send time.
+    // Content-Type is on the entity until then, and the log prints the value 
that will be sent.
+    // Issue #8507: that value is the mime type, without a charset parameter.
     assertTrue(log.contains("Host: example.com"), "the host:\n" + log);
     assertTrue(log.contains("Content-Type: application/json"), "the content 
type:\n" + log);
+    assertFalse(log.toLowerCase().contains("charset"), "no charset 
parameter:\n" + log);
     assertTrue(log.contains("{\"name\":\"hop\"}"), "the body:\n" + log);
   }
 
@@ -138,7 +140,7 @@ class RestRequestLoggingTest {
     meta.getResultField().setFieldName("result");
 
     RestData data = new RestData();
-    data.mediaType = ContentType.APPLICATION_JSON;
+    data.mediaType = ContentType.create("application/json");
     data.method = method;
     data.realUrl = "http://example.com/api/things";;
     data.resultFieldName = "result";

Reply via email to