diff --git a/services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3IntegrationTestBase.java b/services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3IntegrationTestBase.java index d976aa6342cf..2c66d951a715 100644 --- a/services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3IntegrationTestBase.java +++ b/services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3IntegrationTestBase.java @@ -55,11 +55,13 @@ public class S3IntegrationTestBase extends AwsTestBase { protected static S3Client s3; protected static S3AsyncClient s3Async; + protected static S3AsyncClient s3NonMultipartAsync; protected static S3AsyncClient s3CrtAsync; protected static S3TransferManager tmCrt; protected static S3TransferManager tmJava; + protected static S3TransferManager tmNonMultipartJava; /** * Loads the AWS account info for the integration tests and creates an S3 @@ -71,6 +73,11 @@ public static void setUpForAllIntegTests() throws Exception { System.setProperty("aws.crt.debugnative", "true"); s3 = s3ClientBuilder().build(); s3Async = s3AsyncClientBuilder().build(); + s3NonMultipartAsync = S3AsyncClient.builder() + .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) + .region(DEFAULT_REGION) + .build(); + s3CrtAsync = S3CrtAsyncClient.builder() .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) .region(DEFAULT_REGION) @@ -82,6 +89,9 @@ public static void setUpForAllIntegTests() throws Exception { tmJava = S3TransferManager.builder() .s3Client(s3Async) .build(); + tmNonMultipartJava = S3TransferManager.builder() + .s3Client(s3NonMultipartAsync) + .build(); } @@ -89,8 +99,11 @@ public static void setUpForAllIntegTests() throws Exception { public static void cleanUpForAllIntegTests() { s3.close(); s3Async.close(); + s3NonMultipartAsync.close(); s3CrtAsync.close(); tmCrt.close(); + tmJava.close(); + tmNonMultipartJava.close(); CrtResource.waitForNoResources(); } @@ -182,4 +195,11 @@ static Stream transferManagers() { Arguments.of(tmJava)); } + static Stream presignedUrlTransferManagers() { + return Stream.of( + // TODO: uncomment when CRT for presigned URL is supported + // Arguments.of(tmCrt), + Arguments.of(tmNonMultipartJava), + Arguments.of(tmJava)); + } } diff --git a/services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3TransferManagerPresignedUrlDownloadIntegrationTest.java b/services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3TransferManagerPresignedUrlDownloadIntegrationTest.java new file mode 100644 index 000000000000..0fa70b782439 --- /dev/null +++ b/services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3TransferManagerPresignedUrlDownloadIntegrationTest.java @@ -0,0 +1,141 @@ +/* + * 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.transfer.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static software.amazon.awssdk.testutils.service.S3BucketUtils.temporaryBucketName; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.async.AsyncResponseTransformer; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presignedurl.model.PresignedUrlDownloadRequest; +import software.amazon.awssdk.testutils.RandomTempFile; +import software.amazon.awssdk.transfer.s3.model.CompletedDownload; +import software.amazon.awssdk.transfer.s3.model.CompletedFileDownload; +import software.amazon.awssdk.transfer.s3.model.FileDownload; +import software.amazon.awssdk.transfer.s3.model.PresignedDownloadFileRequest; +import software.amazon.awssdk.transfer.s3.model.PresignedDownloadRequest; +import software.amazon.awssdk.transfer.s3.progress.LoggingTransferListener; +import software.amazon.awssdk.utils.Md5Utils; + +public class S3TransferManagerPresignedUrlDownloadIntegrationTest extends S3IntegrationTestBase { + private static final String BUCKET = temporaryBucketName(S3TransferManagerPresignedUrlDownloadIntegrationTest.class); + private static final String SMALL_KEY = "small-key"; + private static final String LARGE_KEY = "large-key"; + private static final int SMALL_OBJ_SIZE = 5 * 1024 * 1024; + private static final int LARGE_OBJ_SIZE = 16 * 1024 * 1024; + + private static File smallFile; + private static File largeFile; + private static S3Presigner presigner; + + @BeforeAll + public static void setup() throws IOException { + createBucket(BUCKET); + smallFile = new RandomTempFile(SMALL_OBJ_SIZE); + largeFile = new RandomTempFile(LARGE_OBJ_SIZE); + s3.putObject(PutObjectRequest.builder().bucket(BUCKET).key(SMALL_KEY).build(), smallFile.toPath()); + s3.putObject(PutObjectRequest.builder().bucket(BUCKET).key(LARGE_KEY).build(), largeFile.toPath()); + presigner = S3Presigner.builder() + .region(DEFAULT_REGION) + .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) + .build(); + } + + @AfterAll + public static void cleanup() { + if (presigner != null) { + presigner.close(); + } + deleteBucketAndAllContents(BUCKET); + } + + static Stream testCases() { + return presignedUrlTransferManagers().flatMap(tmArg -> Stream.of( + Arguments.of(tmArg.get()[0], SMALL_KEY, smallFile, SMALL_OBJ_SIZE), + Arguments.of(tmArg.get()[0], LARGE_KEY, largeFile, LARGE_OBJ_SIZE) + )); + } + + @ParameterizedTest(name = "downloadFileWithPresignedUrl_{1}") + @MethodSource("testCases") + void downloadFileWithPresignedUrl_shouldDownloadCorrectly(S3TransferManager tm, String key, + File sourceFile, int objSize) throws Exception { + Path downloadPath = RandomTempFile.randomUncreatedFile().toPath(); + FileDownload download = tm.downloadFileWithPresignedUrl(createFileDownloadRequest(key, downloadPath)); + CompletedFileDownload completed = download.completionFuture().join(); + + assertThat(Files.exists(downloadPath)).isTrue(); + assertThat(Md5Utils.md5AsBase64(downloadPath.toFile())).isEqualTo(Md5Utils.md5AsBase64(sourceFile)); + assertThat(completed.response().responseMetadata().requestId()).isNotNull(); + } + + @ParameterizedTest(name = "downloadWithPresignedUrl_toBytes_{1}") + @MethodSource("testCases") + void downloadWithPresignedUrl_toBytes_shouldReturnCorrectData(S3TransferManager tm, String key, + File sourceFile, int objSize) { + CompletedDownload> completed = + tm.downloadWithPresignedUrl(createBytesDownloadRequest(key)).completionFuture().join(); + + assertThat(completed.result().asByteArray()).hasSize(objSize); + } + + private static PresignedDownloadFileRequest createFileDownloadRequest(String key, Path destination) { + return PresignedDownloadFileRequest.builder() + .presignedUrlDownloadRequest(PresignedUrlDownloadRequest.builder() + .presignedUrl(createPresignedRequest(key).url()) + .build()) + .destination(destination) + .addTransferListener(LoggingTransferListener.create()) + .build(); + } + + private static PresignedDownloadRequest> createBytesDownloadRequest(String key) { + return PresignedDownloadRequest.builder() + .presignedUrlDownloadRequest(PresignedUrlDownloadRequest.builder() + .presignedUrl(createPresignedRequest(key).url()) + .build()) + .responseTransformer(AsyncResponseTransformer.toBytes()) + .addTransferListener(LoggingTransferListener.create()) + .build(); + } + + private static PresignedGetObjectRequest createPresignedRequest(String key) { + return presigner.presignGetObject(GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .getObjectRequest(GetObjectRequest.builder() + .bucket(BUCKET) + .key(key) + .build()) + .build()); + } +} diff --git a/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/S3TransferManager.java b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/S3TransferManager.java index b5c0e823a91a..42bde7ecdd23 100644 --- a/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/S3TransferManager.java +++ b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/S3TransferManager.java @@ -41,6 +41,8 @@ import software.amazon.awssdk.transfer.s3.model.DownloadRequest; import software.amazon.awssdk.transfer.s3.model.FileDownload; import software.amazon.awssdk.transfer.s3.model.FileUpload; +import software.amazon.awssdk.transfer.s3.model.PresignedDownloadFileRequest; +import software.amazon.awssdk.transfer.s3.model.PresignedDownloadRequest; import software.amazon.awssdk.transfer.s3.model.ResumableFileDownload; import software.amazon.awssdk.transfer.s3.model.ResumableFileUpload; import software.amazon.awssdk.transfer.s3.model.Upload; @@ -696,6 +698,107 @@ default Copy copy(Consumer copyRequestBuilder) { return copy(CopyRequest.builder().applyMutation(copyRequestBuilder).build()); } + /** + * Downloads an object using a pre-signed URL to a local file. For non-file-based downloads, you may use + * {@link #downloadWithPresignedUrl(PresignedDownloadRequest)} instead. + *

+ * This method supports multipart downloads when using a CRT-based or multipart-enabled S3 client, + * providing enhanced throughput and reliability for large objects. Progress can be monitored + * through {@link TransferListener}s attached to the request. + *

+ * The SDK will create a new file if the provided destination doesn't exist. If the file already exists, + * it will be replaced. In the event of an error, the SDK will NOT attempt to delete + * the file, leaving it as-is. + *

+ * Note: The result of the operation doesn't support pause and resume functionality. + *

+ *

+ * Usage Example: + * {@snippet : + * S3TransferManager transferManager = S3TransferManager.create(); + * + * // Create presigned URL (typically done by another service) + * PresignedUrlDownloadRequest presignedRequest = PresignedUrlDownloadRequest.builder() + * .presignedUrl(presignedUrl) + * .build(); + * + * PresignedDownloadFileRequest request = PresignedDownloadFileRequest.builder() + * .presignedUrlDownloadRequest(presignedRequest) + * .destination(Paths.get("downloaded-file.txt")) + * .addTransferListener( + * LoggingTransferListener.create()) + * .build(); + * + * FileDownload download = transferManager.downloadFileWithPresignedUrl(request); + * download.completionFuture().join(); + * } + * + * @param presignedDownloadFileRequest the presigned download file request + * @return A {@link FileDownload} that can be used to track the ongoing transfer + * @see #downloadFileWithPresignedUrl(Consumer) + * @see #downloadWithPresignedUrl(PresignedDownloadRequest) + */ + default FileDownload downloadFileWithPresignedUrl(PresignedDownloadFileRequest presignedDownloadFileRequest) { + throw new UnsupportedOperationException(); + } + + /** + * This is a convenience method that creates an instance of the {@link PresignedDownloadFileRequest} builder, + * avoiding the need to create one manually via {@link PresignedDownloadFileRequest#builder()}. + *

+ * Note: The result of the operation doesn't support pause and resume functionality. + *

+ * + * @see #downloadFileWithPresignedUrl(PresignedDownloadFileRequest) + */ + default FileDownload downloadFileWithPresignedUrl( + Consumer presignedDownloadFileRequest) { + return downloadFileWithPresignedUrl( + PresignedDownloadFileRequest.builder().applyMutation(presignedDownloadFileRequest).build()); + } + + /** + * Downloads an object using a pre-signed URL through the given {@link AsyncResponseTransformer}. For downloading + * to a file, you may use {@link #downloadFileWithPresignedUrl(PresignedDownloadFileRequest)} instead. + *

+ * This method supports multipart downloads when using a CRT-based or multipart-enabled S3 client, + * providing enhanced throughput and reliability for large objects. Progress can be monitored + * through {@link TransferListener}s attached to the request. + *

+ * Note: The result of the operation doesn't support pause and resume functionality. + *

+ *

+ * Usage Example (downloading to memory - not suitable for large objects): + * {@snippet : + * S3TransferManager transferManager = S3TransferManager.create(); + * + * // Create presigned URL (typically done by another service) + * PresignedUrlDownloadRequest presignedRequest = PresignedUrlDownloadRequest.builder() + * .presignedUrl(presignedUrl) + * .build(); + * + * PresignedDownloadRequest> request = + * PresignedDownloadRequest.builder() + * .presignedUrlDownloadRequest(presignedRequest) + * .responseTransformer(AsyncResponseTransformer.toBytes()) + * .addTransferListener(LoggingTransferListener.create()) + * .build(); + * + * Download> download = transferManager.downloadWithPresignedUrl(request); + * ResponseBytes result = download.completionFuture().join().result(); + * } + * + * @param presignedDownloadRequest the presigned download request + * @param The type of data the {@link AsyncResponseTransformer} produces + * @return A {@link Download} that can be used to track the ongoing transfer + * @see #downloadFileWithPresignedUrl(PresignedDownloadFileRequest) + * @see AsyncResponseTransformer + */ + default Download downloadWithPresignedUrl( + PresignedDownloadRequest presignedDownloadRequest) { + throw new UnsupportedOperationException(); + } + /** * Create an {@code S3TransferManager} using the default values. *

diff --git a/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/DelegatingS3TransferManager.java b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/DelegatingS3TransferManager.java index f2929bf8f988..15d309552857 100644 --- a/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/DelegatingS3TransferManager.java +++ b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/DelegatingS3TransferManager.java @@ -27,6 +27,8 @@ import software.amazon.awssdk.transfer.s3.model.DownloadRequest; import software.amazon.awssdk.transfer.s3.model.FileDownload; import software.amazon.awssdk.transfer.s3.model.FileUpload; +import software.amazon.awssdk.transfer.s3.model.PresignedDownloadFileRequest; +import software.amazon.awssdk.transfer.s3.model.PresignedDownloadRequest; import software.amazon.awssdk.transfer.s3.model.ResumableFileDownload; import software.amazon.awssdk.transfer.s3.model.Upload; import software.amazon.awssdk.transfer.s3.model.UploadDirectoryRequest; @@ -85,6 +87,16 @@ public Copy copy(CopyRequest copyRequest) { return delegate.copy(copyRequest); } + @Override + public FileDownload downloadFileWithPresignedUrl(PresignedDownloadFileRequest presignedDownloadFileRequest) { + return delegate.downloadFileWithPresignedUrl(presignedDownloadFileRequest); + } + + @Override + public Download downloadWithPresignedUrl(PresignedDownloadRequest presignedDownloadRequest) { + return delegate.downloadWithPresignedUrl(presignedDownloadRequest); + } + @Override public void close() { delegate.close(); diff --git a/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/GenericS3TransferManager.java b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/GenericS3TransferManager.java index 4814b862e379..8d8825c67eac 100644 --- a/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/GenericS3TransferManager.java +++ b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/GenericS3TransferManager.java @@ -75,6 +75,8 @@ import software.amazon.awssdk.transfer.s3.model.DownloadRequest; import software.amazon.awssdk.transfer.s3.model.FileDownload; import software.amazon.awssdk.transfer.s3.model.FileUpload; +import software.amazon.awssdk.transfer.s3.model.PresignedDownloadFileRequest; +import software.amazon.awssdk.transfer.s3.model.PresignedDownloadRequest; import software.amazon.awssdk.transfer.s3.model.ResumableFileDownload; import software.amazon.awssdk.transfer.s3.model.ResumableFileUpload; import software.amazon.awssdk.transfer.s3.model.Upload; @@ -597,6 +599,80 @@ public final Copy copy(CopyRequest copyRequest) { return new DefaultCopy(returnFuture, progressUpdater.progress()); } + @Override + public final FileDownload downloadFileWithPresignedUrl(PresignedDownloadFileRequest presignedDownloadFileRequest) { + Validate.paramNotNull(presignedDownloadFileRequest, "presignedDownloadFileRequest"); + + AsyncResponseTransformer responseTransformer = + AsyncResponseTransformer.toFile(presignedDownloadFileRequest.destination(), + FileTransformerConfiguration.defaultCreateOrReplaceExisting()); + + CompletableFuture returnFuture = new CompletableFuture<>(); + + TransferProgressUpdater progressUpdater = new TransferProgressUpdater(presignedDownloadFileRequest, null); + progressUpdater.transferInitiated(); + + responseTransformer = isS3ClientMultipartEnabled() + ? progressUpdater.wrapForNonSerialFileDownload( + responseTransformer, GetObjectRequest.builder().build()) + : progressUpdater.wrapResponseTransformer(responseTransformer); + progressUpdater.registerCompletion(returnFuture); + + try { + CompletableFuture future = s3AsyncClient.presignedUrlExtension().getObject( + presignedDownloadFileRequest.presignedUrlDownloadRequest(), responseTransformer); + + CompletableFutureUtils.forwardExceptionTo(returnFuture, future); + CompletableFutureUtils.forwardTransformedResultTo(future, returnFuture, + res -> CompletedFileDownload.builder() + .response(res) + .build()); + } catch (Throwable throwable) { + returnFuture.completeExceptionally(throwable); + } + + return new DefaultFileDownload(returnFuture, progressUpdater.progress(), + () -> { + throw new UnsupportedOperationException( + "Pause is not supported for presigned URL downloads"); + }, null); + } + + @Override + public final Download downloadWithPresignedUrl( + PresignedDownloadRequest presignedDownloadRequest) { + Validate.paramNotNull(presignedDownloadRequest, "presignedDownloadRequest"); + + AsyncResponseTransformer responseTransformer = + presignedDownloadRequest.responseTransformer(); + + CompletableFuture> returnFuture = new CompletableFuture<>(); + + TransferProgressUpdater progressUpdater = new TransferProgressUpdater(presignedDownloadRequest, null); + progressUpdater.transferInitiated(); + + responseTransformer = isS3ClientMultipartEnabled() + ? progressUpdater.wrapForNonSerialFileDownload( + responseTransformer, GetObjectRequest.builder().build()) + : progressUpdater.wrapResponseTransformer(responseTransformer); + progressUpdater.registerCompletion(returnFuture); + + try { + CompletableFuture future = s3AsyncClient.presignedUrlExtension().getObject( + presignedDownloadRequest.presignedUrlDownloadRequest(), responseTransformer); + + CompletableFutureUtils.forwardExceptionTo(returnFuture, future); + CompletableFutureUtils.forwardTransformedResultTo(future, returnFuture, + r -> CompletedDownload.builder() + .result(r) + .build()); + } catch (Throwable throwable) { + returnFuture.completeExceptionally(throwable); + } + + return new DefaultDownload<>(returnFuture, progressUpdater.progress()); + } + @Override public final void close() { if (isDefaultS3AsyncClient) { diff --git a/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/S3TransferManagerPresignedUrlDownloadTest.java b/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/S3TransferManagerPresignedUrlDownloadTest.java new file mode 100644 index 000000000000..16de24bec605 --- /dev/null +++ b/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/S3TransferManagerPresignedUrlDownloadTest.java @@ -0,0 +1,192 @@ +/* + * 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.transfer.s3.internal; + +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.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.net.URL; +import java.nio.file.Paths; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.async.AsyncResponseTransformer; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.presignedurl.AsyncPresignedUrlExtension; +import software.amazon.awssdk.services.s3.presignedurl.model.PresignedUrlDownloadRequest; +import software.amazon.awssdk.transfer.s3.model.CompletedDownload; +import software.amazon.awssdk.transfer.s3.model.CompletedFileDownload; +import software.amazon.awssdk.transfer.s3.model.FileDownload; +import software.amazon.awssdk.transfer.s3.model.PresignedDownloadFileRequest; +import software.amazon.awssdk.transfer.s3.model.PresignedDownloadRequest; + +/** + * Unit tests for S3TransferManager presigned URL download functionality. + */ +class S3TransferManagerPresignedUrlDownloadTest { + private static final String PRESIGNED_URL = "https://test-bucket.s3.amazonaws.com/test-key" + + "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKID" + + "&X-Amz-Date=20260101T000000Z&X-Amz-Expires=600" + + "&X-Amz-SignedHeaders=host&X-Amz-Signature=abc123"; + + private S3AsyncClient mockS3AsyncClient; + private AsyncPresignedUrlExtension mockPresignedUrlExtension; + private GenericS3TransferManager tm; + private URL presignedUrl; + + @BeforeEach + public void methodSetup() throws Exception { + mockS3AsyncClient = mock(S3AsyncClient.class); + mockPresignedUrlExtension = mock(AsyncPresignedUrlExtension.class); + presignedUrl = new URL(PRESIGNED_URL); + + when(mockS3AsyncClient.presignedUrlExtension()).thenReturn(mockPresignedUrlExtension); + + tm = new GenericS3TransferManager(mockS3AsyncClient, + mock(UploadDirectoryHelper.class), + mock(TransferManagerConfiguration.class), + mock(DownloadDirectoryHelper.class)); + } + + @AfterEach + public void methodTeardown() { + tm.close(); + } + + @Test + void downloadFileWithPresignedUrl_withValidRequest_returnsResponse() { + GetObjectResponse response = GetObjectResponse.builder().build(); + stubGetObject(CompletableFuture.completedFuture(response)); + + CompletedFileDownload completed = tm.downloadFileWithPresignedUrl(fileDownloadRequest()) + .completionFuture().join(); + + assertThat(completed.response()).isEqualTo(response); + } + + @Test + void downloadWithPresignedUrl_withValidRequest_returnsResponse() { + ResponseBytes responseBytes = ResponseBytes.fromByteArray( + GetObjectResponse.builder().build(), "test".getBytes()); + stubGetObject(CompletableFuture.completedFuture(responseBytes)); + + CompletedDownload> completed = + tm.downloadWithPresignedUrl(bytesDownloadRequest()).completionFuture().join(); + + assertThat(completed.result()).isEqualTo(responseBytes); + } + + @Test + void downloadFileWithPresignedUrl_withConsumerBuilder_returnsResponse() { + GetObjectResponse response = GetObjectResponse.builder().build(); + stubGetObject(CompletableFuture.completedFuture(response)); + + CompletedFileDownload completed = tm.downloadFileWithPresignedUrl( + request -> request.presignedUrlDownloadRequest(p -> p.presignedUrl(presignedUrl)) + .destination(Paths.get("/tmp/test"))) + .completionFuture().join(); + + assertThat(completed.response()).isEqualTo(response); + } + + @Test + void downloadFileWithPresignedUrl_whenCancelled_shouldForwardCancellation() { + CompletableFuture s3Future = new CompletableFuture<>(); + stubGetObject(s3Future); + + CompletableFuture future = + tm.downloadFileWithPresignedUrl(fileDownloadRequest()).completionFuture(); + + future.cancel(true); + assertThat(s3Future).isCancelled(); + } + + @Test + void downloadFileWithPresignedUrl_whenRequestFails_shouldCompleteExceptionally() { + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("download failed")); + stubGetObject(failedFuture); + + assertThatThrownBy(() -> tm.downloadFileWithPresignedUrl(fileDownloadRequest()).completionFuture().join()) + .hasCauseInstanceOf(RuntimeException.class) + .hasMessageContaining("download failed"); + } + + @Test + void downloadWithPresignedUrl_whenCancelled_shouldForwardCancellation() { + CompletableFuture s3Future = new CompletableFuture<>(); + stubGetObject(s3Future); + + CompletableFuture>> future = + tm.downloadWithPresignedUrl(bytesDownloadRequest()).completionFuture(); + + future.cancel(true); + assertThat(s3Future).isCancelled(); + } + + @Test + void downloadFileWithPresignedUrl_withNullRequest_shouldThrowNullPointerException() { + assertThatThrownBy(() -> tm.downloadFileWithPresignedUrl((PresignedDownloadFileRequest) null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void downloadWithPresignedUrl_withNullRequest_shouldThrowNullPointerException() { + assertThatThrownBy(() -> tm.downloadWithPresignedUrl(null)) + .isInstanceOf(NullPointerException.class); + } + @Test + void downloadFileWithPresignedUrl_pause_shouldThrowUnsupportedOperationException() { + GetObjectResponse response = GetObjectResponse.builder().build(); + stubGetObject(CompletableFuture.completedFuture(response)); + + FileDownload download = tm.downloadFileWithPresignedUrl(fileDownloadRequest()); + download.completionFuture().join(); + + assertThatThrownBy(download::pause) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("Pause is not supported for presigned URL downloads"); + } + + private PresignedDownloadFileRequest fileDownloadRequest() { + return PresignedDownloadFileRequest.builder() + .presignedUrlDownloadRequest(PresignedUrlDownloadRequest.builder() + .presignedUrl(presignedUrl) + .build()) + .destination(Paths.get("/tmp/test")) + .build(); + } + + private PresignedDownloadRequest> bytesDownloadRequest() { + return PresignedDownloadRequest.builder() + .presignedUrlDownloadRequest(PresignedUrlDownloadRequest.builder() + .presignedUrl(presignedUrl) + .build()) + .responseTransformer(AsyncResponseTransformer.toBytes()) + .build(); + } + + private void stubGetObject(CompletableFuture future) { + when(mockPresignedUrlExtension.getObject(any(PresignedUrlDownloadRequest.class), any(AsyncResponseTransformer.class))) + .thenReturn(future); + } +}