Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bindings/nodejs/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,8 @@ impl From<WriteOptions> for opendal::options::WriteOptions {
#[derive(Default)]
pub struct DeleteOptions {
pub version: Option<String>,
/// Set `if_match` for this operation.
pub if_match: Option<String>,
/// Whether to delete recursively.
pub recursive: Option<bool>,
}
Expand All @@ -516,6 +518,7 @@ impl From<DeleteOptions> for opendal::options::DeleteOptions {
fn from(value: DeleteOptions) -> Self {
Self {
version: value.version,
if_match: value.if_match,
recursive: value.recursive.unwrap_or_default(),
}
}
Expand Down
25 changes: 25 additions & 0 deletions core/core/src/raw/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ impl OpCreateDir {
pub struct OpDelete {
version: Option<String>,
recursive: bool,
if_match: Option<String>,
}

impl OpDelete {
Expand Down Expand Up @@ -75,13 +76,25 @@ impl OpDelete {
pub fn recursive(&self) -> bool {
self.recursive
}

/// Set the If-Match of the option
pub fn with_if_match(mut self, if_match: &str) -> Self {
self.if_match = Some(if_match.to_string());
self
}

/// Get If-Match from option
pub fn if_match(&self) -> Option<&str> {
self.if_match.as_deref()
}
}

impl From<options::DeleteOptions> for OpDelete {
fn from(value: options::DeleteOptions) -> Self {
Self {
version: value.version,
recursive: value.recursive,
if_match: value.if_match,
}
}
}
Expand Down Expand Up @@ -878,6 +891,7 @@ impl From<options::WriteOptions> for (OpWrite, OpWriter) {
#[derive(Debug, Clone, Default)]
pub struct OpCopy {
if_not_exists: bool,
if_match: Option<String>,
}

impl OpCopy {
Expand All @@ -899,6 +913,17 @@ impl OpCopy {
pub fn if_not_exists(&self) -> bool {
self.if_not_exists
}

/// Set the If-Match of the option
pub fn with_if_match(mut self, if_match: &str) -> Self {
self.if_match = Some(if_match.to_string());
self
}

/// Get If-Match from option
pub fn if_match(&self) -> Option<&str> {
self.if_match.as_deref()
}
}

/// Args for `rename` operation.
Expand Down
4 changes: 4 additions & 0 deletions core/core/src/types/capability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,17 @@ pub struct Capability {
pub delete_with_version: bool,
/// Indicates if recursive delete operations are supported.
pub delete_with_recursive: bool,
/// Indicates if conditional delete operations with if-match are supported.
pub delete_with_if_match: bool,
/// Maximum size supported for single delete operations.
pub delete_max_size: Option<usize>,

/// Indicates if copy operations are supported.
pub copy: bool,
/// Indicates if conditional copy operations with if-not-exists are supported.
pub copy_with_if_not_exists: bool,
/// Indicates if conditional copy operations with if-match are supported.
pub copy_with_if_match: bool,

/// Indicates if rename operations are supported.
pub rename: bool,
Expand Down
58 changes: 58 additions & 0 deletions core/core/src/types/operator/operator_futures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1263,6 +1263,35 @@ impl<F: Future<Output = Result<()>>> FutureDelete<F> {
self.args.recursive = recursive;
self
}

/// Sets If-Match header for this delete request.
///
/// ### Behavior
///
/// - If supported, the delete operation will only succeed if the file's ETag matches the specified value
/// - The value should be a valid ETag string
/// - If not supported, the value will be ignored
///
/// This operation provides conditional delete functionality based on ETag matching.
///
/// ### Example
///
/// ```
/// # use opendal_core::Result;
/// # use opendal_core::Operator;
///
/// # async fn test(op: Operator) -> Result<()> {
/// let _ = op
/// .delete_with("path/to/file")
/// .if_match("\"686897696a7c876b7e\"")
/// .await?;
/// # Ok(())
/// # }
/// ```
pub fn if_match(mut self, s: &str) -> Self {
self.args.if_match = Some(s.to_string());
self
}
}

/// Future that generated by [`Operator::deleter_with`].
Expand Down Expand Up @@ -1420,4 +1449,33 @@ impl<F: Future<Output = Result<()>>> FutureCopy<F> {
self.args.0.if_not_exists = v;
self
}

/// Sets If-Match header for this copy request.
///
/// ### Behavior
///
/// - If supported, the copy operation will only succeed if the source's ETag matches the specified value
/// - The value should be a valid ETag string
/// - If not supported, the value will be ignored
///
/// This operation provides conditional copy functionality based on ETag matching.
///
/// ### Example
///
/// ```
/// # use opendal_core::Result;
/// # use opendal_core::Operator;
///
/// # async fn test(op: Operator) -> Result<()> {
/// let _ = op
/// .copy_with("source/path", "target/path")
/// .if_match("\"686897696a7c876b7e\"")
/// .await?;
/// # Ok(())
/// # }
/// ```
pub fn if_match(mut self, s: &str) -> Self {
self.args.0.if_match = Some(s.to_string());
self
}
}
17 changes: 16 additions & 1 deletion core/core/src/types/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,17 @@ use crate::raw::{BytesRange, Timestamp};
use std::collections::HashMap;

/// Options for delete operations.
#[derive(Debug, Clone, Default, Eq, PartialEq)]
#[derive(Debug, Clone, Default)]
pub struct DeleteOptions {
/// The version of the file to delete.
pub version: Option<String>,
/// Set `if_match` for this operation.
///
/// This option 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`]
/// will be returned.
pub if_match: Option<String>,
/// Whether to delete the target recursively.
///
/// - If `false`, behaves like the traditional single-object delete.
Expand Down Expand Up @@ -532,4 +539,12 @@ pub struct CopyOptions {
/// This operation provides a way to ensure copy operations only create new resources
/// without overwriting existing ones, useful for implementing "copy if not exists" logic.
pub if_not_exists: bool,

/// Set `if_match` for this operation.
///
/// This option can be used to check if the source file's `ETag` matches the given `ETag`.
///
/// If file exists and it's etag doesn't match, an error with kind [`ErrorKind::ConditionNotMatch`]
/// will be returned.
pub if_match: Option<String>,
}
8 changes: 6 additions & 2 deletions core/services/s3/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -922,12 +922,16 @@ impl Builder for S3Builder {
},

delete: true,
delete_with_if_match: true,
delete_max_size: Some(delete_max_size),
delete_with_version: config.enable_versioning,

copy: true,
copy_with_if_not_exists: true,
copy_with_if_match: true,

list: true,
rename: false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems we changed list: true to rename: false? Why this change?

list_with_limit: true,
list_with_start_after: true,
list_with_recursive: true,
Expand Down Expand Up @@ -1072,8 +1076,8 @@ impl Access for S3Backend {
Ok((RpList::default(), l))
}

async fn copy(&self, from: &str, to: &str, _args: OpCopy) -> Result<RpCopy> {
let resp = self.core.s3_copy_object(from, to).await?;
async fn copy(&self, from: &str, to: &str, args: OpCopy) -> Result<RpCopy> {
let resp = self.core.s3_copy_object(from, to, args).await?;

let status = resp.status();

Expand Down
22 changes: 21 additions & 1 deletion core/services/s3/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,11 @@ impl S3Core {

let mut req = Request::delete(&url);

// Add if_match condition using If-Match header
if let Some(if_match) = args.if_match() {
req = req.header(IF_MATCH, if_match);
}

// Set request payer header if enabled.
req = self.insert_request_payer_header(req);

Expand All @@ -625,7 +630,12 @@ impl S3Core {
self.send(req).await
}

pub async fn s3_copy_object(&self, from: &str, to: &str) -> Result<Response<Buffer>> {
pub async fn s3_copy_object(
&self,
from: &str,
to: &str,
args: OpCopy,
) -> Result<Response<Buffer>> {
let from = build_abs_path(&self.root, from);
let to = build_abs_path(&self.root, to);

Expand Down Expand Up @@ -673,6 +683,16 @@ impl S3Core {
)
}

// Add if_not_exists condition using If-None-Match header
if args.if_not_exists() {
req = req.header(IF_NONE_MATCH, "*");
}

// Add if_match condition using If-Match header
if let Some(if_match) = args.if_match() {
req = req.header(IF_MATCH, if_match);
}

// Set request payer header if enabled.
req = self.insert_request_payer_header(req);

Expand Down
73 changes: 73 additions & 0 deletions core/tests/behavior/async_copy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ pub fn tests(op: &Operator, tests: &mut Vec<Trial>) {
test_copy_with_if_not_exists_to_existing_file
))
}

if cap.read && cap.write && cap.copy && cap.copy_with_if_match {
tests.extend(async_trials!(
op,
test_copy_with_if_match_success,
test_copy_with_if_match_failure
))
}
}

/// Copy a file with ascii name and test contents.
Expand Down Expand Up @@ -313,3 +321,68 @@ pub async fn test_copy_with_if_not_exists_to_existing_file(op: Operator) -> Resu
op.delete(&target_path).await.expect("delete must succeed");
Ok(())
}

/// Copy with if_match should succeed when ETag matches.
pub async fn test_copy_with_if_match_success(op: Operator) -> Result<()> {
if !op.info().full_capability().copy_with_if_match {
return Ok(());
}

let source_path = uuid::Uuid::new_v4().to_string();
let (source_content, _) = gen_bytes(op.info().full_capability());

op.write(&source_path, source_content.clone()).await?;

// Get the ETag of the source file
let source_meta = op.stat(&source_path).await?;
let etag = source_meta.etag().expect("source should have etag");

let target_path = uuid::Uuid::new_v4().to_string();

// Copy with matching ETag should succeed
op.copy_with(&source_path, &target_path)
.if_match(etag)
.await?;

let target_content = op
.read(&target_path)
.await
.expect("read must succeed")
.to_bytes();
assert_eq!(
format!("{:x}", Sha256::digest(target_content)),
format!("{:x}", Sha256::digest(&source_content)),
);

op.delete(&source_path).await.expect("delete must succeed");
op.delete(&target_path).await.expect("delete must succeed");
Ok(())
}

/// Copy with if_match should fail when ETag doesn't match.
pub async fn test_copy_with_if_match_failure(op: Operator) -> Result<()> {
if !op.info().full_capability().copy_with_if_match {
return Ok(());
}

let source_path = uuid::Uuid::new_v4().to_string();
let (source_content, _) = gen_bytes(op.info().full_capability());

op.write(&source_path, source_content.clone()).await?;

let target_path = uuid::Uuid::new_v4().to_string();

// Copy with non-matching ETag should fail
let err = op
.copy_with(&source_path, &target_path)
.if_match("invalid-etag")
.await
.expect_err("copy must fail");
assert_eq!(err.kind(), ErrorKind::ConditionNotMatch);

// Verify target file was not created
assert!(!op.exists(&target_path).await?);

op.delete(&source_path).await.expect("delete must succeed");
Ok(())
}
Loading
Loading