From e3dfbb4f406dde0a0a1a1c907435a70da7445d59 Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 21 Aug 2024 16:42:20 +0200 Subject: [PATCH 01/11] Add support for file metadata, `info` and `exists` --- .../github/jan/supabase/storage/BucketApi.kt | 46 +++++++--- .../jan/supabase/storage/BucketApiImpl.kt | 88 ++++++++++++++----- .../github/jan/supabase/storage/BucketItem.kt | 45 +++++++++- .../jan/supabase/storage/FileOptionBuilder.kt | 23 +++++ .../io/github/jan/supabase/storage/FlowExt.kt | 21 ++--- .../io/github/jan/supabase/storage/Storage.kt | 14 ++- 6 files changed, 189 insertions(+), 48 deletions(-) create mode 100644 Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileOptionBuilder.kt 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 a4c7da748..d0b7a8db3 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 @@ -41,9 +41,9 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun upload(path: String, data: ByteArray, upsert: Boolean = false): FileUploadResponse { + suspend fun upload(path: String, data: ByteArray, upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {}): FileUploadResponse { require(data.isNotEmpty()) { "The data to upload should not be empty" } - return upload(path, UploadData(ByteReadChannel(data), data.size.toLong()), upsert) + return upload(path, UploadData(ByteReadChannel(data), data.size.toLong()), upsert, options) } /** @@ -56,7 +56,7 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun upload(path: String, data: UploadData, upsert: Boolean = false): FileUploadResponse + suspend fun upload(path: String, data: UploadData, upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {}): FileUploadResponse /** * Uploads a file in [bucketId] under [path] using a presigned url @@ -67,10 +67,15 @@ sealed interface BucketApi { * @return the key of the uploaded file * @throws IllegalArgumentException if data to upload is empty */ - suspend fun uploadToSignedUrl(path: String, token: String, data: ByteArray, upsert: Boolean = false + suspend fun uploadToSignedUrl( + path: String, + token: String, + data: ByteArray, + upsert: Boolean = false, + options: FileOptionBuilder.() -> Unit = {} ): FileUploadResponse { require(data.isNotEmpty()) { "The data to upload should not be empty" } - return uploadToSignedUrl(path, token, UploadData(ByteReadChannel(data), data.size.toLong()), upsert) + return uploadToSignedUrl(path, token, UploadData(ByteReadChannel(data), data.size.toLong()), upsert, options) } /** @@ -85,7 +90,7 @@ sealed interface BucketApi { * @throws HttpRequestException on network related issues * @throws HttpRequestException on network related issues */ - suspend fun uploadToSignedUrl(path: String, token: String, data: UploadData, upsert: Boolean = false): FileUploadResponse + suspend fun uploadToSignedUrl(path: String, token: String, data: UploadData, upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {}): FileUploadResponse /** * Updates a file in [bucketId] under [path] @@ -98,9 +103,9 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun update(path: String, data: ByteArray, upsert: Boolean = false): FileUploadResponse { + suspend fun update(path: String, data: ByteArray, upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {}): FileUploadResponse { require(data.isNotEmpty()) { "The data to upload should not be empty" } - return update(path, UploadData(ByteReadChannel(data), data.size.toLong()), upsert) + return update(path, UploadData(ByteReadChannel(data), data.size.toLong()), upsert, options) } /** @@ -113,7 +118,7 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun update(path: String, data: UploadData, upsert: Boolean = false): FileUploadResponse + suspend fun update(path: String, data: UploadData, upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {}): FileUploadResponse /** * Deletes all files in [bucketId] with in [paths] @@ -242,8 +247,8 @@ sealed interface BucketApi { /** - * Searches for buckets with the given [prefix] and [filter] - * @return The filtered buckets + * Searches for files with the given [prefix] and [filter] + * @return The filtered bucket items * @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 @@ -253,6 +258,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 4d664684c..8ebb2694c 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 @@ -29,6 +30,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 { @@ -37,18 +40,24 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage override val resumable = ResumableClientImpl(this, resumableCache) - override suspend fun update(path: String, data: UploadData, upsert: Boolean): FileUploadResponse = + override suspend fun update( + path: String, + data: UploadData, + upsert: Boolean, + options: FileOptionBuilder.() -> Unit + ): FileUploadResponse = uploadOrUpdate( - HttpMethod.Put, bucketId, path, data, upsert + HttpMethod.Put, bucketId, path, data, upsert, options ) override suspend fun uploadToSignedUrl( path: String, token: String, data: UploadData, - upsert: Boolean + upsert: Boolean, + options: FileOptionBuilder.() -> Unit ): FileUploadResponse { - return uploadToSignedUrl(path, token, data, upsert) {} + return uploadToSignedUrl(path, token, data, upsert, options) {} } override suspend fun createSignedUploadUrl(path: String): UploadSignedUrl { @@ -64,9 +73,14 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage ) } - override suspend fun upload(path: String, data: UploadData, upsert: Boolean): FileUploadResponse = + override suspend fun upload( + path: String, + data: UploadData, + upsert: Boolean, + options: FileOptionBuilder.() -> Unit + ): FileUploadResponse = uploadOrUpdate( - HttpMethod.Post, bucketId, path, data, upsert + HttpMethod.Post, bucketId, path, data, upsert, options ) override suspend fun delete(paths: Collection) { @@ -210,24 +224,37 @@ 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/public/$bucketId/$path") + return response.safeBody() + } + + 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(400, 404)) return false + throw e + } + } + + @OptIn(ExperimentalEncodingApi::class) internal suspend fun uploadOrUpdate( method: HttpMethod, bucket: String, path: String, data: UploadData, upsert: Boolean, + options: FileOptionBuilder.() -> Unit, extra: HttpRequestBuilder.() -> Unit = {} ): FileUploadResponse { + val optionBuilder = FileOptionBuilder(storage.serializer).apply(options) val response = storage.api.request("object/$bucket/$path") { this.method = method - setBody(object : OutgoingContent.ReadChannelContent() { - override val contentType: ContentType = ContentType.defaultForFilePath(path) - override val contentLength: Long = data.size - override fun readFrom(): ByteReadChannel = data.stream - }) - header(HttpHeaders.ContentType, ContentType.defaultForFilePath(path)) - header(UPSERT_HEADER, upsert.toString()) - extra() + defaultUploadRequest(path, data, upsert, optionBuilder, extra) }.body() val key = response["Key"]?.jsonPrimitive?.content ?: error("Expected a key in a upload response") @@ -236,23 +263,19 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage return FileUploadResponse(id, path, key) } + @OptIn(ExperimentalEncodingApi::class) internal suspend fun uploadToSignedUrl( path: String, token: String, data: UploadData, upsert: Boolean, + options: FileOptionBuilder.() -> Unit, extra: HttpRequestBuilder.() -> Unit = {} ): FileUploadResponse { + val optionBuilder = FileOptionBuilder(storage.serializer).apply(options) val response = storage.api.put("object/upload/sign/$bucketId/$path") { parameter("token", token) - setBody(object : OutgoingContent.ReadChannelContent() { - override val contentType: ContentType = ContentType.defaultForFilePath(path) - override val contentLength: Long = data.size - override fun readFrom(): ByteReadChannel = data.stream - }) - header(HttpHeaders.ContentType, ContentType.defaultForFilePath(path)) - header("x-upsert", upsert.toString()) - extra() + defaultUploadRequest(path, data, upsert, optionBuilder, extra) }.body() val key = response["Key"]?.jsonPrimitive?.content ?: error("Expected a key in a upload response") @@ -260,6 +283,27 @@ 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, + upsert: Boolean, + optionBuilder: FileOptionBuilder, + extra: HttpRequestBuilder.() -> Unit + ) { + setBody(object : OutgoingContent.ReadChannelContent() { + override val contentType: ContentType = ContentType.defaultForFilePath(path) + override val contentLength: Long = data.size + override fun readFrom(): ByteReadChannel = data.stream + }) + header(HttpHeaders.ContentType, ContentType.defaultForFilePath(path)) + header(UPSERT_HEADER, upsert.toString()) + optionBuilder.userMetadata?.let { + header("x-metadata", Base64.Default.encode(it.toString().encodeToByteArray()).also((::println))) + } + extra() + } + override suspend fun changePublicStatusTo(public: Boolean) = storage.updateBucket(bucketId) { this@updateBucket.public = public } diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketItem.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketItem.kt index 595eb8d96..922f251f5 100644 --- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketItem.kt +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketItem.kt @@ -1,8 +1,12 @@ 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 kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import kotlinx.serialization.json.JsonObject /** @@ -14,6 +18,7 @@ import kotlinx.serialization.json.JsonObject * @param lastAccessedAt The last access date of the item * @param metadata The metadata of the item */ +//TODO: Rename to FileObject @Serializable data class BucketItem( val name: String, @@ -25,4 +30,42 @@ data class BucketItem( @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 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 + */ +@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 contentType: String, + val etag: String?, + @SerialName("last_modified") + val lastModified: Instant?, + @SerialName("cache_control") + val cacheControl: String?, + @Transient @PublishedApi internal val serializer: SupabaseSerializer = KotlinXSerializer() +) { + + 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/FileOptionBuilder.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileOptionBuilder.kt new file mode 100644 index 000000000..e3f9f3070 --- /dev/null +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileOptionBuilder.kt @@ -0,0 +1,23 @@ +package io.github.jan.supabase.storage + +import io.github.jan.supabase.SupabaseSerializer +import io.github.jan.supabase.encodeToJsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject + +class FileOptionBuilder( + @PublishedApi internal val serializer: SupabaseSerializer, + var userMetadata: JsonObject? = null, +) { + + inline fun userMetadata(data: T) { + userMetadata = serializer.encodeToJsonElement(data).jsonObject + } + + inline fun userMetadata(builder: JsonObjectBuilder.() -> Unit) { + userMetadata = buildJsonObject(builder) + } + +} diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FlowExt.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FlowExt.kt index d473c0972..dc3c2d284 100644 --- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FlowExt.kt +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FlowExt.kt @@ -22,9 +22,9 @@ import kotlinx.coroutines.flow.callbackFlow * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ -fun BucketApi.updateAsFlow(path: String, data: UploadData, upsert: Boolean = false): Flow = callbackFlow { +fun BucketApi.updateAsFlow(path: String, data: UploadData, upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {}): Flow = callbackFlow { this@updateAsFlow as BucketApiImpl - val key = uploadOrUpdate(HttpMethod.Put, bucketId, path, data, upsert) { + val key = uploadOrUpdate(HttpMethod.Put, bucketId, path, data, upsert, options) { onUpload { bytesSentTotal, contentLength -> trySend(UploadStatus.Progress(bytesSentTotal, contentLength)) } @@ -43,8 +43,8 @@ fun BucketApi.updateAsFlow(path: String, data: UploadData, upsert: Boolean = fal * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ -fun BucketApi.uploadAsFlow(path: String, data: ByteArray, upsert: Boolean = false): Flow = uploadAsFlow(path, UploadData( - ByteReadChannel(data), data.size.toLong()), upsert) +fun BucketApi.uploadAsFlow(path: String, data: ByteArray, upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {}): Flow = uploadAsFlow(path, UploadData( + ByteReadChannel(data), data.size.toLong()), upsert, options) /** * Uploads a file in [bucketId] under [path] using a presigned url @@ -61,11 +61,12 @@ fun BucketApi.uploadToSignedUrlAsFlow( path: String, token: String, data: UploadData, - upsert: Boolean = false + upsert: Boolean = false, + options: FileOptionBuilder.() -> Unit = {} ): Flow { return callbackFlow { this@uploadToSignedUrlAsFlow as BucketApiImpl - val key = uploadToSignedUrl(path, token, data, upsert) { + val key = uploadToSignedUrl(path, token, data, upsert, options) { onUpload { bytesSentTotal, contentLength -> trySend(UploadStatus.Progress(bytesSentTotal, contentLength)) } @@ -83,7 +84,7 @@ fun BucketApi.uploadToSignedUrlAsFlow( * @param upsert Whether to overwrite an existing file * @return A flow that emits the upload progress and at last the key to the uploaded file */ -fun BucketApi.uploadToSignedUrlAsFlow(path: String, token: String, data: ByteArray, upsert: Boolean = false): Flow = uploadToSignedUrlAsFlow(path, token, UploadData(ByteReadChannel(data), data.size.toLong()), upsert) +fun BucketApi.uploadToSignedUrlAsFlow(path: String, token: String, data: ByteArray, upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {}): Flow = uploadToSignedUrlAsFlow(path, token, UploadData(ByteReadChannel(data), data.size.toLong()), upsert, options) /** * Updates a file in [bucketId] under [path] @@ -95,10 +96,10 @@ fun BucketApi.uploadToSignedUrlAsFlow(path: String, token: String, data: ByteArr * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ -fun BucketApi.uploadAsFlow(path: String, data: UploadData, upsert: Boolean = false): Flow { +fun BucketApi.uploadAsFlow(path: String, data: UploadData, upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {}): Flow { return callbackFlow { this@uploadAsFlow as BucketApiImpl - val key = uploadOrUpdate(HttpMethod.Post, bucketId, path, data, upsert) { + val key = uploadOrUpdate(HttpMethod.Post, bucketId, path, data, upsert, options) { onUpload { bytesSentTotal, contentLength -> trySend(UploadStatus.Progress(bytesSentTotal, contentLength)) } @@ -118,7 +119,7 @@ fun BucketApi.uploadAsFlow(path: String, data: UploadData, upsert: Boolean = fal * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ -fun BucketApi.updateAsFlow(path: String, data: ByteArray, upsert: Boolean = false): Flow = updateAsFlow(path, UploadData(ByteReadChannel(data), data.size.toLong()), upsert) +fun BucketApi.updateAsFlow(path: String, data: ByteArray, upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {}): Flow = updateAsFlow(path, UploadData(ByteReadChannel(data), data.size.toLong()), upsert, options) /** * Downloads a file from [bucketId] under [path] 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 64eea8f3c..7749c6906 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 @@ -1,6 +1,7 @@ package io.github.jan.supabase.storage import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.SupabaseSerializer import io.github.jan.supabase.annotations.SupabaseInternal import io.github.jan.supabase.bodyOrNull import io.github.jan.supabase.collections.AtomicMutableMap @@ -13,6 +14,8 @@ import io.github.jan.supabase.exceptions.UnknownRestException import io.github.jan.supabase.gotrue.authenticatedSupabaseApi import io.github.jan.supabase.logging.SupabaseLogger import io.github.jan.supabase.logging.w +import io.github.jan.supabase.plugins.CustomSerializationConfig +import io.github.jan.supabase.plugins.CustomSerializationPlugin import io.github.jan.supabase.plugins.MainConfig import io.github.jan.supabase.plugins.MainPlugin import io.github.jan.supabase.plugins.SupabasePluginProvider @@ -46,7 +49,7 @@ import kotlin.time.Duration.Companion.seconds * val bytes = bucket.downloadAuthenticated("icon.png") * ``` */ -sealed interface Storage : MainPlugin { +sealed interface Storage : MainPlugin, CustomSerializationPlugin { /** * Creates a new bucket in the storage @@ -116,8 +119,9 @@ sealed interface Storage : MainPlugin { */ data class Config( var transferTimeout: Duration = 120.seconds, - @PublishedApi internal var resumable: Resumable = Resumable() - ) : MainConfig() { + @PublishedApi internal var resumable: Resumable = Resumable(), + override var serializer: SupabaseSerializer? = null + ) : MainConfig(), CustomSerializationConfig { /** * @param cache the cache for caching resumable upload urls @@ -127,7 +131,7 @@ sealed interface Storage : MainPlugin { data class Resumable( var cache: ResumableCache? = null, var retryTimeout: Duration = 5.seconds, - var onlyUpdateStateAfterChunk: Boolean = false + var onlyUpdateStateAfterChunk: Boolean = false, ) { /** @@ -185,6 +189,8 @@ internal class StorageImpl(override val supabaseClient: SupabaseClient, override override val apiVersion: Int get() = Storage.API_VERSION + override val serializer: SupabaseSerializer = config.serializer ?: supabaseClient.defaultSerializer + @OptIn(SupabaseInternal::class) internal val api = supabaseClient.authenticatedSupabaseApi(this) { timeout { From 1d272d86fb1d74d06ba2714cbcc6c9d3611d812a Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 25 Aug 2024 14:03:25 +0200 Subject: [PATCH 02/11] Finish up `info` method and rename `BucketItem` to `FileObject` --- .../io/github/jan/supabase/storage/BucketApi.kt | 2 +- .../github/jan/supabase/storage/BucketApiImpl.kt | 6 +++--- .../storage/{BucketItem.kt => FileObject.kt} | 16 +++++++++++++--- 3 files changed, 17 insertions(+), 7 deletions(-) rename Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/{BucketItem.kt => FileObject.kt} (82%) 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 d0b7a8db3..0895820fc 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,7 +256,7 @@ sealed interface BucketApi { suspend fun list( prefix: String = "", filter: BucketListFilter.() -> Unit = {} - ): List + ): List /** * Returns information about the file under [path] 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 adfd81259..3f3513652 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 @@ -217,7 +217,7 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage override suspend fun list( prefix: String, filter: BucketListFilter.() -> Unit - ): List { + ): List { return storage.api.postJson("object/list/$bucketId", buildJsonObject { put("prefix", prefix) putJsonObject(BucketListFilter().apply(filter).build()) @@ -225,8 +225,8 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage } override suspend fun info(path: String): FileObjectV2 { - val response = storage.api.get("object/info/public/$bucketId/$path") - return response.safeBody() + val response = storage.api.get("object/info/$bucketId/$path") + return response.safeBody().copy(serializer = storage.serializer) } override suspend fun exists(path: String): Boolean { diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketItem.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileObject.kt similarity index 82% rename from Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketItem.kt rename to Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileObject.kt index 922f251f5..1f49bfe29 100644 --- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketItem.kt +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileObject.kt @@ -3,6 +3,7 @@ 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 @@ -18,9 +19,8 @@ import kotlinx.serialization.json.JsonObject * @param lastAccessedAt The last access date of the item * @param metadata The metadata of the item */ -//TODO: Rename to FileObject @Serializable -data class BucketItem( +data class FileObject( val name: String, val id: String?, @SerialName("updated_at") @@ -40,6 +40,12 @@ data class BucketItem( * @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 contentType 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( @@ -57,7 +63,7 @@ data class FileObjectV2( val metadata: JsonObject?, val size: Long, @SerialName("content_type") - val contentType: String, + val rawContentType: String, val etag: String?, @SerialName("last_modified") val lastModified: Instant?, @@ -66,6 +72,10 @@ data class FileObjectV2( @Transient @PublishedApi internal val serializer: SupabaseSerializer = KotlinXSerializer() ) { + val contentType by lazy { + ContentType.parse(rawContentType) + } + inline fun decodeMetadata(): T? = metadata?.let { serializer.decode(it.toString()) } } \ No newline at end of file From d928abedacb06390de66fcdb341ef3bb8317c9ca Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 25 Aug 2024 14:20:49 +0200 Subject: [PATCH 03/11] Add some missing docs and tests --- .../jan/supabase/storage/BucketApiImpl.kt | 3 +- .../github/jan/supabase/storage/FileObject.kt | 10 +- .../jan/supabase/storage/FileOptionBuilder.kt | 13 ++ .../src/commonTest/kotlin/BucketApiTest.kt | 122 ++++++++++++++++-- 4 files changed, 132 insertions(+), 16 deletions(-) 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 3f3513652..df2625c56 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 @@ -16,6 +16,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 @@ -236,7 +237,7 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage } return true } catch (e: RestException) { - if (e.statusCode in listOf(400, 404)) return false + if (e.statusCode in listOf(HttpStatusCode.NotFound.value, HttpStatusCode.BadRequest.value)) return false throw e } } 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 1f49bfe29..9ae862e59 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 @@ -36,12 +36,14 @@ data class FileObject( * 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 contentType The content type 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 @@ -72,10 +74,16 @@ data class FileObjectV2( @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/FileOptionBuilder.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileOptionBuilder.kt index e3f9f3070..d59c30e58 100644 --- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileOptionBuilder.kt +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileOptionBuilder.kt @@ -7,15 +7,28 @@ 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 userMetadata The user metadata to upload with the file + */ class FileOptionBuilder( @PublishedApi internal val serializer: SupabaseSerializer, var userMetadata: JsonObject? = null, ) { + /** + * 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 b5e56849e..2dc3640cf 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,23 +14,35 @@ 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 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 { @@ -48,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( @@ -73,8 +88,10 @@ class BucketApiTest { "Upsert header should be true" ) }, - request = { client, expectedPath, data -> - client.storage[bucketId].upload(expectedPath, data, upsert = true) + request = { client, expectedPath, data, meta -> + client.storage[bucketId].upload(expectedPath, data, upsert = true) { + userMetadata = meta + } } ) } @@ -97,8 +114,10 @@ class BucketApiTest { "Token should be $expectedToken" ) }, - request = { client, expectedPath, data -> - client.storage[bucketId].uploadToSignedUrl(path = expectedPath, token = expectedToken, data = data, upsert = false) + request = { client, expectedPath, data, meta -> + client.storage[bucketId].uploadToSignedUrl(path = expectedPath, token = expectedToken, data = data, upsert = false) { + userMetadata = meta + } } ) } @@ -121,8 +140,10 @@ class BucketApiTest { "Token should be $expectedToken" ) }, - request = { client, expectedPath, data -> - client.storage[bucketId].uploadToSignedUrl(path = expectedPath, token = expectedToken, data = data, upsert = true) + request = { client, expectedPath, data, meta -> + client.storage[bucketId].uploadToSignedUrl(path = expectedPath, token = expectedToken, data = data, upsert = true) { + userMetadata = meta + } } ) } @@ -132,8 +153,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( @@ -157,8 +180,10 @@ class BucketApiTest { "Upsert header should be true" ) }, - request = { client, expectedPath, data -> - client.storage[bucketId].update(expectedPath, data, upsert = true) + request = { client, expectedPath, data, meta -> + client.storage[bucketId].update(expectedPath, data, upsert = true) { + userMetadata = meta + } } ) } @@ -429,6 +454,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 ) { @@ -463,18 +551,24 @@ class BucketApiTest { } } + @OptIn(ExperimentalEncodingApi::class) private fun testUploadMethod( method: HttpMethod, 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) + val expectedMetadata = buildJsonObject { + put("key", "value") + } 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") @@ -492,7 +586,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") From 1f48846b5b1a05fc9d50f3e6ab20242782283f2c Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 25 Aug 2024 14:22:00 +0200 Subject: [PATCH 04/11] fix docs --- .../kotlin/io/github/jan/supabase/storage/BucketApi.kt | 1 - 1 file changed, 1 deletion(-) 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 0895820fc..aa164c974 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 @@ -248,7 +248,6 @@ sealed interface BucketApi { /** * Searches for files with the given [prefix] and [filter] - * @return The filtered bucket items * @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 From 88ceb2f309f77ff0f362ddddc5094728990e6e68 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 25 Aug 2024 14:43:26 +0200 Subject: [PATCH 05/11] suppress warning --- .../kotlin/io/github/jan/supabase/storage/BucketApiImpl.kt | 1 + 1 file changed, 1 insertion(+) 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 df2625c56..2b3a41306 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 @@ -285,6 +285,7 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage return FileUploadResponse(id, path, key) } + @Suppress("LongParameterList") //TODO: maybe refactor @OptIn(ExperimentalEncodingApi::class) private fun HttpRequestBuilder.defaultUploadRequest( path: String, From 4ca681d2e9b329b85e969334791dbc9e5ccf85cf Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 25 Aug 2024 18:51:26 +0200 Subject: [PATCH 06/11] suppress warning for signed urls --- .../kotlin/io/github/jan/supabase/storage/BucketApiImpl.kt | 1 + 1 file changed, 1 insertion(+) 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 2b3a41306..6051ccf32 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 @@ -266,6 +266,7 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage } @OptIn(ExperimentalEncodingApi::class) + @Suppress("LongParameterList") //TODO: maybe refactor internal suspend fun uploadToSignedUrl( path: String, token: String, From b442961e00538779bb8e8951bf8bfca1002b1d48 Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 26 Aug 2024 19:04:33 +0200 Subject: [PATCH 07/11] Move upsert parameter to new FileOptionBuilder --- .../github/jan/supabase/storage/BucketApi.kt | 25 +++++++------------ .../jan/supabase/storage/BucketApiImpl.kt | 22 ++++++---------- .../jan/supabase/storage/FileOptionBuilder.kt | 5 ++++ .../src/commonTest/kotlin/BucketApiTest.kt | 12 ++++++--- 4 files changed, 30 insertions(+), 34 deletions(-) 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 aa164c974..5c76d4958 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 @@ -34,36 +34,33 @@ sealed interface BucketApi { * Uploads a file in [bucketId] under [path] * @param path The path to upload the file to * @param data The data to upload - * @param upsert Whether to overwrite an existing file * @return the key to the uploaded file * @throws IllegalArgumentException if data to upload is empty * @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 upload(path: String, data: ByteArray, upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {}): FileUploadResponse { + suspend fun upload(path: String, data: ByteArray, options: FileOptionBuilder.() -> Unit = {}): FileUploadResponse { require(data.isNotEmpty()) { "The data to upload should not be empty" } - return upload(path, UploadData(ByteReadChannel(data), data.size.toLong()), upsert, options) + return upload(path, UploadData(ByteReadChannel(data), data.size.toLong()), options) } /** * Uploads a file in [bucketId] under [path] * @param path The path to upload the file to * @param data The data to upload - * @param upsert Whether to overwrite an existing file * @return the key to the uploaded file * @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 upload(path: String, data: UploadData, upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {}): FileUploadResponse + suspend fun upload(path: String, data: UploadData, options: FileOptionBuilder.() -> Unit = {}): FileUploadResponse /** * Uploads a file in [bucketId] under [path] using a presigned url * @param path The path to upload the file to - * @param token The presigned url token + * @param token The pre-signed url token * @param data The data to upload - * @param upsert Whether to overwrite an existing file * @return the key of the uploaded file * @throws IllegalArgumentException if data to upload is empty */ @@ -71,11 +68,10 @@ sealed interface BucketApi { path: String, token: String, data: ByteArray, - upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {} ): FileUploadResponse { require(data.isNotEmpty()) { "The data to upload should not be empty" } - return uploadToSignedUrl(path, token, UploadData(ByteReadChannel(data), data.size.toLong()), upsert, options) + return uploadToSignedUrl(path, token, UploadData(ByteReadChannel(data), data.size.toLong()), options) } /** @@ -83,42 +79,39 @@ sealed interface BucketApi { * @param path The path to upload the file to * @param token The presigned url token * @param data The data to upload - * @param upsert Whether to overwrite an existing file * @return the key of the uploaded file * @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 * @throws HttpRequestException on network related issues */ - suspend fun uploadToSignedUrl(path: String, token: String, data: UploadData, upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {}): FileUploadResponse + suspend fun uploadToSignedUrl(path: String, token: String, data: UploadData, options: FileOptionBuilder.() -> Unit = {}): FileUploadResponse /** * Updates a file in [bucketId] under [path] * @param path The path to update the file to * @param data The new data - * @param upsert Whether to overwrite an existing file * @return the key to the updated file * @throws IllegalArgumentException if data to upload is empty * @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 update(path: String, data: ByteArray, upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {}): FileUploadResponse { + suspend fun update(path: String, data: ByteArray, options: FileOptionBuilder.() -> Unit = {}): FileUploadResponse { require(data.isNotEmpty()) { "The data to upload should not be empty" } - return update(path, UploadData(ByteReadChannel(data), data.size.toLong()), upsert, options) + return update(path, UploadData(ByteReadChannel(data), data.size.toLong()), options) } /** * Updates a file in [bucketId] under [path] * @param path The path to update the file to * @param data The new data - * @param upsert Whether to overwrite an existing file * @return the key to the updated file * @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 update(path: String, data: UploadData, upsert: Boolean = false, options: FileOptionBuilder.() -> Unit = {}): FileUploadResponse + suspend fun update(path: String, data: UploadData, options: FileOptionBuilder.() -> Unit = {}): FileUploadResponse /** * Deletes all files in [bucketId] with in [paths] 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 6051ccf32..76c83d165 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 @@ -44,21 +44,19 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage override suspend fun update( path: String, data: UploadData, - upsert: Boolean, options: FileOptionBuilder.() -> Unit ): FileUploadResponse = uploadOrUpdate( - HttpMethod.Put, bucketId, path, data, upsert, options + HttpMethod.Put, bucketId, path, data, options ) override suspend fun uploadToSignedUrl( path: String, token: String, data: UploadData, - upsert: Boolean, options: FileOptionBuilder.() -> Unit ): FileUploadResponse { - return uploadToSignedUrl(path, token, data, upsert, options) {} + return uploadToSignedUrl(path, token, data, options) {} } override suspend fun createSignedUploadUrl(path: String): UploadSignedUrl { @@ -77,11 +75,10 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage override suspend fun upload( path: String, data: UploadData, - upsert: Boolean, options: FileOptionBuilder.() -> Unit ): FileUploadResponse = uploadOrUpdate( - HttpMethod.Post, bucketId, path, data, upsert, options + HttpMethod.Post, bucketId, path, data, options ) override suspend fun delete(paths: Collection) { @@ -249,14 +246,13 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage bucket: String, path: String, data: UploadData, - upsert: Boolean, options: FileOptionBuilder.() -> Unit, extra: HttpRequestBuilder.() -> Unit = {} ): FileUploadResponse { val optionBuilder = FileOptionBuilder(storage.serializer).apply(options) val response = storage.api.request("object/$bucket/$path") { this.method = method - defaultUploadRequest(path, data, upsert, optionBuilder, extra) + defaultUploadRequest(path, data, optionBuilder, extra) }.body() val key = response["Key"]?.jsonPrimitive?.content ?: error("Expected a key in a upload response") @@ -271,14 +267,13 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage path: String, token: String, data: UploadData, - upsert: Boolean, options: FileOptionBuilder.() -> Unit, extra: HttpRequestBuilder.() -> Unit = {} ): FileUploadResponse { val optionBuilder = FileOptionBuilder(storage.serializer).apply(options) val response = storage.api.put("object/upload/sign/$bucketId/$path") { parameter("token", token) - defaultUploadRequest(path, data, upsert, optionBuilder, extra) + defaultUploadRequest(path, data, optionBuilder, extra) }.body() val key = response["Key"]?.jsonPrimitive?.content ?: error("Expected a key in a upload response") @@ -291,17 +286,16 @@ internal class BucketApiImpl(override val bucketId: String, val storage: Storage private fun HttpRequestBuilder.defaultUploadRequest( path: String, data: UploadData, - upsert: Boolean, optionBuilder: FileOptionBuilder, extra: HttpRequestBuilder.() -> Unit ) { setBody(object : OutgoingContent.ReadChannelContent() { - override val contentType: ContentType = ContentType.defaultForFilePath(path) + override val contentType: ContentType = optionBuilder.contentType ?: ContentType.defaultForFilePath(path) override val contentLength: Long = data.size override fun readFrom(): ByteReadChannel = data.stream }) - header(HttpHeaders.ContentType, ContentType.defaultForFilePath(path)) - header(UPSERT_HEADER, upsert.toString()) + header(HttpHeaders.ContentType, optionBuilder.contentType ?: ContentType.defaultForFilePath(path)) + header(UPSERT_HEADER, optionBuilder.upsert.toString()) optionBuilder.userMetadata?.let { header("x-metadata", Base64.Default.encode(it.toString().encodeToByteArray()).also((::println))) } diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileOptionBuilder.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileOptionBuilder.kt index d59c30e58..67b998ca2 100644 --- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileOptionBuilder.kt +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileOptionBuilder.kt @@ -2,6 +2,7 @@ package io.github.jan.supabase.storage import io.github.jan.supabase.SupabaseSerializer import io.github.jan.supabase.encodeToJsonElement +import io.ktor.http.ContentType import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.buildJsonObject @@ -11,10 +12,14 @@ import kotlinx.serialization.json.jsonObject * Builder for uploading files with additional options * @param serializer The serializer to use for encoding the metadata * @param userMetadata The user metadata to upload with the file + * @param upsert Whether to update the file if it already exists + * @param contentType The content type of the file. If null, the content type will be inferred from the file extension */ class FileOptionBuilder( @PublishedApi internal val serializer: SupabaseSerializer, var userMetadata: JsonObject? = null, + var upsert: Boolean = false, + var contentType: ContentType? = null, ) { /** diff --git a/Storage/src/commonTest/kotlin/BucketApiTest.kt b/Storage/src/commonTest/kotlin/BucketApiTest.kt index 2dc3640cf..ac09886ad 100644 --- a/Storage/src/commonTest/kotlin/BucketApiTest.kt +++ b/Storage/src/commonTest/kotlin/BucketApiTest.kt @@ -89,8 +89,9 @@ class BucketApiTest { ) }, request = { client, expectedPath, data, meta -> - client.storage[bucketId].upload(expectedPath, data, upsert = true) { + client.storage[bucketId].upload(expectedPath, data) { userMetadata = meta + upsert = true } } ) @@ -115,8 +116,9 @@ class BucketApiTest { ) }, request = { client, expectedPath, data, meta -> - client.storage[bucketId].uploadToSignedUrl(path = expectedPath, token = expectedToken, data = data, upsert = false) { + client.storage[bucketId].uploadToSignedUrl(path = expectedPath, token = expectedToken, data = data) { userMetadata = meta + upsert = false } } ) @@ -141,8 +143,9 @@ class BucketApiTest { ) }, request = { client, expectedPath, data, meta -> - client.storage[bucketId].uploadToSignedUrl(path = expectedPath, token = expectedToken, data = data, upsert = true) { + client.storage[bucketId].uploadToSignedUrl(path = expectedPath, token = expectedToken, data = data) { userMetadata = meta + upsert = true } } ) @@ -181,8 +184,9 @@ class BucketApiTest { ) }, request = { client, expectedPath, data, meta -> - client.storage[bucketId].update(expectedPath, data, upsert = true) { + client.storage[bucketId].update(expectedPath, data) { userMetadata = meta + upsert = true } } ) From 98556c9cce9f04e03ad4f258b28c1b45e60c672a Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 26 Aug 2024 19:09:46 +0200 Subject: [PATCH 08/11] remove comma --- .../commonMain/kotlin/io/github/jan/supabase/storage/Storage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7749c6906..8e106f4d1 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 @@ -131,7 +131,7 @@ sealed interface Storage : MainPlugin, CustomSerializationPlugin data class Resumable( var cache: ResumableCache? = null, var retryTimeout: Duration = 5.seconds, - var onlyUpdateStateAfterChunk: Boolean = false, + var onlyUpdateStateAfterChunk: Boolean = false ) { /** From a97dce6b470e49766aff4ac94c7c871643946cd9 Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 26 Aug 2024 19:11:50 +0200 Subject: [PATCH 09/11] remove println --- .../kotlin/io/github/jan/supabase/storage/BucketApiImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 76c83d165..4757c0ab5 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 @@ -297,7 +297,7 @@ 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("x-metadata", Base64.Default.encode(it.toString().encodeToByteArray()).also((::println))) + header("x-metadata", Base64.Default.encode(it.toString().encodeToByteArray())) } extra() } From 44687c87bb1afd5bce78ceea611ea96c8f479da5 Mon Sep 17 00:00:00 2001 From: Jan Tennert Date: Tue, 17 Sep 2024 18:23:31 +0200 Subject: [PATCH 10/11] Fix tests --- .../github/jan/supabase/storage/BucketApi.kt | 19 ++++++++++++++++ .../jan/supabase/storage/BucketApiImpl.kt | 19 ++++++++++++++++ .../supabase/storage/UploadOptionBuilder.kt | 22 +++++++++++++++++++ .../src/commonTest/kotlin/BucketApiTest.kt | 2 +- 4 files changed, 61 insertions(+), 1 deletion(-) 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 f9f943992..4fc9d6fa0 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 986f2582d..7c68cdaf7 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/UploadOptionBuilder.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/UploadOptionBuilder.kt index 61bfa326e..2faa67c23 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 ac09886ad..15fa01f4c 100644 --- a/Storage/src/commonTest/kotlin/BucketApiTest.kt +++ b/Storage/src/commonTest/kotlin/BucketApiTest.kt @@ -550,7 +550,7 @@ class BucketApiTest { quality = expectedQuality resize = expectedResize } - val data = if(authenticated) client.storage[bucketId].downloadAuthenticated(expectedPath, transform) else client.storage[bucketId].downloadPublic(expectedPath, 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]") } } From 91b8b9b47996faa95c59a7e9ec18183a894f3284 Mon Sep 17 00:00:00 2001 From: Jan Tennert Date: Tue, 17 Sep 2024 18:24:22 +0200 Subject: [PATCH 11/11] Remove invalid builder --- .../jan/supabase/storage/FileOptionBuilder.kt | 41 ------------------- 1 file changed, 41 deletions(-) delete mode 100644 Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileOptionBuilder.kt diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileOptionBuilder.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileOptionBuilder.kt deleted file mode 100644 index 67b998ca2..000000000 --- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/FileOptionBuilder.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.github.jan.supabase.storage - -import io.github.jan.supabase.SupabaseSerializer -import io.github.jan.supabase.encodeToJsonElement -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 userMetadata The user metadata to upload with the file - * @param upsert Whether to update the file if it already exists - * @param contentType The content type of the file. If null, the content type will be inferred from the file extension - */ -class FileOptionBuilder( - @PublishedApi internal val serializer: SupabaseSerializer, - var userMetadata: JsonObject? = null, - var upsert: Boolean = false, - var contentType: ContentType? = null, -) { - - /** - * 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) - } - -}