You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by te...@apache.org on 2026/04/27 17:54:53 UTC

(opendal) 01/01: fix(services/s3): support write if-none-match

This is an automated email from the ASF dual-hosted git repository.

TennyZhuang pushed a commit to branch codex/s3-if-none-match-write
in repository https://gitbox.apache.org/repos/asf/opendal.git

commit f214be19286288e8d935068a95f324257d3db296
Author: TennyZhuang <91...@users.noreply.github.com>
AuthorDate: Tue Apr 28 01:54:39 2026 +0800

    fix(services/s3): support write if-none-match
---
 core/core/src/types/options.rs  |   2 +-
 core/services/s3/src/backend.rs |   1 +
 core/services/s3/src/core.rs    | 119 +++++++++++++++++++++++++++++++++++++++-
 3 files changed, 118 insertions(+), 4 deletions(-)

diff --git a/core/core/src/types/options.rs b/core/core/src/types/options.rs
index 4ef109c61..8a32b56c3 100644
--- a/core/core/src/types/options.rs
+++ b/core/core/src/types/options.rs
@@ -426,7 +426,7 @@ pub struct WriteOptions {
     pub if_match: Option<String>,
     /// Sets If-None-Match header for this write request.
     ///
-    /// Note: Certain services, like `s3`, support `if_not_exists` but not `if_none_match`.
+    /// Note: Certain services support `if_not_exists` but not `if_none_match`.
     /// Use `if_not_exists` if you only want to check whether a file exists.
     ///
     /// ### Capability
diff --git a/core/services/s3/src/backend.rs b/core/services/s3/src/backend.rs
index 3413fd5e8..4095326e2 100644
--- a/core/services/s3/src/backend.rs
+++ b/core/services/s3/src/backend.rs
@@ -908,6 +908,7 @@ impl Builder for S3Builder {
                             write_with_content_disposition: true,
                             write_with_content_encoding: true,
                             write_with_if_match: !config.disable_write_with_if_match,
+                            write_with_if_none_match: true,
                             write_with_if_not_exists: true,
                             write_with_user_metadata: true,
 
diff --git a/core/services/s3/src/core.rs b/core/services/s3/src/core.rs
index 9bd98cf92..488b1d086 100644
--- a/core/services/s3/src/core.rs
+++ b/core/services/s3/src/core.rs
@@ -318,7 +318,9 @@ impl S3Core {
             req = req.header(IF_MATCH, if_match);
         }
 
-        if args.if_not_exists() {
+        if let Some(if_none_match) = args.if_none_match() {
+            req = req.header(IF_NONE_MATCH, if_none_match);
+        } else if args.if_not_exists() {
             req = req.header(IF_NONE_MATCH, "*");
         }
 
@@ -902,6 +904,18 @@ impl S3Core {
         parts: Vec<CompleteMultipartUploadRequestPart>,
         args: &OpWrite,
     ) -> Result<Response<Buffer>> {
+        let req = self.s3_complete_multipart_upload_request(path, upload_id, parts, args)?;
+
+        self.send(req).await
+    }
+
+    fn s3_complete_multipart_upload_request(
+        &self,
+        path: &str,
+        upload_id: &str,
+        parts: Vec<CompleteMultipartUploadRequestPart>,
+        args: &OpWrite,
+    ) -> Result<Request<Buffer>> {
         let p = build_abs_path(&self.root, path);
 
         let url = format!(
@@ -927,7 +941,9 @@ impl S3Core {
         if let Some(if_match) = args.if_match() {
             req = req.header(IF_MATCH, if_match);
         }
-        if args.if_not_exists() {
+        if let Some(if_none_match) = args.if_none_match() {
+            req = req.header(IF_NONE_MATCH, if_none_match);
+        } else if args.if_not_exists() {
             req = req.header(IF_NONE_MATCH, "*");
         }
 
@@ -941,7 +957,7 @@ impl S3Core {
             .body(Buffer::from(Bytes::from(content)))
             .map_err(new_request_build_error)?;
 
-        self.send(req).await
+        Ok(req)
     }
 
     /// Abort an on-going multipart upload.
@@ -1287,11 +1303,108 @@ impl Display for ChecksumAlgorithm {
 
 #[cfg(test)]
 mod tests {
+    use std::sync::Arc;
+
     use bytes::Buf;
     use bytes::Bytes;
+    use reqsign_aws_v4::RequestSigner as AwsV4Signer;
+    use reqsign_aws_v4::StaticCredentialProvider;
+    use reqsign_core::Context;
+    use reqsign_core::ProvideCredentialChain;
+    use reqsign_core::Signer;
 
     use super::*;
 
+    fn test_core() -> S3Core {
+        let provider =
+            ProvideCredentialChain::new().push(StaticCredentialProvider::new("ak", "sk"));
+
+        S3Core {
+            info: Arc::new(AccessorInfo::default()),
+            bucket: "bucket".to_string(),
+            endpoint: "https://s3.amazonaws.com".to_string(),
+            root: "/root/".to_string(),
+            server_side_encryption: None,
+            server_side_encryption_aws_kms_key_id: None,
+            server_side_encryption_customer_algorithm: None,
+            server_side_encryption_customer_key: None,
+            server_side_encryption_customer_key_md5: None,
+            default_storage_class: None,
+            allow_anonymous: true,
+            disable_list_objects_v2: false,
+            enable_request_payer: false,
+            default_acl: None,
+            signer: Signer::new(
+                Context::new(),
+                provider,
+                AwsV4Signer::new("s3", "us-east-1"),
+            ),
+            checksum_algorithm: None,
+        }
+    }
+
+    #[test]
+    fn test_put_object_request_sets_if_none_match() {
+        let core = test_core();
+        let op = OpWrite::default().with_if_none_match("\"etag\"");
+
+        let req = core
+            .s3_put_object_request("path/to/file", Some(0), &op, Buffer::new())
+            .expect("request must build");
+
+        assert_eq!(
+            req.headers()
+                .get(IF_NONE_MATCH)
+                .expect("If-None-Match must be set")
+                .to_str()
+                .expect("header must be valid"),
+            "\"etag\""
+        );
+    }
+
+    #[test]
+    fn test_put_object_request_keeps_if_not_exists_header() {
+        let core = test_core();
+        let op = OpWrite::default().with_if_not_exists(true);
+
+        let req = core
+            .s3_put_object_request("path/to/file", Some(0), &op, Buffer::new())
+            .expect("request must build");
+
+        assert_eq!(
+            req.headers()
+                .get(IF_NONE_MATCH)
+                .expect("If-None-Match must be set")
+                .to_str()
+                .expect("header must be valid"),
+            "*"
+        );
+    }
+
+    #[test]
+    fn test_complete_multipart_upload_request_sets_if_none_match() {
+        let core = test_core();
+        let op = OpWrite::default().with_if_none_match("\"etag\"");
+        let parts = vec![CompleteMultipartUploadRequestPart {
+            part_number: 1,
+            etag: "\"part-etag\"".to_string(),
+            ..Default::default()
+        }];
+
+        let req = core
+            .s3_complete_multipart_upload_request("path/to/file", "upload-id", parts, &op)
+            .expect("request must build");
+
+        assert_eq!(
+            req.headers()
+                .get(IF_NONE_MATCH)
+                .expect("If-None-Match must be set")
+                .to_str()
+                .expect("header must be valid"),
+            "\"etag\""
+        );
+    }
+
     /// This example is from https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html#API_CreateMultipartUpload_Examples
     #[test]
     fn test_deserialize_initiate_multipart_upload_result() {