Skip to main content

aliyun_oss/operations/
bucket.rs

1//! Bucket-level operations (create, delete, info, stat).
2
3use std::sync::Arc;
4
5use serde::Serialize;
6
7use crate::client::{BucketOperations, OSSClientInner};
8use crate::error::{ErrorContext, OssError, OssErrorKind, Result};
9use crate::http::client::HttpRequest;
10use crate::types::acl::BucketAcl;
11use crate::types::bucket::BucketName;
12use crate::types::region::Region;
13use crate::types::storage::{DataRedundancyType, StorageClass};
14use crate::util::xml::to_xml;
15
16#[derive(Debug, Clone, Serialize)]
17#[serde(rename = "CreateBucketConfiguration")]
18struct CreateBucketConfiguration {
19    #[serde(rename = "StorageClass", skip_serializing_if = "Option::is_none")]
20    storage_class: Option<String>,
21    #[serde(rename = "DataRedundancyType", skip_serializing_if = "Option::is_none")]
22    data_redundancy_type: Option<String>,
23}
24
25pub struct PutBucketBuilder {
26    client: Arc<OSSClientInner>,
27    bucket: BucketName,
28    acl: Option<BucketAcl>,
29    storage_class: Option<StorageClass>,
30    data_redundancy: Option<DataRedundancyType>,
31}
32
33impl PutBucketBuilder {
34    pub(crate) fn new(client: Arc<OSSClientInner>, bucket: BucketName) -> Self {
35        Self {
36            client,
37            bucket,
38            acl: None,
39            storage_class: None,
40            data_redundancy: None,
41        }
42    }
43
44    pub fn acl(mut self, acl: BucketAcl) -> Self {
45        self.acl = Some(acl);
46        self
47    }
48
49    pub fn storage_class(mut self, sc: StorageClass) -> Self {
50        self.storage_class = Some(sc);
51        self
52    }
53
54    pub fn data_redundancy(mut self, dr: DataRedundancyType) -> Self {
55        self.data_redundancy = Some(dr);
56        self
57    }
58
59    pub async fn send(self) -> Result<PutBucketOutput> {
60        let region: Region = self.client.region.clone();
61        let endpoint = self.client.endpoint.clone();
62        let uri = format!("https://{}/{}/", endpoint, self.bucket.as_str());
63
64        let mut req = HttpRequest::builder().method(http::Method::PUT).uri(&uri);
65
66        if let Some(acl) = &self.acl {
67            req = req.header(
68                http::HeaderName::from_static("x-oss-acl"),
69                http::HeaderValue::from_str(acl.as_str()).map_err(|e| OssError {
70                    kind: OssErrorKind::ValidationError,
71                    context: Box::new(ErrorContext {
72                        operation: Some("set x-oss-acl header".into()),
73                        bucket: Some(self.bucket.to_string()),
74                        ..Default::default()
75                    }),
76                    source: Some(Box::new(e)),
77                })?,
78            );
79        }
80
81        let config = CreateBucketConfiguration {
82            storage_class: self.storage_class.map(|sc| sc.as_str().to_string()),
83            data_redundancy_type: self.data_redundancy.map(|dr| dr.as_str().to_string()),
84        };
85
86        if config.storage_class.is_some() || config.data_redundancy_type.is_some() {
87            let body_xml = to_xml(&config)?;
88            req = req.body(bytes::Bytes::from(body_xml));
89        }
90
91        let request = req.build();
92
93        let response = self.client.http.send(request).await.map_err(|e| OssError {
94            kind: OssErrorKind::TransportError,
95            context: Box::new(ErrorContext {
96                operation: Some("PutBucket".into()),
97                bucket: Some(self.bucket.to_string()),
98                endpoint: Some(endpoint),
99                ..Default::default()
100            }),
101            source: Some(Box::new(e)),
102        })?;
103
104        if response.is_success() {
105            Ok(PutBucketOutput {
106                request_id: response
107                    .headers
108                    .get("x-oss-request-id")
109                    .and_then(|v| v.to_str().ok())
110                    .unwrap_or("")
111                    .to_string(),
112                region,
113            })
114        } else {
115            Err(OssError {
116                kind: OssErrorKind::ServiceError(Box::new(crate::error::OssServiceError {
117                    status_code: response.status().as_u16(),
118                    code: String::new(),
119                    message: String::new(),
120                    request_id: String::new(),
121                    host_id: String::new(),
122                    resource: Some(self.bucket.to_string()),
123                    string_to_sign: None,
124                })),
125                context: Box::new(ErrorContext {
126                    operation: Some("PutBucket".into()),
127                    bucket: Some(self.bucket.to_string()),
128                    ..Default::default()
129                }),
130                source: None,
131            })
132        }
133    }
134}
135
136#[derive(Debug, Clone)]
137pub struct PutBucketOutput {
138    pub request_id: String,
139    pub region: Region,
140}
141
142impl BucketOperations {
143    pub fn create(&self) -> PutBucketBuilder {
144        PutBucketBuilder::new(self.client_inner().clone(), self.bucket_name().clone())
145    }
146
147    pub fn delete(&self) -> DeleteBucketBuilder {
148        DeleteBucketBuilder::new(self.client_inner().clone(), self.bucket_name().clone())
149    }
150
151    pub fn get_info(&self) -> GetBucketInfoBuilder {
152        GetBucketInfoBuilder::new(self.client_inner().clone(), self.bucket_name().clone())
153    }
154
155    pub fn get_stat(&self) -> GetBucketStatBuilder {
156        GetBucketStatBuilder::new(self.client_inner().clone(), self.bucket_name().clone())
157    }
158}
159
160pub struct DeleteBucketBuilder {
161    client: Arc<OSSClientInner>,
162    bucket: BucketName,
163}
164
165impl DeleteBucketBuilder {
166    pub(crate) fn new(client: Arc<OSSClientInner>, bucket: BucketName) -> Self {
167        Self { client, bucket }
168    }
169
170    pub async fn send(self) -> Result<DeleteBucketOutput> {
171        let endpoint = self.client.endpoint.clone();
172        let uri = format!("https://{}.{}", self.bucket.as_str(), endpoint);
173
174        let request = HttpRequest::builder()
175            .method(http::Method::DELETE)
176            .uri(&uri)
177            .build();
178
179        let response = self
180            .client
181            .send_signed(request, Some(&self.bucket), Vec::new())
182            .await
183            .map_err(|e| OssError {
184                kind: OssErrorKind::TransportError,
185                context: Box::new(ErrorContext {
186                    operation: Some("DeleteBucket".into()),
187                    bucket: Some(self.bucket.to_string()),
188                    endpoint: Some(endpoint),
189                    ..Default::default()
190                }),
191                source: Some(Box::new(e)),
192            })?;
193
194        if response.status().is_success() {
195            Ok(DeleteBucketOutput {
196                request_id: response
197                    .headers
198                    .get("x-oss-request-id")
199                    .and_then(|v| v.to_str().ok())
200                    .unwrap_or("")
201                    .to_string(),
202            })
203        } else {
204            Err(OssError {
205                kind: OssErrorKind::ServiceError(Box::new(crate::error::OssServiceError {
206                    status_code: response.status().as_u16(),
207                    code: String::new(),
208                    message: String::new(),
209                    request_id: String::new(),
210                    host_id: String::new(),
211                    resource: Some(self.bucket.to_string()),
212                    string_to_sign: None,
213                })),
214                context: Box::new(ErrorContext {
215                    operation: Some("DeleteBucket".into()),
216                    bucket: Some(self.bucket.to_string()),
217                    ..Default::default()
218                }),
219                source: None,
220            })
221        }
222    }
223}
224
225#[derive(Debug, Clone)]
226pub struct DeleteBucketOutput {
227    pub request_id: String,
228}
229
230pub struct GetBucketInfoBuilder {
231    client: Arc<OSSClientInner>,
232    bucket: BucketName,
233}
234
235impl GetBucketInfoBuilder {
236    pub(crate) fn new(client: Arc<OSSClientInner>, bucket: BucketName) -> Self {
237        Self { client, bucket }
238    }
239
240    pub async fn send(self) -> Result<crate::types::response::GetBucketInfoOutput> {
241        let endpoint = self.client.endpoint.clone();
242        let uri = format!("https://{}.{}?bucketInfo", self.bucket.as_str(), endpoint);
243
244        let request = HttpRequest::builder()
245            .method(http::Method::GET)
246            .uri(&uri)
247            .build();
248
249        let query_params = vec![("bucketInfo".into(), "".into())];
250
251        let response = self
252            .client
253            .send_signed(request, Some(&self.bucket), query_params)
254            .await
255            .map_err(|e| OssError {
256                kind: OssErrorKind::TransportError,
257                context: Box::new(ErrorContext {
258                    operation: Some("GetBucketInfo".into()),
259                    bucket: Some(self.bucket.to_string()),
260                    endpoint: Some(endpoint),
261                    ..Default::default()
262                }),
263                source: Some(Box::new(e)),
264            })?;
265
266        if response.is_success() {
267            let body_str = response.body_as_str().unwrap_or("");
268            Ok(crate::util::xml::from_xml(body_str)?)
269        } else {
270            Err(OssError {
271                kind: OssErrorKind::ServiceError(Box::new(crate::error::OssServiceError {
272                    status_code: response.status().as_u16(),
273                    code: String::new(),
274                    message: String::new(),
275                    request_id: String::new(),
276                    host_id: String::new(),
277                    resource: Some(self.bucket.to_string()),
278                    string_to_sign: None,
279                })),
280                context: Box::new(ErrorContext {
281                    operation: Some("GetBucketInfo".into()),
282                    bucket: Some(self.bucket.to_string()),
283                    ..Default::default()
284                }),
285                source: None,
286            })
287        }
288    }
289}
290
291pub struct GetBucketStatBuilder {
292    client: Arc<OSSClientInner>,
293    bucket: BucketName,
294}
295
296impl GetBucketStatBuilder {
297    pub(crate) fn new(client: Arc<OSSClientInner>, bucket: BucketName) -> Self {
298        Self { client, bucket }
299    }
300
301    pub async fn send(self) -> Result<crate::types::response::GetBucketStatOutput> {
302        let endpoint = self.client.endpoint.clone();
303        let uri = format!("https://{}.{}?stat", self.bucket.as_str(), endpoint);
304        let query_params: Vec<(String, String)> = vec![("stat".into(), String::new())];
305
306        let request = HttpRequest::builder()
307            .method(http::Method::GET)
308            .uri(&uri)
309            .build();
310
311        let response = self
312            .client
313            .send_signed(request, Some(&self.bucket), query_params)
314            .await
315            .map_err(|e| OssError {
316                kind: OssErrorKind::TransportError,
317                context: Box::new(ErrorContext {
318                    operation: Some("GetBucketStat".into()),
319                    bucket: Some(self.bucket.to_string()),
320                    endpoint: Some(endpoint),
321                    ..Default::default()
322                }),
323                source: Some(Box::new(e)),
324            })?;
325
326        if response.is_success() {
327            let body_str = response.body_as_str().unwrap_or("");
328            Ok(crate::util::xml::from_xml(body_str)?)
329        } else {
330            Err(OssError {
331                kind: OssErrorKind::ServiceError(Box::new(crate::error::OssServiceError {
332                    status_code: response.status().as_u16(),
333                    code: String::new(),
334                    message: String::new(),
335                    request_id: String::new(),
336                    host_id: String::new(),
337                    resource: Some(self.bucket.to_string()),
338                    string_to_sign: None,
339                })),
340                context: Box::new(ErrorContext {
341                    operation: Some("GetBucketStat".into()),
342                    bucket: Some(self.bucket.to_string()),
343                    ..Default::default()
344                }),
345                source: None,
346            })
347        }
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use std::str::FromStr;
354    use std::sync::Mutex;
355
356    use crate::client::OSSClientInner;
357    use crate::config::credentials::Credentials;
358    use crate::http::client::{HttpClient, HttpRequest, HttpResponse};
359
360    use super::*;
361    use crate::util::xml::to_xml;
362
363    struct RecordingHttpClient {
364        requests: Arc<Mutex<Vec<HttpRequest>>>,
365        response_status: http::StatusCode,
366        response_body: bytes::Bytes,
367    }
368
369    #[async_trait::async_trait]
370    impl HttpClient for RecordingHttpClient {
371        async fn send(&self, request: HttpRequest) -> crate::error::Result<HttpResponse> {
372            self.requests.lock().unwrap().push(request);
373            let mut headers = http::HeaderMap::new();
374            headers.insert(
375                "x-oss-request-id",
376                http::HeaderValue::from_static("rid-bucket"),
377            );
378            Ok(HttpResponse {
379                status: self.response_status,
380                headers,
381                body: self.response_body.clone(),
382            })
383        }
384    }
385
386    fn create_test_inner(
387        status: http::StatusCode,
388        body: bytes::Bytes,
389    ) -> (Arc<OSSClientInner>, Arc<Mutex<Vec<HttpRequest>>>) {
390        let requests = Arc::new(Mutex::new(Vec::new()));
391        let http = Arc::new(RecordingHttpClient {
392            requests: requests.clone(),
393            response_status: status,
394            response_body: body,
395        });
396        let credentials = Arc::new(crate::config::credentials::StaticCredentialsProvider::new(
397            Credentials::builder()
398                .access_key_id("test-ak")
399                .access_key_secret("test-sk")
400                .build()
401                .unwrap(),
402        ));
403        let inner = Arc::new(OSSClientInner {
404            http,
405            credentials,
406            signer: Arc::from(crate::signer::create_signer(crate::signer::SignVersion::V4)),
407            region: crate::types::region::Region::CnHangzhou,
408            endpoint: "oss-cn-hangzhou.aliyuncs.com".into(),
409        });
410        (inner, requests)
411    }
412
413    #[test]
414    fn put_bucket_builder_generates_correct_xml_body() {
415        let config = CreateBucketConfiguration {
416            storage_class: Some("Standard".into()),
417            data_redundancy_type: Some("LRS".into()),
418        };
419        let xml = to_xml(&config).unwrap();
420        assert!(xml.contains("<StorageClass>Standard</StorageClass>"));
421        assert!(xml.contains("<DataRedundancyType>LRS</DataRedundancyType>"));
422    }
423
424    #[test]
425    fn put_bucket_builder_xml_omits_none_fields() {
426        let config = CreateBucketConfiguration {
427            storage_class: Some("IA".into()),
428            data_redundancy_type: None,
429        };
430        let xml = to_xml(&config).unwrap();
431        assert!(xml.contains("<StorageClass>IA</StorageClass>"));
432        assert!(!xml.contains("DataRedundancyType"));
433    }
434
435    #[test]
436    fn put_bucket_builder_empty_config() {
437        let config = CreateBucketConfiguration {
438            storage_class: None,
439            data_redundancy_type: None,
440        };
441        let xml = to_xml(&config).unwrap();
442        assert!(xml.contains("CreateBucketConfiguration"));
443    }
444
445    #[tokio::test]
446    async fn delete_bucket_sends_delete_request() {
447        let (inner, requests) =
448            create_test_inner(http::StatusCode::NO_CONTENT, bytes::Bytes::new());
449        let builder = DeleteBucketBuilder::new(inner, BucketName::new("test-bucket").unwrap());
450
451        builder.send().await.unwrap();
452
453        let captured = requests.lock().unwrap();
454        assert_eq!(captured[0].method, http::Method::DELETE);
455    }
456
457    #[tokio::test]
458    async fn get_bucket_info_parses_response() {
459        let info_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
460<BucketInfo>
461  <Bucket>
462    <Name>test-bucket</Name>
463    <CreationDate>2024-01-01T00:00:00.000Z</CreationDate>
464    <Location>oss-cn-hangzhou</Location>
465    <StorageClass>Standard</StorageClass>
466    <ExtranetEndpoint>test-bucket.oss-cn-hangzhou.aliyuncs.com</ExtranetEndpoint>
467    <IntranetEndpoint>test-bucket.oss-cn-hangzhou-internal.aliyuncs.com</IntranetEndpoint>
468    <Owner>
469      <ID>owner-id</ID>
470    </Owner>
471  </Bucket>
472</BucketInfo>"#;
473
474        let (inner, requests) =
475            create_test_inner(http::StatusCode::OK, bytes::Bytes::from(info_xml));
476        let builder = GetBucketInfoBuilder::new(inner, BucketName::new("test-bucket").unwrap());
477
478        let output = builder.send().await.unwrap();
479        assert_eq!(output.bucket.name, "test-bucket");
480
481        let captured = requests.lock().unwrap();
482        assert!(captured[0].uri.contains("bucketInfo"));
483    }
484
485    #[tokio::test]
486    #[ignore = "requires valid OSS credentials"]
487    async fn e2e_get_bucket_info() {
488        let ak = std::env::var("OSS_ACCESS_KEY_ID").expect("OSS_ACCESS_KEY_ID not set");
489        let sk = std::env::var("OSS_ACCESS_KEY_SECRET").expect("OSS_ACCESS_KEY_SECRET not set");
490        let region_str = std::env::var("OSS_REGION").unwrap_or_else(|_| "cn-wulanchabu".into());
491        let bucket_str = std::env::var("OSS_BUCKET").expect("OSS_BUCKET not set");
492
493        let region = crate::types::region::Region::from_str(&region_str).unwrap_or_else(|_| {
494            crate::types::region::Region::Custom {
495                endpoint: format!("oss-{}.aliyuncs.com", region_str),
496                region_id: region_str.clone(),
497            }
498        });
499
500        let client = crate::client::OSSClient::builder()
501            .region(region)
502            .credentials(ak, sk)
503            .build()
504            .unwrap();
505
506        let output = client
507            .bucket(&bucket_str)
508            .unwrap()
509            .get_info()
510            .send()
511            .await
512            .unwrap();
513
514        assert_eq!(output.bucket.name, bucket_str);
515        eprintln!(
516            "GetBucketInfo: name={}, location={}, storage={}",
517            output.bucket.name, output.bucket.location, output.bucket.storage_class
518        );
519    }
520}