Skip to content

Commit af85774

Browse files
authored
Merge pull request #448 from michaelludwig1/master
feat: add bulk delete (DeleteObjects) support
2 parents 08545c5 + 4ab2c2b commit af85774

5 files changed

Lines changed: 378 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ Each `GET` method has a `PUT` companion `sync` and `async` methods are generic o
134134
| | |
135135
| --------------------------- | ------------------------------------------------------------------------------------------------- |
136136
| `async/sync/async-blocking` | [delete_object](https://docs.rs/rust-s3/latest/s3/bucket/struct.Bucket.html#method.delete_object) |
137+
| `async/sync/async-blocking` | [delete_objects](https://docs.rs/rust-s3/latest/s3/bucket/struct.Bucket.html#method.delete_objects) |
137138

138139
#### Location
139140

s3/src/bucket.rs

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,9 @@ use crate::error::S3Error;
9090
use crate::post_policy::PresignedPost;
9191
use crate::serde_types::{
9292
BucketLifecycleConfiguration, BucketLocationResult, CompleteMultipartUploadData,
93-
CorsConfiguration, GetObjectAttributesOutput, HeadObjectResult,
94-
InitiateMultipartUploadResponse, ListBucketResult, ListMultipartUploadsResult, Part,
93+
CorsConfiguration, DeleteObjectsRequest, DeleteObjectsResult, GetObjectAttributesOutput,
94+
HeadObjectResult, InitiateMultipartUploadResponse, ListBucketResult,
95+
ListMultipartUploadsResult, ObjectIdentifier, Part,
9596
};
9697
#[allow(unused_imports)]
9798
use crate::utils::{PutStreamResponse, error_from_response_data};
@@ -2116,6 +2117,92 @@ impl Bucket {
21162117
request.response_data(false).await
21172118
}
21182119

2120+
/// Delete multiple objects from S3 using the Multi-Object Delete API.
2121+
///
2122+
/// If more than 1000 objects are provided, they are automatically batched
2123+
/// into multiple requests (S3 allows at most 1000 keys per request).
2124+
/// Results from all batches are combined into a single response.
2125+
///
2126+
/// # Example:
2127+
///
2128+
/// ```no_run
2129+
/// use s3::bucket::Bucket;
2130+
/// use s3::creds::Credentials;
2131+
/// use s3::serde_types::ObjectIdentifier;
2132+
/// use anyhow::Result;
2133+
///
2134+
/// # #[tokio::main]
2135+
/// # async fn main() -> Result<()> {
2136+
///
2137+
/// let bucket_name = "rust-s3-test";
2138+
/// let region = "us-east-1".parse()?;
2139+
/// let credentials = Credentials::default()?;
2140+
/// let bucket = Bucket::new(bucket_name, region, credentials)?;
2141+
///
2142+
/// let objects = vec![
2143+
/// ObjectIdentifier::new("file1.txt"),
2144+
/// ObjectIdentifier::new("file2.txt"),
2145+
/// ObjectIdentifier::new("file3.txt"),
2146+
/// ];
2147+
///
2148+
/// // Async variant with `tokio` or `async-std` features
2149+
/// let response = bucket.delete_objects(objects).await?;
2150+
///
2151+
/// // `sync` feature will produce an identical method
2152+
/// #[cfg(feature = "sync")]
2153+
/// let response = bucket.delete_objects(objects)?;
2154+
///
2155+
/// // Blocking variant, generated with `blocking` feature in combination
2156+
/// // with `tokio` or `async-std` features.
2157+
/// #[cfg(feature = "blocking")]
2158+
/// let response = bucket.delete_objects_blocking(objects)?;
2159+
/// #
2160+
/// # Ok(())
2161+
/// # }
2162+
/// ```
2163+
#[maybe_async::maybe_async]
2164+
pub async fn delete_objects<I: Into<Vec<ObjectIdentifier>>>(
2165+
&self,
2166+
objects: I,
2167+
) -> Result<DeleteObjectsResult, S3Error> {
2168+
let objects = objects.into();
2169+
let mut result = DeleteObjectsResult {
2170+
deleted: Vec::new(),
2171+
errors: Vec::new(),
2172+
};
2173+
2174+
// Strip leading '/' from keys to match library convention.
2175+
// Other methods (put_object, delete_object, etc.) strip the leading
2176+
// slash when building the URL; we do the same for the XML body.
2177+
let objects: Vec<ObjectIdentifier> = objects
2178+
.into_iter()
2179+
.map(|mut obj| {
2180+
if let Some(stripped) = obj.key.strip_prefix('/') {
2181+
obj.key = stripped.to_string();
2182+
}
2183+
obj
2184+
})
2185+
.collect();
2186+
2187+
for chunk in objects.chunks(1000) {
2188+
let data = DeleteObjectsRequest {
2189+
objects: chunk.to_vec(),
2190+
quiet: false,
2191+
};
2192+
let command = Command::DeleteObjects { data };
2193+
let request = RequestImpl::new(self, "/", command).await?;
2194+
let response_data = request.response_data(false).await?;
2195+
if response_data.status_code() >= 300 {
2196+
return Err(error_from_response_data(response_data)?);
2197+
}
2198+
let msg: DeleteObjectsResult = quick_xml::de::from_str(response_data.as_str()?)?;
2199+
result.deleted.extend(msg.deleted);
2200+
result.errors.extend(msg.errors);
2201+
}
2202+
2203+
Ok(result)
2204+
}
2205+
21192206
/// Head object from S3.
21202207
///
21212208
/// # Example:
@@ -3784,6 +3871,64 @@ mod test {
37843871
put_head_delete_object_with_headers(*test_r2_bucket()).await;
37853872
}
37863873

3874+
#[maybe_async::maybe_async]
3875+
async fn put_delete_objects(bucket: Bucket) {
3876+
use crate::serde_types::ObjectIdentifier;
3877+
3878+
let paths = [
3879+
"/+bulk_delete_1.file",
3880+
"/+bulk_delete_2.file",
3881+
"/+bulk_delete_3.file",
3882+
];
3883+
let test: Vec<u8> = object(128);
3884+
3885+
// Put test objects
3886+
for path in &paths {
3887+
let response_data = bucket.put_object(*path, &test).await.unwrap();
3888+
assert_eq!(response_data.status_code(), 200);
3889+
}
3890+
3891+
// Bulk delete them
3892+
let objects: Vec<ObjectIdentifier> =
3893+
paths.iter().map(|p| ObjectIdentifier::new(*p)).collect();
3894+
let result = bucket.delete_objects(objects).await.unwrap();
3895+
3896+
assert_eq!(result.deleted.len(), 3);
3897+
assert!(result.errors.is_empty());
3898+
3899+
// Verify they are gone
3900+
for path in &paths {
3901+
let exists = bucket.object_exists(*path).await.unwrap();
3902+
assert!(!exists);
3903+
}
3904+
}
3905+
3906+
#[ignore]
3907+
#[maybe_async::test(
3908+
feature = "sync",
3909+
async(all(not(feature = "sync"), feature = "with-tokio"), tokio::test),
3910+
async(
3911+
all(not(feature = "sync"), feature = "with-async-std"),
3912+
async_std::test
3913+
)
3914+
)]
3915+
async fn aws_test_delete_objects() {
3916+
put_delete_objects(*test_aws_bucket()).await;
3917+
}
3918+
3919+
#[ignore]
3920+
#[maybe_async::test(
3921+
feature = "sync",
3922+
async(all(not(feature = "sync"), feature = "with-tokio"), tokio::test),
3923+
async(
3924+
all(not(feature = "sync"), feature = "with-async-std"),
3925+
async_std::test
3926+
)
3927+
)]
3928+
async fn minio_test_delete_objects() {
3929+
put_delete_objects(*test_minio_bucket()).await;
3930+
}
3931+
37873932
#[maybe_async::test(
37883933
feature = "sync",
37893934
async(all(not(feature = "sync"), feature = "with-tokio"), tokio::test),

