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(¶ms);
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}