Skip to main content

aliyun_oss/operations/
bucket_lifecycle.rs

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