diff --git a/core/src/raw/ops.rs b/core/src/raw/ops.rs index 6d00808ac21..12f4a3a0535 100644 --- a/core/src/raw/ops.rs +++ b/core/src/raw/ops.rs @@ -511,6 +511,8 @@ impl OpReader { pub struct OpStat { if_match: Option, if_none_match: Option, + if_modified_since: Option>, + if_unmodified_since: Option>, override_content_type: Option, override_cache_control: Option, override_content_disposition: Option, @@ -545,6 +547,28 @@ impl OpStat { self.if_none_match.as_deref() } + /// Set the If-Modified-Since of the option + pub fn with_if_modified_since(mut self, v: DateTime) -> Self { + self.if_modified_since = Some(v); + self + } + + /// Get If-Modified-Since from option + pub fn if_modified_since(&self) -> Option> { + self.if_modified_since + } + + /// Set the If-Unmodified-Since of the option + pub fn with_if_unmodified_since(mut self, v: DateTime) -> Self { + self.if_unmodified_since = Some(v); + self + } + + /// Get If-Unmodified-Since from option + pub fn if_unmodified_since(&self) -> Option> { + self.if_unmodified_since + } + /// Sets the content-disposition header that should be sent back by the remote read operation. pub fn with_override_content_disposition(mut self, content_disposition: &str) -> Self { self.override_content_disposition = Some(content_disposition.into()); diff --git a/core/src/services/s3/backend.rs b/core/src/services/s3/backend.rs index 46f0df1ee77..3f3f00521aa 100644 --- a/core/src/services/s3/backend.rs +++ b/core/src/services/s3/backend.rs @@ -926,6 +926,8 @@ impl Access for S3Backend { stat_has_content_encoding: true, stat_with_if_match: true, stat_with_if_none_match: true, + stat_with_if_modified_since: true, + stat_with_if_unmodified_since: true, stat_with_override_cache_control: !self.core.disable_stat_with_override, stat_with_override_content_disposition: !self.core.disable_stat_with_override, stat_with_override_content_type: !self.core.disable_stat_with_override, diff --git a/core/src/services/s3/core.rs b/core/src/services/s3/core.rs index c5c32e9ad51..69cab8f2b93 100644 --- a/core/src/services/s3/core.rs +++ b/core/src/services/s3/core.rs @@ -338,11 +338,23 @@ impl S3Core { if let Some(if_none_match) = args.if_none_match() { req = req.header(IF_NONE_MATCH, if_none_match); } - if let Some(if_match) = args.if_match() { req = req.header(IF_MATCH, if_match); } + if let Some(if_modified_since) = args.if_modified_since() { + req = req.header( + IF_MODIFIED_SINCE, + format_datetime_into_http_date(if_modified_since), + ); + } + if let Some(if_unmodified_since) = args.if_unmodified_since() { + req = req.header( + IF_UNMODIFIED_SINCE, + format_datetime_into_http_date(if_unmodified_since), + ); + } + let req = req.body(Buffer::new()).map_err(new_request_build_error)?; Ok(req) diff --git a/core/src/types/capability.rs b/core/src/types/capability.rs index fcad9a66ebd..b9e60d30508 100644 --- a/core/src/types/capability.rs +++ b/core/src/types/capability.rs @@ -70,6 +70,10 @@ pub struct Capability { pub stat_with_if_match: bool, /// Indicates if conditional stat operations using If-None-Match are supported. pub stat_with_if_none_match: bool, + /// Indicates if conditional stat operations using If-Modified-Since are supported. + pub stat_with_if_modified_since: bool, + /// Indicates if conditional stat operations using If-Unmodified-Since are supported. + pub stat_with_if_unmodified_since: bool, /// Indicates if Cache-Control header override is supported during stat operations. pub stat_with_override_cache_control: bool, /// Indicates if Content-Disposition header override is supported during stat operations. diff --git a/core/src/types/operator/operator.rs b/core/src/types/operator/operator.rs index 085e15c7bf0..a65e572fbbd 100644 --- a/core/src/types/operator/operator.rs +++ b/core/src/types/operator/operator.rs @@ -257,7 +257,7 @@ impl Operator { /// /// This feature can be used to check if the file's `ETag` matches the given `ETag`. /// - /// If file exists and it's etag doesn't match, an error with kind [`ErrorKind::ConditionNotMatch`] + /// If file exists, and it's etag doesn't match, an error with kind [`ErrorKind::ConditionNotMatch`] /// will be returned. /// /// ``` @@ -276,7 +276,7 @@ impl Operator { /// /// This feature can be used to check if the file's `ETag` doesn't match the given `ETag`. /// - /// If file exists and it's etag match, an error with kind [`ErrorKind::ConditionNotMatch`] + /// If file exists, and it's etag match, an error with kind [`ErrorKind::ConditionNotMatch`] /// will be returned. /// /// ``` @@ -289,6 +289,46 @@ impl Operator { /// # } /// ``` /// + /// ## `if_modified_since` + /// + /// set `if_modified_since` for this `stat` request. + /// + /// This feature can be used to check if the file has been modified since the given time. + /// + /// If file exists, and it's not modified after the given time, an error with kind [`ErrorKind::ConditionNotMatch`] + /// will be returned. + /// + /// ``` + /// # use opendal::Result; + /// use opendal::Operator; + /// use chrono::Utc; + /// + /// # async fn test(op: Operator) -> Result<()> { + /// let mut metadata = op.stat_with("path/to/file").if_modified_since(Utc::now()).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// ## `if_unmodified_since` + /// + /// set `if_unmodified_since` for this `stat` request. + /// + /// This feature can be used to check if the file has NOT been modified since the given time. + /// + /// If file exists, and it's modified after the given time, an error with kind [`ErrorKind::ConditionNotMatch`] + /// will be returned. + /// + /// ``` + /// # use opendal::Result; + /// use opendal::Operator; + /// use chrono::Utc; + /// + /// # async fn test(op: Operator) -> Result<()> { + /// let mut metadata = op.stat_with("path/to/file").if_unmodified_since(Utc::now()).await?; + /// # Ok(()) + /// # } + /// ``` + /// /// ## `version` /// /// Set `version` for this `stat` request. diff --git a/core/src/types/operator/operator_futures.rs b/core/src/types/operator/operator_futures.rs index c847b53745e..4c1b90657df 100644 --- a/core/src/types/operator/operator_futures.rs +++ b/core/src/types/operator/operator_futures.rs @@ -104,6 +104,16 @@ impl>> FutureStat { self.map(|args| args.with_if_none_match(v)) } + /// Set the If-Modified-Since for this operation. + pub fn if_modified_since(self, v: DateTime) -> Self { + self.map(|args| args.with_if_modified_since(v)) + } + + /// Set the If-Unmodified-Since for this operation. + pub fn if_unmodified_since(self, v: DateTime) -> Self { + self.map(|args| args.with_if_unmodified_since(v)) + } + /// Set the version for this operation. pub fn version(self, v: &str) -> Self { self.map(|args| args.with_version(v)) diff --git a/core/tests/behavior/async_stat.rs b/core/tests/behavior/async_stat.rs index 38f50704acd..b4b004a7ddf 100644 --- a/core/tests/behavior/async_stat.rs +++ b/core/tests/behavior/async_stat.rs @@ -23,6 +23,7 @@ use anyhow::Result; use http::StatusCode; use log::warn; use reqwest::Url; +use tokio::time::sleep; pub fn tests(op: &Operator, tests: &mut Vec) { let cap = op.info().full_capability(); @@ -38,6 +39,8 @@ pub fn tests(op: &Operator, tests: &mut Vec) { test_stat_not_exist, test_stat_with_if_match, test_stat_with_if_none_match, + test_stat_with_if_modified_since, + test_stat_with_if_unmodified_since, test_stat_with_override_cache_control, test_stat_with_override_content_disposition, test_stat_with_override_content_type, @@ -244,6 +247,66 @@ pub async fn test_stat_with_if_none_match(op: Operator) -> Result<()> { Ok(()) } +/// Stat file with if_modified_since should succeed, otherwise get a ConditionNotMatch error. +pub async fn test_stat_with_if_modified_since(op: Operator) -> Result<()> { + if !op.info().full_capability().stat_with_if_modified_since { + return Ok(()); + } + + let (path, content, _) = TEST_FIXTURE.new_file(op.clone()); + + op.write(&path, content.clone()) + .await + .expect("write must succeed"); + + let meta = op.stat(&path).await?; + assert_eq!(meta.mode(), EntryMode::FILE); + assert_eq!(meta.content_length(), content.len() as u64); + + let since = meta.last_modified().unwrap() - Duration::from_secs(1); + let res = op.stat_with(&path).if_modified_since(since).await?; + assert_eq!(res.last_modified(), meta.last_modified()); + + sleep(Duration::from_secs(1)).await; + + let since = meta.last_modified().unwrap() + Duration::from_secs(1); + let res = op.stat_with(&path).if_modified_since(since).await; + assert!(res.is_err()); + assert_eq!(res.err().unwrap().kind(), ErrorKind::ConditionNotMatch); + + Ok(()) +} + +/// Stat file with if_unmodified_since should succeed, otherwise get a ConditionNotMatch error. +pub async fn test_stat_with_if_unmodified_since(op: Operator) -> Result<()> { + if !op.info().full_capability().stat_with_if_unmodified_since { + return Ok(()); + } + + let (path, content, _) = TEST_FIXTURE.new_file(op.clone()); + + op.write(&path, content.clone()) + .await + .expect("write must succeed"); + + let meta = op.stat(&path).await?; + assert_eq!(meta.mode(), EntryMode::FILE); + assert_eq!(meta.content_length(), content.len() as u64); + + let since = meta.last_modified().unwrap() - Duration::from_secs(1); + let res = op.stat_with(&path).if_unmodified_since(since).await; + assert!(res.is_err()); + assert_eq!(res.err().unwrap().kind(), ErrorKind::ConditionNotMatch); + + sleep(Duration::from_secs(1)).await; + + let since = meta.last_modified().unwrap() + Duration::from_secs(1); + let res = op.stat_with(&path).if_unmodified_since(since).await?; + assert_eq!(res.last_modified(), meta.last_modified()); + + Ok(()) +} + /// Stat file with override-cache-control should succeed. pub async fn test_stat_with_override_cache_control(op: Operator) -> Result<()> { if !(op.info().full_capability().stat_with_override_cache_control