Skip to main content

io_webdav/rfc4918/
request.rs

1//! WebDAV request builder.
2//!
3//! Wraps [`io_http::rfc9110::request::HttpRequest`] with the WebDAV
4//! method shortcuts (`PROPFIND`, `PROPPATCH`, `MKCOL`, `REPORT`,
5//! `COPY`, `MOVE`, `OPTIONS`) plus the `Depth`, `Destination`,
6//! `Overwrite`, `If-Match`, `If-None-Match` and content-type headers
7//! every CalDAV/CardDAV coroutine touches.
8//!
9//! Builds on [`url::Url::join`] for path composition via
10//! `resolve`.
11
12use alloc::{
13    format,
14    string::{String, ToString},
15    vec::Vec,
16};
17
18use io_http::rfc9110::request::HttpRequest;
19use log::trace;
20use url::Url;
21
22use crate::rfc4918::{WebdavAuth, emit_header, resolve};
23
24/// Fluent builder for a WebDAV HTTP request.
25#[derive(Clone, Debug)]
26pub struct WebdavRequest {
27    inner: HttpRequest,
28}
29
30impl WebdavRequest {
31    /// Builds a request targeting `path` (relative to `base_url`) with
32    /// the given HTTP method. Sets `Host` from `base_url` and the
33    /// optional `Authorization` header from `auth`. `user_agent` is
34    /// emitted as the `User-Agent` header.
35    pub fn new(
36        base_url: &Url,
37        auth: &WebdavAuth,
38        user_agent: &str,
39        method: &str,
40        path: &str,
41    ) -> Self {
42        let url = resolve(base_url, path);
43
44        let host = match (url.host_str(), url.port()) {
45            (Some(host), Some(port)) => format!("{host}:{port}"),
46            (Some(host), None) => host.to_string(),
47            (None, _) => String::new(),
48        };
49
50        let mut inner = HttpRequest::get(url).header("User-Agent", user_agent);
51
52        if !host.is_empty() {
53            inner = inner.header("Host", host);
54        }
55
56        if let Some(value) = emit_header(auth) {
57            inner = inner.header("Authorization", value);
58        }
59
60        inner.method = method.to_string();
61
62        Self { inner }
63    }
64
65    /// Builds a `GET` request.
66    pub fn get(base_url: &Url, auth: &WebdavAuth, user_agent: &str, path: &str) -> Self {
67        Self::new(base_url, auth, user_agent, "GET", path)
68    }
69
70    /// Builds a `DELETE` request.
71    pub fn delete(base_url: &Url, auth: &WebdavAuth, user_agent: &str, path: &str) -> Self {
72        Self::new(base_url, auth, user_agent, "DELETE", path)
73    }
74
75    /// Builds a `PUT` request.
76    pub fn put(base_url: &Url, auth: &WebdavAuth, user_agent: &str, path: &str) -> Self {
77        Self::new(base_url, auth, user_agent, "PUT", path)
78    }
79
80    /// Builds an `OPTIONS` request.
81    pub fn options(base_url: &Url, auth: &WebdavAuth, user_agent: &str, path: &str) -> Self {
82        Self::new(base_url, auth, user_agent, "OPTIONS", path)
83    }
84
85    /// Builds a `MKCOL` request (RFC 4918 §9.3).
86    pub fn mkcol(base_url: &Url, auth: &WebdavAuth, user_agent: &str, path: &str) -> Self {
87        Self::new(base_url, auth, user_agent, "MKCOL", path)
88    }
89
90    /// Builds a `PROPFIND` request (RFC 4918 §9.1).
91    pub fn propfind(base_url: &Url, auth: &WebdavAuth, user_agent: &str, path: &str) -> Self {
92        Self::new(base_url, auth, user_agent, "PROPFIND", path)
93    }
94
95    /// Builds a `PROPPATCH` request (RFC 4918 §9.2).
96    pub fn proppatch(base_url: &Url, auth: &WebdavAuth, user_agent: &str, path: &str) -> Self {
97        Self::new(base_url, auth, user_agent, "PROPPATCH", path)
98    }
99
100    /// Builds a `REPORT` request (RFC 3253 §3.6).
101    pub fn report(base_url: &Url, auth: &WebdavAuth, user_agent: &str, path: &str) -> Self {
102        Self::new(base_url, auth, user_agent, "REPORT", path)
103    }
104
105    /// Builds a `COPY` request (RFC 4918 §9.8).
106    pub fn copy(base_url: &Url, auth: &WebdavAuth, user_agent: &str, path: &str) -> Self {
107        Self::new(base_url, auth, user_agent, "COPY", path)
108    }
109
110    /// Builds a `MOVE` request (RFC 4918 §9.9).
111    pub fn r#move(base_url: &Url, auth: &WebdavAuth, user_agent: &str, path: &str) -> Self {
112        Self::new(base_url, auth, user_agent, "MOVE", path)
113    }
114
115    /// Sets the `Depth` header (RFC 4918 §10.2).
116    pub fn depth(mut self, depth: u8) -> Self {
117        self.inner = self.inner.header("Depth", depth);
118        self
119    }
120
121    /// Sets the `Destination` header (RFC 4918 §10.3).
122    pub fn destination(mut self, destination: &str) -> Self {
123        self.inner = self.inner.header("Destination", destination);
124        self
125    }
126
127    /// Sets the `Overwrite` header (RFC 4918 §10.6) to `T` or `F`.
128    pub fn overwrite(mut self, overwrite: bool) -> Self {
129        let value = if overwrite { "T" } else { "F" };
130        self.inner = self.inner.header("Overwrite", value);
131        self
132    }
133
134    /// Sets the `If-Match` header (RFC 9110 §13.1.1) to the given ETag.
135    pub fn if_match(mut self, etag: &str) -> Self {
136        self.inner = self.inner.header("If-Match", entity_tag(etag));
137        self
138    }
139
140    /// Sets the `If-None-Match` header (RFC 9110 §13.1.2) to the given
141    /// ETag.
142    pub fn if_none_match(mut self, etag: &str) -> Self {
143        self.inner = self.inner.header("If-None-Match", entity_tag(etag));
144        self
145    }
146
147    /// Sets the `Content-Type` header.
148    pub fn content_type(mut self, value: &str) -> Self {
149        self.inner = self.inner.header("Content-Type", value);
150        self
151    }
152
153    /// Shortcut for `content_type("text/xml; charset=utf-8")`.
154    pub fn content_type_xml(self) -> Self {
155        self.content_type("text/xml; charset=utf-8")
156    }
157
158    /// Shortcut for `content_type("text/calendar; charset=utf-8")`.
159    pub fn content_type_ical(self) -> Self {
160        self.content_type("text/calendar; charset=utf-8")
161    }
162
163    /// Shortcut for `content_type("text/vcard; charset=utf-8")`.
164    pub fn content_type_vcard(self) -> Self {
165        self.content_type("text/vcard; charset=utf-8")
166    }
167
168    /// Finalizes the request with the given body and returns the
169    /// underlying [`HttpRequest`] ready for [`crate::rfc4918::send`].
170    ///
171    /// Trace-logs the body: WebDAV request bodies are always UTF-8 text
172    /// (XML, iCalendar or vCard), so io-webdav can safely render them,
173    /// whereas io-http (which cannot know the content type) does not.
174    pub fn body(mut self, body: Vec<u8>) -> HttpRequest {
175        if !body.is_empty() {
176            trace!("request body: {}", String::from_utf8_lossy(&body));
177        }
178        self.inner = self.inner.body(body);
179        self.inner
180    }
181}
182
183/// Formats an ETag as a conditional-header entity-tag (RFC 9110 §8.8.3):
184/// a bare strong tag gets wrapped in double quotes; `*`, weak (`W/...`)
185/// and already-quoted values pass through unchanged.
186fn entity_tag(etag: &str) -> String {
187    if etag == "*" || etag.starts_with('"') || etag.starts_with("W/") {
188        etag.to_string()
189    } else {
190        format!("\"{etag}\"")
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use io_http::rfc7617::basic::HttpAuthBasic;
197    use url::Url;
198
199    use crate::rfc4918::{WebdavAuth, request::*};
200
201    fn base() -> Url {
202        Url::parse("https://dav.example.org/dav/").unwrap()
203    }
204
205    #[test]
206    fn empty_path_returns_base() {
207        let req = WebdavRequest::propfind(&base(), &WebdavAuth::None, "io-webdav/test", "");
208        let request = req.body(Vec::new());
209        assert_eq!(request.url.as_str(), "https://dav.example.org/dav/");
210    }
211
212    #[test]
213    fn absolute_path_replaces() {
214        let req =
215            WebdavRequest::propfind(&base(), &WebdavAuth::None, "io-webdav/test", "/principals/");
216        let request = req.body(Vec::new());
217        assert_eq!(request.url.as_str(), "https://dav.example.org/principals/");
218    }
219
220    #[test]
221    fn relative_path_appends() {
222        let req = WebdavRequest::propfind(&base(), &WebdavAuth::None, "io-webdav/test", "personal");
223        let request = req.body(Vec::new());
224        assert_eq!(request.url.as_str(), "https://dav.example.org/dav/personal");
225    }
226
227    #[test]
228    fn auth_basic_emits_header() {
229        let auth = WebdavAuth::Basic(HttpAuthBasic::new("alice", "secret"));
230        let req = WebdavRequest::get(&base(), &auth, "io-webdav/test", "");
231        let request = req.body(Vec::new());
232        assert!(
233            request
234                .headers
235                .iter()
236                .any(|(name, value)| name == "Authorization" && value.starts_with("Basic "))
237        );
238    }
239}