diff --git a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/AWSS3StoragePlugin.java b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/AWSS3StoragePlugin.java index 4af9a374e..f792e608a 100644 --- a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/AWSS3StoragePlugin.java +++ b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/AWSS3StoragePlugin.java @@ -355,10 +355,15 @@ public StorageGetUrlOperation getUrl( ) { boolean useAccelerateEndpoint = options instanceof AWSS3StorageGetPresignedUrlOptions && ((AWSS3StorageGetPresignedUrlOptions) options).useAccelerateEndpoint(); + + boolean validateObjectExistence = options instanceof AWSS3StorageGetPresignedUrlOptions && + ((AWSS3StorageGetPresignedUrlOptions) options).validateObjectExistence(); + AWSS3StoragePathGetPresignedUrlRequest request = new AWSS3StoragePathGetPresignedUrlRequest( path, options.getExpires() != 0 ? options.getExpires() : defaultUrlExpiration, - useAccelerateEndpoint + useAccelerateEndpoint, + validateObjectExistence ); AWSS3StoragePathGetPresignedUrlOperation operation = diff --git a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathGetPresignedUrlOperation.kt b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathGetPresignedUrlOperation.kt index 3faacef71..eb7929358 100644 --- a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathGetPresignedUrlOperation.kt +++ b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathGetPresignedUrlOperation.kt @@ -48,6 +48,24 @@ internal class AWSS3StoragePathGetPresignedUrlOperation( return@submit } + if (request.validateObjectExistence) { + try { + storageService.validateObjectExists(serviceKey) + } catch (se: StorageException) { + onError.accept(se) + return@submit + } catch (exception: Exception) { + onError.accept( + StorageException( + "Encountered an issue while generating pre-signed URL", + exception, + "See included exception for more details and suggestions to fix." + ) + ) + return@submit + } + } + try { val url = storageService.getPresignedUrl( serviceKey, diff --git a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/options/AWSS3StorageGetPresignedUrlOptions.java b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/options/AWSS3StorageGetPresignedUrlOptions.java index 36b7ae7fc..925cba6f6 100644 --- a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/options/AWSS3StorageGetPresignedUrlOptions.java +++ b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/options/AWSS3StorageGetPresignedUrlOptions.java @@ -26,10 +26,12 @@ */ public final class AWSS3StorageGetPresignedUrlOptions extends StorageGetUrlOptions { private final boolean useAccelerationMode; + private final boolean validateObjectExistence; private AWSS3StorageGetPresignedUrlOptions(final Builder builder) { super(builder); this.useAccelerationMode = builder.useAccelerateEndpoint; + this.validateObjectExistence = builder.validateObjectExistence; } /** @@ -80,6 +82,16 @@ public boolean useAccelerateEndpoint() { return useAccelerationMode; } + /** + * Gets the flag to determine whether to validate whether an S3 object exists. + * Note: Setting this to `true` will result in a latency cost since confirming the existence + * of the underlying S3 object will likely require a round-trip network call. + * @return boolean flag + */ + public boolean validateObjectExistence() { + return validateObjectExistence; + } + @Override @SuppressWarnings("deprecation") public boolean equals(Object obj) { @@ -123,6 +135,7 @@ public String toString() { */ public static final class Builder extends StorageGetUrlOptions.Builder { private boolean useAccelerateEndpoint; + private boolean validateObjectExistence; /** * Configure to use acceleration mode on new StorageGetPresignedUrlOptions instances. @@ -134,6 +147,16 @@ public Builder setUseAccelerateEndpoint(boolean useAccelerateEndpoint) { return this; } + /** + * Configure to validate object existence flag on new StorageGetPresignedUrlOptions instances. + * @param validateObjectExistence flag to represent flag to validate object existence for new GetPresignedUrlOptions instance + * @return Current Builder instance for fluent chaining + */ + public Builder setValidateObjectExistence(boolean validateObjectExistence) { + this.validateObjectExistence = validateObjectExistence; + return this; + } + @Override @NonNull public AWSS3StorageGetPresignedUrlOptions build() { diff --git a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/request/AWSS3StoragePathGetPresignedUrlRequest.kt b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/request/AWSS3StoragePathGetPresignedUrlRequest.kt index 47de0dec6..f3b01bc3a 100644 --- a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/request/AWSS3StoragePathGetPresignedUrlRequest.kt +++ b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/request/AWSS3StoragePathGetPresignedUrlRequest.kt @@ -22,5 +22,6 @@ import com.amplifyframework.storage.StoragePath internal data class AWSS3StoragePathGetPresignedUrlRequest( val path: StoragePath, val expires: Int, - val useAccelerateEndpoint: Boolean + val useAccelerateEndpoint: Boolean, + val validateObjectExistence: Boolean ) diff --git a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/service/AWSS3StorageService.kt b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/service/AWSS3StorageService.kt index ec7d087e3..3fc2a83bf 100644 --- a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/service/AWSS3StorageService.kt +++ b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/service/AWSS3StorageService.kt @@ -20,11 +20,14 @@ import aws.sdk.kotlin.services.s3.S3Client import aws.sdk.kotlin.services.s3.deleteObject import aws.sdk.kotlin.services.s3.listObjectsV2 import aws.sdk.kotlin.services.s3.model.GetObjectRequest +import aws.sdk.kotlin.services.s3.model.HeadObjectRequest +import aws.sdk.kotlin.services.s3.model.NotFound import aws.sdk.kotlin.services.s3.paginators.listObjectsV2Paginated import aws.sdk.kotlin.services.s3.presigners.presignGetObject import aws.sdk.kotlin.services.s3.withConfig import com.amplifyframework.auth.AuthCredentialsProvider import com.amplifyframework.storage.ObjectMetadata +import com.amplifyframework.storage.StorageException import com.amplifyframework.storage.StorageItem import com.amplifyframework.storage.result.StorageListResult import com.amplifyframework.storage.s3.transfer.TransferManager @@ -32,6 +35,7 @@ import com.amplifyframework.storage.s3.transfer.TransferObserver import com.amplifyframework.storage.s3.transfer.TransferRecord import com.amplifyframework.storage.s3.transfer.UploadOptions import com.amplifyframework.storage.s3.utils.S3Keys +import kotlinx.coroutines.runBlocking import java.io.File import java.io.IOException import java.io.InputStream @@ -40,7 +44,6 @@ import java.time.Instant import java.util.Date import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime -import kotlinx.coroutines.runBlocking /** * A representation of an S3 backend service endpoint. @@ -85,6 +88,30 @@ internal class AWSS3StorageService( return URL(presignUrlRequest.url.toString()) } + /** + * Validate if S3 object exists for the given key. + * Throws StorageException if NoSuchKey S3 client exception is caught. + * @param serviceKey S3 service key + */ + override fun validateObjectExists(serviceKey: String) { + try { + runBlocking { + s3Client.headObject( + HeadObjectRequest { + bucket = s3BucketName + key = serviceKey + } + ) + } + } catch (ex: NotFound) { + throw StorageException( + "Unable to generate URL for non-existent path: $serviceKey", + ex, + "Please ensure the path is valid or the object has been uploaded" + ) + } + } + /** * Begin downloading a file. * @param serviceKey S3 service key diff --git a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/service/StorageService.java b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/service/StorageService.java index cc0eaf762..916a61906 100644 --- a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/service/StorageService.java +++ b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/service/StorageService.java @@ -20,6 +20,7 @@ import androidx.annotation.Nullable; import com.amplifyframework.storage.ObjectMetadata; +import com.amplifyframework.storage.StorageException; import com.amplifyframework.storage.StorageItem; import com.amplifyframework.storage.result.StorageListResult; import com.amplifyframework.storage.s3.transfer.TransferObserver; @@ -36,6 +37,14 @@ */ public interface StorageService { + /** + * Validate if Storage object exists for the given key. + * Throws StorageException if object is not does not exist. + * + * @param serviceKey key to uniquely specify item to generate URL for + */ + void validateObjectExists(@NonNull String serviceKey); + /** * Generate pre-signed download URL for an object. * diff --git a/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathGetUrlOperationTest.kt b/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathGetUrlOperationTest.kt index 0c43c7566..40cabddf2 100644 --- a/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathGetUrlOperationTest.kt +++ b/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathGetUrlOperationTest.kt @@ -33,7 +33,7 @@ import org.junit.Test class AWSS3StoragePathGetUrlOperationTest { - private lateinit var awsS3StorageDownloadFileOperation: AWSS3StoragePathGetPresignedUrlOperation + private lateinit var awsS3StorageGetPresignedUrlOperation: AWSS3StoragePathGetPresignedUrlOperation private lateinit var storageService: StorageService private lateinit var authCredentialsProvider: AuthCredentialsProvider @@ -53,10 +53,11 @@ class AWSS3StoragePathGetUrlOperationTest { val request = AWSS3StoragePathGetPresignedUrlRequest( path, expectedExpires, - false + false, + validateObjectExistence = false ) val onError = mockk>(relaxed = true) - awsS3StorageDownloadFileOperation = AWSS3StoragePathGetPresignedUrlOperation( + awsS3StorageGetPresignedUrlOperation = AWSS3StoragePathGetPresignedUrlOperation( request = request, storageService = storageService, executorService = MoreExecutors.newDirectExecutorService(), @@ -66,7 +67,7 @@ class AWSS3StoragePathGetUrlOperationTest { ) // WHEN - awsS3StorageDownloadFileOperation.start() + awsS3StorageGetPresignedUrlOperation.start() // THEN verify(exactly = 0) { onError.accept(any()) } @@ -88,10 +89,11 @@ class AWSS3StoragePathGetUrlOperationTest { val request = AWSS3StoragePathGetPresignedUrlRequest( path, expectedExpires, - false + false, + validateObjectExistence = false ) val onError = mockk>(relaxed = true) - awsS3StorageDownloadFileOperation = AWSS3StoragePathGetPresignedUrlOperation( + awsS3StorageGetPresignedUrlOperation = AWSS3StoragePathGetPresignedUrlOperation( request = request, storageService = storageService, executorService = MoreExecutors.newDirectExecutorService(), @@ -101,7 +103,7 @@ class AWSS3StoragePathGetUrlOperationTest { ) // WHEN - awsS3StorageDownloadFileOperation.start() + awsS3StorageGetPresignedUrlOperation.start() // THEN verify(exactly = 0) { onError.accept(any()) } @@ -122,10 +124,11 @@ class AWSS3StoragePathGetUrlOperationTest { val request = AWSS3StoragePathGetPresignedUrlRequest( path, expectedExpires, - false + false, + validateObjectExistence = false ) val onError = mockk>(relaxed = true) - awsS3StorageDownloadFileOperation = AWSS3StoragePathGetPresignedUrlOperation( + awsS3StorageGetPresignedUrlOperation = AWSS3StoragePathGetPresignedUrlOperation( request = request, storageService = storageService, executorService = MoreExecutors.newDirectExecutorService(), @@ -135,7 +138,7 @@ class AWSS3StoragePathGetUrlOperationTest { ) // WHEN - awsS3StorageDownloadFileOperation.start() + awsS3StorageGetPresignedUrlOperation.start() // THEN verify { onError.accept(StoragePathValidationException.invalidStoragePathException()) } @@ -153,10 +156,11 @@ class AWSS3StoragePathGetUrlOperationTest { val request = AWSS3StoragePathGetPresignedUrlRequest( path, expectedExpires, - false + false, + validateObjectExistence = false ) val onError = mockk>(relaxed = true) - awsS3StorageDownloadFileOperation = AWSS3StoragePathGetPresignedUrlOperation( + awsS3StorageGetPresignedUrlOperation = AWSS3StoragePathGetPresignedUrlOperation( request = request, storageService = storageService, executorService = MoreExecutors.newDirectExecutorService(), @@ -166,7 +170,7 @@ class AWSS3StoragePathGetUrlOperationTest { ) // WHEN - awsS3StorageDownloadFileOperation.start() + awsS3StorageGetPresignedUrlOperation.start() // THEN verify { @@ -190,10 +194,11 @@ class AWSS3StoragePathGetUrlOperationTest { val request = AWSS3StoragePathGetPresignedUrlRequest( path, expectedExpires, - false + false, + validateObjectExistence = false ) val onError = mockk>(relaxed = true) - awsS3StorageDownloadFileOperation = AWSS3StoragePathGetPresignedUrlOperation( + awsS3StorageGetPresignedUrlOperation = AWSS3StoragePathGetPresignedUrlOperation( request = request, storageService = storageService, executorService = MoreExecutors.newDirectExecutorService(), @@ -203,7 +208,7 @@ class AWSS3StoragePathGetUrlOperationTest { ) // WHEN - awsS3StorageDownloadFileOperation.start() + awsS3StorageGetPresignedUrlOperation.start() // THEN verify { onError.accept(StoragePathValidationException.unsupportedStoragePathException()) } @@ -212,5 +217,72 @@ class AWSS3StoragePathGetUrlOperationTest { } } + @Test + fun `getPresignedUrl fails with non existent S3 path when validateObjectExistence is enabled`() { + // GIVEN + val path = StoragePath.fromString("public/123") + val expectedException = StorageException("Test", "Test") + coEvery { storageService.validateObjectExists(any()) } throws expectedException + val request = AWSS3StoragePathGetPresignedUrlRequest( + path, + expectedExpires, + false, + validateObjectExistence = true + ) + val onError = mockk>(relaxed = true) + awsS3StorageGetPresignedUrlOperation = AWSS3StoragePathGetPresignedUrlOperation( + request = request, + storageService = storageService, + executorService = MoreExecutors.newDirectExecutorService(), + authCredentialsProvider = authCredentialsProvider, + onSuccess = {}, + onError = onError + ) + + // WHEN + awsS3StorageGetPresignedUrlOperation.start() + + // THEN + verify(exactly = 1) { onError.accept(expectedException) } + verify(exactly = 0) { + storageService.getPresignedUrl(any(), any(), any()) + } + } + + @Test + fun `getPresignedUrl succeeds when validateObjectExistence is enabled`() { + // GIVEN + val path = StoragePath.fromString("public/123") + val expectedServiceKey = "public/123" + val request = AWSS3StoragePathGetPresignedUrlRequest( + path, + expectedExpires, + false, + validateObjectExistence = true + ) + val onError = mockk>(relaxed = true) + awsS3StorageGetPresignedUrlOperation = AWSS3StoragePathGetPresignedUrlOperation( + request = request, + storageService = storageService, + executorService = MoreExecutors.newDirectExecutorService(), + authCredentialsProvider = authCredentialsProvider, + onSuccess = {}, + onError = onError + ) + + // WHEN + awsS3StorageGetPresignedUrlOperation.start() + + // THEN + verify(exactly = 0) { onError.accept(any()) } + verify { + storageService.getPresignedUrl( + expectedServiceKey, + expectedExpires, + false + ) + } + } + class UnsupportedStoragePath : StoragePath() }