s3/src/command.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use std::collections::HashMap;
2424
use crate::error::S3Error;
2525
use crate::serde_types::{
2626
BucketLifecycleConfiguration, CompleteMultipartUploadData, CorsConfiguration,
27+
DeleteObjectsRequest,
2728
};
2829

2930
use crate::EMPTY_PAYLOAD_SHA;
@@ -171,6 +172,9 @@ pub enum Command<'a> {
171172
expected_bucket_owner: String,
172173
version_id: Option<String>,
173174
},
175+
DeleteObjects {
176+
data: DeleteObjectsRequest,
177+
},
174178
}
175179

176180
impl<'a> Command<'a> {
@@ -203,9 +207,9 @@ impl<'a> Command<'a> {
203207
| Command::DeleteBucket
204208
| Command::DeleteBucketCors { .. }
205209
| Command::DeleteBucketLifecycle => HttpMethod::Delete,
206-
Command::InitiateMultipartUpload { .. } | Command::CompleteMultipartUpload { .. } => {
207-
HttpMethod::Post
208-
}
210+
Command::InitiateMultipartUpload { .. }
211+
| Command::CompleteMultipartUpload { .. }
212+
| Command::DeleteObjects { .. } => HttpMethod::Post,
209213
Command::HeadObject => HttpMethod::Head,
210214
Command::GetObjectAttributes { .. } => HttpMethod::Get,
211215
}
@@ -252,6 +256,7 @@ impl<'a> Command<'a> {
252256
Command::GetBucketLifecycle => 0,
253257
Command::DeleteBucketLifecycle { .. } => 0,
254258
Command::GetObjectAttributes { .. } => 0,
259+
Command::DeleteObjects { data } => data.len(),
255260
};
256261
Ok(result)
257262
}
@@ -289,6 +294,7 @@ impl<'a> Command<'a> {
289294
Command::UploadPart { .. } => "text/plain".into(),
290295
Command::CreateBucket { .. } => "text/plain".into(),
291296
Command::GetObjectAttributes { .. } => "text/plain".into(),
297+
Command::DeleteObjects { .. } => "application/xml".into(),
292298
}
293299
}
294300

