libdav 0.10.3

CalDAV and CardDAV client implementations.
Documentation
//! Create an address book collection.

use http::{Method, StatusCode};

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

/// Request to create an address book collection.
///
/// Uses Extended MKCOL (RFC 5689) to create an address book with optional properties.
///
/// See also: <https://www.rfc-editor.org/rfc/rfc6352#section-6.3.1>
pub struct CreateAddressBook<'a> {
    path: &'a str,
    display_name: Option<&'a str>,
    description: Option<&'a str>,
}

impl<'a> CreateAddressBook<'a> {
    /// Create a new `CreateAddressBook` 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,
            description: None,
        }
    }

    /// Set the display name for the address book.
    ///
    /// 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 description for the address book.
    ///
    /// See also: <https://www.rfc-editor.org/rfc/rfc6352#section-6.2.1>
    #[must_use]
    pub fn with_description(mut self, description: &'a str) -> Self {
        self.description = Some(description);
        self
    }
}

/// Response from a `CreateAddressBook` request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAddressBookResponse {
    /// Whether the address book was successfully created.
    pub created: bool,
    /// Etag of the created address book, if returned by the server.
    ///
    /// Note: Some servers don't return etags for collections.
    pub etag: Option<String>,
}

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

    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::ADDRESSBOOK),
        ];

        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(description) = self.description {
            prop_children
                .push(XmlNode::new(&names::ADDRESSBOOK_DESCRIPTION).with_text(description));
        }

        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(CreateAddressBookResponse { created, etag })
    }
}

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

    #[test]
    fn test_prepare_request_minimal() {
        let req = CreateAddressBook::new("/addressbooks/contacts/");
        let prepared = req.prepare_request().unwrap();

        assert_eq!(prepared.method, Method::from_bytes(b"MKCOL").unwrap());
        assert_eq!(prepared.path, "/addressbooks/contacts/");
        assert_eq!(
            prepared.body,
            concat!(
                r#"<D:mkcol xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">"#,
                r#"<D:set><D:prop>"#,
                r#"<D:resourcetype><D:collection/><CARD:addressbook/></D:resourcetype>"#,
                r#"</D:prop></D:set>"#,
                r#"</D:mkcol>"#,
            )
        );
    }

    #[test]
    fn test_prepare_request_with_display_name() {
        let req =
            CreateAddressBook::new("/addressbooks/contacts/").with_display_name("My Contacts");
        let prepared = req.prepare_request().unwrap();

        assert_eq!(
            prepared.body,
            concat!(
                r#"<D:mkcol xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">"#,
                r#"<D:set><D:prop>"#,
                r#"<D:resourcetype><D:collection/><CARD:addressbook/></D:resourcetype>"#,
                r#"<D:displayname>My Contacts</D:displayname>"#,
                r#"</D:prop></D:set>"#,
                r#"</D:mkcol>"#,
            )
        );
    }

    #[test]
    fn test_prepare_request_with_description() {
        let req =
            CreateAddressBook::new("/addressbooks/contacts/").with_description("Personal contacts");
        let prepared = req.prepare_request().unwrap();

        assert_eq!(
            prepared.body,
            concat!(
                r#"<D:mkcol xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">"#,
                r#"<D:set><D:prop>"#,
                r#"<D:resourcetype><D:collection/><CARD:addressbook/></D:resourcetype>"#,
                r#"<CARD:addressbook-description>Personal contacts</CARD:addressbook-description>"#,
                r#"</D:prop></D:set>"#,
                r#"</D:mkcol>"#,
            )
        );
    }

    #[test]
    fn test_prepare_request_with_all_options() {
        let req = CreateAddressBook::new("/addressbooks/contacts/")
            .with_display_name("My Contacts")
            .with_description("Personal contacts");
        let prepared = req.prepare_request().unwrap();

        assert_eq!(
            prepared.body,
            concat!(
                r#"<D:mkcol xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">"#,
                r#"<D:set><D:prop>"#,
                r#"<D:resourcetype><D:collection/><CARD:addressbook/></D:resourcetype>"#,
                r#"<D:displayname>My Contacts</D:displayname>"#,
                r#"<CARD:addressbook-description>Personal contacts</CARD:addressbook-description>"#,
                r#"</D:prop></D:set>"#,
                r#"</D:mkcol>"#,
            )
        );
    }

    #[test]
    fn test_parse_response_created() {
        let req = CreateAddressBook::new("/addressbooks/contacts/");
        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 = CreateAddressBook::new("/addressbooks/contacts/");
        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 = CreateAddressBook::new("/addressbooks/contacts/");
        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());
    }
}