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 f9f94399..4fc9d6fa 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 @@ -256,6 +256,25 @@ sealed interface BucketApi { filter: BucketListFilter.() -> Unit = {} ): List + /** + * 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 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 986f2582..7c68cdaf 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 @@ -220,6 +222,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().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" 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..9ae862e5 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?, + val size: Long, + @SerialName("content_type") + val rawContentType: String, + val etag: String?, + @SerialName("last_modified") + val lastModified: Instant?, + @SerialName("cache_control") + val cacheControl: String?, + @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 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..2faa67c2 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,8 +1,13 @@ 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 @@ -13,6 +18,7 @@ import io.ktor.http.ContentType class UploadOptionBuilder( @PublishedApi internal val serializer: SupabaseSerializer, var upsert: Boolean = false, + var userMetadata: JsonObject? = null, var contentType: ContentType? = null, internal val httpRequestOverrides: MutableList = mutableListOf() ) { @@ -24,4 +30,20 @@ class UploadOptionBuilder( 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 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) + } + } 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(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")