rusty_s3/actions/
list_objects_v2.rs

1use std::borrow::Cow;
2use std::io::{BufReader, Read};
3use std::time::Duration;
4
5use jiff::Timestamp;
6use serde::Deserialize;
7use url::Url;
8
9use crate::actions::Method;
10use crate::actions::S3Action;
11use crate::signing::sign;
12use crate::{Bucket, Credentials, Map};
13
14/// List all objects in the bucket.
15///
16/// If `next_continuation_token` is `Some` the response is truncated, and the
17/// rest of the list can be retrieved by reusing the `ListObjectV2` action
18/// but with `continuation-token` set to the value of `next_continuation_token`
19/// received in the previous response.
20///
21/// Find out more about `ListObjectsV2` from the [AWS API Reference][api]
22///
23/// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html
24#[allow(clippy::module_name_repetitions)]
25#[derive(Debug, Clone)]
26pub struct ListObjectsV2<'a> {
27    bucket: &'a Bucket,
28    credentials: Option<&'a Credentials>,
29
30    query: Map<'a>,
31    headers: Map<'a>,
32}
33
34#[allow(clippy::module_name_repetitions)]
35#[derive(Debug, Clone, Deserialize)]
36pub struct ListObjectsV2Response {
37    // #[serde(rename = "IsTruncated")]
38    // is_truncated: bool,
39    #[serde(rename = "Contents")]
40    #[serde(default)]
41    pub contents: Vec<ListObjectsContent>,
42
43    // #[serde(rename = "Name")]
44    // name: String,
45    // #[serde(rename = "Prefix")]
46    // prefix: String,
47    // #[serde(rename = "Delimiter")]
48    // delimiter: String,
49    #[serde(rename = "MaxKeys")]
50    pub max_keys: Option<u16>,
51    #[serde(rename = "CommonPrefixes", default)]
52    pub common_prefixes: Vec<CommonPrefixes>,
53    // #[serde(rename = "EncodingType")]
54    // encoding_type: String,
55    // #[serde(rename = "KeyCount")]
56    // key_count: u16,
57    // #[serde(rename = "ContinuationToken")]
58    // continuation_token: Option<String>,
59    #[serde(rename = "NextContinuationToken")]
60    pub next_continuation_token: Option<String>,
61    #[serde(rename = "StartAfter")]
62    pub start_after: Option<String>,
63}
64
65#[derive(Debug, Clone, Deserialize)]
66pub struct ListObjectsContent {
67    #[serde(rename = "ETag")]
68    pub etag: String,
69    #[serde(rename = "Key")]
70    pub key: String,
71    #[serde(rename = "LastModified")]
72    pub last_modified: String,
73    #[serde(rename = "Owner")]
74    pub owner: Option<ListObjectsOwner>,
75    #[serde(rename = "Size")]
76    pub size: u64,
77    #[serde(rename = "StorageClass")]
78    pub storage_class: Option<String>,
79}
80
81#[derive(Debug, Clone, Deserialize)]
82pub struct ListObjectsOwner {
83    #[serde(rename = "ID")]
84    pub id: String,
85    #[serde(rename = "DisplayName")]
86    pub display_name: String,
87}
88
89#[derive(Debug, Clone, Deserialize)]
90pub struct CommonPrefixes {
91    #[serde(rename = "Prefix")]
92    pub prefix: String,
93}
94
95impl<'a> ListObjectsV2<'a> {
96    #[must_use]
97    pub fn new(bucket: &'a Bucket, credentials: Option<&'a Credentials>) -> Self {
98        let mut query = Map::new();
99        query.insert("list-type", "2");
100        query.insert("encoding-type", "url");
101
102        Self {
103            bucket,
104            credentials,
105
106            query,
107            headers: Map::new(),
108        }
109    }
110
111    /// Limits the response to keys that begin with the specified prefix.
112    ///
113    /// See <https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html#API_ListObjectsV2_RequestSyntax> for more infos.
114    /// # Example
115    /// ```
116    /// # let bucket = rusty_s3::Bucket::new(url::Url::parse("http://rusty_s3/").unwrap(), rusty_s3::UrlStyle::Path, "doggo", "doggoland").unwrap();
117    /// let mut list = bucket.list_objects_v2(None);
118    /// list.with_prefix("tamo");
119    /// ```
120    pub fn with_prefix(&mut self, prefix: impl Into<Cow<'a, str>>) {
121        self.query_mut().insert("prefix", prefix);
122    }
123
124    /// A delimiter is a character that you use to group keys.
125    ///
126    /// See <https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html#API_ListObjectsV2_RequestSyntax> for more infos.
127    /// # Example
128    /// ```
129    /// # let bucket = rusty_s3::Bucket::new(url::Url::parse("http://rusty_s3/").unwrap(), rusty_s3::UrlStyle::Path, "doggo", "doggoland").unwrap();
130    /// let mut list = bucket.list_objects_v2(None);
131    /// list.with_delimiter("/");
132    /// ```
133    pub fn with_delimiter(&mut self, delimiter: impl Into<Cow<'a, str>>) {
134        self.query_mut().insert("delimiter", delimiter);
135    }
136
137    /// `StartAfter` is where you want Amazon S3 to start listing from.
138    /// Amazon S3 starts listing after this specified key.
139    /// `StartAfter` can be any key in the bucket.
140    ///
141    /// See <https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html#API_ListObjectsV2_RequestSyntax> for more infos.
142    /// # Example
143    /// ```
144    /// # let bucket = rusty_s3::Bucket::new(url::Url::parse("http://rusty_s3/").unwrap(), rusty_s3::UrlStyle::Path, "doggo", "doggoland").unwrap();
145    /// let mut list = bucket.list_objects_v2(None);
146    /// list.with_start_after("tamo"); // <- This token should come from a previous call to the list API.
147    /// ```
148    pub fn with_start_after(&mut self, start_after: impl Into<Cow<'a, str>>) {
149        self.query_mut().insert("start-after", start_after);
150    }
151
152    /// `ContinuationToken` indicates to Amazon S3 that the list is being continued on this bucket with a token.
153    /// `ContinuationToken` is obfuscated and is not a real key.
154    ///
155    /// See <https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html#API_ListObjectsV2_RequestSyntax> for more infos.
156    /// # Example
157    /// ```
158    /// # let bucket = rusty_s3::Bucket::new(url::Url::parse("http://rusty_s3/").unwrap(), rusty_s3::UrlStyle::Path, "doggo", "doggoland").unwrap();
159    /// let mut list = bucket.list_objects_v2(None);
160    /// list.with_continuation_token("tamo"); // <- This token should come from a previous call to the list API.
161    /// ```
162    pub fn with_continuation_token(&mut self, continuation_token: impl Into<Cow<'a, str>>) {
163        self.query_mut()
164            .insert("continuation-token", continuation_token);
165    }
166
167    /// Sets the maximum number of keys returned in the response.
168    /// By default, the action returns up to 1,000 key names.
169    /// The response might contain fewer keys but will never contain more.
170    ///
171    /// See <https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html#API_ListObjectsV2_RequestSyntax> for more infos.
172    /// # Example
173    /// ```
174    /// # let bucket = rusty_s3::Bucket::new(url::Url::parse("http://rusty_s3/").unwrap(), rusty_s3::UrlStyle::Path, "doggo", "doggoland").unwrap();
175    /// let mut list = bucket.list_objects_v2(None);
176    /// list.with_continuation_token("tamo"); // <- This token should come from a previous call to the list API.
177    /// ```
178    pub fn with_max_keys(&mut self, max_keys: usize) {
179        self.query_mut().insert("max-keys", max_keys.to_string());
180    }
181
182    /// Parse the XML response from S3 into a struct.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if the XML response could not be parsed.
187    pub fn parse_response(
188        s: impl AsRef<[u8]>,
189    ) -> Result<ListObjectsV2Response, quick_xml::DeError> {
190        Self::parse_response_from_reader(&mut s.as_ref())
191    }
192
193    /// Parse the XML response from S3 into a struct.
194    ///
195    /// # Errors
196    ///
197    /// Returns an error if the XML response could not be parsed.
198    pub fn parse_response_from_reader(
199        s: impl Read,
200    ) -> Result<ListObjectsV2Response, quick_xml::DeError> {
201        let mut parsed: ListObjectsV2Response = quick_xml::de::from_reader(BufReader::new(s))?;
202
203        // S3 returns an Owner with an empty DisplayName and ID when fetch-owner is disabled
204        for content in &mut parsed.contents {
205            if let Some(owner) = &content.owner {
206                if owner.id.is_empty() && owner.display_name.is_empty() {
207                    content.owner = None;
208                }
209            }
210        }
211
212        Ok(parsed)
213    }
214}
215
216impl<'a> S3Action<'a> for ListObjectsV2<'a> {
217    const METHOD: Method = Method::Get;
218
219    fn query_mut(&mut self) -> &mut Map<'a> {
220        &mut self.query
221    }
222
223    fn headers_mut(&mut self) -> &mut Map<'a> {
224        &mut self.headers
225    }
226
227    fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url {
228        let url = self.bucket.base_url().clone();
229
230        match self.credentials {
231            Some(credentials) => sign(
232                time,
233                Self::METHOD,
234                url,
235                credentials.key(),
236                credentials.secret(),
237                credentials.token(),
238                self.bucket.region(),
239                expires_in.as_secs(),
240                self.query.iter(),
241                self.headers.iter(),
242            ),
243            None => crate::signing::util::add_query_params(url, self.query.iter()),
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use pretty_assertions::assert_eq;
251
252    use super::*;
253    use crate::{Bucket, Credentials, UrlStyle};
254
255    #[test]
256    fn aws_example() {
257        // Fri, 24 May 2013 00:00:00 GMT
258        let date = Timestamp::from_second(1369353600).unwrap();
259        let expires_in = Duration::from_secs(86400);
260
261        let endpoint = "https://s3.amazonaws.com".parse().unwrap();
262        let bucket = Bucket::new(
263            endpoint,
264            UrlStyle::VirtualHost,
265            "examplebucket",
266            "us-east-1",
267        )
268        .unwrap();
269        let credentials = Credentials::new(
270            "AKIAIOSFODNN7EXAMPLE",
271            "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
272        );
273
274        let action = ListObjectsV2::new(&bucket, Some(&credentials));
275
276        let url = action.sign_with_time(expires_in, &date);
277        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&encoding-type=url&list-type=2&X-Amz-Signature=58e7f65928710f045f6a7e1f7a32b3426b4895900fad799db66faa3ff8b18bd5";
278
279        assert_eq!(expected, url.as_str());
280    }
281
282    #[test]
283    fn anonymous_custom_query() {
284        let expires_in = Duration::from_secs(86400);
285
286        let endpoint = "https://s3.amazonaws.com".parse().unwrap();
287        let bucket = Bucket::new(
288            endpoint,
289            UrlStyle::VirtualHost,
290            "examplebucket",
291            "us-east-1",
292        )
293        .unwrap();
294
295        let mut action = ListObjectsV2::new(&bucket, None);
296        action.query_mut().insert("continuation-token", "duck");
297
298        let url = action.sign(expires_in);
299        let expected = "https://examplebucket.s3.amazonaws.com/?continuation-token=duck&encoding-type=url&list-type=2";
300
301        assert_eq!(expected, url.as_str());
302    }
303
304    #[test]
305    fn parse() {
306        let input = r#"
307        <?xml version="1.0" encoding="UTF-8"?>
308        <ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
309            <Name>test</Name>
310            <Prefix></Prefix>
311            <KeyCount>3</KeyCount>
312            <MaxKeys>4500</MaxKeys>
313            <Delimiter></Delimiter>
314            <IsTruncated>false</IsTruncated>
315            <Contents>
316                <Key>duck.jpg</Key>
317                <LastModified>2020-12-01T20:43:11.794Z</LastModified>
318                <ETag>"bfd537a51d15208163231b0711e0b1f3"</ETag>
319                <Size>4274</Size>
320                <Owner>
321                    <ID></ID>
322                    <DisplayName></DisplayName>
323                </Owner>
324                <StorageClass>STANDARD</StorageClass>
325            </Contents>
326            <Contents>
327                <Key>idk.txt</Key>
328                <LastModified>2020-12-05T08:23:52.215Z</LastModified>
329                <ETag>"5927c5d64d94a5786f90003aa26d0159-1"</ETag>
330                <Size>9</Size>
331                <Owner>
332                    <ID></ID>
333                    <DisplayName></DisplayName>
334                </Owner>
335                <StorageClass>STANDARD</StorageClass>
336            </Contents>
337            <Contents>
338                <Key>img.jpg</Key>
339                <LastModified>2020-11-26T20:21:35.858Z</LastModified>
340                <ETag>"f7dbec93a0932ccb4d0f4e512eb1a443"</ETag>
341                <Size>41259</Size>
342                <Owner>
343                    <ID></ID>
344                    <DisplayName></DisplayName>
345                </Owner>
346                <StorageClass>STANDARD</StorageClass>
347            </Contents>
348            <EncodingType>url</EncodingType>
349        </ListBucketResult>
350        "#;
351
352        let parsed = ListObjectsV2::parse_response(input).unwrap();
353        assert_eq!(parsed.contents.len(), 3);
354
355        let item_1 = &parsed.contents[0];
356        assert_eq!(item_1.etag, "\"bfd537a51d15208163231b0711e0b1f3\"");
357        assert_eq!(item_1.key, "duck.jpg");
358        assert_eq!(item_1.last_modified, "2020-12-01T20:43:11.794Z");
359        assert!(item_1.owner.is_none());
360        assert_eq!(item_1.size, 4274);
361        assert_eq!(item_1.storage_class, Some("STANDARD".to_string()));
362
363        let item_2 = &parsed.contents[1];
364        assert_eq!(item_2.etag, "\"5927c5d64d94a5786f90003aa26d0159-1\"");
365        assert_eq!(item_2.key, "idk.txt");
366        assert_eq!(item_2.last_modified, "2020-12-05T08:23:52.215Z");
367        assert!(item_2.owner.is_none());
368        assert_eq!(item_2.size, 9);
369        assert_eq!(item_2.storage_class, Some("STANDARD".to_string()));
370
371        let item_3 = &parsed.contents[2];
372        assert_eq!(item_3.etag, "\"f7dbec93a0932ccb4d0f4e512eb1a443\"");
373        assert_eq!(item_3.key, "img.jpg");
374        assert_eq!(item_3.last_modified, "2020-11-26T20:21:35.858Z");
375        assert!(item_3.owner.is_none());
376        assert_eq!(item_3.size, 41259);
377        assert_eq!(item_3.storage_class, Some("STANDARD".to_string()));
378
379        assert_eq!(parsed.max_keys, Some(4500));
380        assert!(parsed.common_prefixes.is_empty());
381        assert!(parsed.next_continuation_token.is_none());
382        assert!(parsed.start_after.is_none());
383    }
384
385    #[test]
386    fn parse_no_contents() {
387        let input = r#"
388        <?xml version="1.0" encoding="UTF-8"?>
389        <ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
390            <Name>test</Name>
391            <Prefix></Prefix>
392            <KeyCount>0</KeyCount>
393            <MaxKeys>4500</MaxKeys>
394            <Delimiter></Delimiter>
395            <IsTruncated>false</IsTruncated>
396            <EncodingType>url</EncodingType>
397        </ListBucketResult>
398        "#;
399
400        let parsed = ListObjectsV2::parse_response(input).unwrap();
401        assert_eq!(parsed.contents.is_empty(), true);
402
403        assert_eq!(parsed.max_keys, Some(4500));
404        assert!(parsed.common_prefixes.is_empty());
405        assert!(parsed.next_continuation_token.is_none());
406        assert!(parsed.start_after.is_none());
407    }
408}