libdav 0.10.3

CalDAV and CardDAV client implementations.
Documentation
//! `GetProperties` request for retrieving multiple properties.

use crate::{
    Depth, PropertyName,
    dav::{Propfind, WebDavError},
    requests::{DavRequest, ParseResponseError, PreparedRequest},
    xmlutils::validate_xml_response,
};

/// Request to retrieve multiple properties from a resource.
///
/// High-level helper. For a lower-level API providing the raw response and
/// variable depth, see [`crate::dav::Propfind`].
///
/// # Quirks
///
/// The namespace of values in the response from the server is ignored. This is a workaround
/// for an [issue in `cyrus-imapd`][cyrus-issue].
///
/// [cyrus-issue]: https://github.com/cyrusimap/cyrus-imapd/issues/4489
///
/// # See also
///
/// - [`crate::dav::GetProperty`] for fetching a single property.
/// - <https://www.rfc-editor.org/rfc/rfc4918#section-9.1>
pub struct GetProperties<'p> {
    propfind: Propfind<'p>,
}

impl<'p> GetProperties<'p> {
    /// Create a new request to get multiple properties from the given resource.
    ///
    /// `href` should be a path relative to the server's base URL.
    /// Properties will be returned in the same order as provided.
    #[must_use]
    pub fn new(href: &'p str, properties: &[&'p PropertyName<'p, 'p>]) -> Self {
        Self {
            propfind: Propfind::new(href)
                .with_properties(properties)
                .with_depth(Depth::Zero),
        }
    }
}

/// Response from a [`GetProperties`] request.
#[derive(Debug, Clone)]
pub struct GetPropertiesResponse<'p> {
    /// Property values, in the same order as requested.
    ///
    /// Each tuple contains the property name reference and its value.
    /// Value is `None` if the property exists but has no text content.
    pub values: Vec<(&'p PropertyName<'p, 'p>, Option<String>)>,
}

impl<'p> DavRequest for GetProperties<'p> {
    type Response = GetPropertiesResponse<'p>;
    type ParseError = ParseResponseError;
    type Error<E> = WebDavError<E>;

    fn prepare_request(&self) -> Result<PreparedRequest, http::Error> {
        self.propfind.prepare_request()
    }

    fn parse_response(
        &self,
        parts: &http::response::Parts,
        body: &[u8],
    ) -> Result<Self::Response, ParseResponseError> {
        let doc = validate_xml_response(parts, body)?;
        let root = doc.root_element();

        let mut results = Vec::with_capacity(self.propfind.properties().len());
        for property in self.propfind.properties() {
            let prop = root
                .descendants()
                .find(|node| node.tag_name() == **property)
                // Hack to work around: https://github.com/cyrusimap/cyrus-imapd/issues/4489
                .or_else(|| {
                    root.descendants()
                        .find(|node| node.tag_name().name() == property.name())
                })
                .and_then(|p| p.text())
                .map(str::to_owned);

            results.push((*property, prop));
        }

        Ok(GetPropertiesResponse { values: results })
    }
}

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

    #[test]
    fn test_prepare_request() {
        let req = GetProperties::new(
            "/calendars/personal/",
            &[&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(r#"xmlns:D="DAV:""#));
        assert!(
            prepared
                .headers
                .contains(&("Depth".to_string(), "0".to_string()))
        );
    }

    #[test]
    fn test_parse_response_success() {
        let req = GetProperties::new(
            "/calendars/personal/",
            &[&names::DISPLAY_NAME, &names::GETETAG],
        );
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:">
  <response>
    <href>/calendars/personal/</href>
    <propstat>
      <prop>
        <displayname>Personal Calendar</displayname>
        <getetag>"abc123"</getetag>
      </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.values.len(), 2);
        assert_eq!(result.values[0].0, &names::DISPLAY_NAME);
        assert_eq!(result.values[0].1, Some("Personal Calendar".to_string()));
        assert_eq!(result.values[1].0, &names::GETETAG);
        assert_eq!(result.values[1].1, Some("\"abc123\"".to_string()));
    }

    #[test]
    fn test_parse_response_partial() {
        // One property found, one missing
        let req = GetProperties::new(
            "/calendars/personal/",
            &[&names::DISPLAY_NAME, &names::GETETAG],
        );
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:">
  <response>
    <href>/calendars/personal/</href>
    <propstat>
      <prop>
        <displayname>Personal Calendar</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.values.len(), 2);
        assert_eq!(result.values[0].1, Some("Personal Calendar".to_string()));
        assert_eq!(result.values[1].1, None); // getetag not found
    }

    #[test]
    fn test_parse_response_bad_http_status() {
        let req = GetProperties::new("/calendars/personal/", &[&names::DISPLAY_NAME]);
        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_cyrus_quirk() {
        // Cyrus returns properties in wrong namespace
        let req = GetProperties::new(
            "/calendars/personal/",
            &[&names::CALENDAR_COLOUR, &names::DISPLAY_NAME],
        );
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:" xmlns:X="http://wrong.namespace/">
  <response>
    <href>/calendars/personal/</href>
    <propstat>
      <prop>
        <X:calendar-color>#ff0000</X:calendar-color>
        <displayname>Test</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.values.len(), 2);
        assert_eq!(result.values[0].1, Some("#ff0000".to_string()));
        assert_eq!(result.values[1].1, Some("Test".to_string()));
    }
}