+ * 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.
+ *
+ *
+ * Note: The result of the operation doesn't support pause and resume functionality.
+ *
+ *
+ * @see #downloadFileWithPresignedUrl(PresignedDownloadFileRequest)
+ */
+ default PresignedFileDownload 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..6907f1f00d08 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,9 @@
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.PresignedFileDownload;
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 +88,16 @@ public Copy copy(CopyRequest copyRequest) {
return delegate.copy(copyRequest);
}
+ @Override
+ public PresignedFileDownload 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..f92c5196a095 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
@@ -55,6 +55,7 @@
import software.amazon.awssdk.transfer.s3.internal.model.DefaultDownload;
import software.amazon.awssdk.transfer.s3.internal.model.DefaultFileDownload;
import software.amazon.awssdk.transfer.s3.internal.model.DefaultFileUpload;
+import software.amazon.awssdk.transfer.s3.internal.model.DefaultPresignedFileDownload;
import software.amazon.awssdk.transfer.s3.internal.model.DefaultUpload;
import software.amazon.awssdk.transfer.s3.internal.progress.DefaultTransferProgress;
import software.amazon.awssdk.transfer.s3.internal.progress.DefaultTransferProgressSnapshot;
@@ -75,6 +76,9 @@
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.PresignedFileDownload;
import software.amazon.awssdk.transfer.s3.model.ResumableFileDownload;
import software.amazon.awssdk.transfer.s3.model.ResumableFileUpload;
import software.amazon.awssdk.transfer.s3.model.Upload;
@@ -330,7 +334,7 @@ private CopyObjectRequest attachSdkAttribute(CopyObjectRequest copyObjectRequest
}
private GetObjectRequest attachSdkAttribute(GetObjectRequest request,
- Consumer builderMutation) {
+ Consumer builderMutation) {
AwsRequestOverrideConfiguration modifiedRequestOverrideConfig =
request.overrideConfiguration()
.map(o -> o.toBuilder().applyMutation(builderMutation).build())
@@ -597,6 +601,78 @@ public final Copy copy(CopyRequest copyRequest) {
return new DefaultCopy(returnFuture, progressUpdater.progress());
}
+ @Override
+ public final PresignedFileDownload 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()
+ && presignedDownloadFileRequest.presignedUrlDownloadRequest().range() == null
+ ? 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 DefaultPresignedFileDownload(returnFuture, progressUpdater.progress());
+ }
+
+ @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()
+ && presignedDownloadRequest.presignedUrlDownloadRequest().range() == null
+ ? 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/main/java/software/amazon/awssdk/transfer/s3/internal/model/DefaultPresignedFileDownload.java b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/model/DefaultPresignedFileDownload.java
new file mode 100644
index 000000000000..253c60a8a42a
--- /dev/null
+++ b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/model/DefaultPresignedFileDownload.java
@@ -0,0 +1,79 @@
+/*
+ * 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.model;
+
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.transfer.s3.model.CompletedFileDownload;
+import software.amazon.awssdk.transfer.s3.model.PresignedFileDownload;
+import software.amazon.awssdk.transfer.s3.progress.TransferProgress;
+import software.amazon.awssdk.utils.ToString;
+import software.amazon.awssdk.utils.Validate;
+
+@SdkInternalApi
+public final class DefaultPresignedFileDownload implements PresignedFileDownload {
+ private final CompletableFuture completionFuture;
+ private final TransferProgress progress;
+
+ public DefaultPresignedFileDownload(CompletableFuture completionFuture,
+ TransferProgress progress) {
+ this.completionFuture = Validate.paramNotNull(completionFuture, "completionFuture");
+ this.progress = Validate.paramNotNull(progress, "progress");
+ }
+
+ @Override
+ public CompletableFuture completionFuture() {
+ return completionFuture;
+ }
+
+ @Override
+ public TransferProgress progress() {
+ return progress;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ DefaultPresignedFileDownload that = (DefaultPresignedFileDownload) o;
+
+ if (!Objects.equals(completionFuture, that.completionFuture)) {
+ return false;
+ }
+ return Objects.equals(progress, that.progress);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = completionFuture != null ? completionFuture.hashCode() : 0;
+ result = 31 * result + (progress != null ? progress.hashCode() : 0);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return ToString.builder("DefaultPresignedFileDownload")
+ .add("completionFuture", completionFuture)
+ .add("progress", progress)
+ .build();
+ }
+}
diff --git a/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/model/PresignedDownloadFileRequest.java b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/model/PresignedDownloadFileRequest.java
new file mode 100644
index 000000000000..b165292b22e5
--- /dev/null
+++ b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/model/PresignedDownloadFileRequest.java
@@ -0,0 +1,286 @@
+/*
+ * 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.model;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Consumer;
+import software.amazon.awssdk.annotations.NotThreadSafe;
+import software.amazon.awssdk.annotations.SdkPublicApi;
+import software.amazon.awssdk.services.s3.presignedurl.model.PresignedUrlDownloadRequest;
+import software.amazon.awssdk.transfer.s3.S3TransferManager;
+import software.amazon.awssdk.transfer.s3.progress.TransferListener;
+import software.amazon.awssdk.utils.ToString;
+import software.amazon.awssdk.utils.Validate;
+import software.amazon.awssdk.utils.builder.CopyableBuilder;
+import software.amazon.awssdk.utils.builder.ToCopyableBuilder;
+
+/**
+ * Download an object using a pre-signed URL to a local file. For non-file-based downloads, you may use {@link
+ * PresignedDownloadRequest} instead.
+ *
+ * @see S3TransferManager#downloadFileWithPresignedUrl(PresignedDownloadFileRequest)
+ */
+@SdkPublicApi
+public final class PresignedDownloadFileRequest
+ implements TransferObjectRequest, ToCopyableBuilder {
+
+ private final Path destination;
+ private final PresignedUrlDownloadRequest presignedUrlDownloadRequest;
+ private final List transferListeners;
+
+ private PresignedDownloadFileRequest(DefaultBuilder builder) {
+ this.destination = Validate.paramNotNull(builder.destination, "destination");
+ this.presignedUrlDownloadRequest = Validate.paramNotNull(builder.presignedUrlDownloadRequest,
+ "presignedUrlDownloadRequest");
+ this.transferListeners = builder.transferListeners;
+ }
+
+ /**
+ * Creates a builder that can be used to create a {@link PresignedDownloadFileRequest}.
+ *
+ * @see S3TransferManager#downloadFileWithPresignedUrl(PresignedDownloadFileRequest)
+ */
+ public static Builder builder() {
+ return new DefaultBuilder();
+ }
+
+ @Override
+ public Builder toBuilder() {
+ return new DefaultBuilder(this);
+ }
+
+ /**
+ * The {@link Path} to file that response contents will be written to. The file must not exist or this method
+ * will throw an exception. If the file is not writable by the current user then an exception will be thrown.
+ *
+ * @return the destination path
+ */
+ public Path destination() {
+ return destination;
+ }
+
+ /**
+ * @return The {@link PresignedUrlDownloadRequest} request that should be used for the download
+ */
+ public PresignedUrlDownloadRequest presignedUrlDownloadRequest() {
+ return presignedUrlDownloadRequest;
+ }
+
+ /**
+ *
+ * @return List of {@link TransferListener}s that will be notified as part of this request.
+ */
+ @Override
+ public List transferListeners() {
+ return transferListeners;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ PresignedDownloadFileRequest that = (PresignedDownloadFileRequest) o;
+
+ if (!Objects.equals(destination, that.destination)) {
+ return false;
+ }
+ if (!Objects.equals(presignedUrlDownloadRequest, that.presignedUrlDownloadRequest)) {
+ return false;
+ }
+ return Objects.equals(transferListeners, that.transferListeners);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = destination != null ? destination.hashCode() : 0;
+ result = 31 * result + (presignedUrlDownloadRequest != null ? presignedUrlDownloadRequest.hashCode() : 0);
+ result = 31 * result + (transferListeners != null ? transferListeners.hashCode() : 0);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return ToString.builder("PresignedDownloadFileRequest")
+ .add("destination", destination)
+ .add("presignedUrlDownloadRequest", presignedUrlDownloadRequest)
+ .add("transferListeners", transferListeners)
+ .build();
+ }
+
+ public static Class extends Builder> serializableBuilderClass() {
+ return DefaultBuilder.class;
+ }
+
+ /**
+ * A builder for a {@link PresignedDownloadFileRequest}, created with {@link #builder()}
+ */
+ @SdkPublicApi
+ @NotThreadSafe
+ public interface Builder extends CopyableBuilder {
+
+ /**
+ * The {@link Path} to file that response contents will be written to. The file must not exist or this method
+ * will throw an exception. If the file is not writable by the current user then an exception will be thrown.
+ *
+ * @param destination the destination path
+ * @return Returns a reference to this object so that method calls can be chained together.
+ */
+ Builder destination(Path destination);
+
+ /**
+ * The file that response contents will be written to. The file must not exist or this method
+ * will throw an exception. If the file is not writable by the current user then an exception will be thrown.
+ *
+ * @param destination the destination path
+ * @return Returns a reference to this object so that method calls can be chained together.
+ */
+ default Builder destination(File destination) {
+ Validate.paramNotNull(destination, "destination");
+ return destination(destination.toPath());
+ }
+
+ /**
+ * The {@link PresignedUrlDownloadRequest} request that should be used for the download
+ *
+ * @param presignedUrlDownloadRequest the presigned URL download request
+ * @return a reference to this object so that method calls can be chained together.
+ * @see #presignedUrlDownloadRequest(Consumer)
+ */
+ Builder presignedUrlDownloadRequest(PresignedUrlDownloadRequest presignedUrlDownloadRequest);
+
+ /**
+ * The {@link PresignedUrlDownloadRequest} request that should be used for the download
+ *
+ *
+ * This is a convenience method that creates an instance of the {@link PresignedUrlDownloadRequest} builder avoiding the
+ * need to create one manually via {@link PresignedUrlDownloadRequest#builder()}.
+ *
+ * @param presignedUrlDownloadRequestBuilder the presigned URL download request
+ * @return a reference to this object so that method calls can be chained together.
+ * @see #presignedUrlDownloadRequest(PresignedUrlDownloadRequest)
+ */
+ default Builder presignedUrlDownloadRequest(
+ Consumer presignedUrlDownloadRequestBuilder) {
+ PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder()
+ .applyMutation(presignedUrlDownloadRequestBuilder)
+ .build();
+ presignedUrlDownloadRequest(request);
+ return this;
+ }
+
+ /**
+ * The {@link TransferListener}s that will be notified as part of this request. This method overrides and replaces any
+ * transferListeners that have already been set. Add an optional request override configuration.
+ *
+ * @param transferListeners the collection of transferListeners
+ * @return Returns a reference to this object so that method calls can be chained together.
+ * @return This builder for method chaining.
+ * @see TransferListener
+ */
+ Builder transferListeners(Collection transferListeners);
+
+ /**
+ * Add a {@link TransferListener} that will be notified as part of this request.
+ *
+ * @param transferListener the transferListener to add
+ * @return Returns a reference to this object so that method calls can be chained together.
+ * @see TransferListener
+ */
+ Builder addTransferListener(TransferListener transferListener);
+
+ }
+
+ private static final class DefaultBuilder implements Builder {
+ private Path destination;
+ private PresignedUrlDownloadRequest presignedUrlDownloadRequest;
+ private List transferListeners;
+
+ private DefaultBuilder() {
+ }
+
+ private DefaultBuilder(PresignedDownloadFileRequest presignedDownloadFileRequest) {
+ this.destination = presignedDownloadFileRequest.destination;
+ this.presignedUrlDownloadRequest = presignedDownloadFileRequest.presignedUrlDownloadRequest;
+ this.transferListeners = presignedDownloadFileRequest.transferListeners;
+ }
+
+ @Override
+ public Builder destination(Path destination) {
+ this.destination = Validate.paramNotNull(destination, "destination");
+ return this;
+ }
+
+ public Path getDestination() {
+ return destination;
+ }
+
+ public void setDestination(Path destination) {
+ destination(destination);
+ }
+
+ @Override
+ public DefaultBuilder presignedUrlDownloadRequest(PresignedUrlDownloadRequest presignedUrlDownloadRequest) {
+ this.presignedUrlDownloadRequest = presignedUrlDownloadRequest;
+ return this;
+ }
+
+ public PresignedUrlDownloadRequest getPresignedUrlDownloadRequest() {
+ return presignedUrlDownloadRequest;
+ }
+
+ public void setPresignedUrlDownloadRequest(PresignedUrlDownloadRequest presignedUrlDownloadRequest) {
+ presignedUrlDownloadRequest(presignedUrlDownloadRequest);
+ }
+
+ @Override
+ public DefaultBuilder transferListeners(Collection transferListeners) {
+ this.transferListeners = transferListeners != null ? new ArrayList<>(transferListeners) : null;
+ return this;
+ }
+
+ @Override
+ public Builder addTransferListener(TransferListener transferListener) {
+ if (transferListeners == null) {
+ transferListeners = new ArrayList<>();
+ }
+ transferListeners.add(transferListener);
+ return this;
+ }
+
+ public List getTransferListeners() {
+ return transferListeners;
+ }
+
+ public void setTransferListeners(Collection transferListeners) {
+ transferListeners(transferListeners);
+ }
+
+ @Override
+ public PresignedDownloadFileRequest build() {
+ return new PresignedDownloadFileRequest(this);
+ }
+ }
+}
\ No newline at end of file
diff --git a/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/model/PresignedDownloadRequest.java b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/model/PresignedDownloadRequest.java
new file mode 100644
index 000000000000..1a45eeb21254
--- /dev/null
+++ b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/model/PresignedDownloadRequest.java
@@ -0,0 +1,391 @@
+/*
+ * 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.model;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Consumer;
+import software.amazon.awssdk.annotations.SdkPublicApi;
+import software.amazon.awssdk.core.async.AsyncResponseTransformer;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.presignedurl.model.PresignedUrlDownloadRequest;
+import software.amazon.awssdk.transfer.s3.S3TransferManager;
+import software.amazon.awssdk.transfer.s3.model.PresignedDownloadRequest.TypedBuilder;
+import software.amazon.awssdk.transfer.s3.progress.TransferListener;
+import software.amazon.awssdk.utils.ToString;
+import software.amazon.awssdk.utils.Validate;
+import software.amazon.awssdk.utils.builder.CopyableBuilder;
+import software.amazon.awssdk.utils.builder.ToCopyableBuilder;
+
+/**
+ * Represents the request to download an object using a pre-signed URL through the given
+ * {@link AsyncResponseTransformer}. For downloading to a file,
+ * you may use {@link PresignedDownloadFileRequest} instead.
+ *
+ * @see S3TransferManager#downloadWithPresignedUrl(PresignedDownloadRequest)
+ */
+@SdkPublicApi
+public final class PresignedDownloadRequest
+ implements TransferObjectRequest,
+ ToCopyableBuilder, PresignedDownloadRequest> {
+
+ private final AsyncResponseTransformer responseTransformer;
+ private final PresignedUrlDownloadRequest presignedUrlDownloadRequest;
+ private final List transferListeners;
+
+ private PresignedDownloadRequest(DefaultTypedBuilder builder) {
+ this.responseTransformer = Validate.paramNotNull(builder.responseTransformer, "responseTransformer");
+ this.presignedUrlDownloadRequest = Validate.paramNotNull(builder.presignedUrlDownloadRequest,
+ "presignedUrlDownloadRequest");
+ this.transferListeners = builder.transferListeners;
+ }
+
+ /**
+ * Creates a builder that can be used to create a {@link PresignedDownloadRequest}.
+ *
+ * @see UntypedBuilder
+ */
+ public static UntypedBuilder builder() {
+ return new DefaultUntypedBuilder();
+ }
+
+
+ @Override
+ public TypedBuilder toBuilder() {
+ return new DefaultTypedBuilder<>(this);
+ }
+
+ /**
+ * The {@link AsyncResponseTransformer} that response contents will be written to.
+ *
+ * @return the response transformer
+ */
+ public AsyncResponseTransformer responseTransformer() {
+ return responseTransformer;
+ }
+
+ /**
+ * @return The {@link PresignedUrlDownloadRequest} request that should be used for the download
+ */
+ public PresignedUrlDownloadRequest presignedUrlDownloadRequest() {
+ return presignedUrlDownloadRequest;
+ }
+
+ /**
+ * @return the List of transferListeners.
+ * @see TypedBuilder#transferListeners(Collection)
+ */
+ @Override
+ public List transferListeners() {
+ return transferListeners;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ PresignedDownloadRequest> that = (PresignedDownloadRequest>) o;
+
+ if (!Objects.equals(responseTransformer, that.responseTransformer)) {
+ return false;
+ }
+ if (!Objects.equals(presignedUrlDownloadRequest, that.presignedUrlDownloadRequest)) {
+ return false;
+ }
+ return Objects.equals(transferListeners, that.transferListeners);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = responseTransformer != null ? responseTransformer.hashCode() : 0;
+ result = 31 * result + (presignedUrlDownloadRequest != null ? presignedUrlDownloadRequest.hashCode() : 0);
+ result = 31 * result + (transferListeners != null ? transferListeners.hashCode() : 0);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return ToString.builder("PresignedDownloadRequest")
+ .add("responseTransformer", responseTransformer)
+ .add("presignedUrlDownloadRequest", presignedUrlDownloadRequest)
+ .add("transferListeners", transferListeners)
+ .build();
+ }
+
+ /**
+ * Initial calls to {@link PresignedDownloadRequest#builder()} return an {@link UntypedBuilder}, where the builder is not yet
+ * parameterized with the generic type associated with {@link PresignedDownloadRequest}.
+ * This prevents the otherwise awkward syntax of having to explicitly cast the builder type, e.g.,
+ *
+ * {@code PresignedDownloadRequest.>builder()}
+ *
+ * Instead, the type may be inferred as part of specifying the {@link #responseTransformer(AsyncResponseTransformer)}
+ * parameter, at which point the builder chain will return a new {@link TypedBuilder}.
+ */
+ public interface UntypedBuilder {
+
+ /**
+ * The {@link PresignedUrlDownloadRequest} request that should be used for the download
+ *
+ * @param presignedUrlDownloadRequest the presigned URL download request
+ * @return a reference to this object so that method calls can be chained together.
+ * @see #presignedUrlDownloadRequest(Consumer)
+ */
+ UntypedBuilder presignedUrlDownloadRequest(PresignedUrlDownloadRequest presignedUrlDownloadRequest);
+
+ /**
+ * The {@link PresignedUrlDownloadRequest} request that should be used for the download
+ *
+ * This is a convenience method that creates an instance of the {@link PresignedUrlDownloadRequest}
+ * builder, avoiding the need to create one manually via {@link PresignedUrlDownloadRequest#builder()}.
+ *
+ * @param presignedUrlDownloadRequestBuilder the presigned URL download request
+ * @return a reference to this object so that method calls can be chained together.
+ * @see #presignedUrlDownloadRequest(PresignedUrlDownloadRequest)
+ */
+ default UntypedBuilder presignedUrlDownloadRequest(
+ Consumer presignedUrlDownloadRequestBuilder) {
+ PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder()
+ .applyMutation(presignedUrlDownloadRequestBuilder)
+ .build();
+ presignedUrlDownloadRequest(request);
+ return this;
+ }
+
+ /**
+ * The {@link TransferListener}s that will be notified as part of this request. This method
+ * overrides and replaces any transferListeners that have already been set. Add an optional
+ * request override configuration.
+ *
+ * @param transferListeners the collection of transferListeners
+ * @return Returns a reference to this object so that method calls can be chained together.
+ * @return This builder for method chaining.
+ * @see TransferListener
+ */
+ UntypedBuilder transferListeners(
+ Collection transferListeners);
+
+ /**
+ * Adds a {@link TransferListener} that will be notified as part of this request.
+ *
+ * @param transferListener the transferListener to add
+ * @return Returns a reference to this object so that method calls can be chained together.
+ * @see TransferListener
+ */
+ UntypedBuilder addTransferListener(TransferListener transferListener);
+
+ /**
+ * Specifies the {@link AsyncResponseTransformer} that should be used for the download. This
+ * method also infers the generic type of {@link PresignedDownloadRequest} to create,
+ * inferred from the second type parameter of the provided {@link AsyncResponseTransformer}.
+ * E.g, specifying {@link AsyncResponseTransformer#toBytes()} would result in inferring the
+ * type of the {@link PresignedDownloadRequest} to be of {@code ResponseBytes}.
+ * See the static factory methods available in {@link AsyncResponseTransformer}.
+ *
+ * @param responseTransformer the AsyncResponseTransformer
+ * @param the type of {@link PresignedDownloadRequest} to create
+ * @return a reference to this object so that method calls can be chained together.
+ * @see AsyncResponseTransformer
+ */
+ TypedBuilder responseTransformer(
+ AsyncResponseTransformer responseTransformer);
+ }
+
+ private static final class DefaultUntypedBuilder implements UntypedBuilder {
+ private PresignedUrlDownloadRequest presignedUrlDownloadRequest;
+ private List transferListeners;
+
+ private DefaultUntypedBuilder() {
+ }
+
+ @Override
+ public UntypedBuilder presignedUrlDownloadRequest(PresignedUrlDownloadRequest presignedUrlDownloadRequest) {
+ this.presignedUrlDownloadRequest = presignedUrlDownloadRequest;
+ return this;
+ }
+
+ @Override
+ public UntypedBuilder transferListeners(
+ Collection transferListeners) {
+ this.transferListeners = transferListeners != null ?
+ new ArrayList<>(transferListeners) : null;
+ return this;
+ }
+
+ @Override
+ public UntypedBuilder addTransferListener(TransferListener transferListener) {
+ if (transferListeners == null) {
+ transferListeners = new ArrayList<>();
+ }
+ transferListeners.add(transferListener);
+ return this;
+ }
+
+ public List getTransferListeners() {
+ return transferListeners;
+ }
+
+ public void setTransferListeners(
+ Collection transferListeners) {
+ transferListeners(transferListeners);
+ }
+
+ @Override
+ public TypedBuilder responseTransformer(
+ AsyncResponseTransformer responseTransformer) {
+ return new DefaultTypedBuilder()
+ .presignedUrlDownloadRequest(presignedUrlDownloadRequest)
+ .transferListeners(transferListeners)
+ .responseTransformer(responseTransformer);
+ }
+ }
+
+ /**
+ * The type-parameterized version of {@link UntypedBuilder}. This builder's type is inferred as part of specifying {@link
+ * UntypedBuilder#responseTransformer(AsyncResponseTransformer)}, after which this builder can be used to construct a {@link
+ * PresignedDownloadRequest} with the same generic type.
+ */
+ public interface TypedBuilder extends CopyableBuilder, PresignedDownloadRequest> {
+
+ /**
+ * The {@link PresignedUrlDownloadRequest} request that should be used for the download
+ *
+ * @param presignedUrlDownloadRequest the presigned URL download request
+ * @return a reference to this object so that method calls can be chained together.
+ * @see #presignedUrlDownloadRequest(Consumer)
+ */
+ TypedBuilder presignedUrlDownloadRequest(PresignedUrlDownloadRequest presignedUrlDownloadRequest);
+
+ /**
+ * The {@link PresignedUrlDownloadRequest} request that should be used for the download
+ *
+ * This is a convenience method that creates an instance of the {@link PresignedUrlDownloadRequest} builder,
+ * avoiding the need to create one manually via {@link PresignedUrlDownloadRequest#builder()}.
+ *
+ * @param presignedUrlDownloadRequestBuilder the presigned URL download request
+ * @return a reference to this object so that method calls can be chained together.
+ * @see #presignedUrlDownloadRequest(PresignedUrlDownloadRequest)
+ */
+ default TypedBuilder presignedUrlDownloadRequest(
+ Consumer presignedUrlDownloadRequestBuilder) {
+ PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder()
+ .applyMutation(presignedUrlDownloadRequestBuilder)
+ .build();
+ presignedUrlDownloadRequest(request);
+ return this;
+ }
+
+ /**
+ * The {@link TransferListener}s that will be notified as part of this request. This method overrides and
+ * replaces any transferListeners that have already been set. Add an optional request override
+ * configuration.
+ *
+ * @param transferListeners the collection of transferListeners
+ * @return Returns a reference to this object so that method calls can be chained together.
+ * @return This builder for method chaining.
+ * @see TransferListener
+ */
+ TypedBuilder transferListeners(
+ Collection transferListeners);
+
+ /**
+ * Add a {@link TransferListener} that will be notified as part of this request.
+ *
+ * @param transferListener the transferListener to add
+ * @return Returns a reference to this object so that method calls can be chained together.
+ * @see TransferListener
+ */
+ TypedBuilder addTransferListener(TransferListener transferListener);
+
+ /**
+ * Specifies the {@link AsyncResponseTransformer} that should be used for the download. The generic type used is
+ * constrained by the {@link UntypedBuilder#responseTransformer(AsyncResponseTransformer)} that was previously used to
+ * create this {@link TypedBuilder}.
+ *
+ * @param responseTransformer the AsyncResponseTransformer
+ * @return a reference to this object so that method calls can be chained together.
+ * @see AsyncResponseTransformer
+ */
+ TypedBuilder responseTransformer(
+ AsyncResponseTransformer responseTransformer);
+ }
+
+ private static class DefaultTypedBuilder implements TypedBuilder {
+ private PresignedUrlDownloadRequest presignedUrlDownloadRequest;
+ private List transferListeners;
+ private AsyncResponseTransformer responseTransformer;
+
+ private DefaultTypedBuilder() {
+ }
+
+ private DefaultTypedBuilder(PresignedDownloadRequest request) {
+ this.presignedUrlDownloadRequest = request.presignedUrlDownloadRequest;
+ this.responseTransformer = request.responseTransformer;
+ this.transferListeners = request.transferListeners;
+ }
+
+ @Override
+ public TypedBuilder presignedUrlDownloadRequest(PresignedUrlDownloadRequest presignedUrlDownloadRequest) {
+ this.presignedUrlDownloadRequest = presignedUrlDownloadRequest;
+ return this;
+ }
+
+ @Override
+ public TypedBuilder responseTransformer(
+ AsyncResponseTransformer responseTransformer) {
+ this.responseTransformer = responseTransformer;
+ return this;
+ }
+
+ @Override
+ public TypedBuilder transferListeners(
+ Collection transferListeners) {
+ this.transferListeners = transferListeners != null ?
+ new ArrayList<>(transferListeners) : null;
+ return this;
+ }
+
+ @Override
+ public TypedBuilder addTransferListener(TransferListener transferListener) {
+ if (transferListeners == null) {
+ transferListeners = new ArrayList<>();
+ }
+ transferListeners.add(transferListener);
+ return this;
+ }
+
+ public List getTransferListeners() {
+ return transferListeners;
+ }
+
+ public void setTransferListeners(
+ Collection transferListeners) {
+ transferListeners(transferListeners);
+ }
+
+ @Override
+ public PresignedDownloadRequest build() {
+ return new PresignedDownloadRequest<>(this);
+ }
+ }
+}
\ No newline at end of file
diff --git a/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/model/PresignedFileDownload.java b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/model/PresignedFileDownload.java
new file mode 100644
index 000000000000..669712c2dea1
--- /dev/null
+++ b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/model/PresignedFileDownload.java
@@ -0,0 +1,33 @@
+/*
+ * 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.model;
+
+import java.util.concurrent.CompletableFuture;
+import software.amazon.awssdk.annotations.SdkPublicApi;
+import software.amazon.awssdk.annotations.ThreadSafe;
+
+/**
+ * A download transfer of a single object from S3 using a presigned URL.
+ *
+ * Unlike {@link FileDownload}, this type does not support pause/resume.
+ */
+@SdkPublicApi
+@ThreadSafe
+public interface PresignedFileDownload extends ObjectTransfer {
+
+ @Override
+ CompletableFuture completionFuture();
+}
diff --git a/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/DefaultPresignedFileDownloadTest.java b/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/DefaultPresignedFileDownloadTest.java
new file mode 100644
index 000000000000..b98a4cb35454
--- /dev/null
+++ b/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/DefaultPresignedFileDownloadTest.java
@@ -0,0 +1,30 @@
+/*
+ * 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 nl.jqno.equalsverifier.EqualsVerifier;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.transfer.s3.internal.model.DefaultPresignedFileDownload;
+
+public class DefaultPresignedFileDownloadTest {
+
+ @Test
+ public void equals_hashcode() {
+ EqualsVerifier.forClass(DefaultPresignedFileDownload.class)
+ .withNonnullFields("completionFuture", "progress")
+ .verify();
+ }
+}
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..a299e5ca41f7
--- /dev/null
+++ b/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/S3TransferManagerPresignedUrlDownloadTest.java
@@ -0,0 +1,179 @@
+/*
+ * 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.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);
+ }
+
+ 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);
+ }
+}
diff --git a/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/S3TransferManagerPresignedUrlListenerWiremockTest.java b/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/S3TransferManagerPresignedUrlListenerWiremockTest.java
new file mode 100644
index 000000000000..2e9e5dbecca8
--- /dev/null
+++ b/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/S3TransferManagerPresignedUrlListenerWiremockTest.java
@@ -0,0 +1,253 @@
+/*
+ * 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 com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+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.assertThatExceptionOfType;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.times;
+
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URL;
+import java.util.concurrent.CompletionException;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Mockito;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.async.AsyncResponseTransformer;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3AsyncClient;
+import software.amazon.awssdk.services.s3.presignedurl.model.PresignedUrlDownloadRequest;
+import software.amazon.awssdk.testutils.RandomTempFile;
+import software.amazon.awssdk.transfer.s3.S3TransferManager;
+import software.amazon.awssdk.transfer.s3.model.Download;
+import software.amazon.awssdk.transfer.s3.model.PresignedFileDownload;
+import software.amazon.awssdk.transfer.s3.model.PresignedDownloadFileRequest;
+import software.amazon.awssdk.transfer.s3.model.PresignedDownloadRequest;
+import software.amazon.awssdk.transfer.s3.progress.TransferListener;
+
+/**
+ * Tests that TransferListener callbacks fire correctly for presigned URL downloads
+ * with both multipart-enabled and non-multipart clients.
+ */
+@WireMockTest
+public class S3TransferManagerPresignedUrlListenerWiremockTest {
+
+ private static URI testEndpoint;
+ private static RandomTempFile testFile;
+
+ @BeforeAll
+ public static void init(WireMockRuntimeInfo wm) throws IOException {
+ testEndpoint = URI.create(wm.getHttpBaseUrl());
+ testFile = new RandomTempFile("presigned-listener-test", 1024);
+ }
+
+ @BeforeEach
+ void resetWireMock() {
+ WireMock.reset();
+ }
+
+ private static S3AsyncClient s3AsyncClient(boolean multipartEnabled) {
+ return S3AsyncClient.builder()
+ .multipartEnabled(multipartEnabled)
+ .region(Region.US_EAST_1)
+ .endpointOverride(testEndpoint)
+ .credentialsProvider(
+ StaticCredentialsProvider.create(AwsBasicCredentials.create("key", "secret")))
+ .build();
+ }
+
+ static Stream presignedUrlTestCases() {
+ return Stream.of(
+ Arguments.of(true, "toFile", null),
+ Arguments.of(true, "toFile", "bytes=0-511"),
+ Arguments.of(true, "toBytes", null),
+ Arguments.of(true, "toBytes", "bytes=0-511"),
+ Arguments.of(false, "toFile", null),
+ Arguments.of(false, "toFile", "bytes=0-511"),
+ Arguments.of(false, "toBytes", null),
+ Arguments.of(false, "toBytes", "bytes=0-511")
+ );
+ }
+
+ @ParameterizedTest(name = "presignedUrlDownload_multipart={0}_type={1}_range={2}")
+ @MethodSource("presignedUrlTestCases")
+ void presignedUrlDownload_shouldInvokeListener(boolean multipartEnabled, String type, String range) throws Exception {
+ S3AsyncClient s3Async = s3AsyncClient(multipartEnabled);
+ S3TransferManager tm = new GenericS3TransferManager(s3Async, mock(UploadDirectoryHelper.class),
+ mock(TransferManagerConfiguration.class),
+ mock(DownloadDirectoryHelper.class));
+
+ byte[] responseBody = new byte[512];
+ stubFor(get(urlPathEqualTo("/presigned-key")).willReturn(aResponse()
+ .withStatus(206)
+ .withHeader("Content-Length", "512")
+ .withHeader("Content-Range", "bytes 0-511/512")
+ .withHeader("ETag", "\"test-etag\"")
+ .withBody(responseBody)));
+
+ TransferListener listener = mock(TransferListener.class);
+ URL presignedUrl = new URL(testEndpoint + "/presigned-key?X-Amz-Algorithm=AWS4-HMAC-SHA256");
+
+ PresignedUrlDownloadRequest.Builder requestBuilder = PresignedUrlDownloadRequest.builder()
+ .presignedUrl(presignedUrl);
+ if (range != null) {
+ requestBuilder.range(range);
+ }
+
+ if ("toFile".equals(type)) {
+ PresignedFileDownload download = tm.downloadFileWithPresignedUrl(
+ PresignedDownloadFileRequest.builder()
+ .presignedUrlDownloadRequest(requestBuilder.build())
+ .destination(testFile.toPath())
+ .addTransferListener(listener)
+ .build());
+ download.completionFuture().join();
+ } else {
+ Download> download = tm.downloadWithPresignedUrl(
+ PresignedDownloadRequest.builder()
+ .presignedUrlDownloadRequest(requestBuilder.build())
+ .responseTransformer(AsyncResponseTransformer.toBytes())
+ .addTransferListener(listener)
+ .build());
+ download.completionFuture().join();
+ }
+
+ Mockito.verify(listener, timeout(1000).times(1)).transferInitiated(ArgumentMatchers.any());
+ Mockito.verify(listener, timeout(1000).atLeastOnce()).bytesTransferred(ArgumentMatchers.any());
+
+ tm.close();
+ s3Async.close();
+ }
+
+ static Stream presignedUrlFailureTestCases() {
+ return Stream.of(
+ Arguments.of(true, "toFile"),
+ Arguments.of(true, "toBytes"),
+ Arguments.of(false, "toFile"),
+ Arguments.of(false, "toBytes")
+ );
+ }
+
+ @ParameterizedTest(name = "presignedUrlDownload_failure_multipart={0}_type={1}")
+ @MethodSource("presignedUrlFailureTestCases")
+ void presignedUrlDownload_failure_shouldInvokeListener(boolean multipartEnabled, String type) throws Exception {
+ S3AsyncClient s3Async = s3AsyncClient(multipartEnabled);
+ S3TransferManager tm = new GenericS3TransferManager(s3Async, mock(UploadDirectoryHelper.class),
+ mock(TransferManagerConfiguration.class),
+ mock(DownloadDirectoryHelper.class));
+
+ stubFor(get(urlPathEqualTo("/presigned-key"))
+ .willReturn(aResponse().withStatus(404)
+ .withBody("TestErrorTest failure")));
+
+ TransferListener listener = mock(TransferListener.class);
+ URL presignedUrl = new URL(testEndpoint + "/presigned-key?X-Amz-Algorithm=AWS4-HMAC-SHA256");
+
+ if ("toFile".equals(type)) {
+ PresignedFileDownload download = tm.downloadFileWithPresignedUrl(
+ PresignedDownloadFileRequest.builder()
+ .presignedUrlDownloadRequest(PresignedUrlDownloadRequest.builder()
+ .presignedUrl(presignedUrl)
+ .build())
+ .destination(testFile.toPath())
+ .addTransferListener(listener)
+ .build());
+ assertThatExceptionOfType(CompletionException.class).isThrownBy(() -> download.completionFuture().join());
+ } else {
+ Download> download = tm.downloadWithPresignedUrl(
+ PresignedDownloadRequest.builder()
+ .presignedUrlDownloadRequest(PresignedUrlDownloadRequest.builder()
+ .presignedUrl(presignedUrl)
+ .build())
+ .responseTransformer(AsyncResponseTransformer.toBytes())
+ .addTransferListener(listener)
+ .build());
+ assertThatExceptionOfType(CompletionException.class).isThrownBy(() -> download.completionFuture().join());
+ }
+
+ Mockito.verify(listener, timeout(1000).times(1)).transferInitiated(ArgumentMatchers.any());
+ Mockito.verify(listener, timeout(1000).times(1)).transferFailed(ArgumentMatchers.any());
+ Mockito.verify(listener, times(0)).transferComplete(ArgumentMatchers.any());
+
+ tm.close();
+ s3Async.close();
+ }
+
+ @ParameterizedTest(name = "presignedUrlDownload_cancelled_multipart={0}_type={1}")
+ @MethodSource("presignedUrlFailureTestCases")
+ void presignedUrlDownload_cancelled_shouldInvokeTransferFailed(boolean multipartEnabled, String type) throws Exception {
+ S3AsyncClient s3Async = s3AsyncClient(multipartEnabled);
+ S3TransferManager tm = new GenericS3TransferManager(s3Async, mock(UploadDirectoryHelper.class),
+ mock(TransferManagerConfiguration.class),
+ mock(DownloadDirectoryHelper.class));
+
+ // Slow response to keep request in-flight during cancellation
+ stubFor(get(urlPathEqualTo("/presigned-key")).willReturn(aResponse()
+ .withStatus(206)
+ .withHeader("Content-Length", "512")
+ .withHeader("Content-Range", "bytes 0-511/512")
+ .withHeader("ETag", "\"test-etag\"")
+ .withBody(new byte[512])
+ .withFixedDelay(5000)));
+
+ TransferListener listener = mock(TransferListener.class);
+ URL presignedUrl = new URL(testEndpoint + "/presigned-key?X-Amz-Algorithm=AWS4-HMAC-SHA256");
+
+ if ("toFile".equals(type)) {
+ PresignedFileDownload download = tm.downloadFileWithPresignedUrl(
+ PresignedDownloadFileRequest.builder()
+ .presignedUrlDownloadRequest(PresignedUrlDownloadRequest.builder()
+ .presignedUrl(presignedUrl)
+ .build())
+ .destination(testFile.toPath())
+ .addTransferListener(listener)
+ .build());
+ download.completionFuture().cancel(true);
+ } else {
+ Download> download = tm.downloadWithPresignedUrl(
+ PresignedDownloadRequest.builder()
+ .presignedUrlDownloadRequest(PresignedUrlDownloadRequest.builder()
+ .presignedUrl(presignedUrl)
+ .build())
+ .responseTransformer(AsyncResponseTransformer.toBytes())
+ .addTransferListener(listener)
+ .build());
+ download.completionFuture().cancel(true);
+ }
+
+ Mockito.verify(listener, timeout(1000).times(1)).transferInitiated(ArgumentMatchers.any());
+ Mockito.verify(listener, timeout(1000).times(1)).transferFailed(ArgumentMatchers.any());
+ Mockito.verify(listener, times(0)).transferComplete(ArgumentMatchers.any());
+
+ tm.close();
+ s3Async.close();
+ }
+}
diff --git a/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/model/PresignedDownloadFileRequestTest.java b/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/model/PresignedDownloadFileRequestTest.java
new file mode 100644
index 000000000000..fb437e5f8796
--- /dev/null
+++ b/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/model/PresignedDownloadFileRequestTest.java
@@ -0,0 +1,119 @@
+/*
+ * 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.model;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.File;
+import java.net.URL;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import nl.jqno.equalsverifier.EqualsVerifier;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.services.s3.presignedurl.model.PresignedUrlDownloadRequest;
+
+class PresignedDownloadFileRequestTest {
+
+ @Test
+ void build_withoutPresignedUrlDownloadRequest_throwsNullPointerException() {
+ assertThatThrownBy(() -> PresignedDownloadFileRequest.builder()
+ .destination(Paths.get("."))
+ .build())
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("presignedUrlDownloadRequest");
+ }
+
+ @Test
+ void build_withoutDestination_throwsNullPointerException() {
+ assertThatThrownBy(() -> PresignedDownloadFileRequest.builder()
+ .presignedUrlDownloadRequest(createPresignedRequest())
+ .build())
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("destination");
+ }
+
+ @Test
+ void build_withPath_setsDestinationCorrectly() {
+ Path path = Paths.get(".");
+ PresignedUrlDownloadRequest presignedRequest = createPresignedRequest();
+
+ PresignedDownloadFileRequest request = PresignedDownloadFileRequest.builder()
+ .destination(path)
+ .presignedUrlDownloadRequest(presignedRequest)
+ .build();
+
+ assertThat(request.destination()).isEqualTo(path);
+ assertThat(request.presignedUrlDownloadRequest()).isEqualTo(presignedRequest);
+ }
+
+ @Test
+ void build_withFile_convertsToPathCorrectly() {
+ Path path = Paths.get(".");
+ PresignedUrlDownloadRequest presignedRequest = createPresignedRequest();
+
+ PresignedDownloadFileRequest request = PresignedDownloadFileRequest.builder()
+ .destination(path.toFile())
+ .presignedUrlDownloadRequest(presignedRequest)
+ .build();
+
+ assertThat(request.destination()).isEqualTo(path);
+ }
+
+ @Test
+ void destination_withNullFile_throwsNullPointerException() {
+ File file = null;
+
+ assertThatThrownBy(() -> PresignedDownloadFileRequest.builder()
+ .destination(file))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("destination");
+ }
+
+ @Test
+ void presignedUrlDownloadRequest_withConsumerBuilder_buildsCorrectly() {
+ Path path = Paths.get(".");
+
+ PresignedDownloadFileRequest request = PresignedDownloadFileRequest.builder()
+ .destination(path)
+ .presignedUrlDownloadRequest(b -> b.presignedUrl(createTestUrl()))
+ .build();
+
+ assertThat(request.presignedUrlDownloadRequest()).isNotNull();
+ assertThat(request.presignedUrlDownloadRequest().presignedUrl()).isEqualTo(createTestUrl());
+ }
+
+ @Test
+ void equalsHashCodeTest() {
+ EqualsVerifier.forClass(PresignedDownloadFileRequest.class)
+ .withNonnullFields("destination", "presignedUrlDownloadRequest")
+ .verify();
+ }
+
+ private PresignedUrlDownloadRequest createPresignedRequest() {
+ return PresignedUrlDownloadRequest.builder()
+ .presignedUrl(createTestUrl())
+ .build();
+ }
+
+ private URL createTestUrl() {
+ try {
+ return new URL("https://example.com/test");
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/model/PresignedDownloadRequestTest.java b/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/model/PresignedDownloadRequestTest.java
new file mode 100644
index 000000000000..15381bfd50b6
--- /dev/null
+++ b/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/model/PresignedDownloadRequestTest.java
@@ -0,0 +1,136 @@
+/*
+ * 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.model;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.net.URL;
+import nl.jqno.equalsverifier.EqualsVerifier;
+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.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.presignedurl.model.PresignedUrlDownloadRequest;
+
+class PresignedDownloadRequestTest {
+
+ @Test
+ void build_withoutPresignedUrlDownloadRequest_throwsNullPointerException() {
+ assertThatThrownBy(() -> PresignedDownloadRequest.builder()
+ .responseTransformer(AsyncResponseTransformer.toBytes())
+ .build())
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("presignedUrlDownloadRequest");
+ }
+
+ @Test
+ void builder_withoutResponseTransformer_returnsUntypedBuilder() {
+ PresignedDownloadRequest.UntypedBuilder untypedBuilder = PresignedDownloadRequest.builder()
+ .presignedUrlDownloadRequest(createPresignedRequest());
+
+ assertThat(untypedBuilder).isNotNull();
+ }
+
+ @Test
+ void build_withResponseTransformer_createsRequestCorrectly() {
+ AsyncResponseTransformer> responseTransformer =
+ AsyncResponseTransformer.toBytes();
+ PresignedUrlDownloadRequest presignedRequest = createPresignedRequest();
+
+ PresignedDownloadRequest> request =
+ PresignedDownloadRequest.builder()
+ .presignedUrlDownloadRequest(presignedRequest)
+ .responseTransformer(responseTransformer)
+ .build();
+
+ assertThat(request.responseTransformer()).isEqualTo(responseTransformer);
+ assertThat(request.presignedUrlDownloadRequest()).isEqualTo(presignedRequest);
+ }
+
+ @Test
+ void presignedUrlDownloadRequest_withConsumerBuilder_buildsCorrectly() {
+ AsyncResponseTransformer> responseTransformer =
+ AsyncResponseTransformer.toBytes();
+
+ PresignedDownloadRequest> request =
+ PresignedDownloadRequest.builder()
+ .presignedUrlDownloadRequest(b -> b.presignedUrl(createTestUrl()))
+ .responseTransformer(responseTransformer)
+ .build();
+
+ assertThat(request.presignedUrlDownloadRequest()).isNotNull();
+ assertThat(request.presignedUrlDownloadRequest().presignedUrl()).isEqualTo(createTestUrl());
+ }
+
+ @Test
+ void responseTransformer_withNull_throwsNullPointerException() {
+ assertThatThrownBy(() -> PresignedDownloadRequest.builder()
+ .presignedUrlDownloadRequest(createPresignedRequest())
+ .responseTransformer(null)
+ .build())
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("responseTransformer");
+ }
+
+ @Test
+ void builder_untypedToTypedTransition_worksCorrectly() {
+ PresignedDownloadRequest.UntypedBuilder untypedBuilder = PresignedDownloadRequest.builder()
+ .presignedUrlDownloadRequest(createPresignedRequest());
+
+ PresignedDownloadRequest> request =
+ untypedBuilder.responseTransformer(AsyncResponseTransformer.toBytes())
+ .build();
+
+ assertThat(request.responseTransformer()).isNotNull();
+ assertThat(request.presignedUrlDownloadRequest()).isNotNull();
+ }
+
+ @Test
+ void toBuilder_copiesAllFields() {
+ PresignedDownloadRequest> original =
+ PresignedDownloadRequest.builder()
+ .presignedUrlDownloadRequest(createPresignedRequest())
+ .responseTransformer(AsyncResponseTransformer.toBytes())
+ .build();
+
+ PresignedDownloadRequest> copy = original.toBuilder().build();
+
+ assertThat(copy.responseTransformer()).isEqualTo(original.responseTransformer());
+ assertThat(copy.presignedUrlDownloadRequest()).isEqualTo(original.presignedUrlDownloadRequest());
+ }
+
+ @Test
+ void equalsHashCodeTest() {
+ EqualsVerifier.forClass(PresignedDownloadRequest.class)
+ .withNonnullFields("responseTransformer", "presignedUrlDownloadRequest")
+ .verify();
+ }
+
+ private PresignedUrlDownloadRequest createPresignedRequest() {
+ return PresignedUrlDownloadRequest.builder()
+ .presignedUrl(createTestUrl())
+ .build();
+ }
+
+ private URL createTestUrl() {
+ try {
+ return new URL("https://example.com/test");
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/services/s3/src/it/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionIntegrationTest.java b/services/s3/src/it/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionIntegrationTest.java
new file mode 100644
index 000000000000..1ec2cf2df702
--- /dev/null
+++ b/services/s3/src/it/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionIntegrationTest.java
@@ -0,0 +1,32 @@
+/*
+ * 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.presignedurl;
+
+import org.junit.jupiter.api.BeforeAll;
+import software.amazon.awssdk.services.s3.S3AsyncClient;
+
+public class AsyncPresignedUrlExtensionIntegrationTest extends AsyncPresignedUrlExtensionTestSuite {
+
+ @BeforeAll
+ static void setUpIntegrationTest() {
+ S3AsyncClient s3AsyncClient = s3AsyncClientBuilder().build();
+ presignedUrlExtension = s3AsyncClient.presignedUrlExtension();
+ }
+
+ @Override
+ protected S3AsyncClient createS3AsyncClient() {
+ return s3AsyncClientBuilder().build();
+ }
+}
diff --git a/services/s3/src/it/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionMultipartIntegrationTest.java b/services/s3/src/it/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionMultipartIntegrationTest.java
new file mode 100644
index 000000000000..16e41325817f
--- /dev/null
+++ b/services/s3/src/it/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionMultipartIntegrationTest.java
@@ -0,0 +1,35 @@
+/*
+ * 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.presignedurl;
+
+import org.junit.jupiter.api.BeforeAll;
+import software.amazon.awssdk.services.s3.S3AsyncClient;
+
+
+public class AsyncPresignedUrlExtensionMultipartIntegrationTest extends AsyncPresignedUrlExtensionTestSuite {
+
+ @BeforeAll
+ static void setUpIntegrationTest() {
+ S3AsyncClient s3AsyncClient = s3AsyncClientBuilder()
+ .multipartEnabled(true)
+ .build();
+ presignedUrlExtension = s3AsyncClient.presignedUrlExtension();
+ }
+
+ @Override
+ protected S3AsyncClient createS3AsyncClient() {
+ return s3AsyncClientBuilder().build();
+ }
+}
diff --git a/services/s3/src/it/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionTestSuite.java b/services/s3/src/it/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionTestSuite.java
new file mode 100644
index 000000000000..b5a3edbfd8f7
--- /dev/null
+++ b/services/s3/src/it/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionTestSuite.java
@@ -0,0 +1,534 @@
+/*
+ * 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.presignedurl;
+
+import static org.apache.commons.lang3.RandomStringUtils.randomAscii;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
+
+import java.io.ByteArrayInputStream;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+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.AsyncRequestBody;
+import software.amazon.awssdk.core.async.AsyncResponseTransformer;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.metrics.MetricCollection;
+import software.amazon.awssdk.metrics.MetricPublisher;
+import software.amazon.awssdk.services.s3.S3AsyncClient;
+import software.amazon.awssdk.services.s3.S3IntegrationTestBase;
+import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm;
+import software.amazon.awssdk.services.s3.model.ChecksumMode;
+import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload;
+import software.amazon.awssdk.services.s3.model.CompletedPart;
+import software.amazon.awssdk.services.s3.model.CreateMultipartUploadResponse;
+import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.model.UploadPartResponse;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
+import software.amazon.awssdk.services.s3.presignedurl.model.PresignedUrlDownloadRequest;
+import software.amazon.awssdk.services.s3.utils.S3TestUtils;
+import software.amazon.awssdk.testutils.service.S3BucketUtils;
+import software.amazon.awssdk.utils.Md5Utils;
+
+/**
+ * Abstract test suite for AsyncPresignedUrlExtension integration tests.
+ */
+public abstract class AsyncPresignedUrlExtensionTestSuite extends S3IntegrationTestBase {
+ protected static S3Presigner presigner;
+ protected static AsyncPresignedUrlExtension presignedUrlExtension;
+ protected static String testBucket;
+
+ @TempDir
+ static Path temporaryFolder;
+
+ protected static String testGetObjectKey;
+ protected static String testLargeObjectKey;
+ protected static String testMpuChecksumKey;
+ protected static String testObjectContent;
+ protected static byte[] testLargeObjectContent;
+ protected static byte[] testMpuObjectContent;
+ protected static String expectedLargeObjectMd5;
+
+ protected abstract S3AsyncClient createS3AsyncClient();
+
+ @BeforeAll
+ static void setUpTestSuite() throws Exception {
+ setUp();
+
+ presigner = S3Presigner.builder()
+ .region(DEFAULT_REGION)
+ .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN)
+ .build();
+ testBucket = S3BucketUtils.temporaryBucketName("async-presigned-url-extension-test");
+ createBucket(testBucket);
+ testGetObjectKey = generateRandomObjectKey();
+ testLargeObjectKey = generateRandomObjectKey() + "-large";
+ testObjectContent = "Hello AsyncPresignedUrlExtension Integration Test";
+ testLargeObjectContent = randomAscii(5 * 1024 * 1024).getBytes(StandardCharsets.UTF_8);
+
+ try (ByteArrayInputStream originalStream = new ByteArrayInputStream(testLargeObjectContent)) {
+ expectedLargeObjectMd5 = Md5Utils.md5AsBase64(originalStream);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to compute MD5 for test data", e);
+ }
+
+ S3TestUtils.putObject(AsyncPresignedUrlExtensionTestSuite.class, s3, testBucket, testGetObjectKey, testObjectContent);
+ s3Async.putObject(
+ PutObjectRequest.builder()
+ .bucket(testBucket)
+ .key(testLargeObjectKey)
+ .build(),
+ AsyncRequestBody.fromBytes(testLargeObjectContent)
+ ).join();
+ uploadMpuObjectWithChecksum();
+ S3TestUtils.addCleanupTask(AsyncPresignedUrlExtensionTestSuite.class, () -> {
+ s3.deleteObject(DeleteObjectRequest.builder()
+ .bucket(testBucket)
+ .key(testGetObjectKey)
+ .build());
+ s3.deleteObject(DeleteObjectRequest.builder()
+ .bucket(testBucket)
+ .key(testLargeObjectKey)
+ .build());
+ s3.deleteObject(DeleteObjectRequest.builder()
+ .bucket(testBucket)
+ .key(testMpuChecksumKey)
+ .build());
+ deleteBucketAndAllContents(testBucket);
+ });
+ }
+
+ @AfterAll
+ static void tearDownTestSuite() {
+ try {
+ S3TestUtils.runCleanupTasks(AsyncPresignedUrlExtensionTestSuite.class);
+ } catch (Exception e) {
+ }
+ if (presigner != null) {
+ presigner.close();
+ }
+ cleanUpResources();
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("basicFunctionalityTestData")
+ void getObject_withValidPresignedUrl_returnsContent(String testDescription,
+ String objectKey,
+ String expectedContent) throws Exception {
+ PresignedUrlDownloadRequest request = createRequestForKey(objectKey);
+
+ CompletableFuture> future =
+ presignedUrlExtension.getObject(request, AsyncResponseTransformer.toBytes());
+ ResponseBytes response = future.get();
+
+ assertThat(response).isNotNull();
+ if (expectedContent != null) {
+ assertThat(response.asUtf8String()).isEqualTo(expectedContent);
+ assertThat(response.response().contentLength()).isEqualTo(expectedContent.length());
+ } else {
+ try (ByteArrayInputStream downloadedStream = new ByteArrayInputStream(response.asByteArray())) {
+ String downloadedMd5 = Md5Utils.md5AsBase64(downloadedStream);
+ assertThat(downloadedMd5).isEqualTo(expectedLargeObjectMd5);
+ assertThat(response.asByteArray().length).isEqualTo(testLargeObjectContent.length);
+ }
+ assertThat(response.response()).isNotNull();
+ assertThat(response.response().contentLength()).isEqualTo((long) testLargeObjectContent.length);
+ assertThat(response.response().sdkHttpResponse().firstMatchingHeader("x-amz-request-id")).isPresent();
+ }
+ }
+
+ @Test
+ void getObject_withValidPresignedUrl_savesContentToFile() throws Exception {
+ PresignedUrlDownloadRequest request = createRequestForKey(testGetObjectKey);
+ Path downloadFile = temporaryFolder.resolve("download-" + UUID.randomUUID() + ".txt");
+ CompletableFuture future =
+ presignedUrlExtension.getObject(request, downloadFile);
+ GetObjectResponse response = future.get();
+
+ assertThat(response).isNotNull();
+ assertThat(downloadFile).exists();
+ assertThat(downloadFile).hasContent(testObjectContent);
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("rangeTestData")
+ void getObject_withRangeRequest_returnsSpecifiedRange(String testDescription,
+ String range,
+ String expectedContent) throws Exception {
+ PresignedUrlDownloadRequest request = createRequestForKey(testGetObjectKey, range);
+
+ CompletableFuture> future =
+ presignedUrlExtension.getObject(request, AsyncResponseTransformer.toBytes());
+ ResponseBytes response = future.get();
+
+ assertThat(response.asUtf8String()).isEqualTo(expectedContent);
+ }
+
+ @Test
+ void getObject_withMultipleRangeRequestsConcurrently_returnsCorrectContent() throws Exception {
+ String concurrentTestKey = uploadTestObject("concurrent-test", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ");
+ List>> futures = new ArrayList<>();
+
+ futures.add(presignedUrlExtension.getObject(
+ createRequestForKey(concurrentTestKey, "bytes=0-8"), // "012345678"
+ AsyncResponseTransformer.toBytes()));
+ futures.add(presignedUrlExtension.getObject(
+ createRequestForKey(concurrentTestKey, "bytes=9-17"), // "9ABCDEFGH"
+ AsyncResponseTransformer.toBytes()));
+ futures.add(presignedUrlExtension.getObject(
+ createRequestForKey(concurrentTestKey, "bytes=18-26"), // "IJKLMNOPQ"
+ AsyncResponseTransformer.toBytes()));
+ futures.add(presignedUrlExtension.getObject(
+ createRequestForKey(concurrentTestKey, "bytes=27-35"), // "RSTUVWXYZ"
+ AsyncResponseTransformer.toBytes()));
+
+ CompletableFuture allFutures = CompletableFuture.allOf(
+ futures.toArray(new CompletableFuture[0]));
+ allFutures.get(30, TimeUnit.SECONDS);
+
+ StringBuilder result = new StringBuilder();
+ for (CompletableFuture> future : futures) {
+ result.append(future.get().asUtf8String());
+ }
+
+ assertThat(result.toString()).isEqualTo("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ");
+ }
+
+ @Test
+ void getObject_withLargeObjectToFile_savesCompleteContentAndCollectsMetrics() throws Exception {
+ List collectedMetrics = new ArrayList<>();
+ MetricPublisher metricPublisher = new MetricPublisher() {
+ @Override
+ public void publish(MetricCollection metricCollection) {
+ collectedMetrics.add(metricCollection);
+ }
+ @Override
+ public void close() {}
+ };
+
+ try (S3AsyncClient clientWithMetrics = s3AsyncClientBuilder()
+ .overrideConfiguration(o -> o.addMetricPublisher(metricPublisher))
+ .build()) {
+
+ AsyncPresignedUrlExtension metricsExtension = clientWithMetrics.presignedUrlExtension();
+ PresignedUrlDownloadRequest request = createRequestForKey(testLargeObjectKey);
+ Path downloadFile = temporaryFolder.resolve("large-download-with-metrics-" + UUID.randomUUID() + ".bin");
+
+ CompletableFuture future =
+ metricsExtension.getObject(request, downloadFile);
+ GetObjectResponse response = future.get(60, TimeUnit.SECONDS);
+
+ assertThat(response).isNotNull();
+ assertThat(downloadFile).exists();
+ assertThat(downloadFile.toFile().length()).isEqualTo(testLargeObjectContent.length);
+ assertThat(response.contentLength()).isEqualTo((long) testLargeObjectContent.length);
+ assertThat(response.sdkHttpResponse().firstMatchingHeader("x-amz-request-id")).isPresent();
+ assertThat(collectedMetrics).isNotEmpty();
+ }
+ }
+
+ @Test
+ void getObject_emptyObject_toBytes_shouldSucceed() throws Exception {
+ String emptyKey = uploadTestObject("empty-object-bytes", "");
+
+ PresignedUrlDownloadRequest request = createRequestForKey(emptyKey);
+ ResponseBytes response =
+ presignedUrlExtension.getObject(request, AsyncResponseTransformer.toBytes())
+ .get(30, TimeUnit.SECONDS);
+
+ assertThat(response.asByteArray()).isEmpty();
+ assertThat(response.response().contentLength()).isEqualTo(0L);
+ }
+
+ @Test
+ void getObject_emptyObject_toFile_shouldSucceed() throws Exception {
+ String emptyKey = uploadTestObject("empty-object-file", "");
+
+ PresignedUrlDownloadRequest request = createRequestForKey(emptyKey);
+ Path downloadFile = temporaryFolder.resolve("empty-download-" + UUID.randomUUID() + ".bin");
+ GetObjectResponse response =
+ presignedUrlExtension.getObject(request, downloadFile)
+ .get(30, TimeUnit.SECONDS);
+
+ assertThat(downloadFile).exists();
+ assertThat(downloadFile.toFile().length()).isEqualTo(0L);
+ }
+
+ @Test
+ void getObject_emptyObject_withRange_shouldThrow416() throws Exception {
+ String emptyKey = uploadTestObject("empty-object-range", "");
+
+ PresignedUrlDownloadRequest request = createRequestForKey(emptyKey, "bytes=0-1024");
+
+ assertThatThrownBy(() -> presignedUrlExtension.getObject(request, AsyncResponseTransformer.toBytes())
+ .get(30, TimeUnit.SECONDS))
+ .hasCauseInstanceOf(S3Exception.class);
+ }
+
+ @Test
+ void getObject_withChecksumModeEnabled_returnsChecksumHeaders() throws Exception {
+ PresignedGetObjectRequest presigned = presigner.presignGetObject(r -> r
+ .getObjectRequest(req -> req.bucket(testBucket).key(testGetObjectKey)
+ .checksumMode(ChecksumMode.ENABLED))
+ .signatureDuration(Duration.ofMinutes(10)));
+
+ PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder()
+ .presignedUrl(presigned.url())
+ .build();
+
+ ResponseBytes response =
+ presignedUrlExtension.getObject(request, AsyncResponseTransformer.toBytes())
+ .get(30, TimeUnit.SECONDS);
+
+ assertThat(response.asUtf8String()).isEqualTo(testObjectContent);
+ assertThat(response.response().checksumTypeAsString()).isNotNull();
+ }
+
+ @Test
+ void getObject_withoutChecksumMode_doesNotReturnChecksumHeaders() throws Exception {
+ PresignedUrlDownloadRequest request = createRequestForKey(testGetObjectKey);
+
+ ResponseBytes response =
+ presignedUrlExtension.getObject(request, AsyncResponseTransformer.toBytes())
+ .get(30, TimeUnit.SECONDS);
+
+ assertThat(response.asUtf8String()).isEqualTo(testObjectContent);
+ assertThat(response.response().checksumTypeAsString()).isNull();
+ }
+
+ @Test
+ void getObject_withChecksumModeEnabled_requestContainsChecksumHeader() throws Exception {
+ PresignedGetObjectRequest presigned = presigner.presignGetObject(r -> r
+ .getObjectRequest(req -> req.bucket(testBucket).key(testGetObjectKey)
+ .checksumMode(ChecksumMode.ENABLED))
+ .signatureDuration(Duration.ofMinutes(10)));
+
+ // Verify the presigned URL has checksum-mode in SignedHeaders
+ assertThat(presigned.signedHeaders()).containsKey("x-amz-checksum-mode");
+ assertThat(presigned.isBrowserExecutable()).isFalse();
+
+ PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder()
+ .presignedUrl(presigned.url())
+ .build();
+
+ // Download should succeed (proves marshaller sent the header)
+ ResponseBytes response =
+ presignedUrlExtension.getObject(request, AsyncResponseTransformer.toBytes())
+ .get(30, TimeUnit.SECONDS);
+
+ assertThat(response).isNotNull();
+ assertThat(response.asUtf8String()).isEqualTo(testObjectContent);
+ }
+
+ static Stream basicFunctionalityTestData() {
+ return Stream.of(
+ Arguments.of("getObject_withValidUrl_returnsContent",
+ testGetObjectKey, testObjectContent),
+ Arguments.of("getObject_withValidLargeObjectUrl_returnsContent",
+ testLargeObjectKey, null)
+ );
+ }
+
+ static Stream rangeTestData() {
+ String content = "Hello AsyncPresignedUrlExtension Integration Test";
+ return Stream.of(
+ Arguments.of("getObject_withPrefix10BytesRange_returnsFirst10Bytes",
+ "bytes=0-9", content.substring(0, 10)),
+ Arguments.of("getObject_withSuffix10BytesRange_returnsLast10Bytes",
+ "bytes=-10", content.substring(content.length() - 10)),
+ Arguments.of("getObject_withMiddle10BytesRange_returnsMiddle10Bytes",
+ "bytes=10-19", content.substring(10, 20)),
+ Arguments.of("getObject_withSingleByteRange_returnsSingleByte",
+ "bytes=0-0", content.substring(0, 1))
+ );
+ }
+
+ @Test
+ void getObject_withRangeRequest_preservesPartialMetadata() throws Exception {
+ PresignedUrlDownloadRequest request = createRequestForKey(testLargeObjectKey, "bytes=0-1048575");
+ ResponseBytes response =
+ presignedUrlExtension.getObject(request, AsyncResponseTransformer.toBytes())
+ .get(30, TimeUnit.SECONDS);
+
+ assertThat(response.response().contentLength()).isEqualTo(1048576L);
+ assertThat(response.response().contentRange()).isNotNull();
+ assertThat(response.response().sdkHttpResponse().firstMatchingHeader("x-amz-request-id")).isPresent();
+ }
+
+ @ParameterizedTest(name = "getObject_largeObject_{0}_hasCorrectFullObjectMetadata")
+ @MethodSource("transformerTypes")
+ void getObject_largeObject_hasCorrectFullObjectMetadata(String type) throws Exception {
+ PresignedUrlDownloadRequest request = createRequestForKey(testLargeObjectKey);
+
+ if ("toFile".equals(type)) {
+ Path downloadFile = temporaryFolder.resolve("large-metadata-test-" + UUID.randomUUID() + ".bin");
+ GetObjectResponse response =
+ presignedUrlExtension.getObject(request, downloadFile)
+ .get(60, TimeUnit.SECONDS);
+
+ assertThat(response.contentLength()).isEqualTo((long) testLargeObjectContent.length);
+ assertThat(downloadFile.toFile().length()).isEqualTo(testLargeObjectContent.length);
+ assertThat(response.sdkHttpResponse().firstMatchingHeader("x-amz-request-id")).isPresent();
+ } else {
+ ResponseBytes response =
+ presignedUrlExtension.getObject(request, AsyncResponseTransformer.toBytes())
+ .get(60, TimeUnit.SECONDS);
+
+ assertThat(response.asByteArray().length).isEqualTo(testLargeObjectContent.length);
+ assertThat(response.response().contentLength()).isEqualTo((long) testLargeObjectContent.length);
+ assertThat(response.response().sdkHttpResponse().firstMatchingHeader("x-amz-request-id")).isPresent();
+ }
+ }
+
+ static Stream transformerTypes() {
+ return Stream.of("toFile", "toBytes");
+ }
+
+ @ParameterizedTest(name = "getObject_mpuObject_{0}_hasCorrectMetadata")
+ @MethodSource("checksumModes")
+ void getObject_mpuObject_hasCorrectMetadata(String mode) throws Exception {
+ PresignedUrlDownloadRequest request;
+ if ("withChecksumMode".equals(mode)) {
+ PresignedGetObjectRequest presigned = presigner.presignGetObject(r -> r
+ .getObjectRequest(req -> req.bucket(testBucket).key(testMpuChecksumKey)
+ .checksumMode(ChecksumMode.ENABLED))
+ .signatureDuration(Duration.ofMinutes(10)));
+ request = PresignedUrlDownloadRequest.builder().presignedUrl(presigned.url()).build();
+ } else {
+ request = PresignedUrlDownloadRequest.builder()
+ .presignedUrl(createPresignedUrl(testMpuChecksumKey))
+ .build();
+ }
+
+ ResponseBytes response =
+ presignedUrlExtension.getObject(request, AsyncResponseTransformer.toBytes())
+ .get(60, TimeUnit.SECONDS);
+
+ assertThat(response.asByteArray().length).isEqualTo(testMpuObjectContent.length);
+ assertThat(response.response().contentLength()).isEqualTo((long) testMpuObjectContent.length);
+ assertThat(response.response().sdkHttpResponse().firstMatchingHeader("x-amz-request-id")).isPresent();
+ }
+
+ static Stream checksumModes() {
+ return Stream.of("withChecksumMode", "withoutChecksumMode");
+ }
+
+ @Test
+ void getObject_mpuObjectWithRange_preservesPartialMetadata() throws Exception {
+ PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder()
+ .presignedUrl(createPresignedUrl(testMpuChecksumKey))
+ .range("bytes=0-1048575")
+ .build();
+
+ ResponseBytes response =
+ presignedUrlExtension.getObject(request, AsyncResponseTransformer.toBytes())
+ .get(30, TimeUnit.SECONDS);
+
+ assertThat(response.response().contentLength()).isEqualTo(1048576L);
+ assertThat(response.response().contentRange()).contains("bytes 0-1048575/");
+ assertThat(response.response().sdkHttpResponse().firstMatchingHeader("x-amz-request-id")).isPresent();
+ }
+
+ // Helper methods
+ private static void uploadMpuObjectWithChecksum() {
+ testMpuChecksumKey = generateRandomObjectKey() + "-mpu-checksum";
+ int partSize = 5 * 1024 * 1024;
+ int numParts = 2;
+ testMpuObjectContent = new byte[partSize * numParts];
+ new Random(42).nextBytes(testMpuObjectContent);
+
+ CreateMultipartUploadResponse createResp =
+ s3.createMultipartUpload(b -> b.bucket(testBucket).key(testMpuChecksumKey)
+ .checksumAlgorithm(ChecksumAlgorithm.CRC32));
+ String uploadId = createResp.uploadId();
+ List parts = new ArrayList<>();
+
+ for (int i = 0; i < numParts; i++) {
+ byte[] partData = Arrays.copyOfRange(testMpuObjectContent, i * partSize, (i + 1) * partSize);
+ final int partNum = i + 1;
+ UploadPartResponse uploadResp = s3.uploadPart(
+ b -> b.bucket(testBucket).key(testMpuChecksumKey).uploadId(uploadId).partNumber(partNum)
+ .checksumAlgorithm(ChecksumAlgorithm.CRC32),
+ RequestBody.fromBytes(partData));
+ parts.add(CompletedPart.builder()
+ .partNumber(partNum).eTag(uploadResp.eTag())
+ .checksumCRC32(uploadResp.checksumCRC32()).build());
+ }
+
+ s3.completeMultipartUpload(b -> b.bucket(testBucket).key(testMpuChecksumKey).uploadId(uploadId)
+ .multipartUpload(CompletedMultipartUpload.builder()
+ .parts(parts).build()));
+ }
+
+ private static String generateRandomObjectKey() {
+ return "async-presigned-url-extension-test-" + UUID.randomUUID();
+ }
+
+ private PresignedUrlDownloadRequest createRequestForKey(String key) {
+ return PresignedUrlDownloadRequest.builder()
+ .presignedUrl(createPresignedUrl(key))
+ .build();
+ }
+
+ private PresignedUrlDownloadRequest createRequestForKey(String key, String range) {
+ return PresignedUrlDownloadRequest.builder()
+ .presignedUrl(createPresignedUrl(key))
+ .range(range)
+ .build();
+ }
+
+ private URL createPresignedUrl(String key) {
+ PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(r -> r
+ .getObjectRequest(req -> req.bucket(testBucket).key(key))
+ .signatureDuration(Duration.ofMinutes(10)));
+ return presignedRequest.url();
+ }
+
+ private String uploadTestObject(String keyPrefix, String content) {
+ String key = keyPrefix + "-" + UUID.randomUUID();
+ S3TestUtils.putObject(AsyncPresignedUrlExtensionTestSuite.class, s3, testBucket, key, content);
+
+ S3TestUtils.addCleanupTask(AsyncPresignedUrlExtensionTestSuite.class, () -> {
+ s3.deleteObject(DeleteObjectRequest.builder()
+ .bucket(testBucket)
+ .key(key)
+ .build());
+ });
+ return key;
+ }
+}
diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/crt/DefaultS3CrtAsyncClient.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/crt/DefaultS3CrtAsyncClient.java
index 8a7bfebbff43..8ef59ac7cca8 100644
--- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/crt/DefaultS3CrtAsyncClient.java
+++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/crt/DefaultS3CrtAsyncClient.java
@@ -80,6 +80,7 @@
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
+import software.amazon.awssdk.services.s3.presignedurl.AsyncPresignedUrlExtension;
import software.amazon.awssdk.utils.AttributeMap;
import software.amazon.awssdk.utils.CollectionUtils;
import software.amazon.awssdk.utils.Validate;
@@ -515,4 +516,10 @@ private static void validateCrtInClassPath() {
+ "on the classpath.", e);
}
}
+
+ @Override
+ public AsyncPresignedUrlExtension presignedUrlExtension() {
+ // TODO: Implement presigned URL extension support for CRT client
+ throw new UnsupportedOperationException("Presigned URL extension is not supported for CRT client");
+ }
}
diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/GetObjectInterceptor.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/GetObjectInterceptor.java
index 7e13f6b97473..5929765d3b29 100644
--- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/GetObjectInterceptor.java
+++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/GetObjectInterceptor.java
@@ -28,6 +28,7 @@
import software.amazon.awssdk.core.interceptor.SdkExecutionAttribute;
import software.amazon.awssdk.core.internal.util.HttpChecksumUtils;
import software.amazon.awssdk.http.SdkHttpResponse;
+import software.amazon.awssdk.services.s3.internal.presignedurl.model.PresignedUrlDownloadRequestWrapper;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
import software.amazon.awssdk.utils.Pair;
@@ -43,7 +44,8 @@ public class GetObjectInterceptor implements ExecutionInterceptor {
@Override
public void afterTransmission(Context.AfterTransmission context, ExecutionAttributes executionAttributes) {
- if (!(context.request() instanceof GetObjectRequest)) {
+ if (!(context.request() instanceof GetObjectRequest)
+ && !(context.request() instanceof PresignedUrlDownloadRequestWrapper)) {
return;
}
ChecksumSpecs resolvedChecksumSpecs = executionAttributes.getAttribute(SdkExecutionAttribute.RESOLVED_CHECKSUM_SPECS);
diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartAsyncPresignedUrlExtension.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartAsyncPresignedUrlExtension.java
new file mode 100644
index 000000000000..3e4b9ade0033
--- /dev/null
+++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartAsyncPresignedUrlExtension.java
@@ -0,0 +1,57 @@
+/*
+ * 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 java.util.concurrent.CompletableFuture;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+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.utils.Validate;
+
+/**
+ * An {@link AsyncPresignedUrlExtension} that automatically converts presigned URL downloads
+ * to multipart downloads.
+ */
+@SdkInternalApi
+public class MultipartAsyncPresignedUrlExtension implements AsyncPresignedUrlExtension {
+ private final PresignedUrlDownloadHelper downloadHelper;
+
+ public MultipartAsyncPresignedUrlExtension(
+ S3AsyncClient s3AsyncClient,
+ AsyncPresignedUrlExtension asyncPresignedUrlExtension,
+ long bufferSizeInBytes,
+ long partSizeInBytes) {
+ Validate.paramNotNull(s3AsyncClient, "s3AsyncClient");
+ Validate.paramNotNull(asyncPresignedUrlExtension, "asyncPresignedUrlExtension");
+ this.downloadHelper = new PresignedUrlDownloadHelper(
+ s3AsyncClient,
+ asyncPresignedUrlExtension,
+ bufferSizeInBytes,
+ partSizeInBytes);
+ }
+
+ @Override
+ public CompletableFuture getObject(
+ PresignedUrlDownloadRequest presignedUrlDownloadRequest,
+ AsyncResponseTransformer asyncResponseTransformer) {
+ Validate.paramNotNull(presignedUrlDownloadRequest, "presignedUrlDownloadRequest");
+ Validate.paramNotNull(asyncResponseTransformer, "asyncResponseTransformer");
+ return downloadHelper.downloadObject(presignedUrlDownloadRequest, asyncResponseTransformer);
+ }
+}
\ No newline at end of file
diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartDownloadUtils.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartDownloadUtils.java
index 807b6a8bbbc0..a9145f1bc1dc 100644
--- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartDownloadUtils.java
+++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartDownloadUtils.java
@@ -20,12 +20,23 @@
import java.util.Collections;
import java.util.List;
import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.core.SdkResponse;
+import software.amazon.awssdk.core.SplittingTransformerConfiguration;
+import software.amazon.awssdk.core.async.AsyncResponseTransformer;
+import software.amazon.awssdk.core.async.AsyncResponseTransformer.SplitResult;
+import software.amazon.awssdk.services.s3.model.ChecksumType;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.model.S3Request;
@SdkInternalApi
public final class MultipartDownloadUtils {
+ private static final Pattern CONTENT_RANGE_PATTERN = Pattern.compile("bytes\\s+(\\d+)-(\\d+)/(\\d+)");
+
private MultipartDownloadUtils() {
}
@@ -58,4 +69,117 @@ public static Optional multipartDownloadResumeCo
.flatMap(conf -> Optional.ofNullable(conf.executionAttributes().getAttribute(MULTIPART_DOWNLOAD_RESUME_CONTEXT)));
}
+ /**
+ * This method checks the
+ * {@link software.amazon.awssdk.services.s3.multipart.S3MultipartExecutionAttribute#MULTIPART_DOWNLOAD_RESUME_CONTEXT}
+ * execution attributes for a context object and returns it if it finds one. Otherwise, returns an empty Optional.
+ *
+ * @param request the request to look for execution attributes
+ * @return the MultipartDownloadResumeContext if one is found, otherwise an empty Optional.
+ */
+ public static Optional multipartDownloadResumeContext(S3Request request) {
+ return request
+ .overrideConfiguration()
+ .flatMap(conf -> Optional.ofNullable(conf.executionAttributes().getAttribute(MULTIPART_DOWNLOAD_RESUME_CONTEXT)));
+ }
+
+ /**
+ * Parses start byte and end byte from a Content-Range header.
+ *
+ * @param contentRange the Content-Range header value (e.g., "bytes 0-1023/2048")
+ * @return array of [startByte, endByte], or null if parsing fails
+ */
+ public static long[] parseContentRange(String contentRange) {
+ if (contentRange == null) {
+ return null;
+ }
+ Matcher matcher = CONTENT_RANGE_PATTERN.matcher(contentRange);
+ if (!matcher.matches()) {
+ return null;
+ }
+ return new long[] {
+ Long.parseLong(matcher.group(1)),
+ Long.parseLong(matcher.group(2))
+ };
+ }
+
+ /**
+ * Parses the total size from a Content-Range header.
+ *
+ * @param contentRange the Content-Range header value (e.g., "bytes 0-1023/2048")
+ * @return the total size, or empty if parsing fails
+ */
+ public static Optional parseContentRangeForTotalSize(String contentRange) {
+ if (contentRange == null) {
+ return Optional.empty();
+ }
+ Matcher matcher = CONTENT_RANGE_PATTERN.matcher(contentRange);
+ if (!matcher.matches()) {
+ return Optional.empty();
+ }
+ return Optional.of(Long.parseLong(matcher.group(3)));
+ }
+
+ /**
+ * Calculates the total number of parts needed to download an object of the given size.
+ *
+ * @param contentLength total object size in bytes
+ * @param partSize size of each part in bytes
+ * @return the number of parts
+ */
+ public static long calculateTotalParts(long contentLength, long partSize) {
+ return (contentLength / partSize) + (contentLength % partSize == 0 ? 0 : 1);
+
+ }
+
+ /**
+ * Rewrites a first-part response to represent the full object.
+ *
+ * @param firstPartResponse the GetObjectResponse from the first part request
+ * @return full-object response with total content-length, full content-range,
+ * and checksum values nulled if checksum type is COMPOSITE
+ */
+ public static GetObjectResponse toFullObjectResponse(GetObjectResponse firstPartResponse) {
+ String contentRange = firstPartResponse.contentRange();
+ Optional totalOpt = parseContentRangeForTotalSize(contentRange);
+ if (!totalOpt.isPresent()) {
+ return firstPartResponse;
+ }
+ long totalLength = totalOpt.get();
+ String fullRange = "bytes 0-" + (totalLength - 1) + "/" + totalLength;
+
+ GetObjectResponse.Builder builder = firstPartResponse.toBuilder()
+ .contentLength(totalLength)
+ .contentRange(fullRange);
+
+ if (firstPartResponse.checksumType() == ChecksumType.COMPOSITE) {
+ builder.sdkFields().stream()
+ .filter(f -> f.memberName().startsWith("Checksum") && !"ChecksumType".equals(f.memberName()))
+ .forEach(f -> f.set(builder, null));
+ }
+
+ return builder.build();
+ }
+
+ /**
+ * Splits the given transformer with a response mapper that applies {@link #toFullObjectResponse}
+ * to the first part's response before it reaches the customer's transformer.
+ */
+ public static SplitResult splitWithResponseRewrite(
+ AsyncResponseTransformer transformer,
+ SplittingTransformerConfiguration splitConfig) {
+ SplittingTransformerConfiguration configWithMapper =
+ splitConfig.toBuilder()
+ .responseMapper(MultipartDownloadUtils::mapToFullObjectResponse)
+ .build();
+ return transformer.split(configWithMapper);
+ }
+
+ private static SdkResponse mapToFullObjectResponse(SdkResponse response) {
+ if (response instanceof GetObjectResponse) {
+ return toFullObjectResponse((GetObjectResponse) response);
+ }
+ return response;
+ }
+
}
diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartS3AsyncClient.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartS3AsyncClient.java
index 499f36a6dd1b..6d3aca4970e1 100644
--- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartS3AsyncClient.java
+++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartS3AsyncClient.java
@@ -35,6 +35,7 @@
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
import software.amazon.awssdk.services.s3.model.S3Request;
import software.amazon.awssdk.services.s3.multipart.MultipartConfiguration;
+import software.amazon.awssdk.services.s3.presignedurl.AsyncPresignedUrlExtension;
import software.amazon.awssdk.utils.Validate;
/**
@@ -52,6 +53,8 @@ public final class MultipartS3AsyncClient extends DelegatingS3AsyncClient {
private final CopyObjectHelper copyObjectHelper;
private final DownloadObjectHelper downloadObjectHelper;
private final boolean checksumEnabled;
+ private final long apiCallBufferSize;
+ private final long minPartSizeInBytes;
private MultipartS3AsyncClient(S3AsyncClient delegate, MultipartConfiguration multipartConfiguration,
boolean checksumEnabled) {
@@ -63,6 +66,8 @@ private MultipartS3AsyncClient(S3AsyncClient delegate, MultipartConfiguration mu
long threshold = resolver.thresholdInBytes();
long apiCallBufferSize = resolver.apiCallBufferSize();
int maxInFlightParts = resolver.maxInFlightParts();
+ this.apiCallBufferSize = apiCallBufferSize;
+ this.minPartSizeInBytes = minPartSizeInBytes;
mpuHelper = new UploadObjectHelper(delegate, resolver);
copyObjectHelper = new CopyObjectHelper(delegate, minPartSizeInBytes, threshold);
downloadObjectHelper = new DownloadObjectHelper(delegate, apiCallBufferSize, maxInFlightParts);
@@ -111,4 +116,14 @@ protected CompletableFuture invokeOperat
};
return new MultipartS3AsyncClient(clientWithUserAgent, multipartConfiguration, checksumEnabled);
}
+
+ @Override
+ public AsyncPresignedUrlExtension presignedUrlExtension() {
+ AsyncPresignedUrlExtension delegateExtension = ((S3AsyncClient) delegate()).presignedUrlExtension();
+ return new MultipartAsyncPresignedUrlExtension(
+ (S3AsyncClient) delegate(),
+ delegateExtension,
+ apiCallBufferSize,
+ minPartSizeInBytes);
+ }
}
diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/ParallelPresignedUrlMultipartDownloaderSubscriber.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/ParallelPresignedUrlMultipartDownloaderSubscriber.java
new file mode 100644
index 000000000000..3497aa3745e6
--- /dev/null
+++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/ParallelPresignedUrlMultipartDownloaderSubscriber.java
@@ -0,0 +1,336 @@
+/*
+ * 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 java.util.Map;
+import java.util.Optional;
+import java.util.Queue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import org.reactivestreams.Subscriber;
+import org.reactivestreams.Subscription;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.core.async.AsyncResponseTransformer;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.services.s3.S3AsyncClient;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.presignedurl.model.PresignedUrlDownloadRequest;
+import software.amazon.awssdk.utils.CompletableFutureUtils;
+import software.amazon.awssdk.utils.Logger;
+import software.amazon.awssdk.utils.Pair;
+
+/**
+ * A parallel subscriber for multipart presigned URL downloads that writes parts concurrently.
+ * Used with {@link software.amazon.awssdk.core.internal.async.FileAsyncResponseTransformerPublisher}
+ * when {@code parallelSplitSupported() == true} (i.e., toFile() downloads).
+ *
+ * Unlike {@link PresignedUrlMultipartDownloaderSubscriber} which requests one part at a time,
+ * this subscriber requests up to {@code maxInFlightParts} concurrently, similar to
+ * {@link ParallelMultipartDownloaderSubscriber} for regular multipart downloads.
+ */
+@SdkInternalApi
+public class ParallelPresignedUrlMultipartDownloaderSubscriber
+ implements Subscriber> {
+
+ private static final Logger log = Logger.loggerFor(ParallelPresignedUrlMultipartDownloaderSubscriber.class);
+
+ private final S3AsyncClient s3AsyncClient;
+ private final PresignedUrlDownloadRequest presignedUrlDownloadRequest;
+ private final long configuredPartSizeInBytes;
+ /**
+ * The future returned by the SplitResult, representing the overall download completion.
+ * Completed exceptionally on error (before cancel) to prevent ByteArraySplittingTransformer
+ * from assembling invalid data.
+ */
+ private final CompletableFuture resultFuture;
+ private final int maxInFlightParts;
+
+ private final AtomicLong partNumber = new AtomicLong(0);
+ private final AtomicLong completedParts = new AtomicLong(0);
+ private final Semaphore inFlightPermits;
+ /**
+ * CAS gate ensuring only the first part failure triggers error handling and cancellation.
+ */
+ private final AtomicBoolean downloadFailed = new AtomicBoolean(false);
+ private final AtomicBoolean processingPending = new AtomicBoolean(false);
+ private final Map> inFlightRequests = new ConcurrentHashMap<>();
+ private final Queue>> pendingTransformers =
+ new ConcurrentLinkedQueue<>();
+
+ private final Object subscriptionLock = new Object();
+ private Subscription subscription;
+
+ private volatile Long totalContentLength;
+ private volatile Long totalParts;
+ private volatile String eTag;
+ private volatile GetObjectResponse firstResponse;
+
+ public ParallelPresignedUrlMultipartDownloaderSubscriber(
+ S3AsyncClient s3AsyncClient,
+ PresignedUrlDownloadRequest presignedUrlDownloadRequest,
+ long configuredPartSizeInBytes,
+ CompletableFuture resultFuture,
+ int maxInFlightParts) {
+ this.s3AsyncClient = s3AsyncClient;
+ this.presignedUrlDownloadRequest = presignedUrlDownloadRequest;
+ this.configuredPartSizeInBytes = configuredPartSizeInBytes;
+ this.resultFuture = resultFuture;
+ this.maxInFlightParts = maxInFlightParts;
+ this.inFlightPermits = new Semaphore(maxInFlightParts);
+ }
+
+ @Override
+ public void onSubscribe(Subscription s) {
+ if (this.subscription != null) {
+ s.cancel();
+ return;
+ }
+ this.subscription = s;
+ s.request(1);
+ }
+
+ @Override
+ public void onNext(AsyncResponseTransformer asyncResponseTransformer) {
+ if (asyncResponseTransformer == null) {
+ throw new NullPointerException("onNext must not be called with null asyncResponseTransformer");
+ }
+
+ long currentPart = partNumber.getAndIncrement();
+
+ if (currentPart == 0) {
+ sendFirstRequest(asyncResponseTransformer);
+ } else {
+ if (totalParts != null && currentPart >= totalParts) {
+ return;
+ }
+ if (totalParts != null) {
+ processRequest(asyncResponseTransformer, currentPart);
+ } else {
+ pendingTransformers.offer(Pair.of(currentPart, asyncResponseTransformer));
+ }
+ }
+ }
+
+ private void sendFirstRequest(AsyncResponseTransformer transformer) {
+ PresignedUrlDownloadRequest partRequest = createRangedGetRequest(0L);
+ log.debug(() -> "Sending first range request with range=" + partRequest.range());
+
+ if (!inFlightPermits.tryAcquire()) {
+ throw new IllegalStateException("Failed to acquire permit for first request");
+ }
+
+ CompletableFuture response =
+ s3AsyncClient.presignedUrlExtension().getObject(partRequest, transformer);
+
+ inFlightRequests.put(0L, response);
+ CompletableFutureUtils.forwardExceptionTo(resultFuture, response);
+
+ response.whenComplete((res, error) -> {
+ inFlightRequests.remove(0L);
+ inFlightPermits.release();
+
+ if (error != null) {
+ if (PresignedUrlDownloadHelper.isRangeNotSatisfiable(error)) {
+ resultFuture.completeExceptionally(
+ new PresignedUrlDownloadHelper.EmptyObjectRangeNotSatisfiableException(error));
+ synchronized (subscriptionLock) {
+ subscription.cancel();
+ }
+ } else {
+ handlePartError(error, 0L);
+ }
+ return;
+ }
+
+ if (downloadFailed.get()) {
+ return;
+ }
+
+ completedParts.incrementAndGet();
+
+ this.eTag = res.eTag();
+ this.firstResponse = res;
+
+ String contentRange = res.contentRange();
+ if (contentRange == null) {
+ handlePartError(PresignedUrlDownloadHelper.missingContentRangeHeader(), 0L);
+ return;
+ }
+
+ Optional parsedTotal = MultipartDownloadUtils.parseContentRangeForTotalSize(contentRange);
+ if (!parsedTotal.isPresent()) {
+ handlePartError(PresignedUrlDownloadHelper.invalidContentRangeHeader(contentRange), 0L);
+ return;
+ }
+
+ this.totalContentLength = parsedTotal.get();
+ this.totalParts = MultipartDownloadUtils.calculateTotalParts(totalContentLength, configuredPartSizeInBytes);
+ log.debug(() -> String.format("Total content length: %d, Total parts: %d", totalContentLength, totalParts));
+
+ Optional validationError = validatePartResponse(res, 0L);
+ if (validationError.isPresent()) {
+ handlePartError(validationError.get(), 0L);
+ return;
+ }
+
+ if (totalParts <= 1) {
+ resultFuture.complete(MultipartDownloadUtils.toFullObjectResponse(firstResponse));
+ synchronized (subscriptionLock) {
+ subscription.cancel();
+ }
+ return;
+ }
+
+ processPendingTransformers();
+
+ long remainingParts = totalParts - 1;
+ long toRequest = Math.min(remainingParts, maxInFlightParts);
+ synchronized (subscriptionLock) {
+ subscription.request(toRequest);
+ }
+ });
+ }
+
+ private void processRequest(AsyncResponseTransformer transformer,
+ long currentPart) {
+ if (currentPart >= totalParts) {
+ return;
+ }
+
+ if (!inFlightPermits.tryAcquire()) {
+ pendingTransformers.offer(Pair.of(currentPart, transformer));
+ return;
+ }
+
+ sendPartRequest(transformer, currentPart);
+ processPendingTransformers();
+ }
+
+ private void sendPartRequest(AsyncResponseTransformer transformer,
+ long partIndex) {
+ if (downloadFailed.get()) {
+ inFlightPermits.release();
+ return;
+ }
+
+ PresignedUrlDownloadRequest partRequest = createRangedGetRequest(partIndex);
+ log.debug(() -> "Sending range request for part " + partIndex + " with range=" + partRequest.range());
+
+ CompletableFuture response =
+ s3AsyncClient.presignedUrlExtension().getObject(partRequest, transformer);
+
+ inFlightRequests.put(partIndex, response);
+ CompletableFutureUtils.forwardExceptionTo(resultFuture, response);
+
+ response.whenComplete((res, error) -> {
+ inFlightRequests.remove(partIndex);
+ inFlightPermits.release();
+
+ if (error != null) {
+ handlePartError(error, partIndex);
+ return;
+ }
+ if (downloadFailed.get()) {
+ log.debug(() -> "Ignoring late completion for part " + partIndex + ", download already failed");
+ return;
+ }
+
+ Optional validationError = validatePartResponse(res, partIndex);
+ if (validationError.isPresent()) {
+ handlePartError(validationError.get(), partIndex);
+ return;
+ }
+
+ log.debug(() -> "Completed part: " + partIndex);
+ long totalComplete = completedParts.incrementAndGet();
+
+ if (totalComplete == totalParts) {
+ resultFuture.complete(MultipartDownloadUtils.toFullObjectResponse(firstResponse));
+ synchronized (subscriptionLock) {
+ subscription.cancel();
+ }
+ } else {
+ processPendingTransformers();
+ synchronized (subscriptionLock) {
+ subscription.request(1);
+ }
+ }
+ });
+ }
+
+ private void processPendingTransformers() {
+ // Re-check after releasing the gate to catch permits that arrived
+ // while exiting — prevents "missed signal" where no thread drains the queue.
+ do {
+ if (!processingPending.compareAndSet(false, true)) {
+ return;
+ }
+ try {
+ // Drain pending queue while permits are available
+ while (!pendingTransformers.isEmpty() && inFlightPermits.tryAcquire()) {
+ Pair> pendingPart =
+ pendingTransformers.poll();
+ if (pendingPart != null && pendingPart.left() < totalParts) {
+ sendPartRequest(pendingPart.right(), pendingPart.left());
+ } else {
+ inFlightPermits.release();
+ }
+ }
+ } finally {
+ processingPending.set(false);
+ }
+ } while (!pendingTransformers.isEmpty() && inFlightPermits.availablePermits() > 0);
+ }
+
+ private Optional validatePartResponse(GetObjectResponse response, long partIndex) {
+ return PresignedUrlDownloadHelper.validatePartResponse(
+ response, partIndex, configuredPartSizeInBytes, totalContentLength, totalParts);
+ }
+
+ private void handlePartError(Throwable error, long partIndex) {
+ if (downloadFailed.compareAndSet(false, true)) {
+ log.debug(() -> "Error on part " + partIndex, error);
+ resultFuture.completeExceptionally(error);
+ inFlightRequests.values().forEach(future -> future.cancel(true));
+ synchronized (subscriptionLock) {
+ if (subscription != null) {
+ subscription.cancel();
+ }
+ }
+ }
+ }
+
+ private PresignedUrlDownloadRequest createRangedGetRequest(long partIndex) {
+ return PresignedUrlDownloadHelper.createRangedGetRequest(
+ presignedUrlDownloadRequest, partIndex, configuredPartSizeInBytes, totalContentLength, eTag);
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ log.debug(() -> "Error in parallel multipart download", t);
+ resultFuture.completeExceptionally(t);
+ inFlightRequests.values().forEach(future -> future.cancel(true));
+ }
+
+ @Override
+ public void onComplete() {
+ // Completion is handled by resultFuture
+ }
+}
\ No newline at end of file
diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelper.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelper.java
new file mode 100644
index 000000000000..7b0166a250dc
--- /dev/null
+++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelper.java
@@ -0,0 +1,255 @@
+/*
+ * 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 java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.core.SplittingTransformerConfiguration;
+import software.amazon.awssdk.core.async.AsyncResponseTransformer;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.services.s3.S3AsyncClient;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.presignedurl.AsyncPresignedUrlExtension;
+import software.amazon.awssdk.services.s3.presignedurl.model.PresignedUrlDownloadRequest;
+import software.amazon.awssdk.utils.Logger;
+import software.amazon.awssdk.utils.Validate;
+
+@SdkInternalApi
+public class PresignedUrlDownloadHelper {
+ private static final Logger log = Logger.loggerFor(PresignedUrlDownloadHelper.class);
+ private static final int DEFAULT_MAX_IN_FLIGHT_PARTS = 10;
+
+ private final S3AsyncClient s3AsyncClient;
+ private final AsyncPresignedUrlExtension asyncPresignedUrlExtension;
+ private final long bufferSizeInBytes;
+ private final long configuredPartSizeInBytes;
+
+ public PresignedUrlDownloadHelper(S3AsyncClient s3AsyncClient,
+ AsyncPresignedUrlExtension asyncPresignedUrlExtension,
+ long bufferSizeInBytes,
+ long configuredPartSizeInBytes) {
+ this.s3AsyncClient = Validate.paramNotNull(s3AsyncClient, "s3AsyncClient");
+ this.asyncPresignedUrlExtension = Validate.paramNotNull(asyncPresignedUrlExtension, "asyncPresignedUrlExtension");
+ this.bufferSizeInBytes = Validate.isPositive(bufferSizeInBytes, "bufferSizeInBytes");
+ this.configuredPartSizeInBytes = Validate.isPositive(configuredPartSizeInBytes, "configuredPartSizeInBytes");
+ }
+
+ public CompletableFuture downloadObject(
+ PresignedUrlDownloadRequest presignedRequest,
+ AsyncResponseTransformer asyncResponseTransformer) {
+
+ Validate.paramNotNull(presignedRequest, "presignedRequest");
+ Validate.paramNotNull(asyncResponseTransformer, "asyncResponseTransformer");
+
+ if (presignedRequest.range() != null) {
+ log.debug(() -> "Using single part download because presigned URL request range is included in the request. range = "
+ + presignedRequest.range());
+ return asyncPresignedUrlExtension.getObject(presignedRequest, asyncResponseTransformer);
+ }
+
+ CompletableFuture resultFuture = new CompletableFuture<>();
+ doMultipartDownload(presignedRequest, asyncResponseTransformer)
+ .whenComplete((result, error) -> {
+ Throwable cause = error instanceof CompletionException ? error.getCause() : error;
+ // Parallel path wraps it as EmptyObjectRangeNotSatisfiableException;
+ // serial path (toBytes, custom transformers) surfaces raw S3Exception.
+ if (cause instanceof EmptyObjectRangeNotSatisfiableException
+ || isRangeNotSatisfiable(cause)) {
+ log.debug(() -> "Received 416 on first request, falling back to non-range GET for empty object");
+ asyncPresignedUrlExtension.getObject(presignedRequest, asyncResponseTransformer)
+ .whenComplete((r, e) -> {
+ if (e != null) {
+ resultFuture.completeExceptionally(e);
+ } else {
+ resultFuture.complete(r);
+ }
+ });
+ } else if (error != null) {
+ resultFuture.completeExceptionally(error);
+ } else {
+ resultFuture.complete(result);
+ }
+ });
+ return resultFuture;
+ }
+
+ private CompletableFuture doMultipartDownload(
+ PresignedUrlDownloadRequest presignedRequest,
+ AsyncResponseTransformer asyncResponseTransformer) {
+
+ SplittingTransformerConfiguration splittingConfig = SplittingTransformerConfiguration.builder()
+ .bufferSizeInBytes(bufferSizeInBytes)
+ .build();
+
+ AsyncResponseTransformer.SplitResult split =
+ MultipartDownloadUtils.splitWithResponseRewrite(asyncResponseTransformer, splittingConfig);
+
+ if (split.parallelSplitSupported()) {
+ return downloadPartsInParallel(presignedRequest, split);
+ }
+ return downloadPartsSerially(presignedRequest, split);
+ }
+
+ private CompletableFuture downloadPartsInParallel(
+ PresignedUrlDownloadRequest presignedRequest,
+ AsyncResponseTransformer.SplitResult split) {
+ log.debug(() -> "Using parallel multipart download for presigned URL");
+ ParallelPresignedUrlMultipartDownloaderSubscriber subscriber =
+ new ParallelPresignedUrlMultipartDownloaderSubscriber(
+ s3AsyncClient,
+ presignedRequest,
+ configuredPartSizeInBytes,
+ (CompletableFuture) split.resultFuture(),
+ DEFAULT_MAX_IN_FLIGHT_PARTS);
+ split.publisher().subscribe(subscriber);
+ return split.resultFuture();
+ }
+
+ private CompletableFuture downloadPartsSerially(
+ PresignedUrlDownloadRequest presignedRequest,
+ AsyncResponseTransformer.SplitResult split) {
+ log.debug(() -> "Using serial multipart download for presigned URL");
+ PresignedUrlMultipartDownloaderSubscriber subscriber =
+ new PresignedUrlMultipartDownloaderSubscriber(
+ s3AsyncClient,
+ presignedRequest,
+ configuredPartSizeInBytes,
+ split.resultFuture());
+ split.publisher().subscribe(subscriber);
+ return split.resultFuture();
+ }
+
+ static SdkClientException invalidContentRangeHeader(String contentRange) {
+ return SdkClientException.create("Invalid Content-Range header: " + contentRange);
+ }
+
+ static SdkClientException missingContentRangeHeader() {
+ return SdkClientException.create("No Content-Range header in response");
+ }
+
+ static SdkClientException invalidContentLength() {
+ return SdkClientException.create("Invalid or missing Content-Length in response");
+ }
+
+ /**
+ * Validates a part response for data integrity. Checks that Content-Range and Content-Length
+ * match the expected values based on part index, part size, and total object size.
+ *
+ * @param response the GetObject response to validate
+ * @param partIndex zero-based index of this part
+ * @param partSizeInBytes configured part size
+ * @param totalContentLength total object size (from Content-Range), or null if not yet known
+ * @param totalParts total number of parts, or null if not yet known
+ * @return empty if valid, or an SdkClientException describing the mismatch
+ */
+ static Optional validatePartResponse(GetObjectResponse response,
+ long partIndex,
+ long partSizeInBytes,
+ Long totalContentLength,
+ Long totalParts) {
+ String contentRange = response.contentRange();
+ if (contentRange == null) {
+ return Optional.of(missingContentRangeHeader());
+ }
+
+ Long contentLength = response.contentLength();
+ if (contentLength == null || contentLength < 0) {
+ return Optional.of(invalidContentLength());
+ }
+
+ long expectedStartByte = partIndex * partSizeInBytes;
+ long[] parsedRange = MultipartDownloadUtils.parseContentRange(contentRange);
+ if (parsedRange == null) {
+ return Optional.of(invalidContentRangeHeader(contentRange));
+ }
+ long actualStartByte = parsedRange[0];
+ long actualEndByte = parsedRange[1];
+ if (actualStartByte != expectedStartByte) {
+ return Optional.of(SdkClientException.create(
+ String.format("Content-Range mismatch for part %d. Expected start byte: %d, but got: bytes %d-%d",
+ partIndex, expectedStartByte, actualStartByte, actualEndByte)));
+ }
+ if (totalContentLength != null) {
+ long expectedEndByte = Math.min(expectedStartByte + partSizeInBytes - 1, totalContentLength - 1);
+ if (actualEndByte != expectedEndByte) {
+ return Optional.of(SdkClientException.create(
+ String.format("Content-Range mismatch for part %d. Expected: bytes %d-%d, but got: bytes %d-%d",
+ partIndex, expectedStartByte, expectedEndByte, actualStartByte, actualEndByte)));
+ }
+ }
+
+ if (totalContentLength != null && totalParts != null) {
+ long expectedPartSize = (partIndex == totalParts - 1)
+ ? totalContentLength - (partIndex * partSizeInBytes)
+ : partSizeInBytes;
+ if (!contentLength.equals(expectedPartSize)) {
+ return Optional.of(SdkClientException.create(
+ String.format("Part content length validation failed for part %d. Expected: %d, but got: %d",
+ partIndex, expectedPartSize, contentLength)));
+ }
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Creates a range-based GET request for a specific part of a presigned URL download.
+ *
+ * @param originalRequest the original presigned URL request
+ * @param partIndex zero-based index of this part
+ * @param partSizeInBytes configured part size
+ * @param totalContentLength total object size, or null if not yet known (first part)
+ * @param eTag ETag from first response, used for If-Match on parts 1+
+ * @return a new PresignedUrlDownloadRequest with the appropriate Range and If-Match headers
+ */
+ static PresignedUrlDownloadRequest createRangedGetRequest(PresignedUrlDownloadRequest originalRequest,
+ long partIndex,
+ long partSizeInBytes,
+ Long totalContentLength,
+ String eTag) {
+ long startByte = partIndex * partSizeInBytes;
+ long endByte = totalContentLength != null
+ ? Math.min(startByte + partSizeInBytes - 1, totalContentLength - 1)
+ : startByte + partSizeInBytes - 1;
+ PresignedUrlDownloadRequest.Builder builder = originalRequest.toBuilder()
+ .range("bytes=" + startByte + "-" + endByte);
+ if (partIndex > 0 && eTag != null) {
+ builder.ifMatch(eTag);
+ }
+ return builder.build();
+ }
+
+ /**
+ * Returns true if the error is a 416 Range Not Satisfiable response from S3.
+ * Used by subscribers to detect empty object responses on the first range request.
+ */
+ static boolean isRangeNotSatisfiable(Throwable error) {
+ Throwable cause = error instanceof CompletionException ? error.getCause() : error;
+ return cause instanceof S3Exception && ((S3Exception) cause).statusCode() == 416;
+ }
+
+ /**
+ * Marker exception wrapping a 416 on the first range request, signaling an empty object.
+ * Used to distinguish from 416 errors on subsequent requests which should propagate as failures.
+ */
+ static class EmptyObjectRangeNotSatisfiableException extends RuntimeException {
+ EmptyObjectRangeNotSatisfiableException(Throwable cause) {
+ super("Object is empty (416 on first range request)", cause);
+ }
+ }
+}
\ No newline at end of file
diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlMultipartDownloaderSubscriber.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlMultipartDownloaderSubscriber.java
new file mode 100644
index 000000000000..86c4c76308ca
--- /dev/null
+++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlMultipartDownloaderSubscriber.java
@@ -0,0 +1,254 @@
+/*
+ * 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 java.util.Optional;
+import java.util.Queue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.atomic.AtomicLong;
+import org.reactivestreams.Subscriber;
+import org.reactivestreams.Subscription;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.annotations.ThreadSafe;
+import software.amazon.awssdk.core.async.AsyncResponseTransformer;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.services.s3.S3AsyncClient;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.presignedurl.model.PresignedUrlDownloadRequest;
+import software.amazon.awssdk.utils.Logger;
+
+/**
+ * A subscriber implementation that will download all individual parts for a multipart presigned URL download request.
+ * It receives individual {@link AsyncResponseTransformer} instances which will be used to perform the individual
+ * range-based part requests using presigned URLs. This is a 'one-shot' class, it should NOT be reused
+ * for more than one multipart download.
+ *
+ * Unlike the standard {@link MultipartDownloaderSubscriber} which uses S3's native multipart API with part numbers,
+ * this subscriber uses HTTP range requests against presigned URLs to achieve multipart download functionality.
+ *
This implementation is thread-safe and handles concurrent part downloads while maintaining proper
+ * ordering and validation of responses.
+ */
+@ThreadSafe
+@SdkInternalApi
+public class PresignedUrlMultipartDownloaderSubscriber
+ implements Subscriber> {
+
+ private static final Logger log = Logger.loggerFor(PresignedUrlMultipartDownloaderSubscriber.class);
+
+ private final S3AsyncClient s3AsyncClient;
+ private final PresignedUrlDownloadRequest presignedUrlDownloadRequest;
+ private final Long configuredPartSizeInBytes;
+
+ /**
+ * Internal lifecycle future for this subscriber. Completed when all parts are downloaded
+ * or when an error occurs.
+ */
+ private final CompletableFuture future;
+
+ /**
+ * The split transformer's completion future (from {@code SplitResult.resultFuture()}).
+ * Completing this signals the download result to the caller. Must be completed exceptionally
+ * before cancelling the subscription to prevent ByteArraySplittingTransformer from assembling
+ * invalid data.
+ */
+ private final CompletableFuture> resultFuture;
+ private final Object lock = new Object();
+ private final AtomicLong nextPartIndex;
+ private final AtomicLong requestsSent;
+
+ /**
+ * Store the GetObject futures so we can cancel them if onError() is invoked.
+ */
+ private final Queue> getObjectFutures = new ConcurrentLinkedQueue<>();
+
+ private volatile Long totalContentLength;
+ private volatile Long totalParts;
+ private volatile String eTag;
+ private Subscription subscription;
+
+ public PresignedUrlMultipartDownloaderSubscriber(
+ S3AsyncClient s3AsyncClient,
+ PresignedUrlDownloadRequest presignedUrlDownloadRequest,
+ long configuredPartSizeInBytes,
+ CompletableFuture> resultFuture) {
+ this.s3AsyncClient = s3AsyncClient;
+ this.presignedUrlDownloadRequest = presignedUrlDownloadRequest;
+ this.configuredPartSizeInBytes = configuredPartSizeInBytes;
+ this.nextPartIndex = new AtomicLong(0);
+ this.requestsSent = new AtomicLong(0);
+ this.future = new CompletableFuture<>();
+ this.resultFuture = resultFuture;
+ }
+
+ @Override
+ public void onSubscribe(Subscription s) {
+ if (subscription != null) {
+ s.cancel();
+ return;
+ }
+ this.subscription = s;
+ s.request(1);
+ }
+
+ @Override
+ public void onNext(AsyncResponseTransformer