Repository: supabase-community/supabase-kt
Pull request #694 (2024-08-21 14:43:20)

Add support for file metadata, `info` and `exists`
## What kind of change does this PR introduce?

Feature

## What is the new behavior?

Support for the new user metadata and the new `exists` and `info` methods.
```kotlin
client.storage["my-bucket"].upload("file.png", data) {
    userMetadata {
        put("key", "value")
    }
    //Or:
    userMetadata(myCustomObject)
}
```
Additionally, the `Storage` plugin now also provides the ability to set a custom serializer.

## Additional context

See https://github.com/supabase/storage-js/pull/207/


jan-tennert: > Is there hope for the PR? I'm working on a package that provides parity between supabase-swift and supabase-kt, and `info()` and `exists()` are the two big missing bits of functionality.

The PR is ready, @grdsdev?
grdsdev: Approved as this implementation seems to work with latest Storage API, just make sure the `info` method is working and CI is passing.

Patch:
diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt
index 60030ffd..b331e182 100644
--- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt
+++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt
@@ -257,6 +257,25 @@ sealed interface BucketApi {
         filter: BucketListFilter.() -> Unit = {}
     ): List<FileObject>
 
+    /**
+     * Returns information about the file under [path]
+     * @param path The path to get information about
+     * @return The file object
+     * @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 info(path: String): FileObjectV2
+
+    /**
+     * Checks if a file exists under [path]
+     * @return true if the file exists, false otherwise
+     * @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 exists(path: String): Boolean
+
     /**
      * Changes the bucket's public status to [public]
      * @throws RestException or one of its subclasses if receiving an error response
@@ -304,6 +323,11 @@ sealed interface BucketApi {
          */
         const val UPSERT_HEADER = "x-upsert"
 
+        /**
+         * The header to use for the user metadata
+         */
+        const val METADATA_HEADER = "x-metadata"
+
     }
 
 }
diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApiImpl.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApiImpl.kt
index cbb40e7e..5297d936 100644
--- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApiImpl.kt
+++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApiImpl.kt
@@ -1,5 +1,6 @@
 package io.github.jan.supabase.storage
 
+import io.github.jan.supabase.exceptions.RestException
 import io.github.jan.supabase.putJsonObject
 import io.github.jan.supabase.safeBody
 import io.github.jan.supabase.storage.BucketApi.Companion.UPSERT_HEADER
@@ -14,6 +15,7 @@ import io.ktor.client.statement.bodyAsChannel
 import io.ktor.http.ContentType
 import io.ktor.http.HttpHeaders
 import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
 import io.ktor.http.Url
 import io.ktor.http.content.OutgoingContent
 import io.ktor.http.defaultForFilePath
@@ -27,6 +29,8 @@ import kotlinx.serialization.json.jsonPrimitive
 import kotlinx.serialization.json.put
 import kotlinx.serialization.json.putJsonArray
 import kotlinx.serialization.json.putJsonObject
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.ExperimentalEncodingApi
 import kotlin.time.Duration
 
 internal class BucketApiImpl(override val bucketId: String, val storage: StorageImpl, resumableCache: ResumableCache) : BucketApi {
@@ -222,6 +226,23 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage
         }).safeBody()
     }
 
+    override suspend fun info(path: String): FileObjectV2 {
+        val response = storage.api.get("object/info/$bucketId/$path")
+        return response.safeBody<FileObjectV2>().copy(serializer = storage.serializer)
+    }
+
+    override suspend fun exists(path: String): Boolean {
+        try {
+            storage.api.request("object/$bucketId/$path") {
+                method = HttpMethod.Head
+            }
+            return true
+        } catch (e: RestException) {
+            if (e.statusCode in listOf(HttpStatusCode.NotFound.value, HttpStatusCode.BadRequest.value)) return false
+            throw e
+        }
+    }
+
     private fun defaultUploadUrl(path: String) = "object/$bucketId/$path"
 
     private fun uploadToSignedUrlUrl(path: String, token: String) = "object/upload/sign/$bucketId/$path?token=$token"
@@ -246,6 +267,7 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage
         return FileUploadResponse(id, path, key)
     }
 
+    @OptIn(ExperimentalEncodingApi::class)
     private fun HttpRequestBuilder.defaultUploadRequest(
         path: String,
         data: UploadData,
@@ -258,6 +280,9 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage
         })
         header(HttpHeaders.ContentType, optionBuilder.contentType ?: ContentType.defaultForFilePath(path))
         header(UPSERT_HEADER, optionBuilder.upsert.toString())
