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

xuanwo pushed a commit to branch registry-more
in repository https://gitbox.apache.org/repos/asf/opendal.git

commit 7031448901103173af0ad9ffce930b511d7eac4a
Author: Xuanwo <[email protected]>
AuthorDate: Tue Oct 14 17:32:30 2025 +0800

    feat: Add from_uri support for http/webdav/ftp/sftp
    
    Signed-off-by: Xuanwo <[email protected]>
---
 core/src/services/ftp/backend.rs    | 53 +++++++++++++++++++++++++++++
 core/src/services/http/backend.rs   | 66 +++++++++++++++++++++++++++++++++++++
 core/src/services/sftp/backend.rs   | 55 +++++++++++++++++++++++++++++++
 core/src/services/webdav/backend.rs | 64 +++++++++++++++++++++++++++++++++++
 core/src/types/operator/uri.rs      | 41 +++++++++++++++++++----
 5 files changed, 272 insertions(+), 7 deletions(-)

diff --git a/core/src/services/ftp/backend.rs b/core/src/services/ftp/backend.rs
index ff7bf662a..4b5362a95 100644
--- a/core/src/services/ftp/backend.rs
+++ b/core/src/services/ftp/backend.rs
@@ -39,9 +39,30 @@ use super::reader::FtpReader;
 use super::writer::FtpWriter;
 use crate::raw::*;
 use crate::services::FtpConfig;
