r2kit 0.1.0

A safe, ergonomic Rust toolkit for Cloudflare R2 object storage.
Documentation
use std::time::{Duration, UNIX_EPOCH};
use std::{env, time::SystemTime};

use futures_util::TryStreamExt;
use r2kit::{CacheControl, Error, ObjectUploadOptions, R2Client, R2Config, mime};

fn live_client() -> R2Client {
    assert_eq!(env::var("R2KIT_LIVE_TESTS").as_deref(), Ok("1"));
    assert_eq!(
        env::var("R2KIT_LIVE_BUCKET").as_deref(),
        Ok("r2kit-live-tests")
    );
    R2Client::new(R2Config::from_env().expect("R2 live credentials are required"))
}

#[tokio::test]
#[ignore = "requires explicit bucket-scoped R2 credentials"]
async fn live_bucket_preflight_confirms_read_access() {
    live_client()
        .validate_bucket("r2kit-live-tests")
        .await
        .expect("dedicated live bucket must be accessible");
}

#[tokio::test]
#[ignore = "requires explicit bucket-scoped R2 credentials"]
async fn live_core_object_round_trip_and_pagination() {
    let client = live_client();
    let bucket = client.bucket("r2kit-live-tests").unwrap();
    let prefix = format!("_r2kit-tests/{}/objects/", uuid::Uuid::new_v4());
    let first_key = format!("{prefix}a.txt");
    let second_key = format!("{prefix}b.txt");
    let first_body = b"r2kit object API: first".to_vec();
    let second_body = vec![
        0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x2b, 0x32, 0xca, 0xce, 0x2c,
        0x51, 0xc8, 0x4f, 0xca, 0x4a, 0x4d, 0x2e, 0x51, 0x70, 0x0c, 0xf0, 0xb4, 0x52, 0x28, 0x4e,
        0x4d, 0xce, 0xcf, 0x4b, 0x01, 0x00, 0xe2, 0x09, 0xb2, 0x17, 0x18, 0x00, 0x00, 0x00,
    ];
    let expires = UNIX_EPOCH + Duration::from_secs(1_893_456_000);

    let result = async {
        let options = ObjectUploadOptions::builder()
            .content_type(mime::TEXT_PLAIN_UTF_8)
            .content_disposition("attachment; filename=a.txt")
            .content_language("en-US, vi")
            .expires(expires)
            .custom_metadata("test-run", "object-round-trip")
            .custom_metadata("tenant-id", "tenant-42")
            .build();
        let put = bucket
            .put_bytes_with_options(&first_key, first_body.clone(), options)
            .await
            .map_err(|_| "first put failed")?;
        let encoded_options = ObjectUploadOptions::builder()
            .content_type(mime::TEXT_PLAIN_UTF_8)
            .content_encoding("gzip")
            .build();
        bucket
            .put_bytes_with_options(&second_key, second_body.clone(), encoded_options)
            .await
            .map_err(|_| "second put failed")?;

        let metadata = bucket.head(&first_key).await.map_err(|_| "head failed")?;
        if metadata.size() != first_body.len() as u64
            || metadata.etag() != put.etag()
            || metadata.content_disposition() != Some("attachment; filename=a.txt")
            || metadata.content_language() != Some("en-US, vi")
            || metadata.expires() != Some(expires)
            || metadata.custom().get("test-run").map(String::as_str) != Some("object-round-trip")
            || metadata.custom().get("tenant-id").map(String::as_str) != Some("tenant-42")
        {
            return Err("head metadata differs from put result");
        }

        let download = bucket.get(&first_key).await.map_err(|_| "get failed")?;
        if download.metadata().size() != first_body.len() as u64 {
            return Err("download metadata has wrong size");
        }
        let actual = download
            .into_body()
            .collect()
            .await
            .map_err(|_| "download body failed")?
            .into_bytes();
        if actual.as_ref() != first_body {
            return Err("downloaded bytes differ");
        }

        let encoded_metadata = bucket
            .head(&second_key)
            .await
            .map_err(|_| "encoded object head failed")?;
        if encoded_metadata.content_encoding() != Some("gzip") {
            return Err("encoded object metadata differs");
        }
        let encoded = bucket
            .get(&second_key)
            .await
            .map_err(|_| "encoded object get failed")?
            .into_body()
            .collect()
            .await
            .map_err(|_| "encoded object body failed")?
            .into_bytes();
        if encoded.as_ref() != second_body {
            return Err("encoded object bytes differ");
        }

        let pages: Vec<_> = bucket
            .list()
            .prefix(&prefix)
            .limit(1)
            .into_pages()
            .try_collect()
            .await
            .map_err(|_| "page stream failed")?;
        if pages.len() != 2 || pages.iter().any(|page| page.objects().len() != 1) {
            return Err("page stream must return two one-object pages");
        }

        let deleted = bucket
            .delete_objects([&first_key, &second_key])
            .await
            .map_err(|_| "batch delete request failed")?;
        if !deleted.is_complete() || deleted.deleted_keys().len() != 2 {
            return Err("batch delete did not report both keys as deleted");
        }
        if !matches!(bucket.head(&first_key).await, Err(Error::NotFound)) {
            return Err("batch-deleted key is still readable");
        }
        Ok::<(), &'static str>(())
    }
    .await;

    let _ = bucket.delete(&first_key).await;
    let _ = bucket.delete(&second_key).await;
    result.unwrap();
}

