Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for file metadata, info and exists #694

Merged
merged 16 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/**
Expand All @@ -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
Expand All @@ -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)
}

/**
Expand All @@ -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]
Expand All @@ -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)
}

/**
Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand All @@ -253,6 +258,25 @@ sealed interface BucketApi {
filter: BucketListFilter.() -> Unit = {}
): List<BucketItem>

/**
* 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -29,6 +30,8 @@
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 {
Expand All @@ -37,18 +40,24 @@

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 {
Expand All @@ -64,9 +73,14 @@
)
}

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<String>) {
Expand Down Expand Up @@ -210,25 +224,38 @@
}).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
Fixed Show fixed Hide fixed
Fixed Show fixed Hide fixed
throw e
}
}

@OptIn(ExperimentalEncodingApi::class)
@Suppress("LongParameterList") //TODO: maybe refactor
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<JsonObject>()
val key = response["Key"]?.jsonPrimitive?.content
?: error("Expected a key in a upload response")
Expand All @@ -237,30 +264,47 @@
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<JsonObject>()
val key = response["Key"]?.jsonPrimitive?.content
?: error("Expected a key in a upload response")
val id = response["Id"]?.jsonPrimitive?.content ?: error("Expected an id in a upload response")
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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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

/**
Expand All @@ -14,6 +18,7 @@
* @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,
Expand All @@ -25,4 +30,42 @@
@SerialName("last_accessed_at")
val lastAccessedAt: Instant?,
val metadata: JsonObject?
)
)

/**
* 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,
Fixed Show fixed Hide fixed
@SerialName("bucket_id")
val bucketId: String? = null,
Fixed Show fixed Hide fixed
@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,
Fixed Show fixed Hide fixed
@SerialName("content_type")
val contentType: String,
Fixed Show fixed Hide fixed
val etag: String?,
Fixed Show fixed Hide fixed
@SerialName("last_modified")
val lastModified: Instant?,
Fixed Show fixed Hide fixed
@SerialName("cache_control")
val cacheControl: String?,
Fixed Show fixed Hide fixed
@Transient @PublishedApi internal val serializer: SupabaseSerializer = KotlinXSerializer()
) {

inline fun <reified T> decodeMetadata(): T? = metadata?.let { serializer.decode(it.toString()) }
Fixed Show fixed Hide fixed

}
Original file line number Diff line number Diff line change
@@ -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(
Fixed Show fixed Hide fixed
@PublishedApi internal val serializer: SupabaseSerializer,
var userMetadata: JsonObject? = null,
Fixed Show fixed Hide fixed
) {

inline fun <reified T : Any> userMetadata(data: T) {
Fixed Show fixed Hide fixed
userMetadata = serializer.encodeToJsonElement(data).jsonObject
}

inline fun userMetadata(builder: JsonObjectBuilder.() -> Unit) {
Fixed Show fixed Hide fixed
userMetadata = buildJsonObject(builder)
}

}
Loading
Loading