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

davsclaus pushed a commit to branch CAMEL-26138-http-common-chunked-body
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 505abb3c5a7bac0705ad5d6224a2c34cdae9b016
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Nov 2 17:08:56 2026 +0100

    CAMEL-26138: camel-http-common - write a chunked Map, List, POJO or number 
reply, and keep a form POST body with disableStreamCache=true
    
    With chunked=true a body that is not a stream was taken with
    message.getBody(InputStream.class), which falls back to converting the
    HTTP message itself and so returns the already read request stream: the
    reply was a 200 with an empty body. Now only the body is converted, and a
    body that is not convertable as a stream is written as its String form,
    as a non-chunked reply already was.
    
    With disableStreamCache=true the form fields of a form POST were read
    from the raw request stream, which consumed it: jetty gave the route an
    empty body and servlet failed with 500. Now readBody reads such a form
    body as text, before the headers are read (jetty consumes the stream to
    parse the request parameters), and keeps it as the body.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../camel/http/common/DefaultHttpBinding.java      |  32 ++++--
 .../HttpChunkedNonStreamBodyAndFormStreamTest.java |  86 ++++++++++++++++
 ...rvletChunkedNonStreamBodyAndFormStreamTest.java | 110 +++++++++++++++++++++
 3 files changed, 222 insertions(+), 6 deletions(-)

diff --git 
a/components/camel-http-common/src/main/java/org/apache/camel/http/common/DefaultHttpBinding.java
 
b/components/camel-http-common/src/main/java/org/apache/camel/http/common/DefaultHttpBinding.java
index 3d8e32fcc315..24c487d879e0 100644
--- 
a/components/camel-http-common/src/main/java/org/apache/camel/http/common/DefaultHttpBinding.java
+++ 
b/components/camel-http-common/src/main/java/org/apache/camel/http/common/DefaultHttpBinding.java
@@ -23,6 +23,7 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.io.PrintWriter;
+import java.io.Reader;
 import java.io.Serializable;
 import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
@@ -217,6 +218,15 @@ public class DefaultHttpBinding implements HttpBinding {
             streamCache.reset();
         }
 
+        // with disableStreamCache=true the body is the raw request stream (or 
reader), which can only be read once.
+        // A form body that is mapped to headers is read as text now, and kept 
as the body, as reading the headers
+        // can make the servlet container consume the stream to parse the 
request parameters
+        if ((body instanceof InputStream || body instanceof Reader) && !(body 
instanceof StreamCache)
+                && mapHttpMessageHeaders && mapHttpMessageFormUrlEncodedBody 
&& isFormUrlEncodedPost(request, message)) {
+            body = message.getBody(String.class);
+            message.setBody(body);
+        }
+
         // if content type is serialized java object, then de-serialize it to 
a Java object
         if (request.getContentType() != null
                 && 
HttpConstants.CONTENT_TYPE_JAVA_SERIALIZED_OBJECT.equals(request.getContentType()))
 {
@@ -240,6 +250,17 @@ public class DefaultHttpBinding implements HttpBinding {
         }
     }
 