#[tokio::test]
#[ignore = "requires explicit bucket-scoped R2 credentials"]
async fn live_presigned_put_and_get_round_trip() {
    let client = live_client();
    let bucket = client.bucket("r2kit-live-tests").unwrap();
    let key = format!("_r2kit-tests/{}/presigned.bin", uuid::Uuid::new_v4());
    let body = b"r2kit presigned object contract".to_vec();
    let expires: SystemTime = UNIX_EPOCH + Duration::from_secs(1_893_456_000);

    let result = async {
        let http = reqwest::Client::builder()
            .redirect(reqwest::redirect::Policy::none())
            .build()
            .map_err(|_| "failed to build HTTP client")?;
        let options = ObjectUploadOptions::builder()
            .content_type(mime::IMAGE_JPEG)
            .cache_control(
                CacheControl::new()
                    .with_public()
                    .with_max_age(Duration::from_secs(3_600)),
            )
            .content_disposition("attachment; filename=presigned.bin")
            .content_language("en-US")
            .expires(expires)
            .custom_metadata("upload-mode", "presigned")
            .build();
        let put = bucket
            .presign_put_with_options(&key, body.len() as u64, Duration::from_secs(900), options)
            .await
            .map_err(|_| "PUT presign failed")?;
        let (method, url, headers) = put.into_request().into_exposed_parts();
        let method = method.parse().map_err(|_| "invalid PUT method")?;
        let mut request = http.request(method, url).body(body.clone());
        for (name, value) in headers {
            request = request.header(name, value);
        }
        let response = request.send().await.map_err(|_| "PUT transport failed")?;
        if !response.status().is_success() {
            return Err("R2 rejected presigned PUT");
        }

        let get = bucket
            .presign_get(&key, Duration::from_secs(900))
            .await
            .map_err(|_| "GET presign failed")?;
        let (method, url, headers) = get.into_exposed_parts();
        let method = method.parse().map_err(|_| "invalid GET method")?;
        let mut request = http.request(method, url);
        for (name, value) in headers {
            request = request.header(name, value);
        }
        let response = request.send().await.map_err(|_| "GET transport failed")?;
        if !response.status().is_success() {
            return Err("R2 rejected presigned GET");
        }
        let actual = response.bytes().await.map_err(|_| "GET body failed")?;
        if actual.as_ref() != body {
            return Err("presigned GET bytes differ");
        }
        let metadata = bucket.head(&key).await.map_err(|_| "HEAD failed")?;
        if metadata.content_type() != Some("image/jpeg")
            || metadata
                .cache_control()
                .is_none_or(|value| !value.contains("public") || !value.contains("max-age=3600"))
            || metadata.content_disposition() != Some("attachment; filename=presigned.bin")
            || metadata.content_language() != Some("en-US")
            || metadata.expires() != Some(expires)
            || metadata.custom().get("upload-mode").map(String::as_str) != Some("presigned")
        {
            return Err("typed object metadata was not persisted");
        }
        Ok::<(), &'static str>(())
    }
    .await;

    let _ = bucket.delete(&key).await;
    result.unwrap();
}