diff --git a/common/src/example.rs b/common/src/example.rs index 569f772..a36cbfc 100644 --- a/common/src/example.rs +++ b/common/src/example.rs @@ -15,11 +15,11 @@ use chrono::{DateTime, Utc}; use minio::s3::builders::PostPolicy; +use minio::s3::lifecycle_config::{LifecycleConfig, LifecycleRule}; use minio::s3::types::{ AndOperator, CsvInputSerialization, CsvOutputSerialization, Destination, FileHeaderInfo, - Filter, LifecycleConfig, LifecycleRule, NotificationConfig, ObjectLockConfig, PrefixFilterRule, - QueueConfig, QuoteFields, ReplicationConfig, ReplicationRule, RetentionMode, SelectRequest, - SuffixFilterRule, + Filter, NotificationConfig, ObjectLockConfig, PrefixFilterRule, QueueConfig, QuoteFields, + ReplicationConfig, ReplicationRule, RetentionMode, SelectRequest, SuffixFilterRule, }; use minio::s3::utils::utc_now; use std::collections::HashMap; @@ -27,29 +27,19 @@ use std::collections::HashMap; pub fn create_bucket_lifecycle_config_examples() -> LifecycleConfig { LifecycleConfig { rules: vec![LifecycleRule { - abort_incomplete_multipart_upload_days_after_initiation: None, - expiration_date: None, - expiration_days: Some(365), - expiration_expired_object_delete_marker: None, - filter: Filter { - and_operator: None, - prefix: Some(String::from("logs/")), - tag: None, - }, id: String::from("rule1"), - noncurrent_version_expiration_noncurrent_days: None, - noncurrent_version_transition_noncurrent_days: None, - noncurrent_version_transition_storage_class: None, + expiration_days: Some(365), + filter: Filter { + prefix: Some(String::from("logs/")), + ..Default::default() + }, status: true, - transition_date: None, - transition_days: None, - transition_storage_class: None, + ..Default::default() }], } } pub fn create_bucket_notification_config_example() -> NotificationConfig { NotificationConfig { - cloud_func_config_list: None, queue_config_list: Some(vec![QueueConfig { events: vec![ String::from("s3:ObjectCreated:Put"), @@ -64,7 +54,7 @@ pub fn create_bucket_notification_config_example() -> NotificationConfig { }), queue: String::from("arn:minio:sqs::miniojavatest:webhook"), }]), - topic_config_list: None, + ..Default::default() } } pub fn create_bucket_policy_config_example(bucket_name: &str) -> String { @@ -148,31 +138,22 @@ pub fn create_bucket_replication_config_example(dst_bucket: &str) -> Replication ReplicationConfig { role: Some("example1".to_string()), rules: vec![ReplicationRule { + id: Some(String::from("rule1")), destination: Destination { bucket_arn: String::from(&format!("arn:aws:s3:::{}", dst_bucket)), - access_control_translation: None, - account: None, - encryption_config: None, - metrics: None, - replication_time: None, - storage_class: None, + ..Default::default() }, - delete_marker_replication_status: None, - existing_object_replication_status: None, filter: Some(Filter { and_operator: Some(AndOperator { prefix: Some(String::from("TaxDocs")), tags: Some(tags), }), - prefix: None, - tag: None, + ..Default::default() }), - id: Some(String::from("rule1")), - prefix: None, priority: Some(1), - source_selection_criteria: None, delete_replication_status: Some(false), status: true, + ..Default::default() }], } } diff --git a/examples/bucket_lifecycle.rs b/examples/bucket_lifecycle.rs index 5e2ea62..e264beb 100644 --- a/examples/bucket_lifecycle.rs +++ b/examples/bucket_lifecycle.rs @@ -17,10 +17,11 @@ mod common; use crate::common::{create_bucket_if_not_exists, create_client_on_play}; use minio::s3::Client; +use minio::s3::lifecycle_config::{LifecycleConfig, LifecycleRule}; use minio::s3::response::{ DeleteBucketLifecycleResponse, GetBucketLifecycleResponse, PutBucketLifecycleResponse, }; -use minio::s3::types::{Filter, LifecycleConfig, LifecycleRule, S3Api}; +use minio::s3::types::{Filter, S3Api}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -38,23 +39,14 @@ async fn main() -> Result<(), Box> { } let rules: Vec = vec![LifecycleRule { - abort_incomplete_multipart_upload_days_after_initiation: None, - expiration_date: None, - expiration_days: Some(365), - expiration_expired_object_delete_marker: None, - filter: Filter { - and_operator: None, - prefix: Some(String::from("logs/")), - tag: None, - }, id: String::from("rule1"), - noncurrent_version_expiration_noncurrent_days: None, - noncurrent_version_transition_noncurrent_days: None, - noncurrent_version_transition_storage_class: None, + expiration_days: Some(365), + filter: Filter { + prefix: Some(String::from("logs/")), + ..Default::default() + }, status: true, - transition_date: None, - transition_days: None, - transition_storage_class: None, + ..Default::default() }]; let resp: PutBucketLifecycleResponse = client diff --git a/src/s3/builders/get_bucket_lifecycle.rs b/src/s3/builders/get_bucket_lifecycle.rs index ab2554c..c48b646 100644 --- a/src/s3/builders/get_bucket_lifecycle.rs +++ b/src/s3/builders/get_bucket_lifecycle.rs @@ -13,8 +13,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::s3::builders::BucketCommon; +use crate::s3::Client; use crate::s3::error::Error; +use crate::s3::multimap::{Multimap, MultimapExt}; use crate::s3::response::GetBucketLifecycleResponse; use crate::s3::types::{S3Api, S3Request, ToS3Request}; use crate::s3::utils::{check_bucket_name, insert}; @@ -23,10 +24,42 @@ use http::Method; /// Argument builder for the [`GetBucketLifecycle`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketLifecycle.html) S3 API operation. /// /// This struct constructs the parameters required for the [`Client::get_bucket_lifecycle`](crate::s3::client::Client::get_bucket_lifecycle) method. -pub type GetBucketLifecycle = BucketCommon; - #[derive(Clone, Debug, Default)] -pub struct GetBucketLifecyclePhantomData; +pub struct GetBucketLifecycle { + client: Client, + + extra_headers: Option, + extra_query_params: Option, + region: Option, + bucket: String, + + with_updated_at: bool, +} + +impl GetBucketLifecycle { + pub fn new(client: Client, bucket: String) -> Self { + Self { + client, + bucket, + ..Default::default() + } + } + + pub fn extra_headers(mut self, extra_headers: Option) -> Self { + self.extra_headers = extra_headers; + self + } + + pub fn extra_query_params(mut self, extra_query_params: Option) -> Self { + self.extra_query_params = extra_query_params; + self + } + + pub fn with_updated_at(mut self, with_updated_at: bool) -> Self { + self.with_updated_at = with_updated_at; + self + } +} impl S3Api for GetBucketLifecycle { type S3Response = GetBucketLifecycleResponse; @@ -36,10 +69,15 @@ impl ToS3Request for GetBucketLifecycle { fn to_s3request(self) -> Result { check_bucket_name(&self.bucket, true)?; + let mut query_params: Multimap = insert(self.extra_query_params, "lifecycle"); + if self.with_updated_at { + query_params.add("withUpdatedAt", "true"); + } + Ok(S3Request::new(self.client, Method::GET) .region(self.region) .bucket(Some(self.bucket)) - .query_params(insert(self.extra_query_params, "lifecycle")) + .query_params(query_params) .headers(self.extra_headers.unwrap_or_default())) } } diff --git a/src/s3/builders/put_bucket_lifecycle.rs b/src/s3/builders/put_bucket_lifecycle.rs index 092c508..8ba124c 100644 --- a/src/s3/builders/put_bucket_lifecycle.rs +++ b/src/s3/builders/put_bucket_lifecycle.rs @@ -15,15 +15,18 @@ use crate::s3::Client; use crate::s3::error::Error; +use crate::s3::lifecycle_config::LifecycleConfig; use crate::s3::multimap::{Multimap, MultimapExt}; use crate::s3::response::PutBucketLifecycleResponse; use crate::s3::segmented_bytes::SegmentedBytes; -use crate::s3::types::{LifecycleConfig, S3Api, S3Request, ToS3Request}; +use crate::s3::types::{S3Api, S3Request, ToS3Request}; use crate::s3::utils::{check_bucket_name, insert, md5sum_hash}; use bytes::Bytes; use http::Method; -/// Argument builder for [put_bucket_lifecycle()](crate::s3::client::Client::put_bucket_lifecycle) API +/// Argument builder for the [`PutBucketLifecycle`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketLifecycle.html) S3 API operation. +/// +/// This struct constructs the parameters required for the [`Client::put_bucket_lifecycle`](crate::s3::client::Client::put_bucket_lifecycle) method. #[derive(Clone, Debug, Default)] pub struct PutBucketLifecycle { client: Client, diff --git a/src/s3/client/put_bucket_lifecycle.rs b/src/s3/client/put_bucket_lifecycle.rs index aa71c41..32fd457 100644 --- a/src/s3/client/put_bucket_lifecycle.rs +++ b/src/s3/client/put_bucket_lifecycle.rs @@ -31,26 +31,19 @@ impl Client { /// use minio::s3::Client; /// use minio::s3::builders::VersioningStatus; /// use minio::s3::response::PutBucketLifecycleResponse; - /// use minio::s3::types::{Filter, LifecycleConfig, LifecycleRule, S3Api}; + /// use minio::s3::types::{Filter, S3Api}; + /// use minio::s3::lifecycle_config::{LifecycleRule, LifecycleConfig}; /// /// #[tokio::main] /// async fn main() { - /// let client: Client = Default::default(); // configure your client here + /// let client: Client = Default::default(); // configure your client here /// /// let rules: Vec = vec![LifecycleRule { - /// abort_incomplete_multipart_upload_days_after_initiation: None, - /// expiration_date: None, - /// expiration_days: Some(365), - /// expiration_expired_object_delete_marker: None, - /// filter: Filter {and_operator: None, prefix: Some(String::from("logs/")), tag: None}, /// id: String::from("rule1"), - /// noncurrent_version_expiration_noncurrent_days: None, - /// noncurrent_version_transition_noncurrent_days: None, - /// noncurrent_version_transition_storage_class: None, + /// filter: Filter {and_operator: None, prefix: Some(String::from("logs/")), tag: None}, + /// expiration_days: Some(365), /// status: true, - /// transition_date: None, - /// transition_days: None, - /// transition_storage_class: None, + /// ..Default::default() /// }]; /// /// let resp: PutBucketLifecycleResponse = client diff --git a/src/s3/error.rs b/src/s3/error.rs index 51c095b..7d43120 100644 --- a/src/s3/error.rs +++ b/src/s3/error.rs @@ -37,6 +37,7 @@ pub enum ErrorCode { ServerSideEncryptionConfigurationNotFoundError, NoSuchTagSet, NoSuchObjectLockConfiguration, + NoSuchLifecycleConfiguration, NoSuchKey, ResourceNotFound, MethodNotAllowed, @@ -66,6 +67,7 @@ impl ErrorCode { } "nosuchtagset" => ErrorCode::NoSuchTagSet, "nosuchobjectlockconfiguration" => ErrorCode::NoSuchObjectLockConfiguration, + "nosuchlifecycleconfiguration" => ErrorCode::NoSuchLifecycleConfiguration, "nosuchkey" => ErrorCode::NoSuchKey, "resourcenotfound" => ErrorCode::ResourceNotFound, "methodnotallowed" => ErrorCode::MethodNotAllowed, diff --git a/src/s3/lifecycle_config.rs b/src/s3/lifecycle_config.rs new file mode 100644 index 0000000..8f358c7 --- /dev/null +++ b/src/s3/lifecycle_config.rs @@ -0,0 +1,484 @@ +use crate::s3::error::Error; +use crate::s3::types::Filter; +use crate::s3::utils::to_iso8601utc; +use xmltree::Element; + +#[derive(PartialEq, Clone, Debug, Default)] +/// Lifecycle configuration +pub struct LifecycleConfig { + pub rules: Vec, +} + +impl LifecycleConfig { + pub fn from_xml(root: &Element) -> Result { + let mut config = LifecycleConfig { rules: Vec::new() }; + + // Process all Rule elements in the XML + for rule_elem in root.children.iter().filter_map(|c| c.as_element()) { + if rule_elem.name == "Rule" { + config.rules.push(LifecycleRule::from_xml(rule_elem)?); + } + } + + Ok(config) + } + + pub fn validate(&self) -> Result<(), Error> { + // Skip validation if empty + if self.rules.is_empty() { + return Ok(()); + } + + for rule in &self.rules { + rule.validate()?; + } + + Ok(()) + } + + pub fn empty(&self) -> bool { + self.rules.is_empty() + } + + pub fn to_xml(&self) -> String { + let mut data = String::from(""); + + for rule in &self.rules { + data.push_str(""); + + // ID should come earlier in XML based on Go ordering + if !rule.id.is_empty() { + data.push_str(""); + data.push_str(&rule.id); + data.push_str(""); + } + + // Status comes next + data.push_str(""); + if rule.status { + data.push_str("Enabled"); + } else { + data.push_str("Disabled"); + } + data.push_str(""); + + // Filter + data.push_str(&rule.filter.to_xml()); + + // AbortIncompleteMultipartUpload + if let Some(days) = rule.abort_incomplete_multipart_upload_days_after_initiation { + data.push_str(""); + data.push_str(&days.to_string()); + data.push_str(""); + } + + // Expiration + let has_expiration = rule.expiration_date.is_some() + || rule.expiration_days.is_some() + || rule.expiration_expired_object_delete_marker.is_some() + || rule.expiration_expired_object_all_versions.is_some(); + + if has_expiration { + data.push_str(""); + if let Some(date) = rule.expiration_date { + data.push_str(""); + data.push_str(&to_iso8601utc(date)); + data.push_str(""); + } + if let Some(days) = rule.expiration_days { + data.push_str(""); + data.push_str(&days.to_string()); + data.push_str(""); + } + if let Some(delete_marker) = rule.expiration_expired_object_delete_marker { + if delete_marker { + data.push_str( + "true", + ); + } + } + if let Some(delete_all) = rule.expiration_expired_object_all_versions { + if delete_all { + data.push_str("true"); + } + } + data.push_str(""); + } + + // DelMarkerExpiration + if let Some(days) = rule.del_marker_expiration_days { + data.push_str(""); + data.push_str(&days.to_string()); + data.push_str(""); + } + + // AllVersionsExpiration + if let Some(days) = rule.all_versions_expiration_days { + data.push_str(""); + data.push_str(&days.to_string()); + data.push_str(""); + + if let Some(delete_marker) = rule.all_versions_expiration_delete_marker { + if delete_marker { + data.push_str("true"); + } + } + + data.push_str(""); + } + + // NoncurrentVersionExpiration + if let Some(days) = rule.noncurrent_version_expiration_noncurrent_days { + data.push_str(""); + data.push_str(&days.to_string()); + data.push_str(""); + + if let Some(versions) = rule.noncurrent_version_expiration_newer_versions { + data.push_str(""); + data.push_str(&versions.to_string()); + data.push_str(""); + } + + data.push_str(""); + } + + // NoncurrentVersionTransition + let has_noncurrent_transition = + rule.noncurrent_version_transition_noncurrent_days.is_some() + || rule.noncurrent_version_transition_storage_class.is_some() + || rule.noncurrent_version_transition_newer_versions.is_some(); + + if has_noncurrent_transition { + data.push_str(""); + + if let Some(days) = rule.noncurrent_version_transition_noncurrent_days { + data.push_str(""); + data.push_str(&days.to_string()); + data.push_str(""); + } + + if let Some(storage_class) = &rule.noncurrent_version_transition_storage_class { + data.push_str(""); + data.push_str(storage_class); + data.push_str(""); + } + + if let Some(versions) = rule.noncurrent_version_transition_newer_versions { + data.push_str(""); + data.push_str(&versions.to_string()); + data.push_str(""); + } + + data.push_str(""); + } + + // Transition + let has_transition = rule.transition_date.is_some() + || rule.transition_days.is_some() + || rule.transition_storage_class.is_some(); + + if has_transition { + data.push_str(""); + + if let Some(date) = rule.transition_date { + data.push_str(""); + data.push_str(&to_iso8601utc(date)); + data.push_str(""); + } + + if let Some(days) = rule.transition_days { + data.push_str(""); + data.push_str(&days.to_string()); + data.push_str(""); + } + + if let Some(storage_class) = &rule.transition_storage_class { + data.push_str(""); + data.push_str(storage_class); + data.push_str(""); + } + + data.push_str(""); + } + + data.push_str(""); + } + + data.push_str(""); + data + } +} + +#[derive(PartialEq, Clone, Debug, Default)] +pub struct LifecycleRule { + // Common + pub id: String, + pub status: bool, + pub filter: Filter, + + // Expiration + pub expiration_days: Option, + pub expiration_date: Option>, + pub expiration_expired_object_delete_marker: Option, + pub expiration_expired_object_all_versions: Option, + + // DelMarkerExpiration + pub del_marker_expiration_days: Option, + + // AllVersionsExpiration + pub all_versions_expiration_days: Option, + pub all_versions_expiration_delete_marker: Option, + + // Transition + pub transition_days: Option, + pub transition_date: Option>, + pub transition_storage_class: Option, + + // NoncurrentVersionExpiration + pub noncurrent_version_expiration_noncurrent_days: Option, + pub noncurrent_version_expiration_newer_versions: Option, + + // NoncurrentVersionTransition + pub noncurrent_version_transition_noncurrent_days: Option, + pub noncurrent_version_transition_storage_class: Option, + pub noncurrent_version_transition_newer_versions: Option, + + // AbortIncompleteMultipartUpload + pub abort_incomplete_multipart_upload_days_after_initiation: Option, +} + +impl LifecycleRule { + pub fn from_xml(rule_elem: &Element) -> Result { + let mut rule = LifecycleRule::default(); + + // Parse ID + if let Some(id_elem) = rule_elem.get_child("ID") { + if let Some(id_text) = id_elem.get_text() { + rule.id = id_text.to_string(); + } + } + + // Parse Status + if let Some(status_elem) = rule_elem.get_child("Status") { + if let Some(status_text) = status_elem.get_text() { + rule.status = status_text == "Enabled"; + } + } else { + return Err(Error::XmlError("Missing element".to_string())); + } + + // Parse Filter + if let Some(filter_elem) = rule_elem.get_child("Filter") { + rule.filter = Filter::from_xml(filter_elem)?; + } + + // Parse AbortIncompleteMultipartUpload + if let Some(abort_elem) = rule_elem.get_child("AbortIncompleteMultipartUpload") { + if let Some(days_elem) = abort_elem.get_child("DaysAfterInitiation") { + if let Some(days_text) = days_elem.get_text() { + rule.abort_incomplete_multipart_upload_days_after_initiation = + Some(days_text.parse().map_err(|_| { + Error::XmlError("Invalid DaysAfterInitiation value".to_string()) + })?); + } + } + } + + // Parse Expiration + if let Some(expiration_elem) = rule_elem.get_child("Expiration") { + // Date + if let Some(date_elem) = expiration_elem.get_child("Date") { + if let Some(date_text) = date_elem.get_text() { + // Assume a function that parses ISO8601 to DateTime + rule.expiration_date = Some(parse_iso8601(&date_text)?); + } + } + + // Days + if let Some(days_elem) = expiration_elem.get_child("Days") { + if let Some(days_text) = days_elem.get_text() { + rule.expiration_days = Some(days_text.parse().map_err(|_| { + Error::XmlError("Invalid Expiration Days value".to_string()) + })?); + } + } + + // ExpiredObjectDeleteMarker + if let Some(delete_marker_elem) = expiration_elem.get_child("ExpiredObjectDeleteMarker") + { + if let Some(delete_marker_text) = delete_marker_elem.get_text() { + rule.expiration_expired_object_delete_marker = + Some(delete_marker_text == "true"); + } + } + + // ExpiredObjectAllVersions + if let Some(all_versions_elem) = expiration_elem.get_child("ExpiredObjectAllVersions") { + if let Some(all_versions_text) = all_versions_elem.get_text() { + rule.expiration_expired_object_all_versions = Some(all_versions_text == "true"); + } + } + } + + // Parse DelMarkerExpiration + if let Some(del_marker_elem) = rule_elem.get_child("DelMarkerExpiration") { + if let Some(days_elem) = del_marker_elem.get_child("Days") { + if let Some(days_text) = days_elem.get_text() { + rule.del_marker_expiration_days = Some(days_text.parse().map_err(|_| { + Error::XmlError("Invalid DelMarkerExpiration Days value".to_string()) + })?); + } + } + } + + // Parse AllVersionsExpiration + if let Some(all_versions_elem) = rule_elem.get_child("AllVersionsExpiration") { + if let Some(days_elem) = all_versions_elem.get_child("Days") { + if let Some(days_text) = days_elem.get_text() { + rule.all_versions_expiration_days = Some(days_text.parse().map_err(|_| { + Error::XmlError("Invalid AllVersionsExpiration Days value".to_string()) + })?); + } + } + + if let Some(delete_marker_elem) = all_versions_elem.get_child("DeleteMarker") { + if let Some(delete_marker_text) = delete_marker_elem.get_text() { + rule.all_versions_expiration_delete_marker = Some(delete_marker_text == "true"); + } + } + } + + // Parse NoncurrentVersionExpiration + if let Some(noncurrent_exp_elem) = rule_elem.get_child("NoncurrentVersionExpiration") { + if let Some(days_elem) = noncurrent_exp_elem.get_child("NoncurrentDays") { + if let Some(days_text) = days_elem.get_text() { + rule.noncurrent_version_expiration_noncurrent_days = + Some(days_text.parse().map_err(|_| { + Error::XmlError( + "Invalid NoncurrentVersionExpiration NoncurrentDays value" + .to_string(), + ) + })?); + } + } + + if let Some(versions_elem) = noncurrent_exp_elem.get_child("NewerNoncurrentVersions") { + if let Some(versions_text) = versions_elem.get_text() { + rule.noncurrent_version_expiration_newer_versions = + Some(versions_text.parse().map_err(|_| { + Error::XmlError("Invalid NewerNoncurrentVersions value".to_string()) + })?); + } + } + } + + // Parse NoncurrentVersionTransition + if let Some(noncurrent_trans_elem) = rule_elem.get_child("NoncurrentVersionTransition") { + if let Some(days_elem) = noncurrent_trans_elem.get_child("NoncurrentDays") { + if let Some(days_text) = days_elem.get_text() { + rule.noncurrent_version_transition_noncurrent_days = + Some(days_text.parse().map_err(|_| { + Error::XmlError( + "Invalid NoncurrentVersionTransition NoncurrentDays value" + .to_string(), + ) + })?); + } + } + + if let Some(storage_elem) = noncurrent_trans_elem.get_child("StorageClass") { + if let Some(storage_text) = storage_elem.get_text() { + rule.noncurrent_version_transition_storage_class = + Some(storage_text.to_string()); + } + } + + if let Some(versions_elem) = noncurrent_trans_elem.get_child("NewerNoncurrentVersions") + { + if let Some(versions_text) = versions_elem.get_text() { + rule.noncurrent_version_transition_newer_versions = + Some(versions_text.parse().map_err(|_| { + Error::XmlError("Invalid NewerNoncurrentVersions value".to_string()) + })?); + } + } + } + + // Parse Transition + if let Some(transition_elem) = rule_elem.get_child("Transition") { + // Date + if let Some(date_elem) = transition_elem.get_child("Date") { + if let Some(date_text) = date_elem.get_text() { + rule.transition_date = Some(parse_iso8601(&date_text)?); + } + } + + // Days + if let Some(days_elem) = transition_elem.get_child("Days") { + if let Some(days_text) = days_elem.get_text() { + rule.transition_days = Some(days_text.parse().map_err(|_| { + Error::XmlError("Invalid Transition Days value".to_string()) + })?); + } + } + + // StorageClass + if let Some(storage_elem) = transition_elem.get_child("StorageClass") { + if let Some(storage_text) = storage_elem.get_text() { + rule.transition_storage_class = Some(storage_text.to_string()); + } + } + } + + Ok(rule) + } + + pub fn validate(&self) -> Result<(), Error> { + // Basic validation requirements + + // Ensure ID is present + if self.id.is_empty() { + return Err(Error::XmlError("Rule ID cannot be empty".to_string())); + } + + // Validate storage classes in transitions + if let Some(storage_class) = &self.transition_storage_class { + if storage_class.is_empty() { + return Err(Error::XmlError( + "Transition StorageClass cannot be empty".to_string(), + )); + } + } + + if let Some(storage_class) = &self.noncurrent_version_transition_storage_class { + if storage_class.is_empty() { + return Err(Error::XmlError( + "NoncurrentVersionTransition StorageClass cannot be empty".to_string(), + )); + } + } + + // Check that expiration has either days or date, not both + if self.expiration_days.is_some() && self.expiration_date.is_some() { + return Err(Error::XmlError( + "Expiration cannot specify both Days and Date".to_string(), + )); + } + + // Check that transition has either days or date, not both + if self.transition_days.is_some() && self.transition_date.is_some() { + return Err(Error::XmlError( + "Transition cannot specify both Days and Date".to_string(), + )); + } + + Ok(()) + } +} + +// Helper function to parse ISO8601 dates +fn parse_iso8601(date_str: &str) -> Result, Error> { + chrono::DateTime::parse_from_rfc3339(date_str) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .map_err(|_| Error::XmlError(format!("Invalid date format: {}", date_str))) +} diff --git a/src/s3/mod.rs b/src/s3/mod.rs index 37bd2f5..77017e0 100644 --- a/src/s3/mod.rs +++ b/src/s3/mod.rs @@ -20,6 +20,7 @@ pub mod client; pub mod creds; pub mod error; pub mod http; +pub mod lifecycle_config; pub mod multimap; mod object_content; pub mod response; diff --git a/src/s3/response/get_bucket_lifecycle.rs b/src/s3/response/get_bucket_lifecycle.rs index 24d788d..7cca980 100644 --- a/src/s3/response/get_bucket_lifecycle.rs +++ b/src/s3/response/get_bucket_lifecycle.rs @@ -14,10 +14,12 @@ // limitations under the License. use crate::s3::error::Error; -use crate::s3::types::{FromS3Response, LifecycleConfig, S3Request}; -use crate::s3::utils::take_bucket; +use crate::s3::lifecycle_config::LifecycleConfig; +use crate::s3::types::{FromS3Response, S3Request}; +use crate::s3::utils::{UtcTime, take_bucket}; use async_trait::async_trait; use bytes::Buf; +use chrono::{DateTime, NaiveDateTime, Utc}; use http::HeaderMap; use std::mem; use xmltree::Element; @@ -47,6 +49,10 @@ pub struct GetBucketLifecycleResponse { /// /// If the bucket has no lifecycle configuration, this field may contain an empty configuration. pub config: LifecycleConfig, + + /// Optional value of `X-Minio-LifecycleConfig-UpdatedAt` header, indicating the last update + /// time of the lifecycle configuration. + pub updated_at: Option, } #[async_trait] @@ -62,11 +68,21 @@ impl FromS3Response for GetBucketLifecycleResponse { let mut root = Element::parse(body.reader())?; LifecycleConfig::from_xml(&mut root)? }; + let updated_at: Option> = headers + .get("x-minio-lifecycleconfig-updatedat") + .and_then(|v| v.to_str().ok()) + .and_then(|v| { + NaiveDateTime::parse_from_str(v, "%Y%m%dT%H%M%SZ") + .ok() + .map(|naive| DateTime::from_naive_utc_and_offset(naive, Utc)) + }); + Ok(Self { headers, region: req.inner_region, bucket: take_bucket(req.bucket)?, config, + updated_at, }) } } diff --git a/src/s3/types.rs b/src/s3/types.rs index 38f2eb8..bacab64 100644 --- a/src/s3/types.rs +++ b/src/s3/types.rs @@ -17,9 +17,7 @@ use super::client::{Client, DEFAULT_REGION}; use crate::s3::error::Error; -use crate::s3::utils::{ - UtcTime, from_iso8601utc, get_default_text, get_option_text, get_text, to_iso8601utc, -}; +use crate::s3::utils::{UtcTime, get_option_text, get_text}; use crate::s3::multimap::Multimap; use crate::s3::segmented_bytes::SegmentedBytes; @@ -934,7 +932,7 @@ pub struct AndOperator { pub tags: Option>, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Default)] /// Filter information pub struct Filter { pub and_operator: Option, @@ -1050,309 +1048,6 @@ impl Filter { } } -#[derive(PartialEq, Clone, Debug)] -/// Lifecycle rule information -pub struct LifecycleRule { - pub abort_incomplete_multipart_upload_days_after_initiation: Option, - pub expiration_date: Option, - pub expiration_days: Option, - pub expiration_expired_object_delete_marker: Option, - pub filter: Filter, - pub id: String, - pub noncurrent_version_expiration_noncurrent_days: Option, - pub noncurrent_version_transition_noncurrent_days: Option, - pub noncurrent_version_transition_storage_class: Option, - pub status: bool, - pub transition_date: Option, - pub transition_days: Option, - pub transition_storage_class: Option, -} - -impl LifecycleRule { - pub fn from_xml(element: &Element) -> Result { - let expiration = element.get_child("Expiration"); - let noncurrent_version_transition = element.get_child("NoncurrentVersionTransition"); - let transition = element.get_child("Transition"); - - Ok(LifecycleRule { - abort_incomplete_multipart_upload_days_after_initiation: match element - .get_child("AbortIncompleteMultipartUpload") - { - Some(v) => { - let text = get_text(v, "DaysAfterInitiation")?; - Some(text.parse::()?) - } - None => None, - }, - expiration_date: match expiration { - Some(v) => { - let text = get_text(v, "Date")?; - Some(from_iso8601utc(&text)?) - } - None => None, - }, - expiration_days: match expiration { - Some(v) => { - let text = get_text(v, "Days")?; - Some(text.parse::()?) - } - None => None, - }, - expiration_expired_object_delete_marker: match expiration { - Some(v) => Some(get_text(v, "ExpiredObjectDeleteMarker")?.to_lowercase() == "true"), - None => None, - }, - filter: Filter::from_xml( - element - .get_child("Filter") - .ok_or(Error::XmlError(" tag not found".to_string()))?, - )?, - id: get_default_text(element, "ID"), - noncurrent_version_expiration_noncurrent_days: match element - .get_child("NoncurrentVersionExpiration") - { - Some(v) => { - let text = get_text(v, "NoncurrentDays")?; - Some(text.parse::()?) - } - None => None, - }, - noncurrent_version_transition_noncurrent_days: match noncurrent_version_transition { - Some(v) => { - let text = get_text(v, "NoncurrentDays")?; - Some(text.parse::()?) - } - None => None, - }, - noncurrent_version_transition_storage_class: match noncurrent_version_transition { - Some(v) => Some(get_text(v, "StorageClass")?), - None => None, - }, - status: get_text(element, "Status")?.to_lowercase() == "Enabled", - transition_date: match transition { - Some(v) => { - let text = get_text(v, "Date")?; - Some(from_iso8601utc(&text)?) - } - None => None, - }, - transition_days: match transition { - Some(v) => { - let text = get_text(v, "Days")?; - Some(text.parse::()?) - } - None => None, - }, - transition_storage_class: match transition { - Some(v) => Some(get_text(v, "StorageClass")?), - None => None, - }, - }) - } - - pub fn validate(&self) -> Result<(), Error> { - if self - .abort_incomplete_multipart_upload_days_after_initiation - .is_none() - && self.expiration_date.is_none() - && self.expiration_days.is_none() - && self.expiration_expired_object_delete_marker.is_none() - && self.noncurrent_version_expiration_noncurrent_days.is_none() - && self.noncurrent_version_transition_storage_class.is_none() - && self.transition_date.is_none() - && self.transition_days.is_none() - && self.transition_storage_class.is_none() - { - return Err(Error::MissingLifecycleAction); - } - - self.filter.validate()?; - - if self.expiration_expired_object_delete_marker.is_some() { - if self.expiration_date.is_some() || self.expiration_days.is_some() { - return Err(Error::InvalidExpiredObjectDeleteMarker); - } - } else if self.expiration_date.is_some() && self.expiration_days.is_some() { - return Err(Error::InvalidDateAndDays(String::from("expiration"))); - } - - if self.transition_date.is_some() && self.transition_days.is_some() { - return Err(Error::InvalidDateAndDays(String::from("transition"))); - } - - if self.id.len() > 255 { - return Err(Error::InvalidLifecycleRuleId); - } - - Ok(()) - } -} - -#[derive(PartialEq, Clone, Debug, Default)] -/// Lifecycle configuration -pub struct LifecycleConfig { - pub rules: Vec, -} - -impl LifecycleConfig { - pub fn from_xml(root: &mut Element) -> Result { - let mut config = LifecycleConfig { rules: Vec::new() }; - - // TODO consider consuming root - if let Some(v) = root.get_child("Rule") { - for rule in &v.children { - config - .rules - .push(LifecycleRule::from_xml(rule.as_element().ok_or( - Error::XmlError(" tag not found".to_string()), - )?)?); - } - } - - Ok(config) - } - - pub fn validate(&self) -> Result<(), Error> { - for rule in &self.rules { - rule.validate()?; - } - - Ok(()) - } - - pub fn to_xml(&self) -> String { - let mut data = String::from(""); - - for rule in &self.rules { - data.push_str(""); - - if rule - .abort_incomplete_multipart_upload_days_after_initiation - .is_some() - { - data.push_str(""); - data.push_str( - &rule - .abort_incomplete_multipart_upload_days_after_initiation - .unwrap() - .to_string(), - ); - data.push_str(""); - } - - if rule.expiration_date.is_some() - || rule.expiration_days.is_some() - || rule.expiration_expired_object_delete_marker.is_some() - { - data.push_str(""); - if rule.expiration_date.is_some() { - data.push_str(""); - data.push_str(&to_iso8601utc(rule.expiration_date.unwrap())); - data.push_str(""); - } - if rule.expiration_days.is_some() { - data.push_str(""); - data.push_str(&rule.expiration_days.unwrap().to_string()); - data.push_str(""); - } - if rule.expiration_expired_object_delete_marker.is_some() { - data.push_str(""); - data.push_str( - &rule - .expiration_expired_object_delete_marker - .unwrap() - .to_string(), - ); - data.push_str(""); - } - data.push_str(""); - } - - data.push_str(&rule.filter.to_xml()); - - if !rule.id.is_empty() { - data.push_str(""); - data.push_str(&rule.id); - data.push_str(""); - } - - if rule.noncurrent_version_expiration_noncurrent_days.is_some() { - data.push_str(""); - data.push_str( - &rule - .noncurrent_version_expiration_noncurrent_days - .unwrap() - .to_string(), - ); - data.push_str(""); - } - - if rule.noncurrent_version_transition_noncurrent_days.is_some() - || rule.noncurrent_version_transition_storage_class.is_some() - { - data.push_str(""); - if rule.noncurrent_version_transition_noncurrent_days.is_some() { - data.push_str(""); - data.push_str( - &rule - .noncurrent_version_expiration_noncurrent_days - .unwrap() - .to_string(), - ); - data.push_str(""); - } - if rule.noncurrent_version_transition_storage_class.is_some() { - data.push_str(""); - data.push_str( - rule.noncurrent_version_transition_storage_class - .as_ref() - .unwrap(), - ); - data.push_str(""); - } - data.push_str(""); - } - - data.push_str(""); - if rule.status { - data.push_str("Enabled"); - } else { - data.push_str("Disabled"); - } - data.push_str(""); - - if rule.transition_date.is_some() - || rule.transition_days.is_some() - || rule.transition_storage_class.is_some() - { - data.push_str(""); - if rule.transition_date.is_some() { - data.push_str(""); - data.push_str(&to_iso8601utc(rule.transition_date.unwrap())); - data.push_str(""); - } - if rule.transition_days.is_some() { - data.push_str(""); - data.push_str(&rule.transition_days.unwrap().to_string()); - data.push_str(""); - } - if rule.transition_storage_class.is_some() { - data.push_str(""); - data.push_str(rule.transition_storage_class.as_ref().unwrap()); - data.push_str(""); - } - data.push_str(""); - } - - data.push_str(""); - } - - data.push_str(""); - - data - } -} - #[allow(clippy::type_complexity)] fn parse_common_notification_config( element: &mut Element, @@ -1768,7 +1463,7 @@ impl ReplicationTime { } } -#[derive(PartialEq, Clone, Debug)] +#[derive(PartialEq, Clone, Debug, Default)] /// Destination information pub struct Destination { pub bucket_arn: String, @@ -1909,7 +1604,7 @@ pub struct SourceSelectionCriteria { pub sse_kms_encrypted_objects_status: Option, } -#[derive(PartialEq, Clone, Debug)] +#[derive(PartialEq, Clone, Debug, Default)] /// Replication rule information pub struct ReplicationRule { pub destination: Destination, diff --git a/tests/test_bucket_lifecycle.rs b/tests/test_bucket_lifecycle.rs index 44c9ba1..6f4062f 100644 --- a/tests/test_bucket_lifecycle.rs +++ b/tests/test_bucket_lifecycle.rs @@ -14,10 +14,12 @@ // limitations under the License. use minio::s3::client::DEFAULT_REGION; +use minio::s3::error::{Error, ErrorCode}; +use minio::s3::lifecycle_config::LifecycleConfig; use minio::s3::response::{ DeleteBucketLifecycleResponse, GetBucketLifecycleResponse, PutBucketLifecycleResponse, }; -use minio::s3::types::{LifecycleConfig, S3Api}; +use minio::s3::types::S3Api; use minio_common::example::create_bucket_lifecycle_config_examples; use minio_common::test_context::TestContext; @@ -37,39 +39,47 @@ async fn bucket_lifecycle() { .unwrap(); assert_eq!(resp.bucket, bucket_name); assert_eq!(resp.region, DEFAULT_REGION); - //println!("response of setting lifecycle: resp={:?}", resp); - if false { - // TODO panics with: called `Result::unwrap()` on an `Err` value: XmlError(" tag not found") - let resp: GetBucketLifecycleResponse = ctx - .client - .get_bucket_lifecycle(&bucket_name) - .send() - .await - .unwrap(); - assert_eq!(resp.config, config); - assert_eq!(resp.bucket, bucket_name); - assert_eq!(resp.region, DEFAULT_REGION); - println!("response of getting lifecycle: resp={:?}", resp); - } + let resp: GetBucketLifecycleResponse = ctx + .client + .get_bucket_lifecycle(&bucket_name) + .with_updated_at(false) + .send() + .await + .unwrap(); + assert_eq!(resp.config, config); + assert_eq!(resp.bucket, bucket_name); + assert_eq!(resp.region, DEFAULT_REGION); + assert!(resp.updated_at.is_none()); - let _resp: DeleteBucketLifecycleResponse = ctx + let resp: GetBucketLifecycleResponse = ctx + .client + .get_bucket_lifecycle(&bucket_name) + .with_updated_at(true) + .send() + .await + .unwrap(); + assert_eq!(resp.config, config); + assert_eq!(resp.bucket, bucket_name); + assert_eq!(resp.region, DEFAULT_REGION); + assert!(resp.updated_at.is_some()); + + let resp: DeleteBucketLifecycleResponse = ctx .client .delete_bucket_lifecycle(&bucket_name) .send() .await .unwrap(); - //println!("response of deleting lifecycle: resp={:?}", resp); + assert_eq!(resp.bucket, bucket_name); + assert_eq!(resp.region, DEFAULT_REGION); - if false { - // TODO panics with: called `Result::unwrap()` on an `Err` value: XmlError(" tag not found") - let resp: GetBucketLifecycleResponse = ctx - .client - .get_bucket_lifecycle(&bucket_name) - .send() - .await - .unwrap(); - println!("response of getting policy: resp={:?}", resp); - //assert_eq!(resp.config, LifecycleConfig::default()); + let resp: Result = + ctx.client.get_bucket_lifecycle(&bucket_name).send().await; + match resp { + Err(Error::S3Error(e)) => assert_eq!(e.code, ErrorCode::NoSuchLifecycleConfiguration), + v => panic!( + "Expected error S3Error(NoSuchLifecycleConfiguration): but got {:?}", + v + ), } }