libdav 0.10.3

CalDAV and CardDAV client implementations.
Documentation
//! Create a calendar collection.

use http::{Method, StatusCode};

use crate::{
    caldav::{CalendarComponent, supported_calendar_component_set},
    dav::WebDavError,
    names,
    requests::{DavRequest, ParseResponseError, PreparedRequest, xml_content_type_header},
    xmlutils::XmlNode,
};

/// Request to create a calendar collection.
///
/// Uses Extended MKCOL (RFC 5689) to create a calendar with optional properties.
///
/// # Example
///
/// ```
/// # use libdav::caldav::CreateCalendar;
/// # use libdav::caldav::CalendarComponent;
/// # use libdav::dav::WebDavClient;
/// # use tower_service::Service;
/// # async fn example<C>(webdav: &WebDavClient<C>) -> Result<(), Box<dyn std::error::Error>>
/// # where
/// #     C: Service<http::Request<String>, Response = http::Response<hyper::body::Incoming>> + Send + Sync,
/// #     C::Error: std::error::Error + Send + Sync,
/// # {
/// let response = webdav.request(
///     CreateCalendar::new("/calendars/work/")
///         // All of these calls are optional.
///         .with_display_name("Work Calendar")
///         .with_colour("#FF0000")
///         .with_order(10)
///         .with_components(&[CalendarComponent::VEvent, CalendarComponent::VTodo])
/// ).await?;
///
/// println!("Calendar created: {}", response.created);
/// # Ok(())
/// # }
/// ```
pub struct CreateCalendar<'a> {
    path: &'a str,
    display_name: Option<&'a str>,
    colour: Option<&'a str>,
    order: Option<i32>,
    component_set: Option<&'a [CalendarComponent]>,
}

impl<'a> CreateCalendar<'a> {
    /// Create a new `CreateCalendar` request for the given path.
    ///
    /// The path should be a collection path relative to the server's base URL.
    #[must_use]
    pub fn new(path: &'a str) -> Self {
        Self {
            path,
            display_name: None,
            colour: None,
            order: None,
            component_set: None,
        }
    }

    /// Set the display name for the calendar.
    ///
    /// See also: <https://www.rfc-editor.org/rfc/rfc4918#section-15.2>
    #[must_use]
    pub fn with_display_name(mut self, name: &'a str) -> Self {
        self.display_name = Some(name);
        self
    }

    /// Set the calendar color.
    ///
    /// The value should be a hex color with a leading pound sign (e.g., `#ff0000`).
    #[must_use]
    pub fn with_colour(mut self, colour: &'a str) -> Self {
        self.colour = Some(colour);
        self
    }

    /// Set the calendar order (for client-side sorting).
    #[must_use]
    pub fn with_order(mut self, order: i32) -> Self {
        self.order = Some(order);
        self
    }

    /// Set the supported calendar component types (`VEVENT`, `VTODO`, `VJOURNAL`, etc).
    #[must_use]
    pub fn with_components(mut self, components: &'a [CalendarComponent]) -> Self {
        self.component_set = Some(components);
        self
    }
}

