Skip to content

Commit

Permalink
feat(storage): add object existence validation option to get presigne…
Browse files Browse the repository at this point in the history
…d url
  • Loading branch information
phantumcode committed Jun 12, 2024
1 parent b3d20af commit 05f1e84
Show file tree
Hide file tree
Showing 7 changed files with 174 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -123,6 +135,7 @@ public String toString() {
*/
public static final class Builder extends StorageGetUrlOptions.Builder<Builder> {
private boolean useAccelerateEndpoint;
private boolean validateObjectExistence;

/**
* Configure to use acceleration mode on new StorageGetPresignedUrlOptions instances.
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,22 @@ 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
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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -53,10 +53,11 @@ class AWSS3StoragePathGetUrlOperationTest {
val request = AWSS3StoragePathGetPresignedUrlRequest(
path,
expectedExpires,
false
false,
validateObjectExistence = false
)
val onError = mockk<Consumer<StorageException>>(relaxed = true)
awsS3StorageDownloadFileOperation = AWSS3StoragePathGetPresignedUrlOperation(
awsS3StorageGetPresignedUrlOperation = AWSS3StoragePathGetPresignedUrlOperation(
request = request,
storageService = storageService,
executorService = MoreExecutors.newDirectExecutorService(),
Expand All @@ -66,7 +67,7 @@ class AWSS3StoragePathGetUrlOperationTest {
)

// WHEN
awsS3StorageDownloadFileOperation.start()
awsS3StorageGetPresignedUrlOperation.start()

// THEN
verify(exactly = 0) { onError.accept(any()) }
Expand All @@ -88,10 +89,11 @@ class AWSS3StoragePathGetUrlOperationTest {
val request = AWSS3StoragePathGetPresignedUrlRequest(
path,
expectedExpires,
false
false,
validateObjectExistence = false
)
val onError = mockk<Consumer<StorageException>>(relaxed = true)
awsS3StorageDownloadFileOperation = AWSS3StoragePathGetPresignedUrlOperation(
awsS3StorageGetPresignedUrlOperation = AWSS3StoragePathGetPresignedUrlOperation(
request = request,
storageService = storageService,
executorService = MoreExecutors.newDirectExecutorService(),
Expand All @@ -101,7 +103,7 @@ class AWSS3StoragePathGetUrlOperationTest {
)

// WHEN
awsS3StorageDownloadFileOperation.start()
awsS3StorageGetPresignedUrlOperation.start()

// THEN
verify(exactly = 0) { onError.accept(any()) }
Expand All @@ -122,10 +124,11 @@ class AWSS3StoragePathGetUrlOperationTest {
val request = AWSS3StoragePathGetPresignedUrlRequest(
path,
expectedExpires,
false
false,
validateObjectExistence = false
)
val onError = mockk<Consumer<StorageException>>(relaxed = true)
awsS3StorageDownloadFileOperation = AWSS3StoragePathGetPresignedUrlOperation(
awsS3StorageGetPresignedUrlOperation = AWSS3StoragePathGetPresignedUrlOperation(
request = request,
storageService = storageService,
executorService = MoreExecutors.newDirectExecutorService(),
Expand All @@ -135,7 +138,7 @@ class AWSS3StoragePathGetUrlOperationTest {
)

// WHEN
awsS3StorageDownloadFileOperation.start()
awsS3StorageGetPresignedUrlOperation.start()

// THEN
verify { onError.accept(StoragePathValidationException.invalidStoragePathException()) }
Expand All @@ -153,10 +156,11 @@ class AWSS3StoragePathGetUrlOperationTest {
val request = AWSS3StoragePathGetPresignedUrlRequest(
path,
expectedExpires,
false
false,
validateObjectExistence = false
)
val onError = mockk<Consumer<StorageException>>(relaxed = true)
awsS3StorageDownloadFileOperation = AWSS3StoragePathGetPresignedUrlOperation(
awsS3StorageGetPresignedUrlOperation = AWSS3StoragePathGetPresignedUrlOperation(
request = request,
storageService = storageService,
executorService = MoreExecutors.newDirectExecutorService(),
Expand All @@ -166,7 +170,7 @@ class AWSS3StoragePathGetUrlOperationTest {
)

// WHEN
awsS3StorageDownloadFileOperation.start()
awsS3StorageGetPresignedUrlOperation.start()

// THEN
verify {
Expand All @@ -190,10 +194,11 @@ class AWSS3StoragePathGetUrlOperationTest {
val request = AWSS3StoragePathGetPresignedUrlRequest(
path,
expectedExpires,
false
false,
validateObjectExistence = false
)
val onError = mockk<Consumer<StorageException>>(relaxed = true)
awsS3StorageDownloadFileOperation = AWSS3StoragePathGetPresignedUrlOperation(
awsS3StorageGetPresignedUrlOperation = AWSS3StoragePathGetPresignedUrlOperation(
request = request,
storageService = storageService,
executorService = MoreExecutors.newDirectExecutorService(),
Expand All @@ -203,7 +208,7 @@ class AWSS3StoragePathGetUrlOperationTest {
)

// WHEN
awsS3StorageDownloadFileOperation.start()
awsS3StorageGetPresignedUrlOperation.start()

// THEN
verify { onError.accept(StoragePathValidationException.unsupportedStoragePathException()) }
Expand All @@ -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<Consumer<StorageException>>(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<Consumer<StorageException>>(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()
}

0 comments on commit 05f1e84

Please sign in to comment.