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

actions: add DeleteObjects action #24

Merged
merged 2 commits into from
Sep 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ percent-encoding = "2.1.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
quick-xml = { version = "0.22", features = ["serialize"] }
md5 = "0.7"
base64 = "0.13"

[dev-dependencies]
tokio = { version = "1.0.1", features = ["macros", "fs", "rt-multi-thread"] }
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ More examples can be found in the examples directory on GitHub.
* [`GetObject`][getobject]
* [`PutObject`][putobject]
* [`DeleteObject`][deleteobject]
* [`DeleteObjects`][deleteobjects]
* [`ListObjectsV2`][listobjectsv2]
* Multipart upload
* [`CreateMultipartUpload`][completemultipart]
Expand All @@ -65,6 +66,7 @@ More examples can be found in the examples directory on GitHub.
[deletebucket]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucket.html
[createmultipart]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html
[deleteobject]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html
[deleteobjects]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html
[getobject]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html
[headobject]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html
[listobjectsv2]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html
Expand Down
220 changes: 220 additions & 0 deletions src/actions/delete_objects.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
use std::iter;
use std::time::Duration;

use serde::Serialize;
use time::OffsetDateTime;
use url::Url;

use crate::actions::Method;
use crate::actions::S3Action;
use crate::signing::sign;
use crate::sorting_iter::SortingIterator;
use crate::{Bucket, Credentials, Map};

/// Delete multiple objects from a bucket using a single `POST` request.
///
/// Find out more about `DeleteObjects` from the [AWS API Reference][api]
///
/// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html
#[derive(Debug, Clone)]
pub struct DeleteObjects<'a, I> {
bucket: &'a Bucket,
credentials: Option<&'a Credentials>,
objects: I,
quiet: bool,

query: Map<'a>,
headers: Map<'a>,
}

impl<'a, I> DeleteObjects<'a, I> {
#[inline]
pub fn new(bucket: &'a Bucket, credentials: Option<&'a Credentials>, objects: I) -> Self {
Self {
bucket,
credentials,
objects,
quiet: false,
query: Map::new(),
headers: Map::new(),
}
}

pub fn quiet(&self) -> bool {
self.quiet
}

pub fn set_quiet(&mut self, quiet: bool) {
self.quiet = quiet;
}
}

#[derive(Debug, Clone, Default)]
pub struct ObjectIdentifier {
pub key: String,
pub version_id: Option<String>,
}

impl ObjectIdentifier {
pub fn new(key: String) -> Self {
Self {
key,
..Default::default()
}
}
}

impl<'a, I> DeleteObjects<'a, I>
where
I: Iterator<Item = &'a ObjectIdentifier>,
{
fn sign_with_time(&self, expires_in: Duration, time: &OffsetDateTime) -> Url {
let url = self.bucket.base_url().clone();
let query = iter::once(("delete", "1"));

match self.credentials {
Some(credentials) => sign(
time,
Method::Post,
url,
credentials.key(),
credentials.secret(),
credentials.token(),
self.bucket.region(),
expires_in.as_secs(),
SortingIterator::new(query, self.query.iter()),
self.headers.iter(),
),
None => crate::signing::util::add_query_params(url, query),
}
}

pub fn body_with_md5(self) -> (String, String) {
#[derive(Serialize)]
#[serde(rename = "Delete")]
struct DeleteSerde<'a> {
#[serde(rename = "Object")]
objects: Vec<Object<'a>>,
#[serde(rename = "Quiet")]
quiet: Option<bool>,
}

#[derive(Serialize)]
#[serde(rename = "Delete")]
struct Object<'a> {
#[serde(rename = "$value")]
nodes: Vec<Node<'a>>,
}

#[derive(Serialize)]
enum Node<'a> {
Key(&'a str),
VersionId(&'a str),
}

let objects: Vec<Object<'a>> = self
.objects
.map(|o| {
let mut nodes = vec![Node::Key(o.key.as_str())];
if let Some(ref version_id) = o.version_id {
nodes.push(Node::VersionId(version_id.as_str()));
}
Object { nodes }
})
.collect();

let req = DeleteSerde {
objects,
quiet: self.quiet.then(|| true),
};

let body = quick_xml::se::to_string(&req).unwrap();
let content_md5 = base64::encode(md5::compute(body.as_bytes()).as_ref());
(body, content_md5)
}
}

impl<'a, I> S3Action<'a> for DeleteObjects<'a, I>
where
I: Iterator<Item = &'a ObjectIdentifier>,
{
const METHOD: Method = Method::Post;

fn sign(&self, expires_in: Duration) -> Url {
let now = OffsetDateTime::now_utc();
self.sign_with_time(expires_in, &now)
}

fn query_mut(&mut self) -> &mut Map<'a> {
&mut self.query
}

fn headers_mut(&mut self) -> &mut Map<'a> {
&mut self.headers
}
}

#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use time::OffsetDateTime;

use crate::{Bucket, Credentials};

use super::*;

