1use 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(®ion_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}