libdav 0.10.3

CalDAV and CardDAV client implementations.
Documentation
//! Create or update a resource.

use http::Method;

use crate::{
    Precondition,
    dav::WebDavError,
    names,
    requests::{DavRequest, PreparedRequest},
};

/// Marker type indicating no mode has been set yet.
#[derive(Debug, Clone, Copy)]
pub struct NotConfigured;

/// Marker type indicating this is a create operation (resource must not exist).
#[derive(Debug, Clone)]
pub struct Create {
    data: String,
    content_type: String,
}

/// Marker type indicating this is an update operation with etag check.
#[derive(Debug, Clone)]
pub struct Update {
    data: String,
    content_type: String,
    etag: String,
}

/// Request to create or update a resource via PUT.
///
/// Requires specifying either `.create()` for new resources or
/// `.update()` for existing resources with etag verification.
///
/// See also: <https://www.rfc-editor.org/rfc/rfc4918#section-9.7>
#[derive(Debug, Clone)]
pub struct PutResource<'a, Mode> {
    href: &'a str,
    mode: Mode,
}

impl<'a> PutResource<'a, NotConfigured> {
    /// Create a new PUT request for the given href.
    ///
    /// The href should be a path relative to the server's base URL.
    /// Either `.create()` or `.update()` must be called before the request can be executed.
    #[must_use]
    pub fn new(href: &'a str) -> Self {
        Self {
            href,
            mode: NotConfigured,
        }
    }

    /// Create a new resource.
    ///
    /// Uses `If-None-Match: *` header to ensure the resource does not already exist.
    /// The request will fail with a precondition error if a resource already exists at this path.
    #[must_use]
    pub fn create(
        self,
        data: impl Into<String>,
        content_type: impl Into<String>,
    ) -> PutResource<'a, Create> {
        PutResource {
            href: self.href,
            mode: Create {
                data: data.into(),
                content_type: content_type.into(),
            },
        }
    }

    /// Update an existing resource.
    ///
    /// Uses `If-Match: <etag>` header for conditional update.
    /// The request will fail with a precondition error if the resource's current etag
    /// does not match the provided value (indicating it was modified since last read).
    #[must_use]
    pub fn update(
        self,
        data: impl Into<String>,
        content_type: impl Into<String>,
        etag: impl Into<String>,
    ) -> PutResource<'a, Update> {
        PutResource {
            href: self.href,
            mode: Update {
                data: data.into(),
                content_type: content_type.into(),
                etag: etag.into(),
            },
        }
    }
}

/// Response from a [`PutResource`] request.
///
/// Contains the new etag if the server returned one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PutResourceResponse {
    /// The new etag of the resource, if returned by the server.
    ///
    /// Some servers don't return etags in the response headers, in which case
    /// a follow-up request is needed to obtain it.
    pub etag: Option<String>,
}

/// Error when parsing a PUT response.
#[derive(thiserror::Error, Debug)]
pub enum PutResourceParseError {
    /// The server returned an unexpected status code.
    #[error("bad status code: {0}")]
    BadStatusCode(http::StatusCode),
    /// The response body was not valid UTF-8.
    #[error("response not UTF-8: {0}")]
    NotUtf8(#[from] std::str::Utf8Error),
    /// A CalDAV precondition failed.
    #[error("precondition failed: {0}")]
    PreconditionFailed(Precondition<'static>),
}

fn parse_put_response(
    parts: &http::response::Parts,
    body: &[u8],
) -> Result<PutResourceResponse, PutResourceParseError> {
    // Check for precondition failures in the response body
    let body_str = std::str::from_utf8(body)?;
    if let Ok(doc) = roxmltree::Document::parse(body_str) {
        let root = doc.root_element();
        if root
            .descendants()
            .any(|node| node.tag_name() == names::SUPPORTED_CALENDAR_COMPONENT)
        {
            return Err(PutResourceParseError::PreconditionFailed(
                names::SUPPORTED_CALENDAR_COMPONENT.into(),
            ));
        }
    }
    // Note: Some servers return empty responses or textual errors, which we ignore

    if !parts.status.is_success() {
        return Err(PutResourceParseError::BadStatusCode(parts.status));
    }

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

    Ok(PutResourceResponse { etag })
}

impl DavRequest for PutResource<'_, Create> {
    type Response = PutResourceResponse;
    type ParseError = PutResourceParseError;
    type Error<E> = WebDavError<E>;

    fn prepare_request(&self) -> Result<PreparedRequest, http::Error> {
        Ok(PreparedRequest {
            method: Method::PUT,
            path: self.href.to_string(),
            body: self.mode.data.clone(),
            headers: vec![
                ("Content-Type".to_string(), self.mode.content_type.clone()),
                ("If-None-Match".to_string(), "*".to_string()),
            ],
        })
    }

    fn parse_response(
        &self,
        parts: &http::response::Parts,
        body: &[u8],
    ) -> Result<Self::Response, PutResourceParseError> {
        parse_put_response(parts, body)
    }
}

impl DavRequest for PutResource<'_, Update> {
    type Response = PutResourceResponse;
    type ParseError = PutResourceParseError;
    type Error<E> = WebDavError<E>;

