From d506ef6026f446ba91fd2a6d0beb395cb361b587 Mon Sep 17 00:00:00 2001 From: Aayush Atharva Date: Tue, 2 Dec 2025 21:10:15 +0000 Subject: [PATCH 1/3] Maintain Content-Type set explicitly by client --- .../asynchttpclient/RequestBuilderBase.java | 62 +++++- .../asynchttpclient/RequestBuilderTest.java | 192 ++++++++++++++++++ 2 files changed, 251 insertions(+), 3 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java b/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java index dbc5e4144..486c1af87 100644 --- a/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java +++ b/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java @@ -93,6 +93,31 @@ public abstract class RequestBuilderBase> { protected @Nullable Charset charset; protected ChannelPoolPartitioning channelPoolPartitioning = ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE; protected NameResolver nameResolver = DEFAULT_NAME_RESOLVER; + // Flag to track if Content-Type was explicitly set by user (should not be modified) + private boolean contentTypeLocked; + + /** + * Mark the Content-Type header as explicitly set by the user. When locked, the + * Content-Type header will not be modified by the client (e.g., charset addition). + */ + protected final void doContentTypeLock() { + this.contentTypeLocked = true; + } + + /** + * Clear the Content-Type lock, allowing the client to modify the Content-Type header + * if needed (for example, to add charset when it was auto-generated). + */ + protected final void resetContentTypeLock() { + this.contentTypeLocked = false; + } + + /** + * Return whether the Content-Type header has been locked as explicitly set by the user. + */ + protected final boolean isContentTypeLocked() { + return this.contentTypeLocked; + } protected RequestBuilderBase(String method, boolean disableUrlEncoding) { this(method, disableUrlEncoding, true); @@ -116,6 +141,10 @@ protected RequestBuilderBase(Request prototype, boolean disableUrlEncoding, bool localAddress = prototype.getLocalAddress(); headers = new DefaultHttpHeaders(validateHeaders); headers.add(prototype.getHeaders()); + // If prototype has Content-Type, consider it as explicitly set + if (headers.contains(CONTENT_TYPE)) { + doContentTypeLock(); + } if (isNonEmpty(prototype.getCookies())) { cookies = new ArrayList<>(prototype.getCookies()); } @@ -181,6 +210,7 @@ public T setVirtualHost(String virtualHost) { */ public T clearHeaders() { headers.clear(); + resetContentTypeLock(); return asDerivedType(); } @@ -203,6 +233,9 @@ public T setHeader(CharSequence name, String value) { */ public T setHeader(CharSequence name, Object value) { headers.set(name, value); + if (CONTENT_TYPE.contentEqualsIgnoreCase(name)) { + doContentTypeLock(); + } return asDerivedType(); } @@ -215,6 +248,9 @@ public T setHeader(CharSequence name, Object value) { */ public T setHeader(CharSequence name, Iterable values) { headers.set(name, values); + if (CONTENT_TYPE.contentEqualsIgnoreCase(name)) { + doContentTypeLock(); + } return asDerivedType(); } @@ -243,6 +279,9 @@ public T addHeader(CharSequence name, Object value) { } headers.add(name, value); + if (CONTENT_TYPE.contentEqualsIgnoreCase(name)) { + doContentTypeLock(); + } return asDerivedType(); } @@ -256,6 +295,9 @@ public T addHeader(CharSequence name, Object value) { */ public T addHeader(CharSequence name, Iterable values) { headers.add(name, values); + if (CONTENT_TYPE.contentEqualsIgnoreCase(name)) { + doContentTypeLock(); + } return asDerivedType(); } @@ -264,6 +306,9 @@ public T setHeaders(HttpHeaders headers) { this.headers.clear(); } else { this.headers = headers; + if (headers.contains(CONTENT_TYPE)) { + doContentTypeLock(); + } } return asDerivedType(); } @@ -278,7 +323,12 @@ public T setHeaders(HttpHeaders headers) { public T setHeaders(Map> headers) { clearHeaders(); if (headers != null) { - headers.forEach((name, values) -> this.headers.add(name, values)); + headers.forEach((name, values) -> { + this.headers.add(name, values); + if (CONTENT_TYPE.contentEqualsIgnoreCase(name)) { + doContentTypeLock(); + } + }); } return asDerivedType(); } @@ -293,7 +343,12 @@ public T setHeaders(Map> headers) public T setSingleHeaders(Map headers) { clearHeaders(); if (headers != null) { - headers.forEach((name, value) -> this.headers.add(name, value)); + headers.forEach((name, value) -> { + this.headers.add(name, value); + if (CONTENT_TYPE.contentEqualsIgnoreCase(name)) { + doContentTypeLock(); + } + }); } return asDerivedType(); } @@ -634,7 +689,8 @@ private void updateCharset() { String contentTypeHeader = headers.get(CONTENT_TYPE); Charset contentTypeCharset = extractContentTypeCharsetAttribute(contentTypeHeader); charset = withDefault(contentTypeCharset, withDefault(charset, UTF_8)); - if (contentTypeHeader != null && contentTypeHeader.regionMatches(true, 0, "text/", 0, 5) && contentTypeCharset == null) { + // Only add charset if Content-Type was not explicitly set by user + if (!isContentTypeLocked() && contentTypeHeader != null && contentTypeHeader.regionMatches(true, 0, "text/", 0, 5) && contentTypeCharset == null) { // add explicit charset to content-type header headers.set(CONTENT_TYPE, contentTypeHeader + "; charset=" + charset.name()); } diff --git a/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java b/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java index 34e79121d..2da2246d6 100644 --- a/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java +++ b/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java @@ -16,6 +16,8 @@ package org.asynchttpclient; import io.github.artsok.RepeatedIfExceptionsTest; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.cookie.Cookie; import io.netty.handler.codec.http.cookie.DefaultCookie; @@ -29,7 +31,9 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Collections.singletonList; import static org.asynchttpclient.Dsl.get; +import static org.asynchttpclient.Dsl.post; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; public class RequestBuilderTest { @@ -220,4 +224,192 @@ public void testSettingHeadersUsingMapWithStringKeys() { Request request = requestBuilder.build(); assertEquals(request.getHeaders().get("X-Forwarded-For"), "10.0.0.1"); } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testUserSetTextPlainContentTypeShouldNotBeModified() { + Request request = post("http://localhost/test") + .setHeader("Content-Type", "text/plain") + .setBody("Hello World") + .build(); + + String contentType = request.getHeaders().get("Content-Type"); + assertEquals("text/plain", contentType, "Content-Type should not be modified when user explicitly sets it"); + assertFalse(contentType.contains("charset"), "Charset should not be added to user-specified Content-Type"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testUserSetTextXmlContentTypeShouldNotBeModified() { + Request request = post("http://localhost/test") + .setHeader("Content-Type", "text/xml") + .setBody("Hello") + .build(); + + String contentType = request.getHeaders().get("Content-Type"); + assertEquals("text/xml", contentType, "Content-Type should not be modified when user explicitly sets it"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testUserSetTextHtmlContentTypeShouldNotBeModified() { + Request request = post("http://localhost/test") + .setHeader("Content-Type", "text/html") + .setBody("") + .build(); + + String contentType = request.getHeaders().get("Content-Type"); + assertEquals("text/html", contentType, "Content-Type should not be modified when user explicitly sets it"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testUserSetContentTypeWithCharsetShouldBePreserved() { + Request request = post("http://localhost/test") + .setHeader("Content-Type", "text/xml; charset=ISO-8859-1") + .setBody("Hello") + .build(); + + String contentType = request.getHeaders().get("Content-Type"); + assertEquals("text/xml; charset=ISO-8859-1", contentType, "User-specified charset should be preserved"); + assertTrue(contentType.contains("ISO-8859-1"), "ISO-8859-1 charset should be preserved"); + assertFalse(contentType.contains("UTF-8"), "UTF-8 should not be added"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testApplicationJsonContentTypeShouldNotBeModified() { + Request request = post("http://localhost/test") + .setHeader("Content-Type", "application/json") + .setBody("{\"key\": \"value\"}") + .build(); + + String contentType = request.getHeaders().get("Content-Type"); + assertEquals("application/json", contentType, "application/json should not be modified"); + assertFalse(contentType.contains("charset"), "Charset should not be added to application/json"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testAddHeaderContentTypeShouldNotBeModified() { + Request request = post("http://localhost/test") + .addHeader("Content-Type", "text/plain") + .setBody("Hello World") + .build(); + + String contentType = request.getHeaders().get("Content-Type"); + assertEquals("text/plain", contentType, "Content-Type set via addHeader should not be modified"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testSetHeadersWithHttpHeadersShouldLockContentType() { + HttpHeaders httpHeaders = new DefaultHttpHeaders(); + httpHeaders.set("Content-Type", "text/plain"); + + Request request = post("http://localhost/test") + .setHeaders(httpHeaders) + .setBody("Hello World") + .build(); + + String contentType = request.getHeaders().get("Content-Type"); + assertEquals("text/plain", contentType, "Content-Type set via setHeaders(HttpHeaders) should not be modified"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testSetHeadersWithMapShouldLockContentType() { + Map> headerMap = new HashMap<>(); + headerMap.put("Content-Type", singletonList("text/plain")); + + Request request = post("http://localhost/test") + .setHeaders(headerMap) + .setBody("Hello World") + .build(); + + String contentType = request.getHeaders().get("Content-Type"); + assertEquals("text/plain", contentType, "Content-Type set via setHeaders(Map) should not be modified"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testSetSingleHeadersShouldLockContentType() { + Map headerMap = new HashMap<>(); + headerMap.put("Content-Type", "text/plain"); + + Request request = post("http://localhost/test") + .setSingleHeaders(headerMap) + .setBody("Hello World") + .build(); + + String contentType = request.getHeaders().get("Content-Type"); + assertEquals("text/plain", contentType, "Content-Type set via setSingleHeaders should not be modified"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testClearHeadersShouldResetContentTypeLock() { + Request request = post("http://localhost/test") + .setHeader("Content-Type", "text/plain") + .clearHeaders() + .setHeader("Content-Type", "text/xml") + .setBody("") + .build(); + + String contentType = request.getHeaders().get("Content-Type"); + assertEquals("text/xml", contentType, "Content-Type should still be preserved after clear and re-set"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testPrototypeRequestShouldPreserveContentType() { + Request original = post("http://localhost/test") + .setHeader("Content-Type", "text/plain") + .setBody("Hello") + .build(); + + Request copy = post("http://localhost/test") + .setUrl(original.getUri().toUrl()) + .setHeaders(original.getHeaders()) + .setBody("Hello") + .build(); + + String contentType = copy.getHeaders().get("Content-Type"); + assertEquals("text/plain", contentType, "Content-Type should be preserved from prototype"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testRequestBuilderFromPrototypeShouldPreserveContentType() { + Request original = post("http://localhost/test") + .setHeader("Content-Type", "text/plain") + .setBody("Hello") + .build(); + + Request copy = new RequestBuilder(original).build(); + + String contentType = copy.getHeaders().get("Content-Type"); + assertEquals("text/plain", contentType, "Content-Type should be preserved from prototype via RequestBuilder"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testCaseInsensitiveContentTypeHeader() { + Request request = post("http://localhost/test") + .setHeader("content-type", "text/plain") + .setBody("Hello World") + .build(); + + String contentType = request.getHeaders().get("Content-Type"); + assertEquals("text/plain", contentType, "Content-Type should be matched case-insensitively"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testSetHeaderWithIterableShouldLockContentType() { + Request request = post("http://localhost/test") + .setHeader("Content-Type", singletonList("text/plain")) + .setBody("Hello World") + .build(); + + String contentType = request.getHeaders().get("Content-Type"); + assertEquals("text/plain", contentType, "Content-Type set via setHeader(Iterable) should not be modified"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testAddHeaderWithIterableShouldLockContentType() { + Request request = post("http://localhost/test") + .addHeader("Content-Type", singletonList("text/plain")) + .setBody("Hello World") + .build(); + + String contentType = request.getHeaders().get("Content-Type"); + assertEquals("text/plain", contentType, "Content-Type set via addHeader(Iterable) should not be modified"); + } } From 0eff84c937016a1933095576e6b621c98351eb83 Mon Sep 17 00:00:00 2001 From: Aayush Atharva Date: Tue, 2 Dec 2025 21:14:34 +0000 Subject: [PATCH 2/3] Restore protected contentTypeLocked (deprecated) for binary compatibility and prefer helper methods --- .../main/java/org/asynchttpclient/RequestBuilderBase.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java b/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java index 486c1af87..3d3e80e51 100644 --- a/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java +++ b/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java @@ -94,7 +94,12 @@ public abstract class RequestBuilderBase> { protected ChannelPoolPartitioning channelPoolPartitioning = ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE; protected NameResolver nameResolver = DEFAULT_NAME_RESOLVER; // Flag to track if Content-Type was explicitly set by user (should not be modified) - private boolean contentTypeLocked; + // Keep this field protected for binary compatibility with subclasses compiled + // against earlier versions that may have accessed the field directly. New + // code should use the doContentTypeLock/resetContentTypeLock/isContentTypeLocked + // API instead. + @Deprecated + protected boolean contentTypeLocked; /** * Mark the Content-Type header as explicitly set by the user. When locked, the From e2349b430ca1125e25bdef688c7ce28f8db03bea Mon Sep 17 00:00:00 2001 From: Aayush Atharva <24762260+hyperxpro@users.noreply.github.com> Date: Wed, 3 Dec 2025 02:45:26 +0530 Subject: [PATCH 3/3] Remove deprecated contentTypeLocked field Removed deprecated contentTypeLocked field to improve code clarity. --- .../main/java/org/asynchttpclient/RequestBuilderBase.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java b/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java index 3d3e80e51..29bbaa670 100644 --- a/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java +++ b/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java @@ -93,12 +93,6 @@ public abstract class RequestBuilderBase> { protected @Nullable Charset charset; protected ChannelPoolPartitioning channelPoolPartitioning = ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE; protected NameResolver nameResolver = DEFAULT_NAME_RESOLVER; - // Flag to track if Content-Type was explicitly set by user (should not be modified) - // Keep this field protected for binary compatibility with subclasses compiled - // against earlier versions that may have accessed the field directly. New - // code should use the doContentTypeLock/resetContentTypeLock/isContentTypeLocked - // API instead. - @Deprecated protected boolean contentTypeLocked; /**