From dc7c27cae7c9c8f37c688b0a6348f7fcf02aa63c Mon Sep 17 00:00:00 2001 From: John Viegas Date: Thu, 22 Jan 2026 17:34:42 -0800 Subject: [PATCH 1/3] Propagate content-type from AsyncRequestBody for multipart uploads (Issue #6607) --- .../next-release/bugfix-AmazonS3-d039ba6.json | 6 + .../multipart/UploadObjectHelper.java | 7 + .../S3MultipartClientContentTypeTest.java | 212 ++++++++++++++++++ .../multipart/UploadObjectHelperTest.java | 55 ++++- ...oadWithUnknownContentLengthHelperTest.java | 49 +++- 5 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 .changes/next-release/bugfix-AmazonS3-d039ba6.json create mode 100644 services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/S3MultipartClientContentTypeTest.java diff --git a/.changes/next-release/bugfix-AmazonS3-d039ba6.json b/.changes/next-release/bugfix-AmazonS3-d039ba6.json new file mode 100644 index 000000000000..07f89a28e3bf --- /dev/null +++ b/.changes/next-release/bugfix-AmazonS3-d039ba6.json @@ -0,0 +1,6 @@ +{ + "type": "bugfix", + "category": "Amazon S3", + "contributor": "", + "description": "Fixed multipart uploads not propagating content-type from AsyncRequestBody when using S3AsyncClient with multipartEnabled(true). See Issue [#6607](https://github.com/aws/aws-sdk-java-v2/issues/6607)" +} diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/UploadObjectHelper.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/UploadObjectHelper.java index 1ca499b57aa8..5f3162ffe8a3 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/UploadObjectHelper.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/UploadObjectHelper.java @@ -60,6 +60,13 @@ public UploadObjectHelper(S3AsyncClient s3AsyncClient, public CompletableFuture uploadObject(PutObjectRequest putObjectRequest, AsyncRequestBody asyncRequestBody) { + + // Propagate content-type from AsyncRequestBody if not explicitly set on the request + if (putObjectRequest.contentType() == null && asyncRequestBody.contentType() != null) { + putObjectRequest = putObjectRequest.toBuilder() + .contentType(asyncRequestBody.contentType()) + .build(); + } Long contentLength = asyncRequestBody.contentLength().orElseGet(putObjectRequest::contentLength); if (contentLength == null) { diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/S3MultipartClientContentTypeTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/S3MultipartClientContentTypeTest.java new file mode 100644 index 000000000000..76707071b094 --- /dev/null +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/S3MultipartClientContentTypeTest.java @@ -0,0 +1,212 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.services.s3.internal.multipart; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.findAll; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.github.tomakehurst.wiremock.verification.LoggedRequest; +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.reactivestreams.Subscriber; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; + +/** + * Functional tests to verify content-type propagation from AsyncRequestBody for multipart uploads. + *

+ * These tests verify that when using S3AsyncClient with multipartEnabled(true), the content-type + * from AsyncRequestBody is correctly propagated to the CreateMultipartUpload request, ensuring + * consistent behavior between single-part and multipart uploads. + */ +@WireMockTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class S3MultipartClientContentTypeTest { + + private static final String BUCKET = "test-bucket"; + private static final String KEY = "test-key"; + private static final long MULTIPART_THRESHOLD = 10 * 1024; // 10KB + private static final long PART_SIZE = 5 * 1024; // 5KB + + private static final String CREATE_MULTIPART_RESPONSE = + "" + BUCKET + "" + + "" + KEY + "upload-id"; + + private static final String COMPLETE_MULTIPART_RESPONSE = + "" + BUCKET + "" + + "" + KEY + "\"etag\""; + + private S3AsyncClient s3Client; + + private static File createTempFile(String prefix, String suffix, int size) throws IOException { + File file = File.createTempFile(prefix, suffix); + file.deleteOnExit(); + Files.write(file.toPath(), new byte[size]); + return file; + } + + static Stream uploadScenarios() throws IOException { + File singlePartHtmlFile = createTempFile("single", ".html", 5 * 1024); + File multipartHtmlFile = createTempFile("multi", ".html", 15 * 1024); + + return Stream.of( + Arguments.of("singlePart_htmlFile", singlePartHtmlFile, false, "text/html"), + Arguments.of("multipart_htmlFile", multipartHtmlFile, true, "text/html") + ); + } + + @BeforeEach + void setup(WireMockRuntimeInfo wiremock) { + s3Client = S3AsyncClient.builder() + .region(Region.US_EAST_1) + .endpointOverride(URI.create(wiremock.getHttpBaseUrl())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create("key", "secret"))) + .forcePathStyle(true) + .multipartEnabled(true) + .multipartConfiguration(c -> c.thresholdInBytes(MULTIPART_THRESHOLD) + .minimumPartSizeInBytes(PART_SIZE)) + .build(); + } + + @AfterEach + void teardown() { + if (s3Client != null) { + s3Client.close(); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("uploadScenarios") + void putObject_withContentTypeFromRequestBody_shouldPropagateContentType(String scenario, File file, + boolean isMultipart, + String expectedContentType) { + stubSuccessfulResponses(isMultipart); + + s3Client.putObject(r -> r.bucket(BUCKET).key(KEY), AsyncRequestBody.fromFile(file)).join(); + + if (isMultipart) { + assertCreateMultipartUploadContentType(expectedContentType); + } else { + assertPutObjectContentType(expectedContentType); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("uploadScenarios") + void putObject_withExplicitContentType_shouldNotOverride(String scenario, File file, + boolean isMultipart, String ignoredContentType) { + stubSuccessfulResponses(isMultipart); + String explicitContentType = "custom/type"; + + s3Client.putObject(r -> r.bucket(BUCKET).key(KEY).contentType(explicitContentType), + AsyncRequestBody.fromFile(file)).join(); + + if (isMultipart) { + assertCreateMultipartUploadContentType(explicitContentType); + } else { + assertPutObjectContentType(explicitContentType); + } + } + + @Test + void putObject_withNullContentTypeFromRequestBody_shouldUseDefaultContentType() { + stubSuccessfulResponses(true); + byte[] content = new byte[15 * 1024]; // 15KB - above threshold + + AsyncRequestBody bodyWithNullContentType = new AsyncRequestBody() { + @Override + public Optional contentLength() { + return Optional.of((long) content.length); + } + + @Override + public String contentType() { + return null; + } + + @Override + public void subscribe(Subscriber subscriber) { + AsyncRequestBody.fromBytes(content).subscribe(subscriber); + } + }; + + s3Client.putObject(r -> r.bucket(BUCKET).key(KEY), bodyWithNullContentType).join(); + + // When AsyncRequestBody.contentType() is null, should use SDK default + assertCreateMultipartUploadContentType("binary/octet-stream"); + } + + private void stubSuccessfulResponses(boolean isMultipart) { + if (isMultipart) { + stubFor(post(anyUrl()).willReturn(aResponse().withStatus(200).withBody(CREATE_MULTIPART_RESPONSE))); + stubFor(put(anyUrl()).willReturn(aResponse().withStatus(200).withHeader("ETag", "\"etag\""))); + stubFor(post(urlPathEqualTo("/" + BUCKET + "/" + KEY)) + .withQueryParam("uploadId", containing("upload-id")) + .willReturn(aResponse().withStatus(200).withBody(COMPLETE_MULTIPART_RESPONSE))); + } else { + stubFor(put(anyUrl()).willReturn(aResponse().withStatus(200).withHeader("ETag", "\"etag\""))); + } + } + + private void assertCreateMultipartUploadContentType(String expectedContentType) { + List requests = findAll(postRequestedFor(urlPathEqualTo("/" + BUCKET + "/" + KEY)) + .withQueryParam("uploads", containing(""))); + assertThat(requests) + .as("Expected CreateMultipartUpload request") + .hasSize(1); + assertThat(requests.get(0).getHeader("Content-Type")) + .as("Content-Type header in CreateMultipartUpload request") + .isEqualTo(expectedContentType); + } + + private void assertPutObjectContentType(String expectedContentType) { + List requests = findAll(putRequestedFor(urlPathEqualTo("/" + BUCKET + "/" + KEY))); + assertThat(requests) + .as("Expected PutObject request") + .hasSize(1); + assertThat(requests.get(0).getHeader("Content-Type")) + .as("Content-Type header in PutObject request") + .isEqualTo(expectedContentType); + } +} diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/UploadObjectHelperTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/UploadObjectHelperTest.java index 20ae807dc334..d1f490ff52b9 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/UploadObjectHelperTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/UploadObjectHelperTest.java @@ -144,9 +144,18 @@ void uploadObject_unKnownContentLengthDoesNotExceedPartSize_shouldUploadInOneChu CompletableFuture completedFuture = CompletableFuture.completedFuture(PutObjectResponse.builder().build()); - when(s3AsyncClient.putObject(putObjectRequest, asyncRequestBody)).thenReturn(completedFuture); + when(s3AsyncClient.putObject(any(PutObjectRequest.class), any(AsyncRequestBody.class))).thenReturn(completedFuture); uploadHelper.uploadObject(putObjectRequest, asyncRequestBody).join(); - Mockito.verify(s3AsyncClient).putObject(putObjectRequest, asyncRequestBody); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + Mockito.verify(s3AsyncClient).putObject(requestCaptor.capture(), any(AsyncRequestBody.class)); + + PutObjectRequest capturedRequest = requestCaptor.getValue(); + assertThat(capturedRequest.bucket()).isEqualTo(BUCKET); + assertThat(capturedRequest.key()).isEqualTo(KEY); + assertThat(capturedRequest.contentLength()).isEqualTo(contentLength); + // Content-type should be enriched from AsyncRequestBody (default is application/octet-stream) + assertThat(capturedRequest.contentType()).isEqualTo("application/octet-stream"); } @ParameterizedTest @@ -414,6 +423,48 @@ void uploadObject_partsFinishedOutOfOrder_shouldSortThemInCompleteMultipart() { assertThat(actualRequest.multipartUpload().parts()).isEqualTo(completedParts(numTotalParts)); } + @Test + void uploadObject_contentTypeNotSetOnRequest_shouldUseContentTypeFromRequestBody() { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(BUCKET) + .key(KEY) + .build(); + + stubSuccessfulCreateMultipartCall(UPLOAD_ID, s3AsyncClient); + stubSuccessfulUploadPartCalls(s3AsyncClient); + stubSuccessfulCompleteMultipartCall(BUCKET, KEY, s3AsyncClient); + + uploadHelper.uploadObject(putObjectRequest, AsyncRequestBody.fromFile(testFile)).join(); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(CreateMultipartUploadRequest.class); + verify(s3AsyncClient).createMultipartUpload(requestCaptor.capture()); + + assertThat(requestCaptor.getValue().contentType()).isEqualTo("application/octet-stream"); + } + + @Test + void uploadObject_contentTypeSetOnRequest_shouldNotOverride() { + String explicitContentType = "my/content-type"; + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(BUCKET) + .key(KEY) + .contentType(explicitContentType) + .build(); + + stubSuccessfulCreateMultipartCall(UPLOAD_ID, s3AsyncClient); + stubSuccessfulUploadPartCalls(s3AsyncClient); + stubSuccessfulCompleteMultipartCall(BUCKET, KEY, s3AsyncClient); + + uploadHelper.uploadObject(putObjectRequest, AsyncRequestBody.fromFile(testFile)).join(); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(CreateMultipartUploadRequest.class); + verify(s3AsyncClient).createMultipartUpload(requestCaptor.capture()); + + assertThat(requestCaptor.getValue().contentType()).isEqualTo(explicitContentType); + } + private List completedParts(int totalNumParts) { return IntStream.range(1, totalNumParts + 1).mapToObj(i -> CompletedPart.builder().partNumber(i).build()).collect(Collectors.toList()); } diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/UploadWithUnknownContentLengthHelperTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/UploadWithUnknownContentLengthHelperTest.java index 70249ec79a8b..83eb8f284a72 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/UploadWithUnknownContentLengthHelperTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/UploadWithUnknownContentLengthHelperTest.java @@ -18,7 +18,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -28,6 +27,7 @@ import static software.amazon.awssdk.services.s3.internal.multipart.utils.MultipartUploadTestUtils.stubSuccessfulPutObjectCall; import static software.amazon.awssdk.services.s3.internal.multipart.utils.MultipartUploadTestUtils.stubSuccessfulUploadPartCalls; +import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; @@ -53,6 +53,7 @@ import software.amazon.awssdk.services.s3.S3AsyncClient; import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest; import software.amazon.awssdk.services.s3.model.CompletedPart; +import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectResponse; import software.amazon.awssdk.services.s3.model.UploadPartRequest; @@ -255,4 +256,50 @@ private void verifyCompleteMultipartUploadRequest() { CompleteMultipartUploadRequest actualRequest = completeMpuArgumentCaptor.getValue(); assertThat(actualRequest.multipartUpload().parts()).isEqualTo(createCompletedParts(NUM_TOTAL_PARTS)); } + + @Test + void uploadObject_unknownContentLength_contentTypeNotSet_shouldUseContentTypeFromRequestBody() throws IOException { + File tempFile = File.createTempFile("test-file", ".txt"); + tempFile.deleteOnExit(); + byte[] data = new byte[(int) (PART_SIZE * 2 + 1024)]; + java.nio.file.Files.write(tempFile.toPath(), data); + + stubSuccessfulCreateMultipartCall(UPLOAD_ID, s3AsyncClient); + stubSuccessfulUploadPartCalls(s3AsyncClient); + stubSuccessfulCompleteMultipartCall(BUCKET, KEY, s3AsyncClient); + + AsyncRequestBody fileBody = AsyncRequestBody.fromFile(tempFile); + AsyncRequestBody unknownLengthBody = new AsyncRequestBody() { + @Override + public java.util.Optional contentLength() { + return java.util.Optional.empty(); + } + + @Override + public String contentType() { + return fileBody.contentType(); + } + + @Override + public void subscribe(org.reactivestreams.Subscriber s) { + fileBody.subscribe(s); + } + }; + + PutObjectRequest putObjectRequest = createPutObjectRequest(); + + UploadObjectHelper uploadObjectHelper = new UploadObjectHelper(s3AsyncClient, + new MultipartConfigurationResolver(software.amazon.awssdk.services.s3.multipart.MultipartConfiguration.builder() + .minimumPartSizeInBytes(PART_SIZE) + .thresholdInBytes(PART_SIZE) + .build())); + + uploadObjectHelper.uploadObject(putObjectRequest, unknownLengthBody).join(); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(CreateMultipartUploadRequest.class); + verify(s3AsyncClient).createMultipartUpload(requestCaptor.capture()); + + assertThat(requestCaptor.getValue().contentType()).isEqualTo("text/plain"); + } } From e26c767f95fa0d97ea71a92b4e464d6b46b2680f Mon Sep 17 00:00:00 2001 From: John Viegas Date: Fri, 23 Jan 2026 13:54:20 -0800 Subject: [PATCH 2/3] Upgrade the wiremock to see integ issue --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 89dfe8ecc325..730da30dee5f 100644 --- a/pom.xml +++ b/pom.xml @@ -187,8 +187,8 @@ 1.8 4.5.13 4.4.16 - 5.6 - 5.4 + 5.5 + 5.3.4 1.0.4 From ea709439cf5ef00e38b0b36cfd28dcf601947949 Mon Sep 17 00:00:00 2001 From: John Viegas Date: Mon, 26 Jan 2026 11:55:05 -0800 Subject: [PATCH 3/3] reverting the changes after making sure the test passes and cause was in version upgrade of httpcomponents.client5.version --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 730da30dee5f..89dfe8ecc325 100644 --- a/pom.xml +++ b/pom.xml @@ -187,8 +187,8 @@ 1.8 4.5.13 4.4.16 - 5.5 - 5.3.4 + 5.6 + 5.4 1.0.4