libdav 0.10.3

CalDAV and CardDAV client implementations.
Documentation
//! PROPFIND request for retrieving properties.

use http::Method;

use crate::{
    Depth, PropertyName,
    dav::WebDavError,
    names,
    requests::{DavRequest, ParseResponseError, PreparedRequest, xml_content_type_header},
    xmlutils::XmlNode,
};

/// Request to retrieve properties from a resource.
///
/// Adequate for fetching heterogeneous properties, or distinct XML trees or
/// variable depth.
///
/// # See also
///
/// - [`crate::dav::GetProperty`] / [`crate::dav::GetProperties`] for extracting simple
///   text property values automatically.
/// - <https://www.rfc-editor.org/rfc/rfc4918#section-9.1>
pub struct Propfind<'a> {
    href: &'a str,
    properties: Vec<&'a PropertyName<'a, 'a>>,
    depth: Depth,
}

impl<'a> Propfind<'a> {
    /// Create a new PROPFIND request for the given resource href.
    ///
    /// The href should be a path relative to the server's base URL.
    /// By default, uses `Depth::Zero` and no properties (allprop).
    #[must_use]
    pub fn new(href: &'a str) -> Self {
        Self {
            href,
            properties: Vec::new(),
            depth: Depth::Zero,
        }
    }

    /// Specify the properties to retrieve.
    #[must_use]
    pub fn with_properties(mut self, properties: &[&'a PropertyName<'a, 'a>]) -> Self {
        self.properties = properties.to_vec();
        self
    }

    /// Set the depth for the PROPFIND request.
    #[must_use]
    pub fn with_depth(mut self, depth: Depth) -> Self {
        self.depth = depth;
        self
    }

    /// Returns the properties specified so far.
    #[must_use]
    pub fn properties(&self) -> &[&'a PropertyName<'a, 'a>] {
        &self.properties
    }
}

/// Response from a [`Propfind`] request.
///
/// Contains the raw body bytes, allowing callers to parse
/// the XML response according to their specific needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PropfindResponse {
    /// The raw response body.
    ///
    /// Contains the XML multistatus response from the server.
    pub body: Vec<u8>,
}

impl PropfindResponse {
    /// Response body as an XML document.
    ///
    /// # Errors
    ///
    /// Returns an error if the body is not valid UTF-8 or if the XML is malformed.
    pub fn xml_tree(&self) -> Result<roxmltree::Document<'_>, ParseResponseError> {
        let body = std::str::from_utf8(&self.body)?;
        let doc = roxmltree::Document::parse(body)?;
        Ok(doc)
    }
}

impl DavRequest for Propfind<'_> {
    type Response = PropfindResponse;
    type ParseError = ParseResponseError;
    type Error<E> = WebDavError<E>;

    fn prepare_request(&self) -> Result<PreparedRequest, http::Error> {
        let mut propfind = XmlNode::new(&names::PROPFIND);

        propfind.children = if self.properties.is_empty() {
            vec![XmlNode::new(&names::ALLPROP)]
        } else {
            let mut prop = XmlNode::new(&names::PROP);
            prop.children = self.properties.iter().map(|p| XmlNode::new(p)).collect();
            vec![prop]
        };

        Ok(PreparedRequest {
            method: Method::from_bytes(b"PROPFIND")?,
            path: self.href.to_string(),
            body: propfind.render_node(),
            headers: vec![
                ("Depth".to_string(), self.depth.to_string()),
                xml_content_type_header(),
            ],
        })
    }

    fn parse_response(
        &self,
        parts: &http::response::Parts,
        body: &[u8],
    ) -> Result<Self::Response, ParseResponseError> {
        if !parts.status.is_success() {
            return Err(ParseResponseError::BadStatusCode(parts.status));
        }

        Ok(PropfindResponse {
            body: body.to_vec(),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::names;
    use http::StatusCode;

    #[test]
    fn test_prepare_request_allprop() {
        let req = Propfind::new("/calendars/personal/");
        let prepared = req.prepare_request().unwrap();

        assert_eq!(prepared.method, Method::from_bytes(b"PROPFIND").unwrap());
        assert_eq!(prepared.path, "/calendars/personal/");
        assert!(prepared.body.contains("allprop"));
        assert!(prepared.body.contains(r#"xmlns:D="DAV:""#));
        assert!(
            prepared
                .headers
                .contains(&("Depth".to_string(), "0".to_string()))
        );
    }

    #[test]
    fn test_prepare_request_with_properties() {
        let req = Propfind::new("/calendars/personal/")
            .with_properties(&[&names::DISPLAY_NAME, &names::GETETAG]);
        let prepared = req.prepare_request().unwrap();

        assert_eq!(prepared.method, Method::from_bytes(b"PROPFIND").unwrap());
        assert_eq!(prepared.path, "/calendars/personal/");
        assert!(prepared.body.contains("displayname"));
        assert!(prepared.body.contains("getetag"));
        assert!(!prepared.body.contains("allprop"));
    }

    #[test]
    fn test_prepare_request_depth_one() {
        let req = Propfind::new("/calendars/").with_depth(Depth::One);
        let prepared = req.prepare_request().unwrap();

        assert!(
            prepared
                .headers
                .contains(&("Depth".to_string(), "1".to_string()))
        );
    }

    #[test]
    fn test_prepare_request_depth_infinity() {
        let req = Propfind::new("/calendars/").with_depth(Depth::Infinity);
        let prepared = req.prepare_request().unwrap();

        assert!(
            prepared
                .headers
                .contains(&("Depth".to_string(), "infinity".to_string()))
        );
    }

    #[test]
    fn test_parse_response_success() {
        let req = Propfind::new("/calendars/personal/");
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:">
  <response>
    <href>/calendars/personal/</href>
    <propstat>
      <prop>
        <displayname>Personal</displayname>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
</multistatus>"#;

        let response = http::Response::builder()
            .status(StatusCode::MULTI_STATUS)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, body).unwrap();

        assert_eq!(result.body, body.to_vec());
    }

    #[test]
    fn test_parse_response_bad_status() {
        let req = Propfind::new("/calendars/personal/");
        let response = http::Response::builder()
            .status(StatusCode::FORBIDDEN)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, b"");

        assert!(matches!(
            result,
            Err(ParseResponseError::BadStatusCode(StatusCode::FORBIDDEN))
        ));
    }

    #[test]
    fn test_parse_response_not_found() {
        let req = Propfind::new("/calendars/nonexistent/");
        let response = http::Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, b"");

        assert!(matches!(
            result,
            Err(ParseResponseError::BadStatusCode(StatusCode::NOT_FOUND))
        ));
    }

    #[test]
    fn test_xml_tree_parsing() {
        let req = Propfind::new("/calendars/personal/");
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:">
  <response>
    <href>/calendars/personal/</href>
    <propstat>
      <prop>
        <displayname>Personal</displayname>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
</multistatus>"#;

        let response = http::Response::builder()
            .status(StatusCode::MULTI_STATUS)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, body).unwrap();

        let doc = result.xml_tree().unwrap();
        assert_eq!(doc.root_element().tag_name().name(), "multistatus");
    }

    #[test]
    fn test_xml_tree_invalid_utf8() {
        let invalid_utf8 = vec![0xFF, 0xFE];
        let resp = PropfindResponse { body: invalid_utf8 };

        assert!(resp.xml_tree().is_err());
    }

    #[test]
    fn test_xml_tree_malformed_xml() {
        let malformed = b"<not valid xml";
        let resp = PropfindResponse {
            body: malformed.to_vec(),
        };

        assert!(resp.xml_tree().is_err());
    }
}