    fn prepare_request(&self) -> Result<PreparedRequest, http::Error> {
        Ok(PreparedRequest {
            method: Method::PUT,
            path: self.href.to_string(),
            body: self.mode.data.clone(),
            headers: vec![
                ("Content-Type".to_string(), self.mode.content_type.clone()),
                ("If-Match".to_string(), self.mode.etag.clone()),
            ],
        })
    }

    fn parse_response(
        &self,
        parts: &http::response::Parts,
        body: &[u8],
    ) -> Result<Self::Response, PutResourceParseError> {
        parse_put_response(parts, body)
    }
}

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

    #[test]
    fn test_prepare_request_create() {
        let req = PutResource::new("/calendars/personal/event.ics")
            .create("BEGIN:VCALENDAR\r\nEND:VCALENDAR", "text/calendar");
        let prepared = req.prepare_request().unwrap();

        assert_eq!(prepared.method, Method::PUT);
        assert_eq!(prepared.path, "/calendars/personal/event.ics");
        assert_eq!(prepared.body, "BEGIN:VCALENDAR\r\nEND:VCALENDAR");
        assert!(
            prepared
                .headers
                .contains(&("Content-Type".to_string(), "text/calendar".to_string()))
        );
        assert!(
            prepared
                .headers
                .contains(&("If-None-Match".to_string(), "*".to_string()))
        );
    }

    #[test]
    fn test_prepare_request_update() {
        let req = PutResource::new("/calendars/personal/event.ics").update(
            "BEGIN:VCALENDAR\r\nEND:VCALENDAR",
            "text/calendar",
            "\"abc123\"",
        );
        let prepared = req.prepare_request().unwrap();

        assert_eq!(prepared.method, Method::PUT);
        assert_eq!(prepared.path, "/calendars/personal/event.ics");
        assert_eq!(prepared.body, "BEGIN:VCALENDAR\r\nEND:VCALENDAR");
        assert!(
            prepared
                .headers
                .contains(&("Content-Type".to_string(), "text/calendar".to_string()))
        );
        assert!(
            prepared
                .headers
                .contains(&("If-Match".to_string(), "\"abc123\"".to_string()))
        );
    }

    #[test]
    fn test_parse_response_success_with_etag() {
        let req = PutResource::new("/calendars/personal/event.ics").create("data", "text/calendar");
        let response = http::Response::builder()
            .status(StatusCode::CREATED)
            .header("etag", "\"new-etag\"")
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, b"").unwrap();

        assert_eq!(result.etag, Some("\"new-etag\"".to_string()));
    }

    #[test]
    fn test_parse_response_success_no_etag() {
        let req = PutResource::new("/calendars/personal/event.ics").create("data", "text/calendar");
        let response = http::Response::builder()
            .status(StatusCode::CREATED)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, b"").unwrap();

        assert_eq!(result.etag, None);
    }

    #[test]
    fn test_parse_response_update_success() {
        let req = PutResource::new("/calendars/personal/event.ics").update(
            "data",
            "text/calendar",
            "\"old-etag\"",
        );
        let response = http::Response::builder()
            .status(StatusCode::NO_CONTENT)
            .header("etag", "\"new-etag\"")
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, b"").unwrap();

        assert_eq!(result.etag, Some("\"new-etag\"".to_string()));
    }

    #[test]
    fn test_parse_response_precondition_failed() {
        let req = PutResource::new("/calendars/personal/event.ics").update(
            "data",
            "text/calendar",
            "\"old-etag\"",
        );
        let response = http::Response::builder()
            .status(StatusCode::PRECONDITION_FAILED)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, b"");

        assert!(matches!(
            result,
            Err(PutResourceParseError::BadStatusCode(
                StatusCode::PRECONDITION_FAILED
            ))
        ));
    }

    #[test]
    fn test_parse_response_caldav_precondition() {
        let req = PutResource::new("/calendars/personal/event.ics").create("data", "text/calendar");
        let response = http::Response::builder()
            .status(StatusCode::FORBIDDEN)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let body = br#"<?xml version="1.0"?>
            <error xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
                <C:supported-calendar-component/>
            </error>"#;
        let result = req.parse_response(&parts, body);

        assert!(matches!(
            result,
            Err(PutResourceParseError::PreconditionFailed(_))
        ));
    }

    #[test]
    fn test_create_accepts_string() {
        let data = String::from("data");
        let content_type = String::from("text/calendar");
        let req = PutResource::new("/path").create(data, content_type);
        let prepared = req.prepare_request().unwrap();
        assert_eq!(prepared.body, "data");
    }

    #[test]
    fn test_update_accepts_string() {
        let data = String::from("data");
        let content_type = String::from("text/calendar");
        let etag = String::from("\"abc\"");
        let req = PutResource::new("/path").update(data, content_type, etag);
        let prepared = req.prepare_request().unwrap();
        assert_eq!(prepared.body, "data");
        assert!(
            prepared
                .headers
                .contains(&("If-Match".to_string(), "\"abc\"".to_string()))
        );
    }
}