/// Response from a `CreateCalendar` request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateCalendarResponse {
    /// Whether the calendar was successfully created.
    pub created: bool,
    /// Etag of the created calendar, if returned by the server.
    ///
    /// Note: Some servers don't return etags for collections.
    pub etag: Option<String>,
    // TODO: ctag
    //       https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-ctag.txt
}

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

    // FIXME: error type needs refinement
    fn prepare_request(&self) -> Result<PreparedRequest, http::Error> {
        let mut resourcetype = XmlNode::new(&names::RESOURCETYPE);
        resourcetype.children = vec![
            XmlNode::new(&names::COLLECTION),
            XmlNode::new(&names::CALENDAR),
        ];

        let mut prop_children: Vec<XmlNode<'_>> = vec![resourcetype];

        if let Some(name) = self.display_name {
            prop_children.push(XmlNode::new(&names::DISPLAY_NAME).with_text(name));
        }
        if let Some(colour) = self.colour {
            prop_children.push(XmlNode::new(&names::CALENDAR_COLOUR).with_text(colour));
        }
        if let Some(components) = self.component_set {
            prop_children.push(supported_calendar_component_set(components));
        }

        let order_string = self.order.map(|o| o.to_string());
        if let Some(ref order_str) = order_string {
            prop_children.push(XmlNode::new(&names::CALENDAR_ORDER).with_text(order_str));
        }

        let mut prop = XmlNode::new(&names::PROP);
        prop.children = prop_children;

        let set = XmlNode::new(&names::SET).with_children(vec![prop]);
        let mkcol = XmlNode::new(&names::MKCOL).with_children(vec![set]);

        Ok(PreparedRequest {
            method: Method::from_bytes(b"MKCOL")?,
            path: self.path.to_string(),
            body: mkcol.render_node(),
            headers: vec![xml_content_type_header()],
        })
    }

    fn parse_response(
        &self,
        parts: &http::response::Parts,
        _body: &[u8],
    ) -> Result<Self::Response, ParseResponseError> {
        let created = parts.status == StatusCode::CREATED || parts.status.is_success();

        if !created {
            return Err(ParseResponseError::BadStatusCode(parts.status));
        }

        let etag = parts
            .headers
            .get("etag")
            .map(|hv| std::str::from_utf8(hv.as_bytes()))
            .transpose()?
            .map(str::to_string);

        Ok(CreateCalendarResponse { created, etag })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_prepare_request_minimal() {
        let req = CreateCalendar::new("/calendars/work/");
        let prepared = req.prepare_request().unwrap();

        assert_eq!(prepared.method, Method::from_bytes(b"MKCOL").unwrap());
        assert_eq!(prepared.path, "/calendars/work/");
        assert_eq!(
            prepared.body,
            concat!(
                r#"<D:mkcol xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">"#,
                r#"<D:set><D:prop>"#,
                r#"<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>"#,
                r#"</D:prop></D:set>"#,
                r#"</D:mkcol>"#,
            )
        );
    }

    #[test]
    fn test_prepare_request_with_display_name() {
        let req = CreateCalendar::new("/calendars/work/").with_display_name("Work Calendar");
        let prepared = req.prepare_request().unwrap();

        assert_eq!(
            prepared.body,
            concat!(
                r#"<D:mkcol xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">"#,
                r#"<D:set><D:prop>"#,
                r#"<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>"#,
                r#"<D:displayname>Work Calendar</D:displayname>"#,
                r#"</D:prop></D:set>"#,
                r#"</D:mkcol>"#,
            )
        );
    }

    #[test]
    fn test_prepare_request_with_colour() {
        let req = CreateCalendar::new("/calendars/work/").with_colour("#FF0000");
        let prepared = req.prepare_request().unwrap();

        assert_eq!(
            prepared.body,
            concat!(
                r#"<D:mkcol xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/" xmlns:C="urn:ietf:params:xml:ns:caldav">"#,
                r#"<D:set><D:prop>"#,
                r#"<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>"#,
                r#"<A:calendar-color>#FF0000</A:calendar-color>"#,
                r#"</D:prop></D:set>"#,
                r#"</D:mkcol>"#,
            )
        );
    }

    #[test]
    fn test_prepare_request_with_order() {
        let req = CreateCalendar::new("/calendars/work/").with_order(10);
        let prepared = req.prepare_request().unwrap();

        assert_eq!(
            prepared.body,
            concat!(
                r#"<D:mkcol xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/" xmlns:C="urn:ietf:params:xml:ns:caldav">"#,
                r#"<D:set><D:prop>"#,
                r#"<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>"#,
                r#"<A:calendar-order>10</A:calendar-order>"#,
                r#"</D:prop></D:set>"#,
                r#"</D:mkcol>"#,
            )
        );
    }

    #[test]
    fn test_prepare_request_with_components() {
        let req = CreateCalendar::new("/calendars/work/")
            .with_components(&[CalendarComponent::VEvent, CalendarComponent::VTodo]);
        let prepared = req.prepare_request().unwrap();

        assert_eq!(
            prepared.body,
            concat!(
                r#"<D:mkcol xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">"#,
                r#"<D:set><D:prop>"#,
                r#"<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>"#,
                r#"<C:supported-calendar-component-set>"#,
                r#"<C:comp name="VEVENT"/><C:comp name="VTODO"/>"#,
                r#"</C:supported-calendar-component-set>"#,
                r#"</D:prop></D:set>"#,
                r#"</D:mkcol>"#,
            )
        );
    }

    #[test]
    fn test_prepare_request_with_all_options() {
        let req = CreateCalendar::new("/calendars/work/")
            .with_display_name("Work Calendar")
            .with_colour("#FF0000")
            .with_order(10)
            .with_components(&[CalendarComponent::VEvent]);
        let prepared = req.prepare_request().unwrap();

        assert_eq!(
            prepared.body,
            concat!(
                r#"<D:mkcol xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/" xmlns:C="urn:ietf:params:xml:ns:caldav">"#,
                r#"<D:set><D:prop>"#,
                r#"<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>"#,
                r#"<D:displayname>Work Calendar</D:displayname>"#,
                r#"<A:calendar-color>#FF0000</A:calendar-color>"#,
                r#"<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>"#,
                r#"<A:calendar-order>10</A:calendar-order>"#,
                r#"</D:prop></D:set>"#,
                r#"</D:mkcol>"#,
            )
        );
    }

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

        assert!(result.created);
        assert_eq!(result.etag, None);
    }

    #[test]
    fn test_parse_response_created_with_etag() {
        let req = CreateCalendar::new("/calendars/work/");
        let response = http::Response::builder()
            .status(StatusCode::CREATED)
            .header("etag", "\"123abc\"")
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, b"").unwrap();

        assert!(result.created);
        assert_eq!(result.etag, Some("\"123abc\"".to_string()));
    }

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

        assert!(result.is_err());
    }
}