Repository: apache/opendal (language: rust)

Issue:
new feature:  Add Support for Presigned Deletes in OpenDAL Python Bindings
### Feature Description

Proposed Functionality:

- Allow generating a `presigned` **delete** URL for objects in supported storage backends.
- Define a configurable expiration time for the `presigned` URL.
- Ensure compatibility with storage services that natively support presigned delete operations (e.g., AWS S3, Azure Blob Storage).
- Provide clear error handling for backends that do not support presigned deletes.

### Problem and Solution

Problem:
Currently we have support for presigned Reads and Writes with openDAL python bindings, but presigned support for DELETE operations are not yet supported.  Related discussion: https://github.com/apache/opendal/discussions/5423
Solution:
Would like to presigned Delete support to be added to openDAL python bindings with the introduction of this feature. 

### Additional Context

_No response_

### Are you willing to contribute to the development of this feature?

- [X] Yes, I am willing to contribute to the development of this feature.


Discussion / hints:
I believe we can impelment `presign_delete` in rust core first.
@Xuanwo I wanna take this up. Shall I just fork and get started?
> @Xuanwo I wanna take this up. Shall I just fork and get started?

Welcome, thank you in advance!

Fix patch:
diff --git a/core/src/raw/ops.rs b/core/src/raw/ops.rs
index 12f4a3a0535..b76dbaff127 100644
--- a/core/src/raw/ops.rs
+++ b/core/src/raw/ops.rs
@@ -273,6 +273,8 @@ pub enum PresignOperation {
     Read(OpRead),
     /// Presign a write operation.
     Write(OpWrite),
+    /// Presign a delete operation.
+    Delete(OpDelete),
 }
 
 impl From<OpStat> for PresignOperation {
@@ -293,6 +295,12 @@ impl From<OpWrite> for PresignOperation {
     }
 }
 
+impl From<OpDelete> for PresignOperation {
+    fn from(v: OpDelete) -> Self {
+        Self::Delete(v)
+    }
+}
+
 /// Args for `read` operation.
 #[derive(Debug, Clone, Default)]
 pub struct OpRead {
diff --git a/core/src/services/azblob/backend.rs b/core/src/services/azblob/backend.rs
index f2adce968ae..863a586a2b5 100644
--- a/core/src/services/azblob/backend.rs
+++ b/core/src/services/azblob/backend.rs
@@ -634,18 +634,24 @@ impl Access for AzblobBackend {
     }
 
     async fn presign(&self, path: &str, args: OpPresign) -> Result<RpPresign> {
-        let mut req = match args.operation() {
-            PresignOperation::Stat(v) => self.core.azblob_head_blob_request(path, v)?,
+        let req = match args.operation() {
+            PresignOperation::Stat(v) => self.core.azblob_head_blob_request(path, v),
             PresignOperation::Read(v) => {
                 self.core
-                    .azblob_get_blob_request(path, BytesRange::default(), v)?
+                    .azblob_get_blob_request(path, BytesRange::default(), v)
             }
             PresignOperation::Write(_) => {
                 self.core
-                    .azblob_put_blob_request(path, None, &OpWrite::default(), Buffer::new())?
+                    .azblob_put_blob_request(path, None, &OpWrite::default(), Buffer::new())
             }
+            PresignOperation::Delete(_) => Err(Error::new(
+                ErrorKind::Unsupported,
+                "operation is not supported",
+            )),
         };
 
+        let mut req = req?;
+
         self.core.sign_query(&mut req).await?;
 
         let (parts, _) = req.into_parts();
diff --git a/core/src/services/b2/backend.rs b/core/src/services/b2/backend.rs
index e8f4c25130a..d30caf166e6 100644
--- a/core/src/services/b2/backend.rs
+++ b/core/src/services/b2/backend.rs
@@ -471,6 +471,10 @@ impl Access for B2Backend {
                     parts.headers,
                 )))
             }
+            PresignOperation::Delete(_) => Err(Error::new(
+                ErrorKind::Unsupported,
+                "operation is not supported",
+            )),
         }
     }
 }
