libdav 0.10.3

CalDAV and CardDAV client implementations.
Documentation
//! `GetProperty` request for retrieving a single property.

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

/// Request to retrieve a single property from a resource.
///
/// High-level helper. For a lower-level request providing the raw response and
/// variable depth, see [`crate::dav::Propfind`].
///
/// # Common properties
///
/// - [`crate::names::ADDRESSBOOK_DESCRIPTION`]
/// - [`crate::names::CALENDAR_COLOUR`]
/// - [`crate::names::CALENDAR_DESCRIPTION`]
/// - [`crate::names::CALENDAR_ORDER`]
/// - [`crate::names::DISPLAY_NAME`]
///
/// # Quirks
///
/// The namespace of the value 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::GetProperties`] for fetching multiple properties at once.
/// - <https://www.rfc-editor.org/rfc/rfc4918#section-9.1>
pub struct GetProperty<'a> {
    property: &'a PropertyName<'a, 'a>,
    propfind: Propfind<'a>,
}

impl<'a> GetProperty<'a> {
    /// Create a new request to get a property from the given resource.
    ///
    /// `href` should be a path relative to the server's base URL.
    #[must_use]
    pub fn new(href: &'a str, property: &'a PropertyName<'a, 'a>) -> Self {
        Self {
            property,
            propfind: Propfind::new(href)
                .with_properties(&[property])
                .with_depth(Depth::Zero),
        }
    }
}

/// Response from a [`GetProperty`] request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GetPropertyResponse {
    /// The property value, if present.
    ///
    /// `None` if the property exists but has no text content.
    pub value: Option<String>,
}

impl DavRequest for GetProperty<'_> {
    type Response = GetPropertyResponse;
    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 prop = root
            .descendants()
            .find(|node| node.tag_name() == *self.property)
            // Hack to work around: https://github.com/cyrusimap/cyrus-imapd/issues/4489
            .or_else(|| {
                root.descendants()
                    .find(|node| node.tag_name().name() == self.property.name())
            });

        // Property element may still be present (just empty) but with an error status.
        check_multistatus(root)?;

        if let Some(prop) = prop {
            return Ok(GetPropertyResponse {
                value: prop.text().map(str::to_string),
            });
        }

        Err(ParseResponseError::InvalidResponse(
            "Property is missing from response, but response is non-error.".into(),
        ))
    }
}

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

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

    #[test]
    fn test_parse_response_success_with_text() {
        let req = GetProperty::new("/calendars/personal/", &names::DISPLAY_NAME);
        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.value, Some("Personal Calendar".to_string()));
    }

    #[test]
    fn test_parse_response_success_with_cdata() {
        let req = GetProperty::new("/calendars/personal/", &names::DISPLAY_NAME);
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:">
  <response>
    <href>/calendars/personal/</href>
    <propstat>
      <prop>
        <displayname><![CDATA[My 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.value, Some("My Calendar".to_string()));
    }

    #[test]
    fn test_parse_response_empty_property() {
        let req = GetProperty::new("/calendars/personal/", &names::DISPLAY_NAME);
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:">
  <response>
    <href>/calendars/personal/</href>
    <propstat>
      <prop>
        <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.value, None);
    }

    #[test]
    fn test_parse_response_property_not_found_404() {
        let req = GetProperty::new("/calendars/personal/", &names::DISPLAY_NAME);
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:">
  <response>
    <href>/calendars/personal/</href>
    <propstat>
      <prop>
        <displayname/>
      </prop>
      <status>HTTP/1.1 404 Not Found</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);

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

    #[test]
    fn test_parse_response_bad_http_status() {
        let req = GetProperty::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 = GetProperty::new("/calendars/personal/", &names::CALENDAR_COLOUR);
        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>
      </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.value, Some("#ff0000".to_string()));
    }
}