This is an automated email from the ASF dual-hosted git repository. apupier pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
commit 6d66f0852a081d1391ad3ec3837f40c3ec93cf31 Author: smjain <[email protected]> AuthorDate: Sat Oct 3 10:48:35 2026 +0530 CAMEL-25305: camel-netty-http - write a String body in the charset that the Content-Type declares The consumer (response) and the producer (request) converted a String body with the charset of the exchange (UTF-8 by default), also when the Content-Type declared another charset, so for example a response with text/plain; charset=ISO-8859-1 carried UTF-8 bytes. A String body is now written in the declared charset. The charset of a request Content-Type was found with the deprecated HttpUtil.getCharsetFromContentType, which only matches the parameter name in lower case; the parameter name is case-insensitive (RFC 9110, section 5.6.6). Co-Authored-By: Claude Opus 5.5 <[email protected]> --- .../netty/http/DefaultNettyHttpBinding.java | 36 +++++- .../component/netty/http/NettyHttpConverter.java | 3 +- .../component/netty/http/NettyHttpHelper.java | 20 ++++ .../http/handlers/HttpServerChannelHandler.java | 3 +- .../netty/http/NettyHttpStringBodyCharsetTest.java | 121 +++++++++++++++++++++ .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 10 ++ 6 files changed, 186 insertions(+), 7 deletions(-) diff --git a/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/DefaultNettyHttpBinding.java b/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/DefaultNettyHttpBinding.java index 67e8d54704a8..dd73344dc665 100644 --- a/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/DefaultNettyHttpBinding.java +++ b/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/DefaultNettyHttpBinding.java @@ -280,6 +280,26 @@ public class DefaultNettyHttpBinding implements NettyHttpBinding, Cloneable { } + /** + * Converts a String body with the charset that the content type of the message declares, so the bytes match the + * Content-Type header. Returns <tt>null</tt> when the body is not a String or no supported charset is declared, and + * the body is then converted with the type converter (the charset of the exchange). + */ + private static ByteBuf toByteBufWithContentTypeCharset(Message message, Object body) { + if (body instanceof String str) { + String charset = NettyHttpHelper.getCharsetFromContentType(MessageHelper.getContentType(message)); + if (charset != null) { + try { + return NettyConverter.toByteBuffer(str.getBytes(Charset.forName(charset))); + } catch (IllegalArgumentException e) { + // unsupported or illegal charset name: keep the conversion with the exchange charset + LOG.debug("Cannot use charset {} of the content type: {}", charset, e.getMessage()); + } + } + } + return null; + } + private static String stripPath(NettyHttpConfiguration configuration, URI uri) { String path = uri.getRawPath(); if (configuration.getPath() != null) { @@ -493,8 +513,12 @@ public class DefaultNettyHttpBinding implements NettyHttpBinding, Cloneable { if (body instanceof ByteBuf byteBuf) { buffer = byteBuf; } else { - // try to convert to buffer first - buffer = message.getBody(ByteBuf.class); + // a String body is written in the charset of the content type + buffer = toByteBufWithContentTypeCharset(message, body); + if (buffer == null) { + // try to convert to buffer first + buffer = message.getBody(ByteBuf.class); + } if (buffer == null) { // fallback to byte array as last resort byte[] data = message.getBody(byte[].class); @@ -652,8 +676,12 @@ public class DefaultNettyHttpBinding implements NettyHttpBinding, Cloneable { if (body instanceof ByteBuf byteBuf) { buffer = byteBuf; } else { - // try to convert to buffer first - buffer = message.getBody(ByteBuf.class); + // a String body is written in the charset of the content type + buffer = toByteBufWithContentTypeCharset(message, body); + if (buffer == null) { + // try to convert to buffer first + buffer = message.getBody(ByteBuf.class); + } if (buffer == null) { // fallback to byte array as last resort byte[] data = message.getMandatoryBody(byte[].class); diff --git a/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/NettyHttpConverter.java b/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/NettyHttpConverter.java index 7ccd8cc8c278..f7afabf4bbdb 100644 --- a/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/NettyHttpConverter.java +++ b/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/NettyHttpConverter.java @@ -30,7 +30,6 @@ import org.apache.camel.Exchange; import org.apache.camel.ExchangePropertyKey; import org.apache.camel.component.netty.NettyConverter; import org.apache.camel.spi.TypeConverterRegistry; -import org.apache.camel.support.http.HttpUtil; @Converter(generateLoader = true) public final class NettyHttpConverter { @@ -88,7 +87,7 @@ public final class NettyHttpConverter { @Converter public static String toString(FullHttpResponse response, Exchange exchange) { String contentType = response.headers().get(NettyHttpConstants.CONTENT_TYPE); - String charset = HttpUtil.getCharsetFromContentType(contentType); + String charset = NettyHttpHelper.getCharsetFromContentType(contentType); if (charset == null && exchange != null) { charset = exchange.getProperty(ExchangePropertyKey.CHARSET_NAME, String.class); } diff --git a/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/NettyHttpHelper.java b/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/NettyHttpHelper.java index 4ce470be55bd..9454acc599d4 100644 --- a/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/NettyHttpHelper.java +++ b/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/NettyHttpHelper.java @@ -56,6 +56,26 @@ public final class NettyHttpHelper { CollectionHelper.appendEntry(headers, key, value); } + /** + * Gets the charset parameter of the content type. The parameter name is case-insensitive (RFC 9110, section 5.6.6). + * + * @param contentType the content type, may be <tt>null</tt> + * @return the charset name, or <tt>null</tt> if the content type has no charset parameter + */ + public static String getCharsetFromContentType(String contentType) { + if (contentType == null) { + return null; + } + String[] parts = contentType.split(";"); + for (int i = 1; i < parts.length; i++) { + String part = parts[i].trim(); + if (part.regionMatches(true, 0, "charset=", 0, 8)) { + return IOHelper.normalizeCharset(part.substring(8)); + } + } + return null; + } + /** * Creates the {@link HttpMethod} to use to call the remote server, often either its GET or POST. * diff --git a/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/handlers/HttpServerChannelHandler.java b/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/handlers/HttpServerChannelHandler.java index 790ff8cc4903..9720f9ab2521 100644 --- a/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/handlers/HttpServerChannelHandler.java +++ b/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/handlers/HttpServerChannelHandler.java @@ -47,6 +47,7 @@ import org.apache.camel.component.netty.http.InboundStreamHttpRequest; import org.apache.camel.component.netty.http.NettyHttpConfiguration; import org.apache.camel.component.netty.http.NettyHttpConstants; import org.apache.camel.component.netty.http.NettyHttpConsumer; +import org.apache.camel.component.netty.http.NettyHttpHelper; import org.apache.camel.component.netty.http.NettyHttpSecurityConfiguration; import org.apache.camel.component.netty.http.SecurityAuthenticator; import org.apache.camel.http.base.OAuthHttpSecuritySupport; @@ -377,7 +378,7 @@ public class HttpServerChannelHandler extends ServerChannelHandler { // honor the character encoding String contentType = in.getHeader(NettyHttpConstants.CONTENT_TYPE, String.class); - String charset = org.apache.camel.support.http.HttpUtil.getCharsetFromContentType(contentType); + String charset = NettyHttpHelper.getCharsetFromContentType(contentType); if (charset != null) { exchange.setProperty(ExchangePropertyKey.CHARSET_NAME, charset); in.setHeader(NettyHttpConstants.HTTP_CHARACTER_ENCODING, charset); diff --git a/components/camel-netty-http/src/test/java/org/apache/camel/component/netty/http/NettyHttpStringBodyCharsetTest.java b/components/camel-netty-http/src/test/java/org/apache/camel/component/netty/http/NettyHttpStringBodyCharsetTest.java new file mode 100644 index 000000000000..727b17ab75c5 --- /dev/null +++ b/components/camel-netty-http/src/test/java/org/apache/camel/component/netty/http/NettyHttpStringBodyCharsetTest.java @@ -0,0 +1,121 @@ +/* + * 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.netty.http; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; + +import org.apache.camel.Exchange; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mock.MockEndpoint; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * A String body is written in the charset that the Content-Type declares, and the charset parameter is recognized + * whatever its case. + */ +public class NettyHttpStringBodyCharsetTest extends BaseNettyTestSupport { + + private static final String TEXT = "Grüße aus Köln"; + + @Test + public void testResponseInDeclaredCharset() throws Exception { + HttpResponse<byte[]> response = send("/response", "text/plain", TEXT.getBytes(StandardCharsets.UTF_8)); + + assertEquals("text/plain; charset=ISO-8859-1", response.headers().firstValue("Content-Type").orElse(null)); + assertArrayEquals(TEXT.getBytes(StandardCharsets.ISO_8859_1), response.body()); + } + + @Test + public void testResponseWithoutCharsetIsUtf8() throws Exception { + HttpResponse<byte[]> response = send("/plain", "text/plain", TEXT.getBytes(StandardCharsets.UTF_8)); + + assertArrayEquals(TEXT.getBytes(StandardCharsets.UTF_8), response.body()); + } + + @Test + public void testRequestCharsetParameterIgnoresCase() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:text"); + mock.expectedBodiesReceived(TEXT); + + send("/text", "text/plain; Charset=ISO-8859-1", TEXT.getBytes(StandardCharsets.ISO_8859_1)); + + mock.assertIsSatisfied(); + } + + @Test + public void testProducerRequestInDeclaredCharset() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:bytes"); + mock.expectedMessageCount(1); + + template.sendBodyAndHeader("netty-http:http://localhost:{{port}}/bytes", TEXT, Exchange.CONTENT_TYPE, + "text/plain; charset=ISO-8859-1"); + + mock.assertIsSatisfied(); + assertArrayEquals(TEXT.getBytes(StandardCharsets.ISO_8859_1), + mock.getReceivedExchanges().get(0).getMessage().getBody(byte[].class)); + } + + @Test + public void testCharsetFromContentType() { + assertEquals("ISO-8859-1", NettyHttpHelper.getCharsetFromContentType("text/plain; Charset=ISO-8859-1")); + assertEquals("UTF-8", NettyHttpHelper.getCharsetFromContentType("text/plain;charset=\"UTF-8\"")); + assertEquals("utf-16", NettyHttpHelper.getCharsetFromContentType("text/plain; format=flowed; CHARSET=utf-16")); + // no charset parameter: no charset (not a UTF-8 default), so the exchange charset is used as before + assertNull(NettyHttpHelper.getCharsetFromContentType("text/plain")); + assertNull(NettyHttpHelper.getCharsetFromContentType("multipart/form-data; boundary=charset")); + assertNull(NettyHttpHelper.getCharsetFromContentType(null)); + } + + private HttpResponse<byte[]> send(String path, String contentType, byte[] body) throws Exception { + HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + getPort() + path)) + .header("Content-Type", contentType) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)) + .build(); + return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofByteArray()); + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + public void configure() { + from("netty-http:http://localhost:{{port}}/response") + .setHeader(Exchange.CONTENT_TYPE, constant("text/plain; charset=ISO-8859-1")) + .setBody(constant(TEXT)); + + from("netty-http:http://localhost:{{port}}/plain") + .setHeader(Exchange.CONTENT_TYPE, constant("text/plain")) + .setBody(constant(TEXT)); + + from("netty-http:http://localhost:{{port}}/text") + .convertBodyTo(String.class) + .to("mock:text"); + + from("netty-http:http://localhost:{{port}}/bytes") + .convertBodyTo(byte[].class) + .to("mock:bytes"); + } + }; + } +} diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc index d65aa7a14049..4746ecf2b297 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc @@ -803,6 +803,16 @@ The `charset` parameter of a `Content-Type` was only recognized in lower case. A `String` body with such a `Content-Type` was written as UTF-8. The parameter name is now matched case-insensitively (RFC 9110), as in the other HTTP components, on the consumer (request and response) and on the producer. +=== camel-netty-http - String bodies in the charset of the Content-Type + +The Netty HTTP consumer (response) and producer (request) wrote a `String` body in the charset of the exchange +(`CamelCharsetName`, UTF-8 by default), even when the `Content-Type` declared another charset (for example +`text/plain; charset=ISO-8859-1` set by the route). A `String` body is now written in the charset that the +`Content-Type` declares. Bodies that are not a `String`, and messages whose `Content-Type` declares no charset, are +sent as before. Also, the `charset` parameter of a received `Content-Type` (a request on the consumer) is now +recognized whatever its case (`Charset=ISO-8859-1`). A peer that ignored the declared charset and read such a message as UTF-8 must now use the +declared charset, and characters that the declared charset cannot represent are written as `?`. + === camel-snmp - GET_NEXT (walk) A `GET_NEXT` walk now ends at the end of the agent's MIB view (`endOfMibView`, or `noSuchName` for SNMPv1) and at
