use http::Method;
use crate::{
Precondition,
dav::WebDavError,
names,
requests::{DavRequest, PreparedRequest},
};
#[derive(Debug, Clone, Copy)]
pub struct NotConfigured;
#[derive(Debug, Clone)]
pub struct Create {
data: String,
content_type: String,
}
#[derive(Debug, Clone)]
pub struct Update {
data: String,
content_type: String,
etag: String,
}
#[derive(Debug, Clone)]
pub struct PutResource<'a, Mode> {
href: &'a str,
mode: Mode,
}
impl<'a> PutResource<'a, NotConfigured> {
#[must_use]
pub fn new(href: &'a str) -> Self {
Self {
href,
mode: NotConfigured,
}
}
#[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(),
},
}
}
#[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(),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PutResourceResponse {
pub etag: Option<String>,
}
#[derive(thiserror::Error, Debug)]
pub enum PutResourceParseError {
#[error("bad status code: {0}")]
BadStatusCode(http::StatusCode),
#[error("response not UTF-8: {0}")]
NotUtf8(#[from] std::str::Utf8Error),
#[error("precondition failed: {0}")]
PreconditionFailed(Precondition<'static>),
}
fn parse_put_response(
parts: &http::response::Parts,
body: &[u8],
) -> Result<PutResourceResponse, PutResourceParseError> {
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(),
));
}
}
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()))
);
}
}