diff --git a/core/src/services/cos/backend.rs b/core/src/services/cos/backend.rs
index b0542a33a34..ae41b343c43 100644
--- a/core/src/services/cos/backend.rs
+++ b/core/src/services/cos/backend.rs
@@ -418,17 +418,22 @@ impl Access for CosBackend {
     }
 
     async fn presign(&self, path: &str, args: OpPresign) -> Result<RpPresign> {
-        let mut req = match args.operation() {
-            PresignOperation::Stat(v) => self.core.cos_head_object_request(path, v)?,
+        let req = match args.operation() {
+            PresignOperation::Stat(v) => self.core.cos_head_object_request(path, v),
             PresignOperation::Read(v) => {
                 self.core
-                    .cos_get_object_request(path, BytesRange::default(), v)?
+                    .cos_get_object_request(path, BytesRange::default(), v)
             }
             PresignOperation::Write(v) => {
                 self.core
-                    .cos_put_object_request(path, None, v, Buffer::new())?
+                    .cos_put_object_request(path, None, v, Buffer::new())
             }
+            PresignOperation::Delete(_) => Err(Error::new(
+                ErrorKind::Unsupported,
+                "operation is not supported",
+            )),
         };
+        let mut req = req?;
         self.core.sign_query(&mut req, args.expire()).await?;
 
         // We don't need this request anymore, consume it directly.
diff --git a/core/src/services/gcs/backend.rs b/core/src/services/gcs/backend.rs
index fa2c0156d62..f055cbe0126 100644
--- a/core/src/services/gcs/backend.rs
+++ b/core/src/services/gcs/backend.rs
@@ -514,15 +514,19 @@ impl Access for GcsBackend {
 
     async fn presign(&self, path: &str, args: OpPresign) -> Result<RpPresign> {
         // We will not send this request out, just for signing.
-        let mut req = match args.operation() {
-            PresignOperation::Stat(v) => self.core.gcs_head_object_xml_request(path, v)?,
-            PresignOperation::Read(v) => self.core.gcs_get_object_xml_request(path, v)?,
+        let req = match args.operation() {
+            PresignOperation::Stat(v) => self.core.gcs_head_object_xml_request(path, v),
+            PresignOperation::Read(v) => self.core.gcs_get_object_xml_request(path, v),
             PresignOperation::Write(v) => {
                 self.core
-                    .gcs_insert_object_xml_request(path, v, Buffer::new())?
+                    .gcs_insert_object_xml_request(path, v, Buffer::new())
             }
+            PresignOperation::Delete(_) => Err(Error::new(
+                ErrorKind::Unsupported,
+                "operation is not supported",
+            )),
         };
-
+        let mut req = req?;
         self.core.sign_query(&mut req, args.expire())?;
 
         // We don't need this request anymore, consume it directly.
diff --git a/core/src/services/obs/backend.rs b/core/src/services/obs/backend.rs
index 8aaa99741a9..33ceb922f9f 100644
--- a/core/src/services/obs/backend.rs
+++ b/core/src/services/obs/backend.rs
@@ -410,17 +410,22 @@ impl Access for ObsBackend {
     }
 
     async fn presign(&self, path: &str, args: OpPresign) -> Result<RpPresign> {
-        let mut req = match args.operation() {
-            PresignOperation::Stat(v) => self.core.obs_head_object_request(path, v)?,
+        let req = match args.operation() {
+            PresignOperation::Stat(v) => self.core.obs_head_object_request(path, v),
             PresignOperation::Read(v) => {
                 self.core
-                    .obs_get_object_request(path, BytesRange::default(), v)?
+                    .obs_get_object_request(path, BytesRange::default(), v)
             }
             PresignOperation::Write(v) => {
                 self.core
-                    .obs_put_object_request(path, None, v, Buffer::new())?
+                    .obs_put_object_request(path, None, v, Buffer::new())
             }
+            PresignOperation::Delete(_) => Err(Error::new(
+                ErrorKind::Unsupported,
+                "operation is not supported",
+            )),
         };
+        let mut req = req?;
         self.core.sign_query(&mut req, args.expire()).await?;
 
         // We don't need this request anymore, consume it directly.
diff --git a/core/src/services/oss/backend.rs b/core/src/services/oss/backend.rs
index 727e62d72d2..a0a6b1d2e39 100644
--- a/core/src/services/oss/backend.rs
+++ b/core/src/services/oss/backend.rs
@@ -619,14 +619,19 @@ impl Access for OssBackend {
 
     async fn presign(&self, path: &str, args: OpPresign) -> Result<RpPresign> {
         // We will not send this request out, just for signing.
-        let mut req = match args.operation() {
-            PresignOperation::Stat(v) => self.core.oss_head_object_request(path, true, v)?,
-            PresignOperation::Read(v) => self.core.oss_get_object_request(path, true, v)?,
+        let req = match args.operation() {
+            PresignOperation::Stat(v) => self.core.oss_head_object_request(path, true, v),
+            PresignOperation::Read(v) => self.core.oss_get_object_request(path, true, v),
             PresignOperation::Write(v) => {
                 self.core
-                    .oss_put_object_request(path, None, v, Buffer::new(), true)?
+                    .oss_put_object_request(path, None, v, Buffer::new(), true)
             }
+            PresignOperation::Delete(_) => Err(Error::new(
+                ErrorKind::Unsupported,
+                "operation is not supported",
+            )),
         };
+        let mut req = req?;
 
         self.core.sign_query(&mut req, args.expire()).await?;
 
diff --git a/core/src/services/s3/backend.rs b/core/src/services/s3/backend.rs
index 609db520eb6..cf34524a3d0 100644
--- a/core/src/services/s3/backend.rs
+++ b/core/src/services/s3/backend.rs
@@ -1099,19 +1099,23 @@ impl Access for S3Backend {
 
     async fn presign(&self, path: &str, args: OpPresign) -> Result<RpPresign> {
         let (expire, op) = args.into_parts();
-
         // We will not send this request out, just for signing.
-        let mut req = match op {
-            PresignOperation::Stat(v) => self.core.s3_head_object_request(path, v)?,
+        let req = match op {
+            PresignOperation::Stat(v) => self.core.s3_head_object_request(path, v),
             PresignOperation::Read(v) => {
                 self.core
-                    .s3_get_object_request(path, BytesRange::default(), &v)?
+                    .s3_get_object_request(path, BytesRange::default(), &v)
             }
             PresignOperation::Write(_) => {
                 self.core
-                    .s3_put_object_request(path, None, &OpWrite::default(), Buffer::new())?
+                    .s3_put_object_request(path, None, &OpWrite::default(), Buffer::new())
             }
+            PresignOperation::Delete(_) => Err(Error::new(
+                ErrorKind::Unsupported,
+                "operation is not supported",
+            )),
         };
+        let mut req = req?;
 
         self.core.sign_query(&mut req, expire).await?;
 
diff --git a/core/src/types/capability.rs b/core/src/types/capability.rs
index c74fe3628f1..ca280ce5ee2 100644
--- a/core/src/types/capability.rs
+++ b/core/src/types/capability.rs
@@ -227,6 +227,8 @@ pub struct Capability {
     pub presign_stat: bool,
     /// Indicates if presigned URLs for write operations are supported.
     pub presign_write: bool,
+    /// Indicates if presigned URLs for delete operations are supported.
+    pub presign_delete: bool,
 
     /// Indicate if the operator supports shared access.
     pub shared: bool,
diff --git a/core/src/types/operator/operator.rs b/core/src/types/operator/operator.rs
index 9f23f4ed9d2..c0183465614 100644
--- a/core/src/types/operator/operator.rs
+++ b/core/src/types/operator/operator.rs
@@ -1964,4 +1964,61 @@ impl Operator {
             },
         )
     }
+
+    /// Presign an operation for delete.
+    ///
+    /// # Notes
+    ///
+    /// ## Extra Options
+    ///
+    /// `presign_delete` is a wrapper of [`Self::presign_delete_with`] without any options.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// use std::time::Duration;
+    ///
+    /// use anyhow::Result;
+    /// use opendal::Operator;
+    ///
+    /// async fn test(op: Operator) -> Result<()> {
+    ///     let signed_req = op
+    ///         .presign_delete("test.txt", Duration::from_secs(3600))
+    ///         .await?;
+    ///     Ok(())
+    /// }
+    /// ```
+    ///
+    /// - `signed_req.method()`: `DELETE`
+    /// - `signed_req.uri()`: `https://s3.amazonaws.com/examplebucket/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=access_key_id/20130721/us-east-1/s3/aws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=<signature-value>`
+    /// - `signed_req.headers()`: `{ "host": "s3.amazonaws.com" }`
+    ///
+    /// We can delete file as this file via `curl` or other tools without credential:
+    ///
+    /// ```shell
+    /// curl -X DELETE "https://s3.amazonaws.com/examplebucket/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=access_key_id/20130721/us-east-1/s3/aws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=<signature-value>"
+    /// ```
+    pub async fn presign_delete(&self, path: &str, expire: Duration) -> Result<PresignedRequest> {
+        self.presign_delete_with(path, expire).await
+    }
+
+    /// Presign an operation for delete without extra options.
+    pub fn presign_delete_with(
+        &self,
+        path: &str,
+        expire: Duration,
+    ) -> FuturePresignDelete<impl Future<Output = Result<PresignedRequest>>> {
+        let path = normalize_path(path);
+
+        OperatorFuture::new(
+            self.inner().clone(),
+            path,
+            (OpDelete::default(), expire),
+            |inner, path, (args, dur)| async move {
+                let op = OpPresign::new(args, dur);
+                let rp = inner.presign(&path, op).await?;
+                Ok(rp.into_presigned_request())
+            },
+        )
+    }
 }
diff --git a/core/src/types/operator/operator_futures.rs b/core/src/types/operator/operator_futures.rs
index b75e5153ce7..c70481720f2 100644
--- a/core/src/types/operator/operator_futures.rs
+++ b/core/src/types/operator/operator_futures.rs
@@ -152,6 +152,13 @@ impl<F: Future<Output = Result<PresignedRequest>>> FuturePresignStat<F> {
     }
 }
 
+/// Future that generated by [`Operator::presign_delete_with`].
+///
+/// Users can add more options by public functions provided by this struct.
+pub type FuturePresignDelete<F> = OperatorFuture<(OpDelete, Duration), PresignedRequest, F>;
+
+impl<F: Future<Output = Result<PresignedRequest>>> FuturePresignDelete<F> {}
+
 /// Future that generated by [`Operator::presign_read_with`].
 ///
 /// Users can add more options by public functions provided by this struct.


Test patch:
diff --git a/core/tests/behavior/async_presign.rs b/core/tests/behavior/async_presign.rs
index e58d419fb41..7e69100b096 100644
--- a/core/tests/behavior/async_presign.rs
+++ b/core/tests/behavior/async_presign.rs
@@ -36,7 +36,8 @@ pub fn tests(op: &Operator, tests: &mut Vec<Trial>) {
             op,
             test_presign_write,
             test_presign_read,
-            test_presign_stat
+            test_presign_stat,
+            test_presign_delete
         ))
     }
 }
@@ -138,3 +139,37 @@ pub async fn test_presign_read(op: Operator) -> Result<()> {
     op.delete(&path).await.expect("delete must succeed");
     Ok(())
 }
+
+/// Presign delete should succeed.
+pub async fn test_presign_delete(op: Operator) -> Result<()> {
+    let cap = op.info().full_capability();
+    if !cap.presign_delete {
+        return Ok(());
+    }
+
+    let path = uuid::Uuid::new_v4().to_string();
+    debug!("Generate a random file: {}", &path);
+    let (content, _size) = gen_bytes(op.info().full_capability());
+    // create a file
+    op.write(&path, content.clone())
+        .await
+        .expect("write must succeed");
+
+    // presign delete it
+    let signed_req = op.presign_delete(&path, Duration::from_secs(3600)).await?;
+    debug!("Generated request: {signed_req:?}");
+
+    let client = reqwest::Client::new();
+    let mut req = client.request(
+        signed_req.method().clone(),
+        Url::from_str(&signed_req.uri().to_string()).expect("must be valid url"),
+    );
+    for (k, v) in signed_req.header() {
+        req = req.header(k, v);
+    }
+
+    let _resp = req.send().await.expect("send request must succeed");
+
+    assert!(!op.exists(&path).await.expect("delete must succeed"));
+    Ok(())
+}