use http::Method;
use crate::{
Depth,
dav::{ListedResource, WebDavError, extract_listed_resources},
names,
requests::{DavRequest, ParseResponseError, PreparedRequest, xml_content_type_header},
xmlutils::XmlNode,
};
pub const VALID_COMPONENT_TYPES: &[&str] = &["VEVENT", "VTODO", "VJOURNAL", "VFREEBUSY"];
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
#[error("invalid component type")]
pub struct InvalidComponentType;
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
#[error("invalid time range format, expected YYYYMMDDTHHMMSSZ")]
pub struct InvalidTimeRange;
fn is_valid_icalendar_utc(s: &str) -> bool {
if s.len() != 16 {
return false;
}
let bytes = s.as_bytes();
bytes.iter().take(8).all(u8::is_ascii_digit)
&& bytes.get(8) == Some(&b'T')
&& bytes.iter().skip(9).take(6).all(u8::is_ascii_digit)
&& bytes.get(15) == Some(&b'Z')
}
#[derive(Debug, Clone)]
pub(super) enum CalendarFilter<'a> {
All,
Component {
component_type: &'a str,
start: Option<&'a str>,
end: Option<&'a str>,
},
}
#[derive(Debug, Clone)]
pub struct ListCalendarResources<'a> {
collection_href: &'a str,
filter: CalendarFilter<'a>,
}
impl<'a> ListCalendarResources<'a> {
#[must_use]
pub fn new(collection_href: &'a str) -> Self {
Self {
collection_href,
filter: CalendarFilter::All,
}
}
pub fn with_component(mut self, component_type: &'a str) -> Result<Self, InvalidComponentType> {
if !VALID_COMPONENT_TYPES.contains(&component_type) {
return Err(InvalidComponentType);
}
self.filter = CalendarFilter::Component {
component_type,
start: None,
end: None,
};
Ok(self)
}
pub fn with_component_and_time_range(
mut self,
component_type: &'a str,
start: Option<&'a str>,
end: Option<&'a str>,
) -> Result<Self, ComponentFilterError> {
if !VALID_COMPONENT_TYPES.contains(&component_type) {
return Err(ComponentFilterError::InvalidComponentType);
}
if start.is_some_and(|s| !is_valid_icalendar_utc(s)) {
return Err(ComponentFilterError::InvalidTimeRange);
}
if end.is_some_and(|e| !is_valid_icalendar_utc(e)) {
return Err(ComponentFilterError::InvalidTimeRange);
}
self.filter = CalendarFilter::Component {
component_type,
start,
end,
};
Ok(self)
}
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum ComponentFilterError {
#[error("invalid component type")]
InvalidComponentType,
#[error("invalid time range format, expected YYYYMMDDTHHMMSSZ")]
InvalidTimeRange,
}
impl From<InvalidComponentType> for ComponentFilterError {
fn from(_: InvalidComponentType) -> Self {
Self::InvalidComponentType
}
}
impl From<InvalidTimeRange> for ComponentFilterError {
fn from(_: InvalidTimeRange) -> Self {
Self::InvalidTimeRange
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListCalendarResourcesResponse {
pub resources: Vec<ListedResource>,
}
impl DavRequest for ListCalendarResources<'_> {
type Response = ListCalendarResourcesResponse;
type ParseError = ParseResponseError;
type Error<E> = WebDavError<E>;
fn prepare_request(&self) -> Result<PreparedRequest, http::Error> {
let component_filter = match &self.filter {
CalendarFilter::All => {
vec![]
}
CalendarFilter::Component {
component_type,
start,
end,
} => {
let time_range_attrs: Vec<(&str, &str)> = match (start, end) {
(Some(s), Some(e)) => vec![("start", *s), ("end", *e)],
(Some(s), None) => vec![("start", *s)],
(None, Some(e)) => vec![("end", *e)],
(None, None) => vec![],
};
let comp_filter_children = if time_range_attrs.is_empty() {
vec![]
} else {
vec![XmlNode::new(&names::TIME_RANGE).with_attributes(time_range_attrs)]
};
let comp_filter = XmlNode::new(&names::COMP_FILTER)
.with_attributes(vec![("name", component_type)])
.with_children(comp_filter_children);
vec![
XmlNode::new(&names::COMP_FILTER)
.with_attributes(vec![("name", "VCALENDAR")])
.with_children(vec![comp_filter]),
]
}
};
let filter = XmlNode::new(&names::FILTER).with_children(component_filter);
let mut prop = XmlNode::new(&names::PROP);
prop.children = vec![
XmlNode::new(&names::RESOURCETYPE),
XmlNode::new(&names::GETCONTENTTYPE),
XmlNode::new(&names::GETETAG),
];
let mut calendar_query = XmlNode::new(&names::CALENDAR_QUERY);
calendar_query.children = vec![prop, filter];
let body = calendar_query.render_node();
Ok(PreparedRequest {
method: Method::from_bytes(b"REPORT")?,
path: self.collection_href.to_string(),
body,
headers: vec![
("Depth".to_string(), Depth::One.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_listed_resources(body, self.collection_href)?;
Ok(ListCalendarResourcesResponse { resources })
}
}
#[cfg(test)]
mod tests {
use super::*;
use http::StatusCode;
#[test]
fn test_prepare_request_no_filter() {
let req = ListCalendarResources::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:resourcetype/><D:getcontenttype/><D:getetag/></D:prop>"#,
r#"<C:filter/>"#,
r#"</C:calendar-query>"#,
)
);
assert_eq!(
prepared.headers,
vec![
("Depth".to_string(), "1".to_string()),
(
"Content-Type".to_string(),
"application/xml; charset=utf-8".to_string()
)
]
);
}
#[test]
fn test_prepare_request_with_component() {
let req = ListCalendarResources::new("/calendars/personal/")
.with_component("VEVENT")
.unwrap();
let prepared = req.prepare_request().unwrap();
assert_eq!(
prepared.body,
concat!(
r#"<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">"#,
r#"<D:prop><D:resourcetype/><D:getcontenttype/><D:getetag/></D:prop>"#,
r#"<C:filter><C:comp-filter name="VCALENDAR">"#,
r#"<C:comp-filter name="VEVENT"/>"#,
r#"</C:comp-filter></C:filter>"#,
r#"</C:calendar-query>"#,
)
);
}
#[test]
fn test_prepare_request_with_component_and_start_only() {
let req = ListCalendarResources::new("/calendars/personal/")
.with_component_and_time_range("VEVENT", Some("20240101T000000Z"), None)
.unwrap();
let prepared = req.prepare_request().unwrap();
assert_eq!(
prepared.body,
concat!(
r#"<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">"#,
r#"<D:prop><D:resourcetype/><D:getcontenttype/><D:getetag/></D:prop>"#,
r#"<C:filter><C:comp-filter name="VCALENDAR">"#,
r#"<C:comp-filter name="VEVENT"><C:time-range start="20240101T000000Z"/></C:comp-filter>"#,
r#"</C:comp-filter></C:filter>"#,
r#"</C:calendar-query>"#,
)
);
}
#[test]
fn test_prepare_request_with_component_and_end_only() {
let req = ListCalendarResources::new("/calendars/personal/")
.with_component_and_time_range("VTODO", None, Some("20240201T000000Z"))
.unwrap();
let prepared = req.prepare_request().unwrap();
assert_eq!(
prepared.body,
concat!(
r#"<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">"#,
r#"<D:prop><D:resourcetype/><D:getcontenttype/><D:getetag/></D:prop>"#,
r#"<C:filter><C:comp-filter name="VCALENDAR">"#,
r#"<C:comp-filter name="VTODO"><C:time-range end="20240201T000000Z"/></C:comp-filter>"#,
r#"</C:comp-filter></C:filter>"#,
r#"</C:calendar-query>"#,
)
);
}
#[test]
fn test_prepare_request_with_component_and_full_time_range() {
let req = ListCalendarResources::new("/calendars/personal/")
.with_component_and_time_range(
"VJOURNAL",
Some("20240101T000000Z"),
Some("20240201T000000Z"),
)
.unwrap();
let prepared = req.prepare_request().unwrap();
assert_eq!(
prepared.body,
concat!(
r#"<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">"#,
r#"<D:prop><D:resourcetype/><D:getcontenttype/><D:getetag/></D:prop>"#,
r#"<C:filter><C:comp-filter name="VCALENDAR">"#,
r#"<C:comp-filter name="VJOURNAL"><C:time-range start="20240101T000000Z" end="20240201T000000Z"/></C:comp-filter>"#,
r#"</C:comp-filter></C:filter>"#,
r#"</C:calendar-query>"#,
)
);
}
#[test]
fn test_with_component_invalid() {
let result = ListCalendarResources::new("/calendars/personal/").with_component("INVALID");
assert_eq!(result.unwrap_err(), InvalidComponentType);
}
#[test]
fn test_with_component_and_time_range_invalid_component() {
let result = ListCalendarResources::new("/calendars/personal/")
.with_component_and_time_range("INVALID", Some("20240101T000000Z"), None);
assert_eq!(
result.unwrap_err(),
ComponentFilterError::InvalidComponentType
);
}
#[test]
fn test_with_component_and_time_range_invalid_start() {
let result = ListCalendarResources::new("/calendars/personal/")
.with_component_and_time_range("VEVENT", Some("invalid"), None);
assert_eq!(result.unwrap_err(), ComponentFilterError::InvalidTimeRange);
}
#[test]
fn test_with_component_and_time_range_invalid_end() {
let result = ListCalendarResources::new("/calendars/personal/")
.with_component_and_time_range("VEVENT", None, Some("2024-01-01"));
assert_eq!(result.unwrap_err(), ComponentFilterError::InvalidTimeRange);
}
#[test]
fn test_with_component_and_time_range_missing_z_suffix() {
let result = ListCalendarResources::new("/calendars/personal/")
.with_component_and_time_range("VEVENT", Some("20240101T000000"), None);
assert_eq!(result.unwrap_err(), ComponentFilterError::InvalidTimeRange);
}
#[test]
fn test_parse_response_success() {
let req = ListCalendarResources::new("/calendars/personal/");
let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:">
<response>
<href>/calendars/personal/</href>
<propstat>
<prop>
<resourcetype><collection/></resourcetype>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/calendars/personal/event1.ics</href>
<propstat>
<prop>
<getetag>"abc123"</getetag>
<getcontenttype>text/calendar</getcontenttype>
<resourcetype/>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/calendars/personal/event2.ics</href>
<propstat>
<prop>
<getetag>"def456"</getetag>
<getcontenttype>text/calendar</getcontenttype>
<resourcetype/>
</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].etag, Some("\"abc123\"".to_string()));
assert_eq!(
result.resources[0].content_type,
Some("text/calendar".to_string())
);
assert_eq!(result.resources[1].href, "/calendars/personal/event2.ics");
assert_eq!(result.resources[1].etag, Some("\"def456\"".to_string()));
}
#[test]
fn test_parse_response_empty() {
let req = ListCalendarResources::new("/calendars/personal/");
let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:">
<response>
<href>/calendars/personal/</href>
<propstat>
<prop>
<resourcetype><collection/></resourcetype>
</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!(result.resources.is_empty());
}
#[test]
fn test_parse_response_bad_status() {
let req = ListCalendarResources::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))
));
}
}