+        optionBuilder.userMetadata?.let {
+            header(BucketApi.METADATA_HEADER, Base64.encode(it.toString().encodeToByteArray()))
+        }
     }
 
     override suspend fun changePublicStatusTo(public: Boolean) = storage.updateBucket(bucketId) {
diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileObject.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileObject.kt
index 8caf02a8..fca582ad 100644
--- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileObject.kt
+++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileObject.kt
@@ -1,8 +1,13 @@
 package io.github.jan.supabase.storage
 
+import io.github.jan.supabase.SupabaseSerializer
+import io.github.jan.supabase.decode
+import io.github.jan.supabase.serializer.KotlinXSerializer
+import io.ktor.http.ContentType
 import kotlinx.datetime.Instant
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
 import kotlinx.serialization.json.JsonObject
 
 /**
@@ -25,4 +30,60 @@ data class FileObject(
     @SerialName("last_accessed_at")
     val lastAccessedAt: Instant?,
     val metadata: JsonObject?
-)
\ No newline at end of file
+)
+
+/**
+ * Represents a file or a folder in a bucket. If the item is a folder, everything except [name] is null.
+ * @param name The name of the item
+ * @param id The id of the item
+ * @param version The version of the item
+ * @param bucketId The bucket id of the item
+ * @param updatedAt The last update date of the item
+ * @param createdAt The creation date of the item
+ * @param lastAccessedAt The last access date of the item
+ * @param metadata The metadata of the item
+ * @param size The size of the item
+ * @param rawContentType The content type of the item
+ * @param etag The etag of the item
+ * @param lastModified The last modified date of the item
+ * @param cacheControl The cache control of the item
+ * @param serializer The serializer to use for decoding the metadata
+ */
+@Serializable
+data class FileObjectV2(
+    val name: String,
+    val id: String?,
+    val version: String,
+    @SerialName("bucket_id")
+    val bucketId: String? = null,
+    @SerialName("updated_at")
+    val updatedAt: Instant? = null,
+    @SerialName("created_at")
+    val createdAt: Instant,
+    @SerialName("last_accessed_at")
+    val lastAccessedAt: Instant? = null,
+    val metadata: JsonObject? = null,
+    val size: Long,
+    @SerialName("content_type")
+    val rawContentType: String,
+    val etag: String? = null,
+    @SerialName("last_modified")
+    val lastModified: Instant? = null,
+    @SerialName("cache_control")
+    val cacheControl: String? = null,
+    @Transient @PublishedApi internal val serializer: SupabaseSerializer = KotlinXSerializer()
+) {
+
+    /**
+     * The content type of the file
+     */
+    val contentType by lazy {
+        ContentType.parse(rawContentType)
+    }
+
+    /**
+     * Decodes the metadata using the [serializer]
+     */
+    inline fun <reified T> decodeMetadata(): T? = metadata?.let { serializer.decode(it.toString()) }
+
+}
\ No newline at end of file
diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/UploadOptionBuilder.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/UploadOptionBuilder.kt
index 61bfa326..3bd8ce11 100644
--- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/UploadOptionBuilder.kt
+++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/UploadOptionBuilder.kt
@@ -1,27 +1,51 @@
 package io.github.jan.supabase.storage
 
 import io.github.jan.supabase.SupabaseSerializer
+import io.github.jan.supabase.encodeToJsonElement
 import io.github.jan.supabase.network.HttpRequestOverride
 import io.ktor.http.ContentType
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonObjectBuilder
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.jsonObject
 
 /**
  * Builder for uploading files with additional options
  * @param serializer The serializer to use for encoding the metadata
  * @param upsert Whether to update the file if it already exists
+ * @param userMetadata The user metadata to upload with the file
  * @param contentType The content type of the file. If null, the content type will be inferred from the file extension
  */
 class UploadOptionBuilder(
     @PublishedApi internal val serializer: SupabaseSerializer,
     var upsert: Boolean = false,
+    var userMetadata: JsonObject? = null,
     var contentType: ContentType? = null,
     internal val httpRequestOverrides: MutableList<HttpRequestOverride> = mutableListOf()
 ) {
 
     /**
-     * Overrides the HTTP request
+     * Adds an [HttpRequestOverride] to the upload request
+     * @param override The override to add
      */
     fun httpOverride(override: HttpRequestOverride) {
         httpRequestOverrides.add(override)
     }
 
+    /**
+     * Sets the user metadata to upload with the file
+     * @param data The data to upload. Must be serializable by the [serializer]
+     */
+    inline fun <reified T : Any> userMetadata(data: T) {
+        userMetadata = serializer.encodeToJsonElement(data).jsonObject
+    }
+
+    /**
+     * Sets the user metadata to upload with the file
+     * @param builder The builder for the metadata
+     */
+    inline fun userMetadata(builder: JsonObjectBuilder.() -> Unit) {
+        userMetadata = buildJsonObject(builder)
+    }
+
 }


Test patch:
diff --git a/Storage/src/commonTest/kotlin/BucketApiTest.kt b/Storage/src/commonTest/kotlin/BucketApiTest.kt
index ece06524..15fa01f4 100644
--- a/Storage/src/commonTest/kotlin/BucketApiTest.kt
+++ b/Storage/src/commonTest/kotlin/BucketApiTest.kt
@@ -1,6 +1,7 @@
 import io.github.jan.supabase.SupabaseClient
 import io.github.jan.supabase.SupabaseClientBuilder
 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.Storage
@@ -13,14 +14,20 @@ import io.github.jan.supabase.testing.pathAfterVersion
 import io.github.jan.supabase.testing.toJsonElement
 import io.ktor.client.engine.mock.MockRequestHandleScope
 import io.ktor.client.engine.mock.respond
+import io.ktor.client.engine.mock.respondError
+import io.ktor.client.engine.mock.respondOk
 import io.ktor.client.engine.mock.toByteArray
 import io.ktor.client.request.HttpRequestData
 import io.ktor.http.ContentType
 import io.ktor.http.HttpHeaders
 import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
 import io.ktor.http.headersOf
 import kotlinx.coroutines.test.runTest
 import kotlinx.datetime.Clock
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
 import kotlinx.serialization.json.buildJsonObject
 import kotlinx.serialization.json.int
 import kotlinx.serialization.json.jsonArray
@@ -28,11 +35,14 @@ import kotlinx.serialization.json.jsonObject
 import kotlinx.serialization.json.jsonPrimitive
 import kotlinx.serialization.json.long
 import kotlinx.serialization.json.put
+import kotlin.io.encoding.Base64
 import kotlin.io.encoding.ExperimentalEncodingApi
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
+import kotlin.test.assertFalse
 import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
 import kotlin.time.Duration.Companion.seconds
 
 class BucketApiTest {
@@ -51,8 +61,10 @@ class BucketApiTest {
         testUploadMethod(
             method = HttpMethod.Post,
             urlPath = "/object/$bucketId/data.png",
-            request = { client, expectedPath, data ->
-                client.storage[bucketId].upload(expectedPath, data)
+            request = { client, expectedPath, data, meta ->
+                client.storage[bucketId].upload(expectedPath, data) {
+                    userMetadata = meta
+                }
             },
             extra = {
                 assertEquals(
@@ -76,8 +88,9 @@ class BucketApiTest {
                     "Upsert header should be true"
                 )
             },
-            request = { client, expectedPath, data ->
+            request = { client, expectedPath, data, meta ->
                 client.storage[bucketId].upload(expectedPath, data) {
+                    userMetadata = meta
                     upsert = true
                 }
             }
@@ -102,8 +115,9 @@ class BucketApiTest {
                     "Token should be $expectedToken"
                 )
             },
-            request = { client, expectedPath, data ->
+            request = { client, expectedPath, data, meta ->
                 client.storage[bucketId].uploadToSignedUrl(path = expectedPath, token = expectedToken, data = data) {
+                    userMetadata = meta
                     upsert = false
                 }
             }
@@ -128,8 +142,9 @@ class BucketApiTest {
                     "Token should be $expectedToken"
                 )
             },
-            request = { client, expectedPath, data ->
+            request = { client, expectedPath, data, meta ->
                 client.storage[bucketId].uploadToSignedUrl(path = expectedPath, token = expectedToken, data = data) {
+                    userMetadata = meta
                     upsert = true
                 }
             }
@@ -141,8 +156,10 @@ class BucketApiTest {
         testUploadMethod(
             method = HttpMethod.Put,
             urlPath = "/object/$bucketId/data.png",
-            request = { client, expectedPath, data ->
-                client.storage[bucketId].update(expectedPath, data)
+            request = { client, expectedPath, data, meta ->
+                client.storage[bucketId].update(expectedPath, data) {
+                    userMetadata = meta
+                }
             },
             extra = {
                 assertEquals(
@@ -166,8 +183,9 @@ class BucketApiTest {
                     "Upsert header should be true"
                 )
             },
-            request = { client, expectedPath, data ->
+            request = { client, expectedPath, data, meta ->
                 client.storage[bucketId].update(expectedPath, data) {
+                    userMetadata = meta
                     upsert = true
                 }
             }
@@ -440,6 +458,69 @@ class BucketApiTest {
         }
     }
 
+    @Test
+    fun testInfo() {
+        runTest {
+            val expectedPath = "data.png"
+            val file = FileObjectV2(
+                "data.png",
+                "id",
+                "version",
+                createdAt = Clock.System.now(),
+                metadata = null,
+                size = 0,
+                rawContentType = "image/png",
+                etag = null,
+                lastModified = null,
+                cacheControl = null
+            )
+            val client = createMockedSupabaseClient(configuration = configureClient) {
+                assertMethodIs(HttpMethod.Get, it.method)
+                assertPathIs("/object/info/$bucketId/$expectedPath", it.url.pathAfterVersion())
+                respond(
+                    content = Json.encodeToString(file),
+                    headers = headersOf(
+                        HttpHeaders.ContentType,
+                        ContentType.Application.Json.toString()
+                    )
+                )
+            }
+            val data = client.storage[bucketId].info(expectedPath)
+            assertEquals(file.copy(serializer = client.storage.serializer), data, "Data should be $file")
+        }
+    }
+
+    @Test
+    fun testExistsWithExistingFile() {
+        runTest {
+            val expectedPath = "data.png"
+            val client = createMockedSupabaseClient(configuration = configureClient) {
+                assertMethodIs(HttpMethod.Head, it.method)
+                assertPathIs("/object/$bucketId/$expectedPath", it.url.pathAfterVersion())
+                respondOk()
+            }
+            val exists = client.storage[bucketId].exists(expectedPath)
+            assertTrue { exists }
+        }
+    }
+
+    @Test
+    fun testExistsWithNonExistingFile() {
+        val statusCodes = listOf(404, 400)
+        for(code in statusCodes) {
+            runTest {
+                val expectedPath = "data.png"
+                val client = createMockedSupabaseClient(configuration = configureClient) {
+                    assertMethodIs(HttpMethod.Head, it.method)
+                    assertPathIs("/object/$bucketId/$expectedPath", it.url.pathAfterVersion())
+                    respondError(HttpStatusCode(code, "Not Found"))
+                }
+                val exists = client.storage[bucketId].exists(expectedPath)
+                assertFalse { exists }
+            }
+        }
+    }
+
     private fun testDownloadWithTransform(
         authenticated: Boolean
     ) {
@@ -469,7 +550,7 @@ class BucketApiTest {
                 quality = expectedQuality
                 resize = expectedResize
             }
-            val data = if(authenticated) client.storage[bucketId].downloadAuthenticated(expectedPath) { transform(transform) } else client.storage[bucketId].downloadPublic(expectedPath)  { transform(transform) }
+            val data = if(authenticated) client.storage[bucketId].downloadAuthenticated(expectedPath) { transform(transform) } else client.storage[bucketId].downloadPublic(expectedPath) { transform(transform) }
             assertContentEquals(expectedData, data, "Data should be [1, 2, 3]")
         }
     }
@@ -480,7 +561,7 @@ class BucketApiTest {
         urlPath: String,
         expectedPath: String = "data.png",
         extra: suspend MockRequestHandleScope.(HttpRequestData) -> Unit,
-        request: suspend (client: SupabaseClient, expectedPath: String, data: ByteArray) -> FileUploadResponse
+        request: suspend (client: SupabaseClient, expectedPath: String, data: ByteArray, metadata: JsonObject) -> FileUploadResponse
     ) {
         runTest {
             val expectedData = byteArrayOf(1, 2, 3)
@@ -490,6 +571,8 @@ class BucketApiTest {
             val client = createMockedSupabaseClient(configuration = configureClient) {
                 val data = it.body.toByteArray()
                 assertMethodIs(method, it.method)
+                val metadata = Json.decodeFromString<JsonObject>(Base64.decode(it.headers["x-metadata"] ?: error("Metadata should not be null")).decodeToString())
+                assertEquals(expectedMetadata, metadata, "Metadata should be $expectedMetadata")
                 assertPathIs(urlPath, it.url.pathAfterVersion())
                 assertContentEquals(expectedData, data, "Data should be [1, 2, 3]")
                 assertEquals(ContentType.Image.PNG, it.body.contentType, "Content type should be image/png")
@@ -507,7 +590,7 @@ class BucketApiTest {
                     )
                 )
             }
-            val response = request(client, expectedPath, expectedData)
+            val response = request(client, expectedPath, expectedData, expectedMetadata)
             assertEquals("someBucket/$expectedPath", response.key, "Key should be $expectedPath")
             assertEquals("someId", response.id, "Id should be someId")
             assertEquals(expectedPath, response.path, "Path should be $expectedPath")