+use crate::types::OperatorUri;
 use crate::*;
 impl Configurator for FtpConfig {
     type Builder = FtpBuilder;
+
+    fn from_uri(uri: &OperatorUri) -> Result<Self> {
+        let authority = uri.authority().ok_or_else(|| {
+            Error::new(ErrorKind::ConfigInvalid, "uri authority is required")
+                .with_context("service", Scheme::Ftp)
+        })?;
+
+        let mut map = uri.options().clone();
+        map.insert(
+            "endpoint".to_string(),
+            format!("{DEFAULT_SCHEME}://{authority}"),
+        );
+
+        if let Some(root) = uri.root() {
+            map.insert("root".to_string(), root.to_string());
+        }
+
+        Self::from_iter(map)
+    }
+
     fn into_builder(self) -> Self::Builder {
         FtpBuilder { config: self }
     }
@@ -355,6 +376,8 @@ impl FtpBackend {
 #[cfg(test)]
 mod build_test {
     use super::FtpBuilder;
+    use crate::services::FtpConfig;
+    use crate::types::OperatorUri;
     use crate::*;
 
     #[test]
@@ -385,4 +408,34 @@ mod build_test {
         let e = b.unwrap_err();
         assert_eq!(e.kind(), ErrorKind::ConfigInvalid);
     }
+
+    #[test]
+    fn from_uri_sets_endpoint_and_root() {
+        let uri = OperatorUri::new(
+            "ftp://example.com/public/data".parse().unwrap(),
+            Vec::<(String, String)>::new(),
+        )
+        .unwrap();
+
+        let cfg = FtpConfig::from_uri(&uri).unwrap();
+        assert_eq!(cfg.endpoint.as_deref(), Some("ftp://example.com";));
+        assert_eq!(cfg.root.as_deref(), Some("public/data"));
+    }
+
+    #[test]
+    fn from_uri_applies_credentials_from_query() {
+        let uri = OperatorUri::new(
+            "ftp://example.com/data".parse().unwrap(),
+            vec![
+                ("user".to_string(), "alice".to_string()),
+                ("password".to_string(), "secret".to_string()),
+            ],
+        )
+        .unwrap();
+
+        let cfg = FtpConfig::from_uri(&uri).unwrap();
+        assert_eq!(cfg.endpoint.as_deref(), Some("ftp://example.com";));
+        assert_eq!(cfg.user.as_deref(), Some("alice"));
+        assert_eq!(cfg.password.as_deref(), Some("secret"));
+    }
 }
diff --git a/core/src/services/http/backend.rs 
b/core/src/services/http/backend.rs
index 9aa0a32f1..99675cbf5 100644
--- a/core/src/services/http/backend.rs
+++ b/core/src/services/http/backend.rs
@@ -28,10 +28,30 @@ use super::core::HttpCore;
 use super::error::parse_error;
 use crate::raw::*;
 use crate::services::HttpConfig;
+use crate::types::OperatorUri;
 use crate::*;
 impl Configurator for HttpConfig {
     type Builder = HttpBuilder;
 
+    fn from_uri(uri: &OperatorUri) -> Result<Self> {
+        let authority = uri.authority().ok_or_else(|| {
+            Error::new(ErrorKind::ConfigInvalid, "uri authority is required")
+                .with_context("service", Scheme::Http)
+        })?;
+
+        let mut map = uri.options().clone();
+        map.insert(
+            "endpoint".to_string(),
+            format!("{}://{}", uri.scheme(), authority),
+        );
+
+        if let Some(root) = uri.root() {
+            map.insert("root".to_string(), root.to_string());
+        }
+
+        Self::from_iter(map)
+    }
+
     #[allow(deprecated)]
     fn into_builder(self) -> Self::Builder {
         HttpBuilder {
@@ -289,3 +309,49 @@ impl Access for HttpBackend {
         )))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn from_uri_sets_endpoint_and_root() {
+        let uri = OperatorUri::new(
+            "http://example.com/static/assets".parse().unwrap(),
+            Vec::<(String, String)>::new(),
+        )
+        .unwrap();
+
+        let cfg = HttpConfig::from_uri(&uri).unwrap();
+        assert_eq!(cfg.endpoint.as_deref(), Some("http://example.com";));
+        assert_eq!(cfg.root.as_deref(), Some("static/assets"));
+    }
+
+    #[test]
+    fn from_uri_preserves_query_options() {
+        let uri = OperatorUri::new(
+            "http://cdn.example.com/data?token=abc123".parse().unwrap(),
+            Vec::<(String, String)>::new(),
+        )
+        .unwrap();
+        let cfg = HttpConfig::from_uri(&uri).unwrap();
+
+        assert_eq!(cfg.endpoint.as_deref(), Some("http://cdn.example.com";));
+        assert_eq!(cfg.token.as_deref(), Some("abc123"));
+    }
+
+    #[test]
+    fn from_uri_ignores_endpoint_override() {
+        let uri = OperatorUri::new(
+            "http://example.com/data".parse().unwrap(),
+            vec![(
+                "endpoint".to_string(),
+                "https://cdn.example.com".to_string(),
+            )],
+        )
+        .unwrap();
+        let cfg = HttpConfig::from_uri(&uri).unwrap();
+
+        assert_eq!(cfg.endpoint.as_deref(), Some("http://example.com";));
+    }
+}
diff --git a/core/src/services/sftp/backend.rs 
b/core/src/services/sftp/backend.rs
index 15dad4262..c3f592c9a 100644
--- a/core/src/services/sftp/backend.rs
+++ b/core/src/services/sftp/backend.rs
@@ -38,9 +38,27 @@ use super::reader::SftpReader;
 use super::writer::SftpWriter;
 use crate::raw::*;
 use crate::services::SftpConfig;
+use crate::types::OperatorUri;
 use crate::*;
 impl Configurator for SftpConfig {
     type Builder = SftpBuilder;
+
+    fn from_uri(uri: &OperatorUri) -> Result<Self> {
+        let authority = uri.authority().ok_or_else(|| {
+            Error::new(ErrorKind::ConfigInvalid, "uri authority is required")
+                .with_context("service", Scheme::Sftp)
+        })?;
+
+        let mut map = uri.options().clone();
+        map.insert("endpoint".to_string(), authority.to_string());
+
+        if let Some(root) = uri.root() {
+            map.insert("root".to_string(), root.to_string());
+        }
+
+        Self::from_iter(map)
+    }
+
     fn into_builder(self) -> Self::Builder {
         SftpBuilder { config: self }
     }
@@ -395,3 +413,40 @@ impl Access for SftpBackend {
         Ok(RpRename::default())
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn from_uri_sets_endpoint_and_root() {
+        let uri = OperatorUri::new(
+            "sftp://sftp.example.com/home/alice".parse().unwrap(),
+            Vec::<(String, String)>::new(),
+        )
+        .unwrap();
+
+        let cfg = SftpConfig::from_uri(&uri).unwrap();
+        assert_eq!(cfg.endpoint.as_deref(), Some("sftp.example.com"));
+        assert_eq!(cfg.root.as_deref(), Some("home/alice"));
+    }
+
+    #[test]
+    fn from_uri_applies_connection_overrides() {
+        let uri = OperatorUri::new(
+            "sftp://host".parse().unwrap(),
+            vec![
+                ("user".to_string(), "alice".to_string()),
+                ("key".to_string(), "/home/alice/.ssh/id_rsa".to_string()),
+                ("known_hosts_strategy".to_string(), "accept".to_string()),
+            ],
+        )
+        .unwrap();
+
+        let cfg = SftpConfig::from_uri(&uri).unwrap();
+        assert_eq!(cfg.endpoint.as_deref(), Some("host"));
+        assert_eq!(cfg.user.as_deref(), Some("alice"));
+        assert_eq!(cfg.key.as_deref(), Some("/home/alice/.ssh/id_rsa"));
+        assert_eq!(cfg.known_hosts_strategy.as_deref(), Some("accept"));
+    }
+}
diff --git a/core/src/services/webdav/backend.rs 
b/core/src/services/webdav/backend.rs
index ef3d76e4f..c3dc48b64 100644
--- a/core/src/services/webdav/backend.rs
+++ b/core/src/services/webdav/backend.rs
@@ -32,10 +32,27 @@ use super::lister::WebdavLister;
 use super::writer::WebdavWriter;
 use crate::raw::*;
 use crate::services::WebdavConfig;
+use crate::types::OperatorUri;
 use crate::*;
 impl Configurator for WebdavConfig {
     type Builder = WebdavBuilder;
 
+    fn from_uri(uri: &OperatorUri) -> Result<Self> {
+        let authority = uri.authority().ok_or_else(|| {
+            Error::new(ErrorKind::ConfigInvalid, "uri authority is required")
+                .with_context("service", Scheme::Webdav)
+        })?;
+
+        let mut map = uri.options().clone();
+        map.insert("endpoint".to_string(), format!("https://{authority}";));
+
+        if let Some(root) = uri.root() {
+            map.insert("root".to_string(), root.to_string());
+        }
+
+        Self::from_iter(map)
+    }
+
     #[allow(deprecated)]
     fn into_builder(self) -> Self::Builder {
         WebdavBuilder {
@@ -316,3 +333,50 @@ impl Access for WebdavBackend {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn from_uri_sets_endpoint_and_root() {
+        let uri = OperatorUri::new(
+            "webdav://webdav.example.com/remote.php/webdav"
+                .parse()
+                .unwrap(),
+            Vec::<(String, String)>::new(),
+        )
+        .unwrap();
+
+        let cfg = WebdavConfig::from_uri(&uri).unwrap();
+        assert_eq!(cfg.endpoint.as_deref(), 
Some("https://webdav.example.com";));
+        assert_eq!(cfg.root.as_deref(), Some("remote.php/webdav"));
+    }
+
+    #[test]
+    fn from_uri_ignores_endpoint_override() {
+        let uri = OperatorUri::new(
+            "webdav://dav.internal/data".parse().unwrap(),
+            vec![(
+                "endpoint".to_string(),
+                "http://dav.internal:8080".to_string(),
+            )],
+        )
+        .unwrap();
+
+        let cfg = WebdavConfig::from_uri(&uri).unwrap();
+        assert_eq!(cfg.endpoint.as_deref(), Some("https://dav.internal";));
+    }
+
+    #[test]
+    fn from_uri_propagates_disable_copy() {
+        let uri = OperatorUri::new(
+            "webdav://dav.example.com".parse().unwrap(),
+            vec![("disable_copy".to_string(), "true".to_string())],
+        )
+        .unwrap();
+
+        let cfg = WebdavConfig::from_uri(&uri).unwrap();
+        assert!(cfg.disable_copy);
+    }
+}
diff --git a/core/src/types/operator/uri.rs b/core/src/types/operator/uri.rs
index 7cfc7900c..3d00d7b5d 100644
--- a/core/src/types/operator/uri.rs
+++ b/core/src/types/operator/uri.rs
@@ -26,6 +26,7 @@ use crate::{Error, ErrorKind, Result};
 #[derive(Clone, Debug, Eq, PartialEq)]
 pub struct OperatorUri {
     scheme: String,
+    authority: Option<String>,
     name: Option<String>,
     root: Option<String>,
     options: HashMap<String, String>,
@@ -64,14 +65,19 @@ impl OperatorUri {
             options.insert(key.to_ascii_lowercase(), value);
         }
 
-        let name = uri.authority().and_then(|authority| {
-            let host = authority.host();
-            if host.is_empty() {
-                None
-            } else {
-                Some(host.to_string())
+        let (authority, name) = match uri.authority() {
+            Some(authority) => {
+                let authority_str = authority.as_str().to_string();
+                let host = authority.host();
+                let name = if host.is_empty() {
+                    None
+                } else {
+                    Some(host.to_string())
+                };
+                (Some(authority_str), name)
             }
-        });
+            None => (None, None),
+        };
 
         let decoded_path = percent_decode_str(uri.path()).decode_utf8_lossy();
         let trimmed = decoded_path.trim_matches('/');
@@ -83,6 +89,7 @@ impl OperatorUri {
 
         Ok(Self {
             scheme,
+            authority,
             name,
             root,
             options,
@@ -99,6 +106,11 @@ impl OperatorUri {
         self.name.as_deref()
     }
 
+    /// Authority extracted from the URI, if present (host with optional port).
+    pub fn authority(&self) -> Option<&str> {
+        self.authority.as_deref()
+    }
+
     /// Root path (without leading slash) extracted from the URI path, if 
present.
     pub fn root(&self) -> Option<&str> {
         self.root.as_deref()
@@ -235,6 +247,7 @@ mod tests {
         .unwrap();
 
         assert_eq!(uri.scheme(), "s3");
+        assert_eq!(uri.authority(), Some("example-bucket"));
         assert_eq!(uri.name(), Some("example-bucket"));
         assert_eq!(uri.root(), Some("photos/2024"));
         assert!(uri.options().is_empty());
@@ -261,4 +274,18 @@ mod tests {
             Some("https://custom";)
         );
     }
+
+    #[test]
+    fn parse_uri_with_port_preserves_authority() {
+        let uri = OperatorUri::new(
+            "http://example.com:8080/root".parse().unwrap(),
+            Vec::<(String, String)>::new(),
+        )
+        .unwrap();
+
+        assert_eq!(uri.scheme(), "http");
+        assert_eq!(uri.authority(), Some("example.com:8080"));
+        assert_eq!(uri.name(), Some("example.com"));
+        assert_eq!(uri.root(), Some("root"));
+    }
 }

Reply via email to