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

erickguan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal.git


The following commit(s) were added to refs/heads/main by this push:
     new 98f8edf5a feat(services/webdav): support conditional read headers 
(#7637)
98f8edf5a is described below

commit 98f8edf5aa3e3c8c017979d1a500bc9957b15283
Author: Yuang Gao <[email protected]>
AuthorDate: Wed Jun 24 19:31:03 2026 -0700

    feat(services/webdav): support conditional read headers (#7637)
    
    * feat(services/webdav): support conditional read headers
    
    Inject If-Match, If-None-Match, If-Modified-Since and If-Unmodified-Since
    in webdav_get, declare the matching read_with_if_* capabilities, and map
    412/304 responses to ConditionNotMatch.
    
    Add a disable_conditional_read backend option (default false) for servers
    that don't honor these headers, and turn it on for the nginx fixtures
    which don't return ETags in PROPFIND.
    
    Part of #5486.
    
    * rename to enable_conditional_read
    
    * chore: cargo fmt bindings/python
    
    * inline header list in conditional-read doc
---
 .github/services/webdav/0_nginx/action.yml         |  1 +
 .../webdav/nginx_with_empty_password/action.yml    |  1 +
 .../services/webdav/nginx_with_password/action.yml |  1 +
 .../services/webdav/nginx_with_redirect/action.yml |  1 +
 .../java/org/apache/opendal/ServiceConfig.java     | 16 ++++++++++
 bindings/python/src/services.rs                    | 32 +++++++++++++++++++
 core/services/webdav/src/backend.rs                | 24 +++++++++++++++
 core/services/webdav/src/config.rs                 | 36 +++++++++++++++++++++-
 core/services/webdav/src/core.rs                   | 31 ++++++++++++++++++-
 website/data/services.json                         | 15 ++++++---
 10 files changed, 152 insertions(+), 6 deletions(-)

diff --git a/.github/services/webdav/0_nginx/action.yml 
b/.github/services/webdav/0_nginx/action.yml
index c90a710cf..8b3c2cd86 100644
--- a/.github/services/webdav/0_nginx/action.yml
+++ b/.github/services/webdav/0_nginx/action.yml
@@ -30,5 +30,6 @@ runs:
       run: |
         cat << EOF >> $GITHUB_ENV
         OPENDAL_WEBDAV_ENDPOINT=http://127.0.0.1:8080
+        OPENDAL_WEBDAV_ENABLE_CONDITIONAL_READ=false
         OPENDAL_TEST_CAPABILITY_OVERRIDES=write_with_user_metadata=false
         EOF
diff --git a/.github/services/webdav/nginx_with_empty_password/action.yml 
b/.github/services/webdav/nginx_with_empty_password/action.yml
index ec1cf6212..1be1599e3 100644
--- a/.github/services/webdav/nginx_with_empty_password/action.yml
+++ b/.github/services/webdav/nginx_with_empty_password/action.yml
@@ -31,5 +31,6 @@ runs:
         cat << EOF >> $GITHUB_ENV
         OPENDAL_WEBDAV_ENDPOINT=http://127.0.0.1:8080
         OPENDAL_WEBDAV_USERNAME=foo
+        OPENDAL_WEBDAV_ENABLE_CONDITIONAL_READ=false
         OPENDAL_TEST_CAPABILITY_OVERRIDES=write_with_user_metadata=false
         EOF
diff --git a/.github/services/webdav/nginx_with_password/action.yml 
b/.github/services/webdav/nginx_with_password/action.yml
index 181a75043..77d4affc1 100644
--- a/.github/services/webdav/nginx_with_password/action.yml
+++ b/.github/services/webdav/nginx_with_password/action.yml
@@ -32,5 +32,6 @@ runs:
         OPENDAL_WEBDAV_ENDPOINT=http://127.0.0.1:8080
         OPENDAL_WEBDAV_USERNAME=bar
         OPENDAL_WEBDAV_PASSWORD=bar
+        OPENDAL_WEBDAV_ENABLE_CONDITIONAL_READ=false
         OPENDAL_TEST_CAPABILITY_OVERRIDES=write_with_user_metadata=false
         EOF
diff --git a/.github/services/webdav/nginx_with_redirect/action.yml 
b/.github/services/webdav/nginx_with_redirect/action.yml
index 114d86177..579b5ce84 100644
--- a/.github/services/webdav/nginx_with_redirect/action.yml
+++ b/.github/services/webdav/nginx_with_redirect/action.yml
@@ -30,5 +30,6 @@ runs:
       run: |
         cat << EOF >> $GITHUB_ENV
         OPENDAL_WEBDAV_ENDPOINT=http://127.0.0.1:8081
+        OPENDAL_WEBDAV_ENABLE_CONDITIONAL_READ=false
         OPENDAL_TEST_CAPABILITY_OVERRIDES=write_with_user_metadata=false
         EOF
diff --git a/bindings/java/src/main/java/org/apache/opendal/ServiceConfig.java 
b/bindings/java/src/main/java/org/apache/opendal/ServiceConfig.java
index d4cc5ad99..a49ff277d 100644
--- a/bindings/java/src/main/java/org/apache/opendal/ServiceConfig.java
+++ b/bindings/java/src/main/java/org/apache/opendal/ServiceConfig.java
@@ -3866,6 +3866,19 @@ public interface ServiceConfig {
          * <p>Default: false</p>
          */
         public final Boolean disableCreateDir;
+        /**
+         * <p>Enable conditional read support.</p>
+         * <p>When enabled (the default), OpenDAL forwards the RFC 7232 headers
+         * <code>If-Match</code>, <code>If-None-Match</code>, 
<code>If-Modified-Since</code> and
+         * <code>If-Unmodified-Since</code> to the server when callers provide 
them.</p>
+         * <p>Some WebDAV-compatible servers (e.g., nginx-dav) don't return 
ETags
+         * in PROPFIND or don't honor these headers on GET. Setting this to
+         * <code>false</code> drops the four <code>read_with_if_*</code> 
capabilities, so calls like
+         * <code>reader_with(path).if_match(...)</code> return 
<code>ErrorKind::Unsupported</code>
+         * locally instead of being silently ignored by the server.</p>
+         * <p>Default: true</p>
+         */
+        public final Boolean enableConditionalRead;
         /**
          * <p>Deprecated: WebDAV user metadata capability is enabled by 
default.</p>
          *
@@ -3922,6 +3935,9 @@ public interface ServiceConfig {
             if (disableCreateDir != null) {
                 map.put("disable_create_dir", 
String.valueOf(disableCreateDir));
             }
+            if (enableConditionalRead != null) {
+                map.put("enable_conditional_read", 
String.valueOf(enableConditionalRead));
+            }
             if (enableUserMetadata != null) {
                 map.put("enable_user_metadata", 
String.valueOf(enableUserMetadata));
             }
diff --git a/bindings/python/src/services.rs b/bindings/python/src/services.rs
index 0bb0f190e..5cf661f9a 100644
--- a/bindings/python/src/services.rs
+++ b/bindings/python/src/services.rs
@@ -2659,6 +2659,7 @@ submit! {
                 *,
                 disable_copy: builtins.bool = ...,
                 disable_create_dir: builtins.bool = ...,
+                enable_conditional_read: builtins.bool = ...,
                 enable_user_metadata: builtins.bool = ...,
                 endpoint: builtins.str = ...,
                 password: builtins.str = ...,
@@ -2689,6 +2690,21 @@ submit! {
                     Enable this option to skip the MKCOL calls and write
                     files directly.
                     Default: false
+                enable_conditional_read : builtins.bool, optional
+                    Enable conditional read support.
+                    When enabled (the default), OpenDAL forwards the RFC
+                    7232 headers `If-Match`, `If-None-Match`,
+                    `If-Modified-Since` and `If-Unmodified-Since` to the
+                    server when callers provide them.
+                    Some WebDAV-compatible servers (e.g., nginx-dav)
+                    don't return ETags in PROPFIND or don't honor these
+                    headers on GET.
+                    Setting this to `false` drops the four
+                    `read_with_if_*` capabilities, so calls like
+                    `reader_with(path).if_match(...)` return
+                    `ErrorKind::Unsupported` locally instead of being
+                    silently ignored by the server.
+                    Default: true
                 enable_user_metadata : builtins.bool, optional
                     Deprecated: WebDAV user metadata capability is
                     enabled by default.
@@ -5303,6 +5319,7 @@ submit! {
                 *,
                 disable_copy: builtins.bool = ...,
                 disable_create_dir: builtins.bool = ...,
+                enable_conditional_read: builtins.bool = ...,
                 enable_user_metadata: builtins.bool = ...,
                 endpoint: builtins.str = ...,
                 password: builtins.str = ...,
@@ -5333,6 +5350,21 @@ submit! {
                     Enable this option to skip the MKCOL calls and write
                     files directly.
                     Default: false
+                enable_conditional_read : builtins.bool, optional
+                    Enable conditional read support.
+                    When enabled (the default), OpenDAL forwards the RFC
+                    7232 headers `If-Match`, `If-None-Match`,
+                    `If-Modified-Since` and `If-Unmodified-Since` to the
+                    server when callers provide them.
+                    Some WebDAV-compatible servers (e.g., nginx-dav)
+                    don't return ETags in PROPFIND or don't honor these
+                    headers on GET.
+                    Setting this to `false` drops the four
+                    `read_with_if_*` capabilities, so calls like
+                    `reader_with(path).if_match(...)` return
+                    `ErrorKind::Unsupported` locally instead of being
+                    silently ignored by the server.
+                    Default: true
                 enable_user_metadata : builtins.bool, optional
                     Deprecated: WebDAV user metadata capability is
                     enabled by default.
diff --git a/core/services/webdav/src/backend.rs 
b/core/services/webdav/src/backend.rs
index 804376f28..e18f0037d 100644
--- a/core/services/webdav/src/backend.rs
+++ b/core/services/webdav/src/backend.rs
@@ -153,6 +153,24 @@ impl WebdavBuilder {
         }
         self
     }
+
+    /// Enable conditional read support.
+    ///
+    /// When enabled (the default), OpenDAL forwards the RFC 7232 headers
+    /// `If-Match`, `If-None-Match`, `If-Modified-Since` and
+    /// `If-Unmodified-Since` to the server when callers provide them.
+    ///
+    /// Some WebDAV-compatible servers (e.g., nginx-dav) don't return ETags
+    /// in PROPFIND or don't honor these headers on GET. Setting this to
+    /// `false` drops the four `read_with_if_*` capabilities, so calls like
+    /// `reader_with(path).if_match(...)` return `ErrorKind::Unsupported`
+    /// locally instead of being silently ignored by the server.
+    ///
+    /// Default: true
+    pub fn enable_conditional_read(mut self, enable: bool) -> Self {
+        self.config.enable_conditional_read = enable;
+        self
+    }
 }
 
 impl Builder for WebdavBuilder {
@@ -193,6 +211,8 @@ impl Builder for WebdavBuilder {
             authorization = Some(format_authorization_by_bearer(token)?)
         }
 
+        let conditional_read = self.config.enable_conditional_read;
+
         let core = Arc::new(WebdavCore {
             info: ServiceInfo::new(WEBDAV_SCHEME, &root, ""),
             capability: Capability {
@@ -200,6 +220,10 @@ impl Builder for WebdavBuilder {
 
                 read: true,
                 read_with_suffix: true,
+                read_with_if_match: conditional_read,
+                read_with_if_none_match: conditional_read,
+                read_with_if_modified_since: conditional_read,
+                read_with_if_unmodified_since: conditional_read,
 
                 write: true,
                 write_can_empty: true,
diff --git a/core/services/webdav/src/config.rs 
b/core/services/webdav/src/config.rs
index 255027afc..af0a7422c 100644
--- a/core/services/webdav/src/config.rs
+++ b/core/services/webdav/src/config.rs
@@ -23,7 +23,7 @@ use serde::Serialize;
 use super::backend::WebdavBuilder;
 
 /// Config for [WebDAV](https://datatracker.ietf.org/doc/html/rfc4918) backend 
support.
-#[derive(Default, Serialize, Deserialize, Clone, PartialEq, Eq)]
+#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)]
 #[serde(default)]
 #[non_exhaustive]
 pub struct WebdavConfig {
@@ -75,6 +75,39 @@ pub struct WebdavConfig {
     ///
     /// Default: `https://opendal.apache.org/ns`
     pub user_metadata_uri: Option<String>,
+    /// Enable conditional read support.
+    ///
+    /// When enabled (the default), OpenDAL forwards the RFC 7232 headers
+    /// `If-Match`, `If-None-Match`, `If-Modified-Since` and
+    /// `If-Unmodified-Since` to the server when callers provide them.
+    ///
+    /// Some WebDAV-compatible servers (e.g., nginx-dav) don't return ETags
+    /// in PROPFIND or don't honor these headers on GET. Setting this to
+    /// `false` drops the four `read_with_if_*` capabilities, so calls like
+    /// `reader_with(path).if_match(...)` return `ErrorKind::Unsupported`
+    /// locally instead of being silently ignored by the server.
+    ///
+    /// Default: true
+    pub enable_conditional_read: bool,
+}
+
+#[allow(deprecated)]
+impl Default for WebdavConfig {
+    fn default() -> Self {
+        Self {
+            endpoint: None,
+            username: None,
+            password: None,
+            token: None,
+            root: None,
+            disable_copy: false,
+            disable_create_dir: false,
+            enable_user_metadata: false,
+            user_metadata_prefix: None,
+            user_metadata_uri: None,
+            enable_conditional_read: true,
+        }
+    }
 }
 
 impl Debug for WebdavConfig {
@@ -86,6 +119,7 @@ impl Debug for WebdavConfig {
             .field("disable_create_dir", &self.disable_create_dir)
             .field("user_metadata_prefix", &self.user_metadata_prefix)
             .field("user_metadata_uri", &self.user_metadata_uri)
+            .field("enable_conditional_read", &self.enable_conditional_read)
             .finish_non_exhaustive()
     }
 }
diff --git a/core/services/webdav/src/core.rs b/core/services/webdav/src/core.rs
index 11220b652..75d6dc0f6 100644
--- a/core/services/webdav/src/core.rs
+++ b/core/services/webdav/src/core.rs
@@ -171,13 +171,35 @@ impl WebdavCore {
         ctx: &OperationContext,
         path: &str,
         range: BytesRange,
-        _: &OpRead,
+        args: &OpRead,
     ) -> Result<Response<HttpBody>> {
         let path = build_rooted_abs_path(&self.root, path);
         let url: String = format!("{}{}", self.endpoint, 
percent_encode_path(&path));
 
         let mut req = Request::get(&url);
 
+        if let Some(if_match) = args.if_match() {
+            req = req.header(header::IF_MATCH, if_match);
+        }
+
+        if let Some(if_none_match) = args.if_none_match() {
+            req = req.header(header::IF_NONE_MATCH, if_none_match);
+        }
+
+        if let Some(if_modified_since) = args.if_modified_since() {
+            req = req.header(
+                header::IF_MODIFIED_SINCE,
+                if_modified_since.format_http_date(),
+            );
+        }
+
+        if let Some(if_unmodified_since) = args.if_unmodified_since() {
+            req = req.header(
+                header::IF_UNMODIFIED_SINCE,
+                if_unmodified_since.format_http_date(),
+            );
+        }
+
         if let Some(auth) = &self.authorization {
             req = req.header(header::AUTHORIZATION, auth.clone())
         }
@@ -1537,6 +1559,13 @@ mod error {
             StatusCode::NOT_FOUND => (ErrorKind::NotFound, false),
             // Some services (like owncloud) return 403 while file locked.
             StatusCode::FORBIDDEN => (ErrorKind::PermissionDenied, true),
+            // RFC 7232: 412 means an If-Match / If-Unmodified-Since
+            // precondition failed; 304 means an If-None-Match /
+            // If-Modified-Since precondition matched. Surface both as
+            // ConditionNotMatch so callers can branch on it.
+            StatusCode::PRECONDITION_FAILED | StatusCode::NOT_MODIFIED => {
+                (ErrorKind::ConditionNotMatch, false)
+            }
             // Allowing retry for resource locked.
             StatusCode::LOCKED => (ErrorKind::Unexpected, true),
             StatusCode::INTERNAL_SERVER_ERROR
diff --git a/website/data/services.json b/website/data/services.json
index 80c78f6e2..2580993e8 100644
--- a/website/data/services.json
+++ b/website/data/services.json
@@ -4648,6 +4648,13 @@
           "required": false,
           "group": "General",
           "comments": "The XML namespace URI for user metadata 
properties.\n\nThis URI uniquely identifies the namespace for custom 
properties.\nDifferent servers may require different namespace URIs.\nFor 
example, Nextcloud might work better with its own namespace.\n\nDefault: 
`https://opendal.apache.org/ns`";
+        },
+        {
+          "name": "enable_conditional_read",
+          "type": "bool",
+          "required": false,
+          "group": "General",
+          "comments": "Enable conditional read support.\n\nWhen enabled (the 
default), OpenDAL forwards the RFC 7232 headers\n`If-Match`, `If-None-Match`, 
`If-Modified-Since` and\n`If-Unmodified-Since` to the server when callers 
provide them.\n\nSome WebDAV-compatible servers (e.g., nginx-dav) don't return 
ETags\nin PROPFIND or don't honor these headers on GET. Setting this 
to\n`false` drops the four `read_with_if_*` capabilities, so calls 
like\n`reader_with(path).if_match(...)` return ` [...]
         }
       ],
       "examples": [
@@ -4655,25 +4662,25 @@
           "binding": "rust",
           "language": "rust",
           "minimal": "use opendal::Operator;\n\nlet op = 
Operator::via_iter(\"webdav\", [\n])?;",
-          "full": "use opendal::Operator;\n\nlet op = 
Operator::via_iter(\"webdav\", [\n    // endpoint of this backend\n    // 
(\"endpoint\".to_string(), \"...\".to_string()),\n\n    // username of this 
backend\n    // (\"username\".to_string(), \"...\".to_string()),\n\n    // 
password of this backend\n    // (\"password\".to_string(), 
\"...\".to_string()),\n\n    // token of this backend\n    // 
(\"token\".to_string(), \"...\".to_string()),\n\n    // root of this backend\n  
  // (\"roo [...]
+          "full": "use opendal::Operator;\n\nlet op = 
Operator::via_iter(\"webdav\", [\n    // endpoint of this backend\n    // 
(\"endpoint\".to_string(), \"...\".to_string()),\n\n    // username of this 
backend\n    // (\"username\".to_string(), \"...\".to_string()),\n\n    // 
password of this backend\n    // (\"password\".to_string(), 
\"...\".to_string()),\n\n    // token of this backend\n    // 
(\"token\".to_string(), \"...\".to_string()),\n\n    // root of this backend\n  
  // (\"roo [...]
         },
         {
           "binding": "python",
           "language": "python",
           "minimal": "import opendal\n\nop = opendal.Operator(\n    
\"webdav\",\n)",
-          "full": "import opendal\n\nop = opendal.Operator(\n    \"webdav\",\n 
   # endpoint of this backend\n    # endpoint=\"...\",\n\n    # username of 
this backend\n    # username=\"...\",\n\n    # password of this backend\n    # 
password=\"...\",\n\n    # token of this backend\n    # token=\"...\",\n\n    # 
root of this backend\n    # root=\"...\",\n\n    # Disable automatic parent 
directory creation before write operations.\n    #\n    # By default, OpenDAL 
creates parent directori [...]
+          "full": "import opendal\n\nop = opendal.Operator(\n    \"webdav\",\n 
   # endpoint of this backend\n    # endpoint=\"...\",\n\n    # username of 
this backend\n    # username=\"...\",\n\n    # password of this backend\n    # 
password=\"...\",\n\n    # token of this backend\n    # token=\"...\",\n\n    # 
root of this backend\n    # root=\"...\",\n\n    # Disable automatic parent 
directory creation before write operations.\n    #\n    # By default, OpenDAL 
creates parent directori [...]
         },
         {
           "binding": "nodejs",
           "language": "javascript",
           "minimal": "import { Operator } from \"opendal\";\n\nconst op = new 
Operator(\"webdav\", {\n});",
-          "full": "import { Operator } from \"opendal\";\n\nconst op = new 
Operator(\"webdav\", {\n  // endpoint of this backend\n  // endpoint: 
\"...\",\n\n  // username of this backend\n  // username: \"...\",\n\n  // 
password of this backend\n  // password: \"...\",\n\n  // token of this 
backend\n  // token: \"...\",\n\n  // root of this backend\n  // root: 
\"...\",\n\n  // Disable automatic parent directory creation before write 
operations.\n  //\n  // By default, OpenDAL creates par [...]
+          "full": "import { Operator } from \"opendal\";\n\nconst op = new 
Operator(\"webdav\", {\n  // endpoint of this backend\n  // endpoint: 
\"...\",\n\n  // username of this backend\n  // username: \"...\",\n\n  // 
password of this backend\n  // password: \"...\",\n\n  // token of this 
backend\n  // token: \"...\",\n\n  // root of this backend\n  // root: 
\"...\",\n\n  // Disable automatic parent directory creation before write 
operations.\n  //\n  // By default, OpenDAL creates par [...]
         },
         {
           "binding": "java",
           "language": "java",
           "minimal": "import java.util.HashMap;\nimport java.util.Map;\nimport 
org.apache.opendal.Operator;\n\nMap<String, String> conf = new 
HashMap<>();\nOperator op = Operator.of(\"webdav\", conf);",
-          "full": "import java.util.HashMap;\nimport java.util.Map;\nimport 
org.apache.opendal.Operator;\n\nMap<String, String> conf = new HashMap<>();\n// 
endpoint of this backend\n// conf.put(\"endpoint\", \"...\");\n\n// username of 
this backend\n// conf.put(\"username\", \"...\");\n\n// password of this 
backend\n// conf.put(\"password\", \"...\");\n\n// token of this backend\n// 
conf.put(\"token\", \"...\");\n\n// root of this backend\n// conf.put(\"root\", 
\"...\");\n\n// Disable au [...]
+          "full": "import java.util.HashMap;\nimport java.util.Map;\nimport 
org.apache.opendal.Operator;\n\nMap<String, String> conf = new HashMap<>();\n// 
endpoint of this backend\n// conf.put(\"endpoint\", \"...\");\n\n// username of 
this backend\n// conf.put(\"username\", \"...\");\n\n// password of this 
backend\n// conf.put(\"password\", \"...\");\n\n// token of this backend\n// 
conf.put(\"token\", \"...\");\n\n// root of this backend\n// conf.put(\"root\", 
\"...\");\n\n// Disable au [...]
         }
       ]
     },

Reply via email to