@@ -353,6 +359,11 @@ impl<'a> Command<'a> {
353359
Command::UploadPart { .. } => EMPTY_PAYLOAD_SHA.into(),
354360
Command::InitiateMultipartUpload { .. } => EMPTY_PAYLOAD_SHA.into(),
355361
Command::GetObjectAttributes { .. } => EMPTY_PAYLOAD_SHA.into(),
362+
Command::DeleteObjects { data } => {
363+
let mut sha = Sha256::default();
364+
sha.update(data.to_string().as_bytes());
365+
hex::encode(sha.finalize().as_slice())
366+
}
356367
};
357368
Ok(result)
358369
}

s3/src/request/request_trait.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,8 @@ pub trait Request {
249249
} else if let Command::PutBucketCors { configuration, .. } = &self.command() {
250250
let cors = configuration.to_string();
251251
cors.as_bytes().to_vec()
252+
} else if let Command::DeleteObjects { data } = &self.command() {
253+
data.to_string().as_bytes().to_vec()
252254
} else {
253255
Vec::new()
254256
};
@@ -550,6 +552,9 @@ pub trait Request {
550552
Command::PutObjectTagging { .. } => {}
551553
Command::UploadPart { .. } => {}
552554
Command::CreateBucket { .. } => {}
555+
Command::DeleteObjects { .. } => {
556+
url_str.push_str("?delete");
557+
}
553558
}
554559

555560
let mut url = Url::parse(&url_str)?;
@@ -813,6 +818,11 @@ pub trait Request {
813818
HeaderName::from_static("x-amz-object-attributes"),
814819
"ETag".parse()?,
815820
);
821+
} else if let Command::DeleteObjects { ref data } = self.command() {
822+
let body = data.to_string();
823+
let digest = md5::compute(body.as_bytes());
824+
let hash = general_purpose::STANDARD.encode(digest.as_ref());
825+
headers.insert(HeaderName::from_static("content-md5"), hash.parse()?);
816826
}
817827

818828
// This must be last, as it signs the other headers, omitted if no secret key is provided

0 commit comments

Comments
 (0)