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 [...]
}
]
},