From cf64793635d485649b59f8a9a7889823fc97d4df Mon Sep 17 00:00:00 2001 From: Tuan Pham <103537251+phantumcode@users.noreply.github.com> Date: Wed, 24 Jul 2024 12:28:40 -0500 Subject: [PATCH] feat(storage): add delimiter support (#2871) --- aws-storage-s3/api/aws-storage-s3.api | 4 + aws-storage-s3/build.gradle.kts | 1 + .../s3/AWSS3StorageSubPathStrategyListTest.kt | 201 ++++++++++++++++++ .../storage/s3/AWSS3StoragePlugin.java | 6 +- .../operation/AWSS3StorageListOperation.java | 9 +- .../AWSS3StoragePathListOperation.kt | 2 +- .../s3/request/AWSS3StorageListRequest.java | 43 ++++ .../s3/request/AWSS3StoragePathListRequest.kt | 4 +- .../storage/s3/service/AWSS3StorageService.kt | 98 ++++++++- .../storage/s3/service/StorageService.java | 33 +++ .../storage/s3/StorageComponentTest.java | 4 +- .../AWSS3StorageListOperationTest.kt | 68 +++++- .../AWSS3StoragePathListOperationTest.kt | 95 ++++++++- core/api/core.api | 27 +++ .../options/StoragePagedListOptions.java | 23 +- .../storage/options/SubpathStrategy.kt | 24 +++ .../storage/result/StorageListResult.java | 33 ++- .../storage/SubpathStrategyTest.kt | 38 ++++ 18 files changed, 684 insertions(+), 29 deletions(-) create mode 100644 aws-storage-s3/src/androidTest/java/com/amplifyframework/storage/s3/AWSS3StorageSubPathStrategyListTest.kt create mode 100644 core/src/main/java/com/amplifyframework/storage/options/SubpathStrategy.kt create mode 100644 core/src/test/java/com/amplifyframework/storage/SubpathStrategyTest.kt diff --git a/aws-storage-s3/api/aws-storage-s3.api b/aws-storage-s3/api/aws-storage-s3.api index 96aa6229b1..ae6a3c58f1 100644 --- a/aws-storage-s3/api/aws-storage-s3.api +++ b/aws-storage-s3/api/aws-storage-s3.api @@ -297,10 +297,12 @@ public final class com/amplifyframework/storage/s3/request/AWSS3StorageGetPresig public final class com/amplifyframework/storage/s3/request/AWSS3StorageListRequest { public fun (Ljava/lang/String;Lcom/amplifyframework/storage/StorageAccessLevel;Ljava/lang/String;)V public fun (Ljava/lang/String;Lcom/amplifyframework/storage/StorageAccessLevel;Ljava/lang/String;ILjava/lang/String;)V + public fun (Ljava/lang/String;Lcom/amplifyframework/storage/StorageAccessLevel;Ljava/lang/String;ILjava/lang/String;Lcom/amplifyframework/storage/options/SubpathStrategy;)V public fun getAccessLevel ()Lcom/amplifyframework/storage/StorageAccessLevel; public fun getNextToken ()Ljava/lang/String; public fun getPageSize ()I public fun getPath ()Ljava/lang/String; + public fun getSubpathStrategy ()Lcom/amplifyframework/storage/options/SubpathStrategy; public fun getTargetIdentityId ()Ljava/lang/String; } @@ -331,6 +333,8 @@ public abstract interface class com/amplifyframework/storage/s3/service/StorageS public abstract fun getTransfer (Ljava/lang/String;)Lcom/amplifyframework/storage/s3/transfer/TransferRecord; public abstract fun listFiles (Ljava/lang/String;Ljava/lang/String;)Ljava/util/List; public abstract fun listFiles (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;)Lcom/amplifyframework/storage/result/StorageListResult; + public abstract fun listFiles (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Lcom/amplifyframework/storage/options/SubpathStrategy;)Lcom/amplifyframework/storage/result/StorageListResult; + public abstract fun listFiles (Ljava/lang/String;Ljava/lang/String;Lcom/amplifyframework/storage/options/SubpathStrategy;)Lcom/amplifyframework/storage/result/StorageListResult; public abstract fun pauseTransfer (Lcom/amplifyframework/storage/s3/transfer/TransferObserver;)V public abstract fun resumeTransfer (Lcom/amplifyframework/storage/s3/transfer/TransferObserver;)V public abstract fun uploadFile (Ljava/lang/String;Ljava/lang/String;Ljava/io/File;Lcom/amplifyframework/storage/ObjectMetadata;Z)Lcom/amplifyframework/storage/s3/transfer/TransferObserver; diff --git a/aws-storage-s3/build.gradle.kts b/aws-storage-s3/build.gradle.kts index 40cdf964fc..8193324481 100644 --- a/aws-storage-s3/build.gradle.kts +++ b/aws-storage-s3/build.gradle.kts @@ -60,6 +60,7 @@ dependencies { androidTestImplementation(libs.test.androidx.runner) androidTestImplementation(libs.test.androidx.junit) androidTestImplementation(libs.test.androidx.workmanager) + androidTestImplementation(libs.test.kotest.assertions) androidTestImplementation(project(":aws-storage-s3")) androidTestUtil(libs.test.androidx.orchestrator) diff --git a/aws-storage-s3/src/androidTest/java/com/amplifyframework/storage/s3/AWSS3StorageSubPathStrategyListTest.kt b/aws-storage-s3/src/androidTest/java/com/amplifyframework/storage/s3/AWSS3StorageSubPathStrategyListTest.kt new file mode 100644 index 0000000000..df55140947 --- /dev/null +++ b/aws-storage-s3/src/androidTest/java/com/amplifyframework/storage/s3/AWSS3StorageSubPathStrategyListTest.kt @@ -0,0 +1,201 @@ +/* + * Copyright 2023 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 com.amplifyframework.storage.s3 + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.amplifyframework.auth.cognito.AWSCognitoAuthPlugin +import com.amplifyframework.storage.StorageCategory +import com.amplifyframework.storage.StoragePath +import com.amplifyframework.storage.options.StorageRemoveOptions +import com.amplifyframework.storage.options.StorageUploadFileOptions +import com.amplifyframework.storage.options.SubpathStrategy +import com.amplifyframework.storage.s3.options.AWSS3StoragePagedListOptions +import com.amplifyframework.storage.s3.test.R +import com.amplifyframework.storage.s3.util.WorkmanagerTestUtils +import com.amplifyframework.testutils.random.RandomTempFile +import com.amplifyframework.testutils.sync.SynchronousAuth +import com.amplifyframework.testutils.sync.SynchronousStorage +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.ints.shouldBeExactly +import io.kotest.matchers.nulls.shouldBeNull +import java.io.File +import org.junit.After +import org.junit.BeforeClass +import org.junit.Test + +/** + * Integration tests for using SubpathStrategy with Storage List API + */ +class AWSS3StorageSubPathStrategyListTest { + companion object { + private const val SMALL_FILE_SIZE = 100L + private const val FIRST_FILE_NAME = "01" + private const val SECOND_FILE_NAME = "02" + private const val THIRD_FILE_NAME = "03" + private const val FOURTH_FILE_NAME = "04" + private const val FIFTH_FILE_NAME = "05" + private const val CUSTOM_FILE_NAME = "custom" + private const val FIRST_FILE_STRING_PATH = "public/photos/2023/$FIRST_FILE_NAME" + private val FIRST_FILE_PATH = StoragePath.fromString(FIRST_FILE_STRING_PATH) + private const val SECOND_FILE_STRING_PATH = "public/photos/2023/$SECOND_FILE_NAME" + private val SECOND_FILE_PATH = StoragePath.fromString(SECOND_FILE_STRING_PATH) + private const val THIRD_FILE_STRING_PATH = "public/photos/2024/$THIRD_FILE_NAME" + private val THIRD_FILE_PATH = StoragePath.fromString(THIRD_FILE_STRING_PATH) + private const val FOURTH_FILE_STRING_PATH = "public/photos/2024/$FOURTH_FILE_NAME" + private val FOURTH_FILE_PATH = StoragePath.fromString(FOURTH_FILE_STRING_PATH) + private const val FIFTH_FILE_STRING_PATH = "public/photos/$FIFTH_FILE_NAME" + private val FIFTH_FILE_PATH = StoragePath.fromString(FIFTH_FILE_STRING_PATH) + private const val CUSTOM_FILE_STRING_PATH = "public/photos/202$/$CUSTOM_FILE_NAME" + private val CUSTOM_FILE_PATH = StoragePath.fromString(CUSTOM_FILE_STRING_PATH) + + lateinit var storageCategory: StorageCategory + lateinit var synchronousStorage: SynchronousStorage + lateinit var synchronousAuth: SynchronousAuth + private lateinit var first: File + private lateinit var second: File + private lateinit var third: File + private lateinit var fourth: File + private lateinit var fifth: File + private lateinit var customFile: File + + /** + * Initialize mobile client and configure the storage. + * Upload the test files ahead of time. + */ + @JvmStatic + @BeforeClass + fun setUpOnce() { + val context = ApplicationProvider.getApplicationContext() + WorkmanagerTestUtils.initializeWorkmanagerTestUtil(context) + + synchronousAuth = SynchronousAuth.delegatingToCognito(context, AWSCognitoAuthPlugin()) + + // Get a handle to storage + storageCategory = TestStorageCategory.create(context, R.raw.amplifyconfiguration) + synchronousStorage = SynchronousStorage.delegatingTo(storageCategory) + + // Upload test files + first = RandomTempFile(FIRST_FILE_NAME, SMALL_FILE_SIZE) + synchronousStorage.uploadFile(FIRST_FILE_PATH, first, StorageUploadFileOptions.defaultInstance()) + second = RandomTempFile(SECOND_FILE_NAME, SMALL_FILE_SIZE) + synchronousStorage.uploadFile(SECOND_FILE_PATH, second, StorageUploadFileOptions.defaultInstance()) + third = RandomTempFile(THIRD_FILE_NAME, SMALL_FILE_SIZE) + synchronousStorage.uploadFile(THIRD_FILE_PATH, third, StorageUploadFileOptions.defaultInstance()) + fourth = RandomTempFile(FOURTH_FILE_NAME, SMALL_FILE_SIZE) + synchronousStorage.uploadFile(FOURTH_FILE_PATH, fourth, StorageUploadFileOptions.defaultInstance()) + fifth = RandomTempFile(FIFTH_FILE_NAME, SMALL_FILE_SIZE) + synchronousStorage.uploadFile(FIFTH_FILE_PATH, fifth, StorageUploadFileOptions.defaultInstance()) + + customFile = RandomTempFile(CUSTOM_FILE_NAME, SMALL_FILE_SIZE) + synchronousStorage.uploadFile(CUSTOM_FILE_PATH, customFile, StorageUploadFileOptions.defaultInstance()) + } + } + + @After + fun tearDown() { + synchronousStorage.remove("photos/2023/$FIRST_FILE_NAME", StorageRemoveOptions.defaultInstance()) + synchronousStorage.remove("photos/2023/$SECOND_FILE_NAME", StorageRemoveOptions.defaultInstance()) + synchronousStorage.remove("photos/2024/$THIRD_FILE_NAME", StorageRemoveOptions.defaultInstance()) + synchronousStorage.remove("photos/2024/$FOURTH_FILE_NAME", StorageRemoveOptions.defaultInstance()) + synchronousStorage.remove("photos/$FIFTH_FILE_NAME", StorageRemoveOptions.defaultInstance()) + synchronousStorage.remove("photos/$CUSTOM_FILE_NAME", StorageRemoveOptions.defaultInstance()) + } + + @Test + fun testListWithIncludeStrategyAndStoragePath() { + val path = StoragePath.fromString("public/photos/") + val options = AWSS3StoragePagedListOptions + .builder() + .setPageSize(10) + .setSubpathStrategy(SubpathStrategy.Include) + .build() + + val result = synchronousStorage.list(path, options) + + result.items.size shouldBeExactly(6) + result.items.mapNotNull { it.path } shouldContainExactly listOf( + "public/photos/05", + "public/photos/202$/custom", + "public/photos/2023/01", + "public/photos/2023/02", + "public/photos/2024/03", + "public/photos/2024/04" + ) + } + + @Test + fun testListWithExcludeStrategyAndStoragePath() { + val options = AWSS3StoragePagedListOptions + .builder() + .setPageSize(10) + .setSubpathStrategy(SubpathStrategy.Exclude()) + .build() + + var result = synchronousStorage.list(StoragePath.fromString("public/photos/"), options) + + result.items.size shouldBeExactly(1) + result.items.mapNotNull { it.path } shouldContainExactly listOf("public/photos/05") + + result.excludedSubpaths.size shouldBeExactly(3) + result.excludedSubpaths shouldContainExactly listOf( + "public/photos/202$/", + "public/photos/2023/", + "public/photos/2024/" + ) + + result = synchronousStorage.list(StoragePath.fromString("public/photos/2023/"), options) + + result.items.size shouldBeExactly(2) + result.items.mapNotNull { it.path } shouldContainExactly listOf( + "public/photos/2023/01", + "public/photos/2023/02" + ) + + result.excludedSubpaths.shouldBeNull() + } + + @Test + fun testListWithExcludeCustomDelimiterStrategyAndStoragePath() { + val options = AWSS3StoragePagedListOptions + .builder() + .setPageSize(10) + .setSubpathStrategy(SubpathStrategy.Exclude("$")) + .build() + + var result = synchronousStorage.list(StoragePath.fromString("public/photos/"), options) + + result.items.size shouldBeExactly(5) + result.items.mapNotNull { it.path } shouldContainExactly listOf( + "public/photos/05", + "public/photos/2023/01", + "public/photos/2023/02", + "public/photos/2024/03", + "public/photos/2024/04" + ) + + result.excludedSubpaths.size shouldBeExactly(1) + result.excludedSubpaths shouldContainExactly listOf("public/photos/202$") + + result = synchronousStorage.list(StoragePath.fromString("public/photos/2023/"), options) + + result.items.size shouldBeExactly(2) + result.items.mapNotNull { it.path } shouldContainExactly listOf( + "public/photos/2023/01", + "public/photos/2023/02", + ) + } +} 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 e12f047d16..9375a14d9b 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 @@ -955,7 +955,8 @@ public StorageListOperation list(@NonNull String path, options.getAccessLevel() != null ? options.getAccessLevel() : defaultAccessLevel, options.getTargetIdentityId(), options.getPageSize(), - options.getNextToken()); + options.getNextToken(), + options.getSubpathStrategy()); AWSS3StorageListOperation operation = new AWSS3StorageListOperation( @@ -983,7 +984,8 @@ public StorageListOperation list( AWSS3StoragePathListRequest request = new AWSS3StoragePathListRequest( path, options.getPageSize(), - options.getNextToken()); + options.getNextToken(), + options.getSubpathStrategy()); AWSS3StoragePathListOperation operation = new AWSS3StoragePathListOperation( diff --git a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/operation/AWSS3StorageListOperation.java b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/operation/AWSS3StorageListOperation.java index 70b57353a8..b767262255 100644 --- a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/operation/AWSS3StorageListOperation.java +++ b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/operation/AWSS3StorageListOperation.java @@ -20,15 +20,14 @@ import com.amplifyframework.auth.AuthCredentialsProvider; import com.amplifyframework.core.Consumer; import com.amplifyframework.storage.StorageException; -import com.amplifyframework.storage.StorageItem; import com.amplifyframework.storage.operation.StorageListOperation; +import com.amplifyframework.storage.options.SubpathStrategy; import com.amplifyframework.storage.result.StorageListResult; import com.amplifyframework.storage.s3.configuration.AWSS3StoragePluginConfiguration; import com.amplifyframework.storage.s3.options.AWSS3StoragePagedListOptions; import com.amplifyframework.storage.s3.request.AWSS3StorageListRequest; import com.amplifyframework.storage.s3.service.StorageService; -import java.util.List; import java.util.concurrent.ExecutorService; /** @@ -86,14 +85,14 @@ public void start() { prefix -> { try { String serviceKey = prefix.concat(getRequest().getPath()); + SubpathStrategy subpathStrategy = getRequest().getSubpathStrategy(); if (getRequest().getPageSize() == AWSS3StoragePagedListOptions.ALL_PAGE_SIZE) { // fetch all the keys - List listedItems = storageService.listFiles(serviceKey, prefix); - onSuccess.accept(StorageListResult.fromItems(listedItems, null)); + onSuccess.accept(storageService.listFiles(serviceKey, prefix, subpathStrategy)); } else { onSuccess.accept( storageService.listFiles(serviceKey, prefix, getRequest().getPageSize(), - getRequest().getNextToken())); + getRequest().getNextToken(), subpathStrategy)); } } catch (Exception exception) { onError.accept(new StorageException( diff --git a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathListOperation.kt b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathListOperation.kt index 3679e9a3d6..b119d07992 100644 --- a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathListOperation.kt +++ b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathListOperation.kt @@ -49,7 +49,7 @@ internal class AWSS3StoragePathListOperation( try { onSuccess.accept( - storageService.listFiles(serviceKey, request.pageSize, request.nextToken) + storageService.listFiles(serviceKey, request.pageSize, request.nextToken, request.subpathStrategy) ) } catch (exception: Exception) { onError.accept( diff --git a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/request/AWSS3StorageListRequest.java b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/request/AWSS3StorageListRequest.java index 515e84e61b..32e90c4e34 100644 --- a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/request/AWSS3StorageListRequest.java +++ b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/request/AWSS3StorageListRequest.java @@ -19,6 +19,7 @@ import androidx.annotation.Nullable; import com.amplifyframework.storage.StorageAccessLevel; +import com.amplifyframework.storage.options.SubpathStrategy; import com.amplifyframework.storage.s3.options.AWSS3StoragePagedListOptions; /** @@ -33,6 +34,7 @@ public final class AWSS3StorageListRequest { private final String targetIdentityId; private final int pageSize; private final String nextToken; + private final SubpathStrategy subpathStrategy; /** * Constructs a new AWSS3StorageListRequest. @@ -55,6 +57,7 @@ public AWSS3StorageListRequest( this.targetIdentityId = targetIdentityId; this.pageSize = AWSS3StoragePagedListOptions.ALL_PAGE_SIZE; this.nextToken = null; + this.subpathStrategy = null; } /** @@ -82,6 +85,37 @@ public AWSS3StorageListRequest( this.targetIdentityId = targetIdentityId; this.pageSize = pageSize; this.nextToken = nextToken; + this.subpathStrategy = null; + } + + /** + * Constructs a new AWSS3StorageListRequest. + * Although this has public access, it is intended for internal use and should not be used directly by host + * applications. The behavior of this may change without warning. + * + * @param path the path in S3 to list items from + * @param accessLevel Storage access level + * @param targetIdentityId If set, this should override the current user's identity ID. + * If null, the operation will fetch the current identity ID. + * @param pageSize number of keys to be retrieved from s3 + * @param nextToken next continuation token to be passed to s3 + * @param subpathStrategy strategy to include or exclude sub-paths in s3 path + */ + @SuppressWarnings("deprecation") + public AWSS3StorageListRequest( + @NonNull String path, + @NonNull StorageAccessLevel accessLevel, + @Nullable String targetIdentityId, + int pageSize, + @Nullable String nextToken, + @Nullable SubpathStrategy subpathStrategy + ) { + this.path = path; + this.accessLevel = accessLevel; + this.targetIdentityId = targetIdentityId; + this.pageSize = pageSize; + this.nextToken = nextToken; + this.subpathStrategy = subpathStrategy; } /** @@ -128,5 +162,14 @@ public int getPageSize() { public String getNextToken() { return nextToken; } + + /** + * Get SubpathStrategy to include/exclude sub-paths. + * @return SubpathStrategy to include/exclude sub-paths. + * */ + @Nullable + public SubpathStrategy getSubpathStrategy() { + return subpathStrategy; + } } diff --git a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/request/AWSS3StoragePathListRequest.kt b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/request/AWSS3StoragePathListRequest.kt index b238a2dd8d..9e4a65143d 100644 --- a/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/request/AWSS3StoragePathListRequest.kt +++ b/aws-storage-s3/src/main/java/com/amplifyframework/storage/s3/request/AWSS3StoragePathListRequest.kt @@ -15,6 +15,7 @@ package com.amplifyframework.storage.s3.request import com.amplifyframework.storage.StoragePath +import com.amplifyframework.storage.options.SubpathStrategy /** * Parameters to provide to S3 that describe a request to list files. @@ -22,5 +23,6 @@ import com.amplifyframework.storage.StoragePath internal data class AWSS3StoragePathListRequest( val path: StoragePath, val pageSize: Int, - val nextToken: String? + val nextToken: String?, + val subpathStrategy: SubpathStrategy? ) 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 b4d733a730..7d9eef745d 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 @@ -29,6 +29,8 @@ import com.amplifyframework.auth.AuthCredentialsProvider import com.amplifyframework.storage.ObjectMetadata import com.amplifyframework.storage.StorageException import com.amplifyframework.storage.StorageItem +import com.amplifyframework.storage.options.SubpathStrategy +import com.amplifyframework.storage.options.SubpathStrategy.Exclude import com.amplifyframework.storage.result.StorageListResult import com.amplifyframework.storage.s3.transfer.TransferManager import com.amplifyframework.storage.s3.transfer.TransferObserver @@ -239,17 +241,108 @@ internal class AWSS3StorageService( } } + /** + * List items inside an S3 path. + * @param path The path to list items from + * @param prefix The prefix to the path + * @param subPathStrategy The SubpathStrategy to include/exclude sub-paths + * @return A list of parsed items + */ + override fun listFiles(path: String, prefix: String, subPathStrategy: SubpathStrategy?): StorageListResult { + return runBlocking { + val items = mutableListOf() + val excludedSubPaths = mutableListOf() + val delimiter: String? = (subPathStrategy as? Exclude)?.delimiter + val result = s3Client.listObjectsV2Paginated { + this.bucket = s3BucketName + this.prefix = path + this.delimiter = delimiter + } + result.collect { + it.contents?.forEach { value -> + val serviceKey = value.key + val lastModified = value.lastModified + val eTag = value.eTag + if (serviceKey != null && lastModified != null && eTag != null) { + items += StorageItem( + serviceKey, + S3Keys.extractAmplifyKey(serviceKey, prefix), + value.size ?: 0, + Date.from(Instant.ofEpochMilli(lastModified.epochSeconds)), + eTag, + null + ) + } + } + it.commonPrefixes?.forEach { + val subpath = it.prefix + if (subpath != null) { + excludedSubPaths.add(subpath) + } + } + } + + StorageListResult.fromItems(items, null, excludedSubPaths) + } + } + + override fun listFiles( + path: String, + prefix: String, + pageSize: Int, + nextToken: String?, + subPathStrategy: SubpathStrategy? + ): StorageListResult { + return runBlocking { + val delimiter = (subPathStrategy as? Exclude)?.delimiter + val result = s3Client.listObjectsV2 { + this.bucket = s3BucketName + this.prefix = path + this.maxKeys = pageSize + this.continuationToken = nextToken + this.delimiter = delimiter + } + val items = result.contents?.mapNotNull { value -> + val serviceKey = value.key + val lastModified = value.lastModified + val eTag = value.eTag + if (serviceKey != null && lastModified != null && eTag != null) { + StorageItem( + serviceKey, + S3Keys.extractAmplifyKey(serviceKey, prefix), + value.size ?: 0, + Date.from(Instant.ofEpochMilli(lastModified.epochSeconds)), + eTag, + null + ) + } else { + null + } + } + + val subPaths = result.commonPrefixes?.mapNotNull { it.prefix } + StorageListResult.fromItems(items, result.nextContinuationToken, subPaths) + } + } + /** * This method is used to list files when StoragePath was used. * When StoragePath is used, we provide the full serviceKey for both StorageItem.key and StorageItem.path */ - fun listFiles(path: String, pageSize: Int, nextToken: String?): StorageListResult { + fun listFiles( + path: String, + pageSize: Int, + nextToken: String?, + subPathStrategy: SubpathStrategy? + ): StorageListResult { return runBlocking { + val delimiter = (subPathStrategy as? Exclude)?.delimiter val result = s3Client.listObjectsV2 { this.bucket = s3BucketName this.prefix = path this.maxKeys = pageSize this.continuationToken = nextToken + this.delimiter = delimiter } val items = result.contents?.mapNotNull { value -> val serviceKey = value.key @@ -268,7 +361,8 @@ internal class AWSS3StorageService( null } } - StorageListResult.fromItems(items, result.nextContinuationToken) + val subPaths = result.commonPrefixes?.mapNotNull { it.prefix } + StorageListResult.fromItems(items, result.nextContinuationToken, subPaths) } } 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 00fd39ede0..f78155f17c 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 @@ -22,6 +22,7 @@ import com.amplifyframework.storage.ObjectMetadata; import com.amplifyframework.storage.StorageException; import com.amplifyframework.storage.StorageItem; +import com.amplifyframework.storage.options.SubpathStrategy; import com.amplifyframework.storage.result.StorageListResult; import com.amplifyframework.storage.s3.transfer.TransferObserver; import com.amplifyframework.storage.s3.transfer.TransferRecord; @@ -131,6 +132,38 @@ TransferObserver uploadInputStream(@NonNull String transferId, */ StorageListResult listFiles(@NonNull String path, @NonNull String prefix, int pageSize, @Nullable String nextToken); + /** + * Returns a list of items from provided path inside the storage. + * + * @param path path inside storage to inspect for list of items + * @param prefix path appended to S3 keys + * @param subpathStrategy SubpathStrategy to include/exclude sub-paths + * @return A list of parsed items present inside given path + */ + StorageListResult listFiles( + @NonNull String path, + @NonNull String prefix, + @Nullable SubpathStrategy subpathStrategy + ); + + /** + * Returns a list of items from provided path inside the storage. + * + * @param path path inside storage to inspect for list of items + * @param prefix path appended to S3 keys + * @param pageSize number of keys to be retrieved from s3 + * @param nextToken next continuation token to be passed to s3 + * @param subpathStrategy SubpathStrategy to include/exclude sub-paths + * @return A list of parsed items present inside given path + */ + StorageListResult listFiles( + @NonNull String path, + @NonNull String prefix, + int pageSize, + @Nullable String nextToken, + @Nullable SubpathStrategy subpathStrategy + ); + /** * Delete an object with specific key inside the storage. * diff --git a/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/StorageComponentTest.java b/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/StorageComponentTest.java index 509b73b2d7..c1ac9ca541 100644 --- a/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/StorageComponentTest.java +++ b/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/StorageComponentTest.java @@ -442,8 +442,8 @@ public void testListObject() throws StorageException { null ); - when(storageService.listFiles(anyString(), anyString())) - .thenReturn(Collections.singletonList(item)); + when(storageService.listFiles(anyString(), anyString(), any())) + .thenReturn(StorageListResult.fromItems(Collections.singletonList(item), null)); StorageListResult result = Await.result((onResult, onError) -> diff --git a/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/operation/AWSS3StorageListOperationTest.kt b/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/operation/AWSS3StorageListOperationTest.kt index a3a275961f..502dc0616e 100644 --- a/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/operation/AWSS3StorageListOperationTest.kt +++ b/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/operation/AWSS3StorageListOperationTest.kt @@ -19,6 +19,7 @@ import com.amplifyframework.auth.AuthCredentialsProvider import com.amplifyframework.core.Consumer import com.amplifyframework.storage.StorageAccessLevel import com.amplifyframework.storage.StorageException +import com.amplifyframework.storage.options.SubpathStrategy import com.amplifyframework.storage.s3.configuration.AWSS3PluginPrefixResolver import com.amplifyframework.storage.s3.configuration.AWSS3StoragePluginConfiguration import com.amplifyframework.storage.s3.request.AWSS3StorageListRequest @@ -51,7 +52,7 @@ public class AWSS3StorageListOperationTest { val request = AWSS3StorageListRequest( path, StorageAccessLevel.PUBLIC, - "", + "" ) coEvery { authCredentialsProvider.getIdentityId() } returns "abc" awsS3StorageListOperation = AWSS3StorageListOperation( @@ -64,7 +65,7 @@ public class AWSS3StorageListOperationTest { {} ) awsS3StorageListOperation.start() - Mockito.verify(storageService).listFiles(expectedKey, "public/") + Mockito.verify(storageService).listFiles(expectedKey, "public/", null) } @Test @@ -75,7 +76,7 @@ public class AWSS3StorageListOperationTest { val request = AWSS3StorageListRequest( path, StorageAccessLevel.PUBLIC, - "", + "" ) coEvery { authCredentialsProvider.getIdentityId() } returns "abc" awsS3StorageListOperation = AWSS3StorageListOperation( @@ -99,7 +100,7 @@ public class AWSS3StorageListOperationTest { {} ) awsS3StorageListOperation.start() - Mockito.verify(storageService).listFiles(expectedKey, "") + Mockito.verify(storageService).listFiles(expectedKey, "", null) } @Test @@ -110,7 +111,7 @@ public class AWSS3StorageListOperationTest { val request = AWSS3StorageListRequest( path, StorageAccessLevel.PUBLIC, - "", + "" ) coEvery { authCredentialsProvider.getIdentityId() } returns "abc" awsS3StorageListOperation = AWSS3StorageListOperation( @@ -134,6 +135,61 @@ public class AWSS3StorageListOperationTest { {} ) awsS3StorageListOperation.start() - Mockito.verify(storageService).listFiles(expectedKey, "publicCustom/") + Mockito.verify(storageService).listFiles(expectedKey, "publicCustom/", null) + } + + @Test + @Suppress("deprecation") + fun SubpathStrategyIncludeOperationTest() { + val path = "" + val expectedKey = "public/" + val request = AWSS3StorageListRequest( + path, + StorageAccessLevel.PUBLIC, + "", + 1000, + null, + SubpathStrategy.Include + ) + coEvery { authCredentialsProvider.getIdentityId() } returns "abc" + awsS3StorageListOperation = AWSS3StorageListOperation( + storageService, + MoreExecutors.newDirectExecutorService(), + authCredentialsProvider, + request, + AWSS3StoragePluginConfiguration {}, + {}, + {} + ) + awsS3StorageListOperation.start() + Mockito.verify(storageService).listFiles(expectedKey, "public/", 1000, null, SubpathStrategy.Include) + } + + @Test + @Suppress("deprecation") + fun SubpathStrategyExcludeOperationTest() { + val path = "" + val expectedKey = "public/" + val expectedSubpathStrategy = SubpathStrategy.Exclude() + val request = AWSS3StorageListRequest( + path, + StorageAccessLevel.PUBLIC, + "", + 1000, + null, + expectedSubpathStrategy + ) + coEvery { authCredentialsProvider.getIdentityId() } returns "abc" + awsS3StorageListOperation = AWSS3StorageListOperation( + storageService, + MoreExecutors.newDirectExecutorService(), + authCredentialsProvider, + request, + AWSS3StoragePluginConfiguration {}, + {}, + {} + ) + awsS3StorageListOperation.start() + Mockito.verify(storageService).listFiles(expectedKey, "public/", 1000, null, expectedSubpathStrategy) } } diff --git a/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathListOperationTest.kt b/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathListOperationTest.kt index c0630cdef7..18f671502f 100644 --- a/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathListOperationTest.kt +++ b/aws-storage-s3/src/test/java/com/amplifyframework/storage/s3/operation/AWSS3StoragePathListOperationTest.kt @@ -20,6 +20,7 @@ import com.amplifyframework.core.Consumer import com.amplifyframework.storage.StorageException import com.amplifyframework.storage.StoragePath import com.amplifyframework.storage.StoragePathValidationException +import com.amplifyframework.storage.options.SubpathStrategy import com.amplifyframework.storage.s3.extensions.invalidStoragePathException import com.amplifyframework.storage.s3.extensions.unsupportedStoragePathException import com.amplifyframework.storage.s3.request.AWSS3StoragePathListRequest @@ -57,7 +58,8 @@ class AWSS3StoragePathListOperationTest { val request = AWSS3StoragePathListRequest( path, expectedPageSize, - expectedNextToken + expectedNextToken, + SubpathStrategy.Include ) val onError = mockk>(relaxed = true) listOperation = AWSS3StoragePathListOperation( @@ -78,7 +80,8 @@ class AWSS3StoragePathListOperationTest { storageService.listFiles( expectedServiceKey, expectedPageSize, - expectedNextToken + expectedNextToken, + SubpathStrategy.Include ) } } @@ -92,7 +95,8 @@ class AWSS3StoragePathListOperationTest { val request = AWSS3StoragePathListRequest( path, expectedPageSize, - expectedNextToken + expectedNextToken, + SubpathStrategy.Include ) val onError = mockk>(relaxed = true) listOperation = AWSS3StoragePathListOperation( @@ -113,7 +117,8 @@ class AWSS3StoragePathListOperationTest { storageService.listFiles( expectedServiceKey, expectedPageSize, - expectedNextToken + expectedNextToken, + SubpathStrategy.Include ) } } @@ -126,7 +131,8 @@ class AWSS3StoragePathListOperationTest { val request = AWSS3StoragePathListRequest( path, expectedPageSize, - expectedNextToken + expectedNextToken, + SubpathStrategy.Include ) val onError = mockk>(relaxed = true) listOperation = AWSS3StoragePathListOperation( @@ -157,7 +163,8 @@ class AWSS3StoragePathListOperationTest { val request = AWSS3StoragePathListRequest( path, expectedPageSize, - expectedNextToken + expectedNextToken, + SubpathStrategy.Include ) val onError = mockk>(relaxed = true) listOperation = AWSS3StoragePathListOperation( @@ -194,7 +201,8 @@ class AWSS3StoragePathListOperationTest { val request = AWSS3StoragePathListRequest( path, expectedPageSize, - expectedNextToken + expectedNextToken, + SubpathStrategy.Include ) val onError = mockk>(relaxed = true) listOperation = AWSS3StoragePathListOperation( @@ -216,5 +224,78 @@ class AWSS3StoragePathListOperationTest { } } + @Test + fun `success string storage path with include subpath strategy`() { + // GIVEN + val path = StoragePath.fromString("public/123") + val expectedServiceKey = "public/123" + val request = AWSS3StoragePathListRequest( + path, + expectedPageSize, + expectedNextToken, + SubpathStrategy.Include + ) + val onError = mockk>(relaxed = true) + listOperation = AWSS3StoragePathListOperation( + storageService = storageService, + executorService = MoreExecutors.newDirectExecutorService(), + authCredentialsProvider = authCredentialsProvider, + request = request, + {}, + onError + ) + + // WHEN + listOperation.start() + + // THEN + verify(exactly = 0) { onError.accept(any()) } + verify { + storageService.listFiles( + expectedServiceKey, + expectedPageSize, + expectedNextToken, + SubpathStrategy.Include + ) + } + } + + @Test + fun `success string storage path with exclude subpath strategy`() { + // GIVEN + val path = StoragePath.fromString("public/123") + val expectedServiceKey = "public/123" + val expectedSubpathStrategy = SubpathStrategy.Exclude() + val request = AWSS3StoragePathListRequest( + path, + expectedPageSize, + expectedNextToken, + expectedSubpathStrategy + ) + val onError = mockk>(relaxed = true) + listOperation = AWSS3StoragePathListOperation( + storageService = storageService, + executorService = MoreExecutors.newDirectExecutorService(), + authCredentialsProvider = authCredentialsProvider, + request = request, + {}, + onError + ) + + // WHEN + listOperation.start() + + // THEN + verify(exactly = 0) { onError.accept(any()) } + verify { + storageService.listFiles( + expectedServiceKey, + expectedPageSize, + expectedNextToken, + expectedSubpathStrategy + ) + } + } + class UnsupportedStoragePath : StoragePath() } diff --git a/core/api/core.api b/core/api/core.api index 8daf9c908e..4beafbf12c 100644 --- a/core/api/core.api +++ b/core/api/core.api @@ -4281,6 +4281,7 @@ public class com/amplifyframework/storage/options/StoragePagedListOptions { public static fun builder ()Lcom/amplifyframework/storage/options/StoragePagedListOptions$Builder; public fun getNextToken ()Ljava/lang/String; public fun getPageSize ()I + public fun getSubpathStrategy ()Lcom/amplifyframework/storage/options/SubpathStrategy; } public class com/amplifyframework/storage/options/StoragePagedListOptions$Builder { @@ -4289,6 +4290,7 @@ public class com/amplifyframework/storage/options/StoragePagedListOptions$Builde public fun build ()Lcom/amplifyframework/storage/options/StoragePagedListOptions; public fun setNextToken (Ljava/lang/String;)Lcom/amplifyframework/storage/options/StoragePagedListOptions$Builder; public fun setPageSize (I)Lcom/amplifyframework/storage/options/StoragePagedListOptions$Builder; + public fun setSubpathStrategy (Lcom/amplifyframework/storage/options/SubpathStrategy;)Lcom/amplifyframework/storage/options/StoragePagedListOptions$Builder; } public class com/amplifyframework/storage/options/StorageRemoveOptions { @@ -4354,6 +4356,29 @@ public abstract class com/amplifyframework/storage/options/StorageUploadOptions$ public final fun metadata (Ljava/util/Map;)Lcom/amplifyframework/storage/options/StorageUploadOptions$Builder; } +public abstract class com/amplifyframework/storage/options/SubpathStrategy { +} + +public final class com/amplifyframework/storage/options/SubpathStrategy$Exclude : com/amplifyframework/storage/options/SubpathStrategy { + public fun ()V + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lcom/amplifyframework/storage/options/SubpathStrategy$Exclude; + public static synthetic fun copy$default (Lcom/amplifyframework/storage/options/SubpathStrategy$Exclude;Ljava/lang/String;ILjava/lang/Object;)Lcom/amplifyframework/storage/options/SubpathStrategy$Exclude; + public fun equals (Ljava/lang/Object;)Z + public final fun getDelimiter ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/amplifyframework/storage/options/SubpathStrategy$Include : com/amplifyframework/storage/options/SubpathStrategy { + public static final field INSTANCE Lcom/amplifyframework/storage/options/SubpathStrategy$Include; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/amplifyframework/storage/result/StorageDownloadFileResult : com/amplifyframework/storage/result/StorageTransferResult { public static fun fromFile (Ljava/io/File;)Lcom/amplifyframework/storage/result/StorageDownloadFileResult; public fun getFile ()Ljava/io/File; @@ -4366,6 +4391,8 @@ public final class com/amplifyframework/storage/result/StorageGetUrlResult { public final class com/amplifyframework/storage/result/StorageListResult { public static fun fromItems (Ljava/util/List;Ljava/lang/String;)Lcom/amplifyframework/storage/result/StorageListResult; + public static fun fromItems (Ljava/util/List;Ljava/lang/String;Ljava/util/List;)Lcom/amplifyframework/storage/result/StorageListResult; + public fun getExcludedSubpaths ()Ljava/util/List; public fun getItems ()Ljava/util/List; public fun getNextToken ()Ljava/lang/String; } diff --git a/core/src/main/java/com/amplifyframework/storage/options/StoragePagedListOptions.java b/core/src/main/java/com/amplifyframework/storage/options/StoragePagedListOptions.java index bcfd884cb0..515f92f259 100644 --- a/core/src/main/java/com/amplifyframework/storage/options/StoragePagedListOptions.java +++ b/core/src/main/java/com/amplifyframework/storage/options/StoragePagedListOptions.java @@ -24,6 +24,7 @@ public class StoragePagedListOptions extends StorageOptions { private int pageSize; private String nextToken; + private SubpathStrategy subpathStrategy; /** * Constructs a StoragePagedListOptions instance with the @@ -35,6 +36,7 @@ protected StoragePagedListOptions(Builder builder) { super(builder.getAccessLevel(), builder.getTargetIdentityId()); pageSize = builder.pageSize; nextToken = builder.nextToken; + subpathStrategy = builder.subpathStrategy; } /** @@ -62,6 +64,14 @@ public String getNextToken() { return nextToken; } + /** + * Get the SubpathStrategy. + * @return the SubpathStrategy to include/exclude sub-paths. + */ + public SubpathStrategy getSubpathStrategy() { + return subpathStrategy; + } + /** * Used to construct instance of StorageListOptions via * fluent configuration methods. @@ -73,6 +83,7 @@ public static class Builder> private int pageSize; private String nextToken; + private SubpathStrategy subpathStrategy; /** * Set page size for the request. @@ -86,7 +97,7 @@ public B setPageSize(int pageSize) { /** * Set next continuation token. - * @param nextToken next contiuation token to be passed to S3. + * @param nextToken next continuation token to be passed to S3. * @return Current Builder instance for fluent chaining */ public B setNextToken(String nextToken) { @@ -107,5 +118,15 @@ public B setNextToken(String nextToken) { public StoragePagedListOptions build() { return new StoragePagedListOptions(this); } + + /** + * Set the SubpathStrategy. + * @param subpathStrategy strategy to include/exclude sub-paths. + * @return Current Builder instance for fluent chaining + */ + public B setSubpathStrategy(SubpathStrategy subpathStrategy) { + this.subpathStrategy = subpathStrategy; + return (B) this; + } } } diff --git a/core/src/main/java/com/amplifyframework/storage/options/SubpathStrategy.kt b/core/src/main/java/com/amplifyframework/storage/options/SubpathStrategy.kt new file mode 100644 index 0000000000..40990a250d --- /dev/null +++ b/core/src/main/java/com/amplifyframework/storage/options/SubpathStrategy.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2023 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 com.amplifyframework.storage.options + +/** + * The strategy to include or exclude sub-paths when interacting with Storage List API + */ +sealed class SubpathStrategy { + data object Include : SubpathStrategy() + data class Exclude(val delimiter: String = "/") : SubpathStrategy() +} diff --git a/core/src/main/java/com/amplifyframework/storage/result/StorageListResult.java b/core/src/main/java/com/amplifyframework/storage/result/StorageListResult.java index 447aab4f16..7a476c2272 100644 --- a/core/src/main/java/com/amplifyframework/storage/result/StorageListResult.java +++ b/core/src/main/java/com/amplifyframework/storage/result/StorageListResult.java @@ -30,10 +30,12 @@ public final class StorageListResult { private final List items; private final String nextToken; + private final List excludedSubpaths; - private StorageListResult(List items, String nextToken) { + private StorageListResult(List items, String nextToken, List excludedSubpaths) { this.items = items; this.nextToken = nextToken; + this.excludedSubpaths = excludedSubpaths; } /** @@ -48,7 +50,26 @@ public static StorageListResult fromItems(@Nullable List items, @Nu if (items != null) { safeItems.addAll(items); } - return new StorageListResult(Collections.unmodifiableList(safeItems), nextToken); + return new StorageListResult(Collections.unmodifiableList(safeItems), nextToken, Collections.emptyList()); + } + + /** + * Factory method to construct a storage list result from a list of items. + * @param items A possibly null, possibly empty list of items + * @param nextToken next continuation token + * @param excludedSubpaths sub-paths that are excluded based on the delimiter + * @return A new immutable instance of StorageListResult + */ + public static StorageListResult fromItems( + List items, + String nextToken, + List excludedSubpaths + ) { + final List safeItems = new ArrayList<>(); + if (items != null) { + safeItems.addAll(items); + } + return new StorageListResult(Collections.unmodifiableList(safeItems), nextToken, excludedSubpaths); } /** @@ -67,4 +88,12 @@ public List getItems() { public String getNextToken() { return nextToken; } + + /** + * Gets the excluded sub-paths. + * @return excluded sub-paths . + */ + public List getExcludedSubpaths() { + return excludedSubpaths; + } } diff --git a/core/src/test/java/com/amplifyframework/storage/SubpathStrategyTest.kt b/core/src/test/java/com/amplifyframework/storage/SubpathStrategyTest.kt new file mode 100644 index 0000000000..db21bff6d5 --- /dev/null +++ b/core/src/test/java/com/amplifyframework/storage/SubpathStrategyTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 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 com.amplifyframework.auth + +import com.amplifyframework.storage.options.SubpathStrategy +import io.kotest.matchers.shouldBe +import org.junit.Test + +/** + * Test the sub-path include/exclude strategy + */ +class SubpathStrategyTest { + + @Test + fun `Exclude strategy returns default delimiter`() { + val excludeSubpathStrategy = SubpathStrategy.Exclude() + excludeSubpathStrategy.delimiter shouldBe "/" + } + + @Test + fun `Exclude strategy returns overriden delimiter`() { + val excludeSubpathStrategy = SubpathStrategy.Exclude("$") + excludeSubpathStrategy.delimiter shouldBe "$" + } +}