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"; + } + } +}