+    /**
+     * Whether the request is a POST of a form 
(application/x-www-form-urlencoded) whose fields are mapped to headers.
+     */
+    private static boolean isFormUrlEncodedPost(HttpServletRequest request, 
Message message) {
+        Boolean flag = message.getHeader(Exchange.SKIP_WWW_FORM_URLENCODED, 
Boolean.class);
+        boolean skipWwwFormUrlEncoding = flag != null ? flag : false;
+        return request.getMethod().equals("POST") && request.getContentType() 
!= null
+                && 
request.getContentType().startsWith(HttpConstants.CONTENT_TYPE_WWW_FORM_URLENCODED)
+                && !skipWwwFormUrlEncoding;
+    }
+
     protected void populateRequestParameters(HttpServletRequest request, 
Message message) {
         //we populate the http request parameters without checking the request 
method
         Map<String, Object> headers = message.getHeaders();
@@ -273,11 +294,7 @@ public class DefaultHttpBinding implements HttpBinding {
         if (mapHttpMessageBody && mapHttpMessageHeaders) {
             LOG.trace("HTTP method {} with Content-Type {}", 
request.getMethod(), request.getContentType());
             Map<String, Object> headers = message.getHeaders();
-            Boolean flag = 
message.getHeader(Exchange.SKIP_WWW_FORM_URLENCODED, Boolean.class);
-            boolean skipWwwFormUrlEncoding = flag != null ? flag : false;
-            if (request.getMethod().equals("POST") && request.getContentType() 
!= null
-                    && 
request.getContentType().startsWith(HttpConstants.CONTENT_TYPE_WWW_FORM_URLENCODED)
-                    && !skipWwwFormUrlEncoding) {
+            if (isFormUrlEncodedPost(request, message)) {
                 String charset = request.getCharacterEncoding();
                 if (charset == null || charset.isBlank()) {
                     charset = "UTF-8";
@@ -536,7 +553,10 @@ public class DefaultHttpBinding implements HttpBinding {
             // a String body is written in the charset of the content type
             is = toInputStreamWithContentTypeCharset(message, contentType);
             if (is == null) {
-                is = message.getBody(InputStream.class);
+                // convert the body only: message.getBody(InputStream.class) 
falls back to converting the HTTP message,
+                // which gives the request stream (already read), so a Map, 
List, POJO or number body would be written
+                // as an empty response. A body that is not convertable as a 
stream is written as a String below
+                is = 
exchange.getContext().getTypeConverter().convertTo(InputStream.class, exchange, 
message.getBody());
             }
         } else {
             // try to use input stream first, so we can copy directly
diff --git 
a/components/camel-jetty/src/test/java/org/apache/camel/component/jetty/HttpChunkedNonStreamBodyAndFormStreamTest.java
 
b/components/camel-jetty/src/test/java/org/apache/camel/component/jetty/HttpChunkedNonStreamBodyAndFormStreamTest.java
new file mode 100644
index 000000000000..14f801c5889e
--- /dev/null
+++ 
b/components/camel-jetty/src/test/java/org/apache/camel/component/jetty/HttpChunkedNonStreamBodyAndFormStreamTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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.camel.component.jetty;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.http.HttpMethods;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * A chunked response of a body that is not a stream (a Map, List or number) 
is written as its String form, as a
+ * non-chunked one is, and a form POST keeps its body when stream caching is 
disabled.
+ */
+public class HttpChunkedNonStreamBodyAndFormStreamTest extends BaseJettyTest {
+
+    @Test
+    public void chunkedMapResponse() {
+        assertEquals("{a=1}", 
template.requestBody("http://localhost:{{port}}/map";, "Hello", String.class));
+    }
+
+    @Test
+    public void chunkedListResponse() {
+        assertEquals("[a, b]", 
template.requestBody("http://localhost:{{port}}/list";, "Hello", String.class));
+    }
+
+    @Test
+    public void chunkedNumberResponse() {
+        assertEquals("42", 
template.requestBody("http://localhost:{{port}}/number";, "Hello", 
String.class));
+    }
+
+    @Test
+    public void chunkedMapResponseOfGetRequest() {
+        assertEquals("{a=1}", 
template.requestBodyAndHeader("http://localhost:{{port}}/map";, null,
+                Exchange.HTTP_METHOD, HttpMethods.GET, String.class));
+    }
+
+    @Test
+    public void notChunkedMapResponse() {
+        assertEquals("{a=1}", 
template.requestBody("http://localhost:{{port}}/mapNotChunked";, "Hello", 
String.class));
+    }
+
+    @Test
+    public void formPostWithStreamCacheDisabled() {
+        String out = 
template.requestBodyAndHeaders("http://localhost:{{port}}/form";, "b1=x&b2=y",
+                Map.of(Exchange.CONTENT_TYPE, 
"application/x-www-form-urlencoded", Exchange.HTTP_METHOD, HttpMethods.POST),
+                String.class);
+        assertEquals("b1=x&b2=y x y", out);
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // set the body on the HTTP message itself, as a processor or 
bean does (the setBody EIP replaces the
+                // HTTP message with a plain message)
+                from("jetty://http://localhost:{{port}}/map";).process(e -> 
e.getMessage().setBody(Map.of("a", 1)));
+                from("jetty://http://localhost:{{port}}/list";).process(e -> 
e.getMessage().setBody(List.of("a", "b")));
+                from("jetty://http://localhost:{{port}}/number";).process(e -> 
e.getMessage().setBody(42));
+                
from("jetty://http://localhost:{{port}}/mapNotChunked?chunked=false";)
+                        .process(e -> e.getMessage().setBody(Map.of("a", 1)));
+                
from("jetty://http://localhost:{{port}}/form?disableStreamCache=true";)
+                        .setBody(simple("${bodyAs(String)} ${header.b1} 
${header.b2}"));
+            }
+        };
+    }
+}
diff --git 
a/components/camel-servlet/src/test/java/org/apache/camel/component/servlet/ServletChunkedNonStreamBodyAndFormStreamTest.java
 
b/components/camel-servlet/src/test/java/org/apache/camel/component/servlet/ServletChunkedNonStreamBodyAndFormStreamTest.java
new file mode 100644
index 000000000000..784c9301c717
--- /dev/null
+++ 
b/components/camel-servlet/src/test/java/org/apache/camel/component/servlet/ServletChunkedNonStreamBodyAndFormStreamTest.java
@@ -0,0 +1,110 @@
+/*
+ * 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.camel.component.servlet;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * A chunked response of a body that is not a stream (a Map, List, POJO or 
number) is written as its String form, as a
+ * non-chunked one is, and a form POST keeps its body when stream caching is 
disabled.
+ */
+class ServletChunkedNonStreamBodyAndFormStreamTest extends 
ServletCamelRouterTestSupport {
+
+    @Test
+    void chunkedMapResponse() throws Exception {
+        assertResponse("/map", "{a=1}");
+    }
+
+    @Test
+    void chunkedListResponse() throws Exception {
+        assertResponse("/list", "[a, b]");
+    }
+
+    @Test
+    void chunkedPojoResponse() throws Exception {
+        assertResponse("/pojo", "a pojo");
+    }
+
+    @Test
+    void chunkedNumberResponse() throws Exception {
+        assertResponse("/number", "42");
+    }
+
+    @Test
+    void chunkedMapResponseOfPostRequest() throws Exception {
+        WebRequest req = new PostMethodWebRequest(
+                contextUrl + "/services/map", new 
ByteArrayInputStream("Hello".getBytes(StandardCharsets.UTF_8)),
+                "text/plain");
+        WebResponse response = query(req);
+        assertEquals(200, response.getResponseCode());
+        assertEquals("{a=1}", response.getText());
+    }
+
+    @Test
+    void notChunkedMapResponse() throws Exception {
+        assertResponse("/mapNotChunked", "{a=1}");
+    }
+
+    @Test
+    void formPostWithStreamCacheDisabled() throws Exception {
+        WebRequest req = new PostMethodWebRequest(
+                contextUrl + "/services/form", new 
ByteArrayInputStream("b1=x&b2=y".getBytes(StandardCharsets.UTF_8)),
+                "application/x-www-form-urlencoded");
+        WebResponse response = query(req);
+        assertEquals(200, response.getResponseCode());
+        assertEquals("b1=x&b2=y x y", response.getText());
+    }
+
+    private void assertResponse(String path, String expected) throws Exception 
{
+        WebResponse response = query(new GetMethodWebRequest(contextUrl + 
"/services" + path));
+        assertEquals(200, response.getResponseCode());
+        assertEquals(expected, response.getText());
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // set the body on the HTTP message itself, as a processor or 
bean does (the setBody EIP replaces the
+                // HTTP message with a plain message)
+                from("servlet:/map").process(e -> 
e.getMessage().setBody(Map.of("a", 1)));
+                from("servlet:/list").process(e -> 
e.getMessage().setBody(List.of("a", "b")));
+                from("servlet:/pojo").process(e -> e.getMessage().setBody(new 
MyPojo()));
+                from("servlet:/number").process(e -> 
e.getMessage().setBody(42));
+                from("servlet:/mapNotChunked?chunked=false").process(e -> 
e.getMessage().setBody(Map.of("a", 1)));
+                from("servlet:/form?disableStreamCache=true")
+                        .setBody(simple("${bodyAs(String)} ${header.b1} 
${header.b2}"));
+            }
+        };
+    }
+
+    private static final class MyPojo {
+        @Override
+        public String toString() {
+            return "a pojo";
+        }
+    }
+}

Reply via email to