nextcloud-cooking-schedule/src/api_client.rs

171 lines
5.7 KiB
Rust

// SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
use {
crate::config::Config, crate::constants, anyhow::anyhow, anyhow::Result,
base64::write::EncoderWriter as Base64Encoder, chrono::DateTime, icalendar::CalendarComponent,
reqwest::Url, std::io::Write, std::ops::Range,
};
pub struct ApiClient {
rest: reqwest::Client,
base_url: Url,
caldav_base_url: Url,
username: String,
}
impl ApiClient {
pub fn new(server_name: &str, configuration: &Config) -> Result<Self> {
let server = configuration
.credentials
.servers
.get(server_name)
.ok_or_else(|| {
anyhow!(
"Unknown server {}. Did you use '{} init' first? Known servers: {:#?}",
server_name,
env!("CARGO_BIN_NAME"),
configuration.credentials.servers.keys().collect::<Vec<_>>()
)
})?;
use reqwest::header;
let mut default_headers = header::HeaderMap::new();
let mut auth_header = b"Basic ".to_vec();
{
let mut encoder = Base64Encoder::new(&mut auth_header, base64::STANDARD);
write!(encoder, "{}:{}", server.login_name, server.password).unwrap();
}
let mut auth_header = header::HeaderValue::from_bytes(&auth_header)?;
auth_header.set_sensitive(true);
default_headers.insert(header::AUTHORIZATION, auth_header);
let rest_client = reqwest::Client::builder()
.user_agent(constants::USER_AGENT)
.default_headers(default_headers)
.build()?;
let base_url = Url::parse(&server.url)?;
let caldav_base_url = futures::executor::block_on(
rest_client
.head(base_url.join("/.well-known/caldav")?)
.send(),
)?
.url()
.clone();
Ok(ApiClient {
base_url,
caldav_base_url,
username: server.login_name.clone(),
rest: rest_client,
})
}
pub fn rest(&self) -> &reqwest::Client {
&self.rest
}
pub fn base_url(&self) -> &Url {
&self.base_url
}
pub fn username(&self) -> &str {
&self.username
}
pub async fn get_events<Tz>(
&self,
calendar_name: &str,
date_range: Range<DateTime<Tz>>,
) -> Result<Vec<icalendar::Event>>
where
Tz: chrono::TimeZone,
Tz::Offset: std::fmt::Display,
{
let report_method = reqwest::Method::from_bytes(b"REPORT").unwrap();
let events_xml = self
.rest()
.request(
report_method,
// TODO extract into helper method
self.caldav_base_url.join(&format!(
"calendars/{}/{}",
self.username(),
calendar_name.to_lowercase().replace(" ", "-")
))?,
)
.header("Prefer", "return-minimal")
.header("Content-Type", "application/xml; charset=utf-8")
.header("Depth", 1)
.body(format!(
"<c:calendar-query xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\">
<d:prop>
<c:calendar-data />
</d:prop>
<c:filter>
<c:comp-filter name=\"VCALENDAR\">
<c:prop-filter name=\"PRODID\">
<c:text-match>{}</c:text-match>
</c:prop-filter>
<c:comp-filter name=\"VEVENT\">
<c:prop-filter name=\"DTSTART\">
<time-range start=\"{}\" end=\"{}\" />
</c:prop-filter>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>
",
constants::CALENDAR_PROVIDER,
date_range
.start
.naive_utc()
.format(constants::ICAL_UTCTIME_FMT)
.to_string(),
date_range
.end
.naive_utc()
.format(constants::ICAL_UTCTIME_FMT)
.to_string()
))
.send()
.await?
.text()
.await?;
let xml_doc = libxml::parser::Parser::default().parse_string(events_xml.as_bytes())?;
let mut xpath_ctx = libxml::xpath::Context::new(&xml_doc).unwrap();
xpath_ctx
.register_namespace("cal", "urn:ietf:params:xml:ns:caldav")
.unwrap();
let calendars_txt = xpath_ctx
.findnodes("//cal:calendar-data/text()", None)
.unwrap()
.into_iter()
.map(|n| icalendar::parser::unfold(&n.get_content()))
.collect::<Vec<_>>();
let calendars = calendars_txt
.iter()
.map(|cal| icalendar::parser::read_calendar(cal).unwrap())
.collect::<Vec<_>>();
let events = calendars
.into_iter()
.flat_map(|cal| {
cal.components
.into_iter()
.filter(|comp| comp.name == "VEVENT")
})
.map(|comp| {
let event: CalendarComponent = comp.into();
match event {
CalendarComponent::Event(e) => e,
_ => unreachable!(),
}
});
Ok(events.collect::<Vec<_>>())
}
}