use std::time::Duration;
use super::{Blob, BlobMeta, BlobStore, BlobStoreError};
#[derive(Debug, Clone, serde::Serialize)]
pub struct PresignPutResult {
pub url: String,
pub method: String,
pub headers: std::collections::HashMap<String, String>,
pub expires_in: Duration,
}
pub async fn complete_direct_upload(
store: &dyn BlobStore,
key: &str,
) -> Result<Blob, BlobStoreError> {
store.head(key).await?.map_or_else(
|| {
Err(BlobStoreError::NotFound(format!(
"direct upload incomplete or upload has expired: \
key {key:?} not found in storage; \
ensure the browser PUT completed before calling complete_direct_upload"
)))
},
|meta| Ok(blob_from_meta(store.provider_id(), key, meta)),
)
}
fn blob_from_meta(provider_id: &str, key: &str, meta: BlobMeta) -> Blob {
Blob {
provider_id: provider_id.to_owned(),
key: key.to_owned(),
content_type: meta.content_type,
byte_size: meta.byte_size,
etag: meta.etag,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::{BlobFuture, ByteStream};
use bytes::Bytes;
use std::collections::HashMap;
struct AlwaysFoundStore {
meta: BlobMeta,
}
impl BlobStore for AlwaysFoundStore {
fn provider_id(&self) -> &'static str {
"test"
}
fn put<'a>(&'a self, _: &'a str, _: &'a str, _: Bytes) -> BlobFuture<'a, Blob> {
Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
}
fn put_stream<'a>(
&'a self,
_: &'a str,
_: &'a str,
_: ByteStream<'a>,
) -> BlobFuture<'a, Blob> {
Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
}
fn get<'a>(&'a self, _: &'a str) -> BlobFuture<'a, Bytes> {
Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
}
fn delete<'a>(&'a self, _: &'a str) -> BlobFuture<'a, ()> {
Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
}
fn head<'a>(&'a self, key: &'a str) -> BlobFuture<'a, Option<BlobMeta>> {
let meta = BlobMeta {
key: key.to_owned(),
content_type: self.meta.content_type.clone(),
byte_size: self.meta.byte_size,
etag: self.meta.etag.clone(),
};
Box::pin(async move { Ok(Some(meta)) })
}
fn presigned_url<'a>(&'a self, _: &'a str, _: Duration) -> BlobFuture<'a, String> {
Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
}
}
struct NotFoundStore;
impl BlobStore for NotFoundStore {
fn provider_id(&self) -> &'static str {
"test"
}
fn put<'a>(&'a self, _: &'a str, _: &'a str, _: Bytes) -> BlobFuture<'a, Blob> {
Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
}
fn put_stream<'a>(
&'a self,
_: &'a str,
_: &'a str,
_: ByteStream<'a>,
) -> BlobFuture<'a, Blob> {
Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
}
fn get<'a>(&'a self, _: &'a str) -> BlobFuture<'a, Bytes> {
Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
}
fn delete<'a>(&'a self, _: &'a str) -> BlobFuture<'a, ()> {
Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
}
fn head<'a>(&'a self, _: &'a str) -> BlobFuture<'a, Option<BlobMeta>> {
Box::pin(async { Ok(None) })
}
fn presigned_url<'a>(&'a self, _: &'a str, _: Duration) -> BlobFuture<'a, String> {
Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
}
}
#[tokio::test]
async fn complete_direct_upload_returns_blob_from_head() {
let store = AlwaysFoundStore {
meta: BlobMeta {
key: "ignored".into(),
content_type: "image/png".into(),
byte_size: 1234,
etag: Some("abc123".into()),
},
};
let blob = complete_direct_upload(&store, "avatars/me.png")
.await
.unwrap();
assert_eq!(blob.provider_id, "test");
assert_eq!(blob.key, "avatars/me.png");
assert_eq!(blob.content_type, "image/png");
assert_eq!(blob.byte_size, 1234);
assert_eq!(blob.etag.as_deref(), Some("abc123"));
}
#[tokio::test]
async fn complete_direct_upload_returns_not_found_when_missing() {
let store = NotFoundStore;
let err = complete_direct_upload(&store, "missing/file.png")
.await
.unwrap_err();
assert!(
matches!(err, BlobStoreError::NotFound(_)),
"expected NotFound, got {err:?}"
);
assert!(err.to_string().contains("direct upload incomplete"));
}
#[test]
fn presign_put_result_clone_and_debug() {
let r = PresignPutResult {
url: "https://s3.example.com/bucket/key?sig=abc".into(),
method: "PUT".into(),
headers: HashMap::from([("Content-Type".into(), "image/jpeg".into())]),
expires_in: Duration::from_secs(900),
};
let cloned = r.clone();
assert_eq!(cloned.url, r.url);
assert!(!format!("{r:?}").is_empty());
}
}