libdav 0.10.3

CalDAV and CardDAV client implementations.
Documentation
//! Fetch calendar resources.

use http::Method;

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

/// Fetch calendar resources.
///
/// Fetches iCalendar resources from a calendar collection. When specific hrefs are provided,
/// uses `calendar-multiget`. When no hrefs are specified, uses `calendar-query`.
pub struct GetCalendarResources<'a> {
    collection_href: &'a str,
    hrefs: Option<Vec<String>>,
}

impl<'a> GetCalendarResources<'a> {
    /// Create a new request to fetch all calendar resources in a collection.
    ///
    /// Without specifying hrefs, this fetches all items in the collection.
    #[must_use]
    pub fn new(collection_href: &'a str) -> Self {
        Self {
            collection_href,
            hrefs: None,
        }
    }

    /// Specify specific resource hrefs to fetch.
    ///
    /// Only those resources are fetched, using `calendar-multiget`.
    #[must_use]
    pub fn with_hrefs<S: AsRef<str>>(mut self, hrefs: impl IntoIterator<Item = S>) -> Self
    where
        Self: Sized,
    {
        self.hrefs = Some(hrefs.into_iter().map(|s| s.as_ref().to_string()).collect());
        self
    }
}

/// Response from a [`GetCalendarResources`] request.
#[derive(Debug, PartialEq, Eq)]
pub struct GetCalendarResourcesResponse {
    /// The fetched resources.
    ///
    /// Each resource contains its href and either its content (data + etag) or an error status.
    pub resources: Vec<FetchedResource>,
}

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

    fn prepare_request(&self) -> Result<PreparedRequest, http::Error> {
        let mut prop = XmlNode::new(&names::PROP);
        prop.children = vec![
            XmlNode::new(&names::GETETAG),
            XmlNode::new(&names::CALENDAR_DATA),
        ];

        let body = if let Some(hrefs) = &self.hrefs {
            let mut multiget = XmlNode::new(&names::CALENDAR_MULTIGET);
            multiget.children = vec![prop];
            for href in hrefs {
                multiget
                    .children
                    .push(XmlNode::new(&names::HREF).with_text(href));
            }
            multiget.render_node()
        } else {
            let comp_filter =
                XmlNode::new(&names::COMP_FILTER).with_attributes(vec![("name", "VCALENDAR")]);
            let filter = XmlNode::new(&names::FILTER).with_children(vec![comp_filter]);

            let mut query = XmlNode::new(&names::CALENDAR_QUERY);
            query.children = vec![prop, filter];
            query.render_node()
        };

        Ok(PreparedRequest {
            method: Method::from_bytes(b"REPORT")?,
            path: self.collection_href.to_string(),
            body,
            headers: vec![
                ("Depth".to_string(), "1".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));
        }

        let resources = extract_fetched_resources(body, &names::CALENDAR_DATA)?;
        Ok(GetCalendarResourcesResponse { resources })
    }
}

#[cfg(test)]
mod tests {
    use http::StatusCode;

    use super::*;

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

        assert_eq!(prepared.method, Method::from_bytes(b"REPORT").unwrap());
        assert_eq!(prepared.path, "/calendars/personal/");
        assert_eq!(
            prepared.body,
            concat!(
                r#"<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">"#,
                r#"<D:prop><D:getetag/><C:calendar-data/></D:prop>"#,
                r#"<C:filter><C:comp-filter name="VCALENDAR"/></C:filter>"#,
                r#"</C:calendar-query>"#,
            )
        );
        assert!(
            prepared
                .headers
                .contains(&("Depth".to_string(), "1".to_string()))
        );
    }

    #[test]
    fn test_prepare_request_specific_hrefs() {
        let req = GetCalendarResources::new("/calendars/personal/").with_hrefs([
            "/calendars/personal/event1.ics",
            "/calendars/personal/event2.ics",
        ]);
        let prepared = req.prepare_request().unwrap();

        assert_eq!(prepared.method, Method::from_bytes(b"REPORT").unwrap());
        assert_eq!(prepared.path, "/calendars/personal/");
        assert_eq!(
            prepared.body,
            concat!(
                r#"<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">"#,
                r#"<D:prop><D:getetag/><C:calendar-data/></D:prop>"#,
                r#"<D:href>/calendars/personal/event1.ics</D:href>"#,
                r#"<D:href>/calendars/personal/event2.ics</D:href>"#,
                r#"</C:calendar-multiget>"#,
            )
        );
        assert!(
            prepared
                .headers
                .contains(&("Depth".to_string(), "1".to_string()))
        );
    }

    #[test]
    fn test_prepare_request_escapes_hrefs() {
        let req = GetCalendarResources::new("/calendars/personal/")
            .with_hrefs(["/calendars/personal/<special>&event.ics"]);
        let prepared = req.prepare_request().unwrap();

        assert_eq!(
            prepared.body,
            concat!(
                r#"<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">"#,
                r#"<D:prop><D:getetag/><C:calendar-data/></D:prop>"#,
                r#"<D:href>/calendars/personal/&lt;special&gt;&amp;event.ics</D:href>"#,
                r#"</C:calendar-multiget>"#,
            )
        );
    }

    #[test]
    fn test_parse_response_success() {
        let req = GetCalendarResources::new("/calendars/personal/");
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  <response>
    <href>/calendars/personal/event1.ics</href>
    <propstat>
      <prop>
        <getetag>"abc123"</getetag>
        <C:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:event1
END:VEVENT
END:VCALENDAR
</C:calendar-data>
      </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.resources.len(), 1);
        assert_eq!(result.resources[0].href, "/calendars/personal/event1.ics");
        let content = result.resources[0].content.as_ref().unwrap();
        assert_eq!(content.etag, "\"abc123\"");
        assert!(content.data.contains("BEGIN:VCALENDAR"));
    }

    #[test]
    fn test_parse_response_with_missing_resource() {
        let req = GetCalendarResources::new("/calendars/personal/");
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  <response>
    <href>/calendars/personal/event1.ics</href>
    <propstat>
      <prop>
        <getetag>"abc123"</getetag>
        <C:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
END:VCALENDAR
</C:calendar-data>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
  <response>
    <href>/calendars/personal/missing.ics</href>
    <status>HTTP/1.1 404 Not Found</status>
  </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.resources.len(), 2);
        assert!(result.resources[0].content.is_ok());
        assert_eq!(result.resources[1].content, Err(StatusCode::NOT_FOUND));
    }

    #[test]
    fn test_parse_response_bad_status() {
        let req = GetCalendarResources::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_multiple_resources() {
        let req = GetCalendarResources::new("/calendars/personal/");
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  <response>
    <href>/calendars/personal/event1.ics</href>
    <propstat>
      <prop>
        <getetag>"etag1"</getetag>
        <C:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:event1
END:VEVENT
END:VCALENDAR
</C:calendar-data>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
  <response>
    <href>/calendars/personal/event2.ics</href>
    <propstat>
      <prop>
        <getetag>"etag2"</getetag>
        <C:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:event2
END:VEVENT
END:VCALENDAR
</C:calendar-data>
      </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.resources.len(), 2);
        assert_eq!(result.resources[0].href, "/calendars/personal/event1.ics");
        assert_eq!(
            result.resources[0].content.as_ref().unwrap().etag,
            "\"etag1\""
        );
        assert_eq!(result.resources[1].href, "/calendars/personal/event2.ics");
        assert_eq!(
            result.resources[1].content.as_ref().unwrap().etag,
            "\"etag2\""
        );
    }
}