diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketFilter.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketFilter.kt new file mode 100644 index 000000000..a2d32b797 --- /dev/null +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketFilter.kt @@ -0,0 +1,71 @@ +package io.github.jan.supabase.storage + +import io.ktor.http.parameters + +/** + * A filter builder for [Storage.listBuckets] + */ +class BucketFilter { + + /** + * The maximum number of buckets to return. If null, no limit is applied. + */ + var limit: Int? = null + + /** + * The number of buckets to skip before returning results. Useful for pagination. + */ + var offset: Int? = null + + /** + * A search query to filter buckets by name. If null, no search filter is applied. + */ + var search: String? = null + + /** + * The sort order for the results. Can be [SortOrder.ASC] (ascending) or [SortOrder.DESC] (descending). + * If null, the default sort order from the API is used. + */ + private var sortOrder: SortOrder? = null + + /** + * The column to sort the results by. If null, the default sort column from the API is used. + */ + private var sortColumn: SortColumn? = null + + /** + * Sets the sorting criteria for the bucket list results + * @param column The column to sort by + * @param order The sort order (ascending or descending) + */ + fun sortBy(column: SortColumn, order: SortOrder) { + sortColumn = column + sortOrder = order + } + + internal fun build() = parameters { + limit?.let { set("limit", it.toString()) } + offset?.let { set("offset", it.toString()) } + search?.let { set("search", it) } + sortOrder?.let { set("sortOrder", it.name.lowercase()) } + sortColumn?.let { set("sortColumn", it.name.lowercase()) } + } + + /** + * Represents the available columns for sorting bucket results. + */ + enum class SortColumn { + /** Sort by bucket ID */ + ID, + + /** Sort by bucket name */ + NAME, + + /** Sort by bucket creation timestamp */ + CREATED_AT, + + /** Sort by bucket last updated timestamp */ + UPDATED_AT + } + +} \ No newline at end of file diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketListFilter.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketListFilter.kt index 30314026b..6a20f7edb 100644 --- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketListFilter.kt +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketListFilter.kt @@ -29,10 +29,12 @@ class BucketListFilter { /** * Sorts the result by the given [column] in the given [order] + * @param column The column to sort by + * @param order The sort order (ascending or descending) */ - fun sortBy(column: String, order: String) { + fun sortBy(column: String, order: SortOrder) { this.column = column - this.order = order + this.order = order.name.lowercase() } @SupabaseInternal diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/SortOrder.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/SortOrder.kt new file mode 100644 index 000000000..9eba30acc --- /dev/null +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/SortOrder.kt @@ -0,0 +1,16 @@ +package io.github.jan.supabase.storage + +/** + * Represents the sort order for query results. + */ +enum class SortOrder { + /** + * Ascending order + */ + ASC, + + /** + * Descending order + */ + DESC +} \ No newline at end of file diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/Storage.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/Storage.kt index 6774a80e7..7885c8225 100644 --- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/Storage.kt +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/Storage.kt @@ -75,7 +75,24 @@ interface Storage : MainPlugin, CustomSerializationPlugin { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun retrieveBuckets(): List + suspend fun listBuckets(filter: BucketFilter.() -> Unit = {}): List + + /** + * Returns all buckets in the storage + * @throws RestException or one of its subclasses if receiving an error response + * @throws HttpRequestTimeoutException if the request timed out + * @throws HttpRequestException on network related issues + */ + @Deprecated("Use listBuckets instead", ReplaceWith("listBuckets()")) + suspend fun retrieveBuckets(): List = listBuckets() + + /** + * Retrieves a bucket by its [bucketId] + * @throws RestException or one of its subclasses if receiving an error response + * @throws HttpRequestTimeoutException if the request timed out + * @throws HttpRequestException on network related issues + */ + suspend fun getBucket(bucketId: String): Bucket? /** * Retrieves a bucket by its [bucketId] @@ -83,7 +100,8 @@ interface Storage : MainPlugin, CustomSerializationPlugin { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun retrieveBucketById(bucketId: String): Bucket? + @Deprecated("Use getBucket instead", ReplaceWith("getBucket(bucketId)")) + suspend fun retrieveBucketById(bucketId: String): Bucket? = getBucket(bucketId) /** * Empties a bucket by its [bucketId] @@ -200,9 +218,14 @@ internal class StorageImpl(override val supabaseClient: SupabaseClient, override private val resumableClients = AtomicMutableMap() - override suspend fun retrieveBuckets(): List = api.get("bucket").safeBody() + override suspend fun listBuckets(filter: BucketFilter.() -> Unit): List { + val response = api.get("bucket") { + url.parameters.appendAll(BucketFilter().apply(filter).build()) + } + return response.safeBody() + } - override suspend fun retrieveBucketById(bucketId: String): Bucket? = api.get("bucket/$bucketId").safeBody() + override suspend fun getBucket(bucketId: String): Bucket? = api.get("bucket/$bucketId").safeBody() override suspend fun deleteBucket(bucketId: String) { api.delete("bucket/$bucketId") diff --git a/Storage/src/commonTest/kotlin/BucketApiTest.kt b/Storage/src/commonTest/kotlin/BucketApiTest.kt index 12a948950..ee2e2d91c 100644 --- a/Storage/src/commonTest/kotlin/BucketApiTest.kt +++ b/Storage/src/commonTest/kotlin/BucketApiTest.kt @@ -4,6 +4,7 @@ import io.github.jan.supabase.storage.BucketApi import io.github.jan.supabase.storage.FileObjectV2 import io.github.jan.supabase.storage.FileUploadResponse import io.github.jan.supabase.storage.ImageTransformation +import io.github.jan.supabase.storage.SortOrder import io.github.jan.supabase.storage.Storage import io.github.jan.supabase.storage.resumable.MemoryResumableCache import io.github.jan.supabase.storage.storage @@ -451,7 +452,7 @@ class BucketApiTest { limit = expectedLimit offset = expectedOffset search = expectedSearch - sortBy(expectedColumn, expectedOrder) + sortBy(expectedColumn, SortOrder.ASC) } // assertContentEquals(expectedData, data, "Data should be $expectedData") } diff --git a/Storage/src/commonTest/kotlin/BucketFilterTest.kt b/Storage/src/commonTest/kotlin/BucketFilterTest.kt new file mode 100644 index 000000000..0964cff01 --- /dev/null +++ b/Storage/src/commonTest/kotlin/BucketFilterTest.kt @@ -0,0 +1,148 @@ +import io.github.jan.supabase.storage.BucketFilter +import io.github.jan.supabase.storage.SortOrder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class BucketFilterTest { + + @Test + fun testBucketFilterWithAllParameters() { + val filter = BucketFilter().apply { + limit = 10 + offset = 5 + search = "test" + sortBy(BucketFilter.SortColumn.NAME, SortOrder.ASC) + } + val params = filter.build() + assertEquals("10", params["limit"]) + assertEquals("5", params["offset"]) + assertEquals("test", params["search"]) + assertEquals("asc", params["sortOrder"]) + assertEquals("name", params["sortColumn"]) + } + + @Test + fun testBucketFilterEmpty() { + val filter = BucketFilter() + val params = filter.build() + assertNull(params["limit"]) + assertNull(params["offset"]) + assertNull(params["search"]) + assertNull(params["sortOrder"]) + assertNull(params["sortColumn"]) + } + + @Test + fun testBucketFilterIndividualParameters() { + // Test limit only + var filter = BucketFilter().apply { limit = 20 } + var params = filter.build() + assertEquals("20", params["limit"]) + assertNull(params["offset"]) + + // Test offset only + filter = BucketFilter().apply { offset = 15 } + params = filter.build() + assertEquals("15", params["offset"]) + assertNull(params["limit"]) + + // Test search only + filter = BucketFilter().apply { search = "my-bucket" } + params = filter.build() + assertEquals("my-bucket", params["search"]) + assertNull(params["limit"]) + } + + @Test + fun testBucketFilterSortColumns() { + // Test all sort columns with both orders + val columns = listOf( + BucketFilter.SortColumn.ID to "id", + BucketFilter.SortColumn.NAME to "name", + BucketFilter.SortColumn.CREATED_AT to "created_at", + BucketFilter.SortColumn.UPDATED_AT to "updated_at" + ) + + for ((column, expectedName) in columns) { + // Test ascending + var filter = BucketFilter().apply { sortBy(column, SortOrder.ASC) } + var params = filter.build() + assertEquals(expectedName, params["sortColumn"]) + assertEquals("asc", params["sortOrder"]) + + // Test descending + filter = BucketFilter().apply { sortBy(column, SortOrder.DESC) } + params = filter.build() + assertEquals(expectedName, params["sortColumn"]) + assertEquals("desc", params["sortOrder"]) + } + } + + @Test + fun testBucketFilterEdgeCases() { + // Zero values + var filter = BucketFilter().apply { + limit = 0 + offset = 0 + } + var params = filter.build() + assertEquals("0", params["limit"]) + assertEquals("0", params["offset"]) + + // Empty search string + filter = BucketFilter().apply { search = "" } + params = filter.build() + assertEquals("", params["search"]) + + // Special characters in search + filter = BucketFilter().apply { search = "test-bucket_123" } + params = filter.build() + assertEquals("test-bucket_123", params["search"]) + + // Large numbers + filter = BucketFilter().apply { + limit = 1000 + offset = 5000 + } + params = filter.build() + assertEquals("1000", params["limit"]) + assertEquals("5000", params["offset"]) + } + + @Test + fun testBucketFilterCombinations() { + // Limit and offset + var filter = BucketFilter().apply { + limit = 25 + offset = 50 + } + var params = filter.build() + assertEquals("25", params["limit"]) + assertEquals("50", params["offset"]) + assertNull(params["search"]) + + // Search and sort + filter = BucketFilter().apply { + search = "images" + sortBy(BucketFilter.SortColumn.UPDATED_AT, SortOrder.ASC) + } + params = filter.build() + assertEquals("images", params["search"]) + assertEquals("updated_at", params["sortColumn"]) + assertEquals("asc", params["sortOrder"]) + + // Pagination with sort + filter = BucketFilter().apply { + limit = 10 + offset = 30 + sortBy(BucketFilter.SortColumn.NAME, SortOrder.ASC) + } + params = filter.build() + assertEquals("10", params["limit"]) + assertEquals("30", params["offset"]) + assertEquals("name", params["sortColumn"]) + assertEquals("asc", params["sortOrder"]) + } + +} \ No newline at end of file diff --git a/Storage/src/commonTest/kotlin/BucketListFilterTest.kt b/Storage/src/commonTest/kotlin/BucketListFilterTest.kt index f05715789..eddd45e3c 100644 --- a/Storage/src/commonTest/kotlin/BucketListFilterTest.kt +++ b/Storage/src/commonTest/kotlin/BucketListFilterTest.kt @@ -1,19 +1,21 @@ import io.github.jan.supabase.storage.BucketListFilter +import io.github.jan.supabase.storage.SortOrder import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull class BucketListFilterTest { @Test - fun testBucketListFilter() { + fun testBucketListFilterWithAllParameters() { val filter = BucketListFilter().apply { limit = 10 offset = 0 search = "string" - sortBy("name", "asc") + sortBy("name", SortOrder.ASC) } val filterJson = filter.build() assertEquals(10, filterJson["limit"]!!.jsonPrimitive.int) @@ -23,4 +25,125 @@ class BucketListFilterTest { assertEquals("asc", filterJson["sortBy"]!!.jsonObject["order"]!!.jsonPrimitive.content) } + @Test + fun testBucketListFilterEmpty() { + val filter = BucketListFilter() + val filterJson = filter.build() + assertNull(filterJson["limit"]) + assertNull(filterJson["offset"]) + assertNull(filterJson["search"]) + assertNull(filterJson["sortBy"]) + } + + @Test + fun testBucketListFilterIndividualParameters() { + // Test limit only + var filter = BucketListFilter().apply { limit = 25 } + var filterJson = filter.build() + assertEquals(25, filterJson["limit"]!!.jsonPrimitive.int) + assertNull(filterJson["offset"]) + + // Test offset only + filter = BucketListFilter().apply { offset = 50 } + filterJson = filter.build() + assertEquals(50, filterJson["offset"]!!.jsonPrimitive.int) + assertNull(filterJson["limit"]) + + // Test search only + filter = BucketListFilter().apply { search = "my-file" } + filterJson = filter.build() + assertEquals("my-file", filterJson["search"]!!.jsonPrimitive.content) + assertNull(filterJson["limit"]) + + // Test sort only + filter = BucketListFilter().apply { sortBy("created_at", SortOrder.DESC) } + filterJson = filter.build() + assertEquals("created_at", filterJson["sortBy"]!!.jsonObject["column"]!!.jsonPrimitive.content) + assertEquals("desc", filterJson["sortBy"]!!.jsonObject["order"]!!.jsonPrimitive.content) + assertNull(filterJson["limit"]) + } + + @Test + fun testBucketListFilterSortColumns() { + // Test common sort columns with both orders + val columns = listOf("id", "name", "created_at", "updated_at") + + for (column in columns) { + // Test ascending + var filter = BucketListFilter().apply { sortBy(column, SortOrder.ASC) } + var filterJson = filter.build() + assertEquals(column, filterJson["sortBy"]!!.jsonObject["column"]!!.jsonPrimitive.content) + assertEquals("asc", filterJson["sortBy"]!!.jsonObject["order"]!!.jsonPrimitive.content) + + // Test descending + filter = BucketListFilter().apply { sortBy(column, SortOrder.DESC) } + filterJson = filter.build() + assertEquals(column, filterJson["sortBy"]!!.jsonObject["column"]!!.jsonPrimitive.content) + assertEquals("desc", filterJson["sortBy"]!!.jsonObject["order"]!!.jsonPrimitive.content) + } + } + + @Test + fun testBucketListFilterEdgeCases() { + // Zero values + var filter = BucketListFilter().apply { + limit = 0 + offset = 0 + } + var filterJson = filter.build() + assertEquals(0, filterJson["limit"]!!.jsonPrimitive.int) + assertEquals(0, filterJson["offset"]!!.jsonPrimitive.int) + + // Empty search string + filter = BucketListFilter().apply { search = "" } + filterJson = filter.build() + assertEquals("", filterJson["search"]!!.jsonPrimitive.content) + + // Special characters in search + filter = BucketListFilter().apply { search = "file-name_123.png" } + filterJson = filter.build() + assertEquals("file-name_123.png", filterJson["search"]!!.jsonPrimitive.content) + + // Large numbers + filter = BucketListFilter().apply { + limit = 1000 + offset = 5000 + } + filterJson = filter.build() + assertEquals(1000, filterJson["limit"]!!.jsonPrimitive.int) + assertEquals(5000, filterJson["offset"]!!.jsonPrimitive.int) + + // Custom column name + filter = BucketListFilter().apply { sortBy("custom_field", SortOrder.ASC) } + filterJson = filter.build() + assertEquals("custom_field", filterJson["sortBy"]!!.jsonObject["column"]!!.jsonPrimitive.content) + } + + @Test + fun testBucketListFilterCombinations() { + // Limit and offset + var filter = BucketListFilter().apply { + limit = 20 + offset = 40 + } + var filterJson = filter.build() + assertEquals(20, filterJson["limit"]!!.jsonPrimitive.int) + assertEquals(40, filterJson["offset"]!!.jsonPrimitive.int) + assertNull(filterJson["search"]) + + // All parameters together + filter = BucketListFilter().apply { + limit = 100 + offset = 200 + search = "production-files" + sortBy("updated_at", SortOrder.DESC) + } + filterJson = filter.build() + assertEquals(100, filterJson["limit"]!!.jsonPrimitive.int) + assertEquals(200, filterJson["offset"]!!.jsonPrimitive.int) + assertEquals("production-files", filterJson["search"]!!.jsonPrimitive.content) + assertEquals("updated_at", filterJson["sortBy"]!!.jsonObject["column"]!!.jsonPrimitive.content) + assertEquals("desc", filterJson["sortBy"]!!.jsonObject["order"]!!.jsonPrimitive.content) + } + } \ No newline at end of file diff --git a/Storage/src/commonTest/kotlin/StorageTest.kt b/Storage/src/commonTest/kotlin/StorageTest.kt index 250ed2979..68bd16d56 100644 --- a/Storage/src/commonTest/kotlin/StorageTest.kt +++ b/Storage/src/commonTest/kotlin/StorageTest.kt @@ -2,6 +2,8 @@ import io.github.jan.supabase.SupabaseClientBuilder import io.github.jan.supabase.auth.Auth import io.github.jan.supabase.auth.auth import io.github.jan.supabase.auth.minimalConfig +import io.github.jan.supabase.storage.BucketFilter +import io.github.jan.supabase.storage.SortOrder import io.github.jan.supabase.storage.Storage import io.github.jan.supabase.storage.resumable.MemoryResumableCache import io.github.jan.supabase.storage.storage @@ -137,7 +139,7 @@ class StorageTest { """ ) } - val buckets = client.storage.retrieveBuckets() + val buckets = client.storage.listBuckets() assertEquals(1, buckets.size, "Buckets should contain 1 item") assertEquals(expectedId, buckets[0].id, "Bucket id should be 'test-bucket'") assertEquals(expectedId, buckets[0].name, "Bucket name should be 'test-bucket'") @@ -150,6 +152,54 @@ class StorageTest { } } + @Test + fun testListBucketsWithFilter() { + runTest { + val expectedId = "test-bucket" + val expectedPublic = true + val expectedFileSizeLimit = 10000L + val expectedAllowedMimeTypes = listOf("image/jpeg", "image/png") + val expectedCreatedAt = Clock.System.now() + val expectedUpdatedAt = Clock.System.now() + val owner = "uuid" + val client = createMockedSupabaseClient(configuration = configureClient) { + assertPathIs("/bucket", it.url.pathAfterVersion()) + assertMethodIs(HttpMethod.Get, it.method) + assertEquals("10", it.url.parameters["limit"], "Limit should be 10") + assertEquals("5", it.url.parameters["offset"], "Offset should be 5") + assertEquals("test", it.url.parameters["search"], "Search should be 'test'") + assertEquals("asc", it.url.parameters["sortOrder"], "Sort order should be 'asc'") + assertEquals("name", it.url.parameters["sortColumn"], "Sort column should be 'name'") + respond( + """ + [ + ${ + createSampleBucket( + id = expectedId, + name = expectedId, + public = expectedPublic, + fileSizeLimit = expectedFileSizeLimit, + allowedMimeTypes = expectedAllowedMimeTypes, + createdAt = expectedCreatedAt.toString(), + updatedAt = expectedUpdatedAt.toString(), + owner = owner + ) + } + ] + """ + ) + } + val buckets = client.storage.listBuckets { + limit = 10 + offset = 5 + search = "test" + sortBy(BucketFilter.SortColumn.NAME, SortOrder.ASC) + } + assertEquals(1, buckets.size, "Buckets should contain 1 item") + assertEquals(expectedId, buckets[0].id, "Bucket id should be 'test-bucket'") + } + } + @Test fun testRetrieveBucket() { runTest { @@ -178,7 +228,7 @@ class StorageTest { """ ) } - val bucket = client.storage.retrieveBucketById(expectedId) + val bucket = client.storage.getBucket(expectedId) assertEquals(expectedId, bucket?.id, "Bucket id should be 'test-bucket'") assertEquals(expectedId, bucket?.name, "Bucket name should be 'test-bucket'") assertEquals(expectedPublic, bucket?.public, "Bucket public should be true") @@ -211,7 +261,7 @@ class StorageTest { respond("[]") } client.auth.importAuthToken(key) - client.storage.retrieveBuckets() + client.storage.listBuckets() } }