Skip to main content

s3/
buckets.rs

1use std::collections::BTreeMap;
2
3use quick_xml::de::from_str;
4use serde::Deserialize;
5
6use crate::client::{
7    Client, HttpClient, HttpMethod, bytes_to_string, canonical_bucket_uri, canonical_query_string, collect_body,
8    consume_empty, xml_escape,
9};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ListObjectsOutput {
13    pub name: String,
14    pub prefix: Option<String>,
15    pub key_count: Option<u64>,
16    pub max_keys: Option<u64>,
17    pub is_truncated: bool,
18    pub next_continuation_token: Option<String>,
19    pub contents: Vec<ListObject>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ListObject {
24    pub key: String,
25    pub last_modified: Option<String>,
26    pub e_tag: Option<String>,
27    pub size: Option<u64>,
28    pub storage_class: Option<String>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Bucket {
33    pub name: String,
34    pub creation_date: Option<String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ListBucketsOutput {
39    pub buckets: Vec<Bucket>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct GetBucketLocationOutput {
44    pub location_constraint: Option<String>,
45}
46
47impl<H: HttpClient> Client<H> {
48    pub async fn list_buckets(&self) -> Result<ListBucketsOutput, crate::client::Error> {
49        let response = self.execute(HttpMethod::Get, "/", "", b"", "").await?;
50        let body = bytes_to_string(collect_body(response.body).await?)?;
51        let xml: ListAllMyBucketsResultXml = from_str(&body)?;
52
53        Ok(ListBucketsOutput {
54            buckets: xml
55                .buckets
56                .bucket
57                .into_iter()
58                .map(|entry| Bucket {
59                    name: entry.name,
60                    creation_date: entry.creation_date,
61                })
62                .collect(),
63        })
64    }
65
66    pub async fn create_bucket(
67        &self,
68        bucket: &str,
69        location_constraint: Option<&str>,
70    ) -> Result<(), crate::client::Error> {
71        let canonical_uri = canonical_bucket_uri(bucket);
72        let body = match location_constraint {
73            Some(region) => format!(
74                "<CreateBucketConfiguration><LocationConstraint>{}</LocationConstraint></CreateBucketConfiguration>",
75                xml_escape(region)
76            )
77            .into_bytes(),
78            None => Vec::new(),
79        };
80        let response = self
81            .execute_with_headers(HttpMethod::Put, &canonical_uri, "", &body, &[], bucket)
82            .await?;
83        consume_empty(response)
84    }
85
86    pub async fn head_bucket(&self, bucket: &str) -> Result<(), crate::client::Error> {
87        let canonical_uri = canonical_bucket_uri(bucket);
88        let response = self.execute(HttpMethod::Head, &canonical_uri, "", b"", bucket).await?;
89        consume_empty(response)
90    }
91
92    pub async fn delete_bucket(&self, bucket: &str) -> Result<(), crate::client::Error> {
93        let canonical_uri = canonical_bucket_uri(bucket);
94        let response = self
95            .execute(HttpMethod::Delete, &canonical_uri, "", b"", bucket)
96            .await?;
97        consume_empty(response)
98    }
99
100    pub async fn list_objects(
101        &self,
102        bucket: &str,
103        prefix: Option<&str>,
104        continuation_token: Option<&str>,
105        max_keys: Option<u32>,
106    ) -> Result<ListObjectsOutput, crate::client::Error> {
107        let canonical_uri = canonical_bucket_uri(bucket);
108
109        let mut params = BTreeMap::new();
110        params.insert("list-type".to_string(), "2".to_string());
111        if let Some(prefix) = prefix {
112            params.insert("prefix".to_string(), prefix.to_string());
113        }
114        if let Some(token) = continuation_token {
115            params.insert("continuation-token".to_string(), token.to_string());
116        }
117        if let Some(max_keys) = max_keys {
118            params.insert("max-keys".to_string(), max_keys.to_string());
119        }
120
121        let canonical_query = canonical_query_string(&params);
122        let response = self
123            .execute(HttpMethod::Get, &canonical_uri, &canonical_query, b"", bucket)
124            .await?;
125        let body = bytes_to_string(collect_body(response.body).await?)?;
126        let xml: ListBucketResultXml = from_str(&body)?;
127
128        Ok(ListObjectsOutput {
129            name: xml.name,
130            prefix: xml.prefix,
131            key_count: xml.key_count,
132            max_keys: xml.max_keys,
133            is_truncated: xml.is_truncated,
134            next_continuation_token: xml.next_continuation_token,
135            contents: xml
136                .contents
137                .into_iter()
138                .map(|entry| ListObject {
139                    key: entry.key,
140                    last_modified: entry.last_modified,
141                    e_tag: entry.e_tag,
142                    size: entry.size,
143                    storage_class: entry.storage_class,
144                })
145                .collect(),
146        })
147    }
148
149    pub async fn get_bucket_location(&self, bucket: &str) -> Result<GetBucketLocationOutput, crate::client::Error> {
150        let canonical_uri = canonical_bucket_uri(bucket);
151        let response = self
152            .execute(HttpMethod::Get, &canonical_uri, "location=", b"", bucket)
153            .await?;
154        let body = bytes_to_string(collect_body(response.body).await?)?;
155        let xml: LocationConstraintXml = from_str(&body)?;
156        Ok(GetBucketLocationOutput {
157            location_constraint: xml.location_constraint.and_then(|s| {
158                let trimmed = s.trim();
159                if trimmed.is_empty() {
160                    None
161                } else {
162                    Some(trimmed.to_string())
163                }
164            }),
165        })
166    }
167}
168
169#[derive(Debug, Deserialize)]
170#[serde(rename = "ListAllMyBucketsResult")]
171struct ListAllMyBucketsResultXml {
172    #[serde(rename = "Buckets")]
173    buckets: BucketsXml,
174}
175
176#[derive(Debug, Deserialize)]
177struct BucketsXml {
178    #[serde(rename = "Bucket", default)]
179    bucket: Vec<BucketXml>,
180}
181
182#[derive(Debug, Deserialize)]
183struct BucketXml {
184    #[serde(rename = "Name")]
185    name: String,
186    #[serde(rename = "CreationDate")]
187    creation_date: Option<String>,
188}
189
190#[derive(Debug, Deserialize)]
191#[serde(rename = "ListBucketResult")]
192struct ListBucketResultXml {
193    #[serde(rename = "Name")]
194    name: String,
195    #[serde(rename = "Prefix")]
196    prefix: Option<String>,
197    #[serde(rename = "KeyCount")]
198    key_count: Option<u64>,
199    #[serde(rename = "MaxKeys")]
200    max_keys: Option<u64>,
201    #[serde(rename = "IsTruncated")]
202    is_truncated: bool,
203    #[serde(rename = "NextContinuationToken")]
204    next_continuation_token: Option<String>,
205    #[serde(rename = "Contents", default)]
206    contents: Vec<ObjectXml>,
207}
208
209#[derive(Debug, Deserialize)]
210struct ObjectXml {
211    #[serde(rename = "Key")]
212    key: String,
213    #[serde(rename = "LastModified")]
214    last_modified: Option<String>,
215    #[serde(rename = "ETag")]
216    e_tag: Option<String>,
217    #[serde(rename = "Size")]
218    size: Option<u64>,
219    #[serde(rename = "StorageClass")]
220    storage_class: Option<String>,
221}
222
223#[derive(Debug, Deserialize)]
224#[serde(rename = "LocationConstraint")]
225struct LocationConstraintXml {
226    #[serde(rename = "$text")]
227    location_constraint: Option<String>,
228}
229
230#[cfg(test)]
231mod tests {
232    use quick_xml::de::from_str;
233
234    use super::*;
235
236    #[test]
237    fn parses_list_objects_v2_xml() {
238        let xml = r#"
239<ListBucketResult>
240  <Name>my-bucket</Name>
241  <Prefix>photos/</Prefix>
242  <KeyCount>1</KeyCount>
243  <MaxKeys>1000</MaxKeys>
244  <IsTruncated>false</IsTruncated>
245  <Contents>
246    <Key>photos/a.jpg</Key>
247    <LastModified>2026-01-01T00:00:00.000Z</LastModified>
248    <ETag>\"abc\"</ETag>
249    <Size>42</Size>
250    <StorageClass>STANDARD</StorageClass>
251  </Contents>
252</ListBucketResult>
253"#;
254
255        let parsed: ListBucketResultXml = from_str(xml).unwrap();
256        assert_eq!(parsed.name, "my-bucket");
257        assert_eq!(parsed.prefix.as_deref(), Some("photos/"));
258        assert_eq!(parsed.key_count, Some(1));
259        assert_eq!(parsed.max_keys, Some(1000));
260        assert!(!parsed.is_truncated);
261        assert_eq!(parsed.contents.len(), 1);
262        assert_eq!(parsed.contents[0].key, "photos/a.jpg");
263    }
264
265    #[test]
266    fn parses_list_buckets_xml() {
267        let xml = r#"
268<ListAllMyBucketsResult>
269  <Buckets>
270    <Bucket>
271      <Name>bucket-a</Name>
272      <CreationDate>2026-01-01T00:00:00.000Z</CreationDate>
273    </Bucket>
274    <Bucket>
275      <Name>bucket-b</Name>
276    </Bucket>
277  </Buckets>
278</ListAllMyBucketsResult>
279"#;
280
281        let parsed: ListAllMyBucketsResultXml = from_str(xml).unwrap();
282        assert_eq!(parsed.buckets.bucket.len(), 2);
283        assert_eq!(parsed.buckets.bucket[0].name, "bucket-a");
284        assert_eq!(
285            parsed.buckets.bucket[0].creation_date.as_deref(),
286            Some("2026-01-01T00:00:00.000Z")
287        );
288        assert_eq!(parsed.buckets.bucket[1].name, "bucket-b");
289    }
290
291    #[test]
292    fn parses_bucket_location_xml() {
293        let xml = "<LocationConstraint>auto</LocationConstraint>";
294        let parsed: LocationConstraintXml = from_str(xml).unwrap();
295        assert_eq!(parsed.location_constraint.as_deref(), Some("auto"));
296    }
297}