rusty_oss/actions/
list_objects_v2.rs

1use std::borrow::Cow;
2use std::time::Duration;
3
4use serde::Deserialize;
5use time::OffsetDateTime;
6use url::Url;
7
8use crate::actions::Method;
9use crate::actions::OSSAction;
10use crate::signing::sign;
11use crate::{Bucket, Credentials, Map};
12
13/// List all objects in the bucket.
14///
15/// If `next_continuation_token` is `Some` the response is truncated, and the
16/// rest of the list can be retrieved by reusing the `ListObjectV2` action
17/// but with `continuation-token` set to the value of `next_continuation_token`
18/// received in the previous response.
19///
20/// Find out more about `ListObjectsV2` from the [OSS API Reference][api]
21///
22/// [api]: https://help.aliyun.com/zh/oss/developer-reference/listobjectsv2
23#[derive(Debug, Clone)]
24pub struct ListObjectsV2<'a> {
25    bucket: &'a Bucket,
26    credentials: Option<&'a Credentials>,
27
28    query: Map<'a>,
29    headers: Map<'a>,
30}
31
32#[derive(Debug, Clone, Deserialize)]
33pub struct ListObjectsV2Response {
34    // #[serde(rename = "IsTruncated")]
35    // is_truncated: bool,
36    #[serde(rename = "Contents")]
37    #[serde(default)]
38    pub contents: Vec<ListObjectsContent>,
39
40    // #[serde(rename = "Name")]
41    // name: String,
42    // #[serde(rename = "Prefix")]
43    // prefix: String,
44    // #[serde(rename = "Delimiter")]
45    // delimiter: String,
46    #[serde(rename = "MaxKeys")]
47    pub max_keys: Option<u16>,
48    #[serde(rename = "CommonPrefixes", default)]
49    pub common_prefixes: Vec<CommonPrefixes>,
50    // #[serde(rename = "EncodingType")]
51    // encoding_type: String,
52    // #[serde(rename = "KeyCount")]
53    // key_count: u16,
54    // #[serde(rename = "ContinuationToken")]
55    // continuation_token: Option<String>,
56    #[serde(rename = "NextContinuationToken")]
57    pub next_continuation_token: Option<String>,
58    #[serde(rename = "StartAfter")]
59    pub start_after: Option<String>,
60}
61
62#[derive(Debug, Clone, Deserialize)]
63pub struct ListObjectsContent {
64    #[serde(rename = "ETag")]
65    pub etag: String,
66    #[serde(rename = "Key")]
67    pub key: String,
68    #[serde(rename = "LastModified")]
69    pub last_modified: String,
70    #[serde(rename = "Owner")]
71    pub owner: Option<ListObjectsOwner>,
72    #[serde(rename = "Size")]
73    pub size: u64,
74    #[serde(rename = "StorageClass")]
75    pub storage_class: Option<String>,
76}
77
78#[derive(Debug, Clone, Deserialize)]
79pub struct ListObjectsOwner {
80    #[serde(rename = "ID")]
81    pub id: String,
82    #[serde(rename = "DisplayName")]
83    pub display_name: String,
84}
85
86#[derive(Debug, Clone, Deserialize)]
87pub struct CommonPrefixes {
88    #[serde(rename = "Prefix")]
89    pub prefix: String,
90}
91
92impl<'a> ListObjectsV2<'a> {
93    pub fn new(bucket: &'a Bucket, credentials: Option<&'a Credentials>) -> Self {
94        let mut query = Map::new();
95        query.insert("encoding-type", "url");
96        query.insert("list-type", "2");
97
98        Self {
99            bucket,
100            credentials,
101
102            query,
103            headers: Map::new(),
104        }
105    }
106
107    /// Limits the response to keys that begin with the specified prefix.
108    ///
109    /// See https://help.aliyun.com/zh/oss/developer-reference/listobjectsv2#section-5n7-lx2-piv for more infos.
110    pub fn with_prefix(&mut self, prefix: impl Into<Cow<'a, str>>) {
111        self.query_mut().insert("prefix", prefix);
112    }
113
114    /// StartAfter is where you want Aliyun OSS to start listing from.
115    /// Aliyun OSS starts listing after this specified key.
116    /// StartAfter can be any key in the bucket.
117    ///
118    /// See https://help.aliyun.com/zh/oss/developer-reference/listobjectsv2#section-5n7-lx2-piv for more infos.
119    pub fn with_start_after(&mut self, start_after: impl Into<Cow<'a, str>>) {
120        self.query_mut().insert("start-after", start_after);
121    }
122
123    /// ContinuationToken indicates to Aliyun OSS that the list is being continued on this bucket with a token.
124    /// ContinuationToken is obfuscated and is not a real key.
125    ///
126    /// See https://help.aliyun.com/zh/oss/developer-reference/listobjectsv2#section-5n7-lx2-piv for more infos.
127    pub fn with_continuation_token(&mut self, continuation_token: impl Into<Cow<'a, str>>) {
128        self.query_mut()
129            .insert("continuation-token", continuation_token);
130    }
131
132    /// Sets the maximum number of keys returned in the response.
133    /// By default, the action returns up to 1,000 key names.
134    /// The response might contain fewer keys but will never contain more.
135    ///
136    /// See https://help.aliyun.com/zh/oss/developer-reference/listobjectsv2#section-5n7-lx2-piv for more infos.
137    pub fn with_max_keys(&mut self, max_keys: usize) {
138        self.query_mut().insert("max-keys", max_keys.to_string());
139    }
140
141    pub fn parse_response(s: &str) -> Result<ListObjectsV2Response, quick_xml::DeError> {
142        let mut parsed: ListObjectsV2Response = quick_xml::de::from_str(s)?;
143
144        // OSS returns an Owner with an empty DisplayName and ID when fetch-owner is disabled
145        for content in parsed.contents.iter_mut() {
146            if let Some(owner) = &content.owner {
147                if owner.id.is_empty() && owner.display_name.is_empty() {
148                    content.owner = None;
149                }
150            }
151        }
152
153        Ok(parsed)
154    }
155}
156
157impl<'a> OSSAction<'a> for ListObjectsV2<'a> {
158    const METHOD: Method = Method::Get;
159
160    fn query_mut(&mut self) -> &mut Map<'a> {
161        &mut self.query
162    }
163
164    fn headers_mut(&mut self) -> &mut Map<'a> {
165        &mut self.headers
166    }
167
168    fn sign_with_time(&self, expires_in: Duration, time: &OffsetDateTime) -> Url {
169        let url = self.bucket.base_url().clone();
170
171        match self.credentials {
172            Some(credentials) => sign(
173                time,
174                Method::Get,
175                url,
176                credentials.key(),
177                credentials.secret(),
178                credentials.token(),
179                self.bucket.region(),
180                expires_in.as_secs(),
181                self.query.iter(),
182                self.headers.iter(),
183            ),
184            None => crate::signing::util::add_query_params(url, self.query.iter()),
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use time::OffsetDateTime;
192
193    use pretty_assertions::assert_eq;
194
195    use super::*;
196    use crate::{Bucket, Credentials, UrlStyle};
197
198    #[test]
199    fn oss_example() {
200        // Fri, 24 May 2013 00:00:00 GMT
201        let date = OffsetDateTime::from_unix_timestamp(1369353600).unwrap();
202        let expires_in = Duration::from_secs(86400);
203
204        let endpoint = "https://oss-cn-hangzhou.aliyuncs.com".parse().unwrap();
205        let bucket = Bucket::new(
206            endpoint,
207            UrlStyle::VirtualHost,
208            "examplebucket",
209            "cn-hangzhou",
210        )
211        .unwrap();
212        let credentials = Credentials::new(
213            "access_key_id",
214            "access_key_secret",
215        );
216
217        let action = ListObjectsV2::new(&bucket, Some(&credentials));
218
219        let url = action.sign_with_time(expires_in, &date);
220        let expected = "https://examplebucket.oss-cn-hangzhou.aliyuncs.com/?encoding-type=url&list-type=2&x-oss-additional-headers=host&x-oss-credential=access_key_id%2F20130524%2Fcn-hangzhou%2Foss%2Faliyun_v4_request&x-oss-date=20130524T000000Z&x-oss-expires=86400&x-oss-signature-version=OSS4-HMAC-SHA256&x-oss-signature=b3f1c590a5375a45e9ad593f2b55adc660686d92c5ad8807fe54e40b7cc3fff5";
221
222        assert_eq!(expected, url.as_str());
223    }
224
225    #[test]
226    fn anonymous_custom_query() {
227        let expires_in = Duration::from_secs(86400);
228
229        let endpoint = "https://oss-cn-hangzhou.aliyuncs.com".parse().unwrap();
230        let bucket = Bucket::new(
231            endpoint,
232            UrlStyle::VirtualHost,
233            "examplebucket",
234            "cn-hangzhou",
235        )
236        .unwrap();
237
238        let mut action = ListObjectsV2::new(&bucket, None);
239        action.query_mut().insert("continuation-token", "duck");
240
241        let url = action.sign(expires_in);
242        let expected = "https://examplebucket.oss-cn-hangzhou.aliyuncs.com/?continuation-token=duck&encoding-type=url&list-type=2";
243
244        assert_eq!(expected, url.as_str());
245    }
246
247    #[test]
248    fn parse() {
249        let input = r#"
250        <?xml version="1.0" encoding="UTF-8"?>
251        <ListBucketResult xmlns="http://doc.oss-cn-hangzhou.aliyuncs.com">
252            <Name>test</Name>
253            <Prefix></Prefix>
254            <KeyCount>3</KeyCount>
255            <MaxKeys>4500</MaxKeys>
256            <Delimiter></Delimiter>
257            <IsTruncated>false</IsTruncated>
258            <Contents>
259                <Key>duck.jpg</Key>
260                <LastModified>2020-12-01T20:43:11.794Z</LastModified>
261                <ETag>"bfd537a51d15208163231b0711e0b1f3"</ETag>
262                <Size>4274</Size>
263                <Owner>
264                    <ID></ID>
265                    <DisplayName></DisplayName>
266                </Owner>
267                <StorageClass>STANDARD</StorageClass>
268            </Contents>
269            <Contents>
270                <Key>idk.txt</Key>
271                <LastModified>2020-12-05T08:23:52.215Z</LastModified>
272                <ETag>"5927c5d64d94a5786f90003aa26d0159-1"</ETag>
273                <Size>9</Size>
274                <Owner>
275                    <ID></ID>
276                    <DisplayName></DisplayName>
277                </Owner>
278                <StorageClass>STANDARD</StorageClass>
279            </Contents>
280            <Contents>
281                <Key>img.jpg</Key>
282                <LastModified>2020-11-26T20:21:35.858Z</LastModified>
283                <ETag>"f7dbec93a0932ccb4d0f4e512eb1a443"</ETag>
284                <Size>41259</Size>
285                <Owner>
286                    <ID></ID>
287                    <DisplayName></DisplayName>
288                </Owner>
289                <StorageClass>STANDARD</StorageClass>
290            </Contents>
291            <EncodingType>url</EncodingType>
292        </ListBucketResult>
293        "#;
294
295        let parsed = ListObjectsV2::parse_response(input).unwrap();
296        assert_eq!(parsed.contents.len(), 3);
297
298        let item_1 = &parsed.contents[0];
299        assert_eq!(item_1.etag, "\"bfd537a51d15208163231b0711e0b1f3\"");
300        assert_eq!(item_1.key, "duck.jpg");
301        assert_eq!(item_1.last_modified, "2020-12-01T20:43:11.794Z");
302        assert!(item_1.owner.is_none());
303        assert_eq!(item_1.size, 4274);
304        assert_eq!(item_1.storage_class, Some("STANDARD".to_string()));
305
306        let item_2 = &parsed.contents[1];
307        assert_eq!(item_2.etag, "\"5927c5d64d94a5786f90003aa26d0159-1\"");
308        assert_eq!(item_2.key, "idk.txt");
309        assert_eq!(item_2.last_modified, "2020-12-05T08:23:52.215Z");
310        assert!(item_2.owner.is_none());
311        assert_eq!(item_2.size, 9);
312        assert_eq!(item_2.storage_class, Some("STANDARD".to_string()));
313
314        let item_3 = &parsed.contents[2];
315        assert_eq!(item_3.etag, "\"f7dbec93a0932ccb4d0f4e512eb1a443\"");
316        assert_eq!(item_3.key, "img.jpg");
317        assert_eq!(item_3.last_modified, "2020-11-26T20:21:35.858Z");
318        assert!(item_3.owner.is_none());
319        assert_eq!(item_3.size, 41259);
320        assert_eq!(item_3.storage_class, Some("STANDARD".to_string()));
321
322        assert_eq!(parsed.max_keys, Some(4500));
323        assert!(parsed.common_prefixes.is_empty());
324        assert!(parsed.next_continuation_token.is_none());
325        assert!(parsed.start_after.is_none());
326    }
327
328    #[test]
329    fn parse_no_contents() {
330        let input = r#"
331        <?xml version="1.0" encoding="UTF-8"?>
332        <ListBucketResult xmlns="http://doc.oss-cn-hangzhou.aliyuncs.com">
333            <Name>test</Name>
334            <Prefix></Prefix>
335            <KeyCount>0</KeyCount>
336            <MaxKeys>4500</MaxKeys>
337            <Delimiter></Delimiter>
338            <IsTruncated>false</IsTruncated>
339            <EncodingType>url</EncodingType>
340        </ListBucketResult>
341        "#;
342
343        let parsed = ListObjectsV2::parse_response(input).unwrap();
344        assert_eq!(parsed.contents.is_empty(), true);
345
346        assert_eq!(parsed.max_keys, Some(4500));
347        assert!(parsed.common_prefixes.is_empty());
348        assert!(parsed.next_continuation_token.is_none());
349        assert!(parsed.start_after.is_none());
350    }
351}