#[test]
fn aws_example() {
// Fri, 24 May 2013 00:00:00 GMT
let date = OffsetDateTime::from_unix_timestamp(1369353600).unwrap();
let expires_in = Duration::from_secs(86400);

let endpoint = "https://s3.amazonaws.com".parse().unwrap();
let bucket = Bucket::new(endpoint, false, "examplebucket", "us-east-1").unwrap();
let credentials = Credentials::new(
"AKIAIOSFODNN7EXAMPLE",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
);

let objects = [
ObjectIdentifier {
key: "123".to_owned(),
..Default::default()
},
ObjectIdentifier {
key: "456".to_owned(),
version_id: Some("ver1234".to_owned()),
},
];
let action = DeleteObjects::new(&bucket, Some(&credentials), objects.iter());

let url = action.sign_with_time(expires_in, &date);
let expected = "https://examplebucket.s3.amazonaws.com/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&delete=1&X-Amz-Signature=0e6170ba8cb7873da76b7fb63638658607f484265935099b3d8cea5195af843c";

assert_eq!(expected, url.as_str());
}

#[test]
fn anonymous_custom_query() {
let expires_in = Duration::from_secs(86400);

let endpoint = "https://s3.amazonaws.com".parse().unwrap();
let bucket = Bucket::new(endpoint, false, "examplebucket", "us-east-1").unwrap();

let objects = [
ObjectIdentifier {
key: "123".to_owned(),
..Default::default()
},
ObjectIdentifier {
key: "456".to_owned(),
version_id: Some("ver1234".to_owned()),
},
];
let action = DeleteObjects::new(&bucket, None, objects.iter());
let url = action.sign(expires_in);
let expected = "https://examplebucket.s3.amazonaws.com/?delete=1";

assert_eq!(expected, url.as_str());
}
}
2 changes: 2 additions & 0 deletions src/actions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use url::Url;
pub use self::create_bucket::CreateBucket;
pub use self::delete_bucket::DeleteBucket;
pub use self::delete_object::DeleteObject;
pub use self::delete_objects::{DeleteObjects, ObjectIdentifier};
pub use self::get_object::GetObject;
pub use self::head_object::HeadObject;
#[doc(inline)]
Expand All @@ -21,6 +22,7 @@ use crate::{Map, Method};
mod create_bucket;
mod delete_bucket;
mod delete_object;
mod delete_objects;
mod get_object;
mod head_object;
pub mod list_objects_v2;
Expand Down
14 changes: 13 additions & 1 deletion src/bucket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ use url::{ParseError, Url};

use crate::actions::{
AbortMultipartUpload, CompleteMultipartUpload, CreateBucket, CreateMultipartUpload,
DeleteBucket, DeleteObject, GetObject, HeadObject, ListObjectsV2, PutObject, UploadPart,
DeleteBucket, DeleteObject, DeleteObjects, GetObject, HeadObject, ListObjectsV2, PutObject,
UploadPart,
};
use crate::signing::util::percent_encode_path;
use crate::Credentials;
Expand Down Expand Up @@ -181,6 +182,17 @@ impl Bucket {
) -> DeleteObject<'a> {
DeleteObject::new(self, credentials, object)
}

/// Delete multiple objects from S3 using a single `POST` request.
///
/// See [`DeleteObjects`] for more details.
pub fn delete_objects<'a, I>(
&'a self,
credentials: Option<&'a Credentials>,
objects: I,
) -> DeleteObjects<'a, I> {
DeleteObjects::new(self, credentials, objects)
}
}

// === Multipart Upload ===
Expand Down
67 changes: 67 additions & 0 deletions tests/delete_objects.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use std::time::Duration;

use reqwest::Client;
use url::Url;

use rusty_s3::actions::{ListObjectsV2, ListObjectsV2Response, ObjectIdentifier, S3Action};

mod common;

#[tokio::test]
async fn delete_objects() {
let (bucket, credentials, client) = common::bucket().await;

let action = bucket.list_objects_v2(Some(&credentials));
let list_url = action.sign(Duration::from_secs(600));
let list = get_objects_list(&client, list_url.clone()).await;
assert!(list.contents.is_empty());

// Fill bucket by objects
let body = vec![b'r'; 1024];
let mut objects = vec![];
for i in 0..100 {
let key = format!("obj{}.txt", i);
let action = bucket.put_object(Some(&credentials), &key);
let url = action.sign(Duration::from_secs(60));
client
.put(url)
.body(body.clone())
.send()
.await
.expect("send PutObject")
.error_for_status()
.expect("PutObject unexpected status code");
objects.push(ObjectIdentifier::new(key));
}

let list = get_objects_list(&client, list_url.clone()).await;
assert_eq!(list.contents.len(), 100);

let action = bucket.delete_objects(Some(&credentials), objects.iter());
let url = action.sign(Duration::from_secs(60));
let (body, content_md5) = action.body_with_md5();
client
.post(url)
.header("Content-MD5", content_md5)
.body(body)
.send()
.await
.expect("send DeleteObjects")
.error_for_status()
.expect("DeleteObjects unexpected status code");

let list = get_objects_list(&client, list_url.clone()).await;
assert!(list.contents.is_empty());
}

async fn get_objects_list(client: &Client, url: Url) -> ListObjectsV2Response {
let resp = client
.get(url)
.send()
.await
.expect("send ListObjectsV2")
.error_for_status()
.expect("ListObjectsV2 unexpected status code");
let text = resp.text().await.expect("ListObjectsV2 read response body");
ListObjectsV2::parse_response(&text).expect("ListObjectsV2 parse response")
}