Skip to main content

rusty_s3/actions/
delete_objects.rs

1use std::iter;
2use std::time::Duration;
3
4use instant_xml::{FromXml, ToXml};
5use jiff::Timestamp;
6use md5::{Digest as _, Md5};
7use url::Url;
8
9use crate::actions::{Method, S3_XML_NS, S3Action};
10use crate::signing::sign;
11use crate::sorting_iter::SortingIterator;
12use crate::{Bucket, Credentials, Map};
13
14/// Delete multiple objects from a bucket using a single `POST` request.
15///
16/// Find out more about `DeleteObjects` from the [AWS API Reference][api]
17///
18/// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html
19#[derive(Debug, Clone)]
20pub struct DeleteObjects<'a, I> {
21    bucket: &'a Bucket,
22    credentials: Option<&'a Credentials>,
23    objects: I,
24    quiet: bool,
25
26    query: Map<'a>,
27    headers: Map<'a>,
28}
29
30impl<'a, I> DeleteObjects<'a, I> {
31    #[inline]
32    pub const fn new(bucket: &'a Bucket, credentials: Option<&'a Credentials>, objects: I) -> Self {
33        Self {
34            bucket,
35            credentials,
36            objects,
37            quiet: false,
38            query: Map::new(),
39            headers: Map::new(),
40        }
41    }
42
43    pub const fn quiet(&self) -> bool {
44        self.quiet
45    }
46
47    pub const fn set_quiet(&mut self, quiet: bool) {
48        self.quiet = quiet;
49    }
50}
51
52#[derive(Debug, Clone, Default)]
53pub struct ObjectIdentifier {
54    pub key: String,
55    pub version_id: Option<String>,
56}
57
58impl ObjectIdentifier {
59    #[must_use]
60    pub fn new(key: String) -> Self {
61        Self {
62            key,
63            ..Default::default()
64        }
65    }
66}
67
68#[derive(Debug, Clone, FromXml)]
69#[xml(rename = "DeleteResult", ns(S3_XML_NS))]
70pub struct DeleteObjectsResponse {
71    pub deleted: Vec<DeletedObject>,
72    #[xml(rename = "Error")]
73    pub errors: Vec<ErrorObject>,
74}
75
76#[derive(Debug, Clone, FromXml)]
77#[xml(rename = "Deleted", ns(S3_XML_NS))]
78pub struct DeletedObject {
79    #[xml(rename = "Key")]
80    pub key: String,
81    #[xml(rename = "VersionId")]
82    pub version_id: Option<String>,
83    #[xml(rename = "DeleteMarker")]
84    pub delete_marker: Option<bool>,
85    #[xml(rename = "DeleteMarkerVersionId")]
86    pub delete_marker_version_id: Option<String>,
87}
88
89#[derive(Debug, Clone, FromXml)]
90#[xml(rename = "Error", ns(S3_XML_NS))]
91pub struct ErrorObject {
92    #[xml(rename = "Key")]
93    pub key: String,
94    #[xml(rename = "VersionId")]
95    pub version_id: Option<String>,
96    #[xml(rename = "Code")]
97    pub code: String,
98    #[xml(rename = "Message")]
99    pub message: String,
100}
101
102impl DeleteObjectsResponse {
103    /// Parse the XML response from S3 into a struct.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if the XML response could not be parsed.
108    pub fn parse(s: &str) -> Result<Self, instant_xml::Error> {
109        instant_xml::from_str(s)
110    }
111}
112
113impl<'a, I> DeleteObjects<'a, I>
114where
115    I: Iterator<Item = &'a ObjectIdentifier>,
116{
117    /// Generate the XML body for the request.
118    ///
119    /// # Panics
120    ///
121    /// Panics if XML serialization fails, which shouldn't happen in practice.
122    pub fn body_with_md5(self) -> (String, String) {
123        #[derive(ToXml)]
124        #[xml(rename = "Delete")]
125        struct DeleteBody<'a> {
126            objects: Vec<Object<'a>>,
127            #[xml(rename = "Quiet")]
128            quiet: Option<bool>,
129        }
130        #[derive(ToXml)]
131        #[xml(rename = "Object")]
132        struct Object<'a> {
133            #[xml(rename = "Key")]
134            key: &'a str,
135            #[xml(rename = "VersionId")]
136            version_id: Option<&'a str>,
137        }
138
139        let objects: Vec<Object<'a>> = self
140            .objects
141            .map(|o| Object {
142                key: o.key.as_str(),
143                version_id: o.version_id.as_deref(),
144            })
145            .collect();
146
147        let req = DeleteBody {
148            objects,
149            quiet: self.quiet.then_some(true),
150        };
151
152        let body = instant_xml::to_string(&req).unwrap();
153
154        let content_md5 = crate::base64::encode(Md5::digest(body.as_bytes()));
155        (body, content_md5)
156    }
157}
158
159impl<'a, I> S3Action<'a> for DeleteObjects<'a, I>
160where
161    I: Iterator<Item = &'a ObjectIdentifier>,
162{
163    const METHOD: Method = Method::Post;
164
165    fn query_mut(&mut self) -> &mut Map<'a> {
166        &mut self.query
167    }
168
169    fn headers_mut(&mut self) -> &mut Map<'a> {
170        &mut self.headers
171    }
172
173    fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url {
174        let url = self.bucket.base_url().clone();
175        let query = SortingIterator::new(iter::once(("delete", "1")), self.query.iter());
176
177        match self.credentials {
178            Some(credentials) => sign(
179                time,
180                Self::METHOD,
181                url,
182                credentials.key(),
183                credentials.secret(),
184                credentials.token(),
185                self.bucket.region(),
186                expires_in.as_secs(),
187                query,
188                self.headers.iter(),
189            ),
190            None => crate::signing::util::add_query_params(url, query),
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use pretty_assertions::assert_eq;
198
199    use crate::{Bucket, Credentials, UrlStyle};
200
201    use super::*;
202
203    #[test]
204    fn aws_example() {
205        // Fri, 24 May 2013 00:00:00 GMT
206        let date = Timestamp::from_second(1369353600).unwrap();
207        let expires_in = Duration::from_secs(86400);
208
209        let endpoint = "https://s3.amazonaws.com".parse().unwrap();
210        let bucket = Bucket::new(
211            endpoint,
212            UrlStyle::VirtualHost,
213            "examplebucket",
214            "us-east-1",
215        )
216        .unwrap();
217        let credentials = Credentials::new(
218            "AKIAIOSFODNN7EXAMPLE",
219            "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
220        );
221
222        let objects = [
223            ObjectIdentifier {
224                key: "123".to_owned(),
225                ..Default::default()
226            },
227            ObjectIdentifier {
228                key: "456".to_owned(),
229                version_id: Some("ver1234".to_owned()),
230            },
231        ];
232        let action = DeleteObjects::new(&bucket, Some(&credentials), objects.iter());
233
234        let url = action.sign_with_time(expires_in, &date);
235        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";
236
237        assert_eq!(expected, url.as_str());
238    }
239
240    #[test]
241    fn anonymous_custom_query() {
242        let expires_in = Duration::from_secs(86400);
243
244        let endpoint = "https://s3.amazonaws.com".parse().unwrap();
245        let bucket = Bucket::new(
246            endpoint,
247            UrlStyle::VirtualHost,
248            "examplebucket",
249            "us-east-1",
250        )
251        .unwrap();
252
253        let objects = [
254            ObjectIdentifier {
255                key: "123".to_owned(),
256                ..Default::default()
257            },
258            ObjectIdentifier {
259                key: "456".to_owned(),
260                version_id: Some("ver1234".to_owned()),
261            },
262        ];
263        let action = DeleteObjects::new(&bucket, None, objects.iter());
264        let url = action.sign(expires_in);
265        let expected = "https://examplebucket.s3.amazonaws.com/?delete=1";
266
267        assert_eq!(expected, url.as_str());
268    }
269
270    #[test]
271    fn parse_response_success() {
272        let input = r#"<?xml version="1.0" encoding="UTF-8"?>
273        <DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
274            <Deleted>
275                <Key>duck.jpg</Key>
276                <VersionId>ver1234</VersionId>
277                <DeleteMarker>true</DeleteMarker>
278                <DeleteMarkerVersionId>del1234</DeleteMarkerVersionId>
279            </Deleted>
280            <Deleted>
281                <Key>duck2.jpg</Key>
282            </Deleted>
283        </DeleteResult>
284        "#;
285
286        let parsed = DeleteObjectsResponse::parse(input).unwrap();
287        assert_eq!(parsed.deleted.len(), 2);
288        assert_eq!(parsed.errors.len(), 0);
289
290        let deleted = &parsed.deleted[0];
291        assert_eq!(deleted.key, "duck.jpg");
292        assert_eq!(deleted.version_id, Some("ver1234".to_string()));
293        assert_eq!(deleted.delete_marker, Some(true));
294        assert_eq!(
295            deleted.delete_marker_version_id,
296            Some("del1234".to_string())
297        );
298
299        let deleted = &parsed.deleted[1];
300        assert_eq!(deleted.key, "duck2.jpg");
301        assert!(deleted.version_id.is_none());
302        assert!(deleted.delete_marker.is_none());
303        assert!(deleted.delete_marker_version_id.is_none());
304    }
305
306    #[test]
307    fn parse_response_errors() {
308        let input = r#"<?xml version="1.0" encoding="UTF-8"?>
309        <DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
310            <Error>
311                <Key>idk.txt</Key>
312                <Code>ErrorCode</Code>
313                <Message>Error message</Message>
314            </Error>
315            <Error>
316                <Key>idk2.txt</Key>
317                <VersionId>ver2</VersionId>
318                <Code>ErrorCode2</Code>
319                <Message>Error message 2</Message>
320            </Error>
321        </DeleteResult>
322        "#;
323
324        let parsed = DeleteObjectsResponse::parse(input).unwrap();
325        assert_eq!(parsed.deleted.len(), 0);
326        assert_eq!(parsed.errors.len(), 2);
327
328        let error = &parsed.errors[0];
329        assert_eq!(error.key, "idk.txt");
330        assert!(error.version_id.is_none());
331        assert_eq!(error.code, "ErrorCode");
332        assert_eq!(error.message, "Error message");
333
334        let error = &parsed.errors[1];
335        assert_eq!(error.key, "idk2.txt");
336        assert_eq!(error.version_id, Some("ver2".to_string()));
337        assert_eq!(error.code, "ErrorCode2");
338        assert_eq!(error.message, "Error message 2");
339    }
340}