//! CalDAV and CardDAV. //! //! URL layout under [`PIM`]: //! //! * `/principals/{user}/` //! * `/calendars/{user}/` and `/addressbooks/{user}/`, the homes //! * `/calendars/{user}/{collection}/` and `.../{collection}/{object}`, the //! same for address books //! //! The protocol logic is `pimdav`'s. This module authenticates, maps URLs onto //! the store and assembles the responses. use std::sync::Arc; use api_types::PIM; use axum::body::Body; use axum::extract::State; use axum::http::header::{ALLOW, CONTENT_TYPE, ETAG, LOCATION}; use axum::http::{HeaderMap, Method, Request, Response, StatusCode}; use axum::response::IntoResponse; use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode}; use pimdav::object; use pimdav::xml::{ self, APPLE, CALDAV, CALSERVER, CARDDAV, DAV, Name, Propfind, Update, el, hrefs, with_attr, with_children, with_text, }; use sha2::{Digest, Sha256}; use xmltree::Element; use crate::db::{PimCollection, PimKind, PimObject, PimWrite, Precondition}; use crate::error::{ApiError, AppState}; /// Largest object a PUT may store. Contacts carry photos inline. const MAX_RESOURCE_SIZE: usize = 10 * 1024 * 1024; /// Largest XML request body. const MAX_XML_SIZE: usize = 1024 * 1024; /// The domain of the addresses users schedule with. `.invalid` is reserved /// (RFC 2606), so nothing sent there can reach anyone. const MAIL_DOMAIN: &str = "filebrowser.invalid"; /// Characters escaped in an href segment. const SEGMENT: &AsciiSet = &CONTROLS .add(b' ') .add(b'"') .add(b'#') .add(b'%') .add(b'/') .add(b'<') .add(b'>') .add(b'?') .add(b'[') .add(b']') .add(b'`') .add(b'{') .add(b'}'); type Reply = Result, ApiError>; /// `{WELL_KNOWN_CALDAV}` and `{WELL_KNOWN_CARDDAV}`. pub async fn well_known() -> Response { ( StatusCode::MOVED_PERMANENTLY, [(LOCATION, format!("{PIM}/"))], ) .into_response() } /// `{PIM}` and everything under it. pub async fn handle(State(state): State>, req: Request) -> Response { let Some((user_id, _)) = super::dav::authenticate(&state, req.headers()).await else { return super::dav::challenge(); }; serve(&state, user_id, req) .await .unwrap_or_else(IntoResponse::into_response) } /// The signed-in user. struct Me { id: i64, name: String, } impl Me { fn principal(&self) -> String { format!("{PIM}/principals/{}/", seg(&self.name)) } fn home(&self, kind: PimKind) -> String { format!("{PIM}/{}/{}/", kind_segment(kind), seg(&self.name)) } fn collection(&self, kind: PimKind, slug: &str) -> String { format!("{}{}/", self.home(kind), seg(slug)) } fn object(&self, kind: PimKind, slug: &str, name: &str) -> String { format!("{}{}", self.collection(kind, slug), seg(name)) } } async fn serve(state: &AppState, user_id: i64, req: Request) -> Reply { let Some(name) = state.db.user_name(user_id).await? else { return Ok(status(StatusCode::UNAUTHORIZED)); }; let me = Me { id: user_id, name }; let path = req.uri().path().strip_prefix(PIM).unwrap_or_default(); let Some(target) = parse_target(path) else { return Ok(status(StatusCode::NOT_FOUND)); }; // ponytail: own resources only. Sharing between users comes with the // access model. if target .owner() .is_some_and(|o| !o.eq_ignore_ascii_case(&me.name)) { return Ok(status(StatusCode::FORBIDDEN)); } state.db.pim_ensure_defaults(me.id).await?; let method = req.method().clone(); let (parts, body) = req.into_parts(); match method.as_str() { "OPTIONS" => Ok(options()), "PROPFIND" => propfind(state, &me, &target, &parts.headers, body).await, "PROPPATCH" => proppatch(state, &me, &target, body).await, "MKCALENDAR" | "MKCOL" => mkcol(state, &me, &target, method.as_str(), body).await, "GET" | "HEAD" => get(state, &me, &target, method == Method::HEAD).await, "PUT" => put(state, &me, &target, &parts.headers, body).await, "DELETE" => delete(state, &me, &target, &parts.headers).await, "REPORT" => Ok(error(StatusCode::FORBIDDEN, el(DAV, "supported-report"))), _ => Ok(status(StatusCode::METHOD_NOT_ALLOWED)), } } #[derive(Debug)] enum Target { Root, Principal(String), Home(PimKind, String), Collection(PimKind, String, String), Object(PimKind, String, String, String), } impl Target { fn owner(&self) -> Option<&str> { match self { Target::Root => None, Target::Principal(u) | Target::Home(_, u) | Target::Collection(_, u, _) | Target::Object(_, u, _, _) => Some(u), } } } fn parse_target(path: &str) -> Option { let segs = path .split('/') .filter(|s| !s.is_empty()) .map(|s| { let s = percent_decode_str(s).decode_utf8().ok()?; (s != "." && s != "..").then(|| s.into_owned()) }) .collect::>>()?; let kind = |s: &str| match s { "calendars" => Some(PimKind::Calendar), "addressbooks" => Some(PimKind::AddressBook), _ => None, }; let mut it = segs.into_iter(); let Some(first) = it.next() else { return Some(Target::Root); }; let rest: Vec = it.collect(); if first == "principals" { return match <[String; 1]>::try_from(rest) { Ok([user]) => Some(Target::Principal(user)), Err(_) => None, }; } let kind = kind(&first)?; let mut rest = rest.into_iter(); Some(match (rest.next(), rest.next(), rest.next(), rest.next()) { (Some(u), None, None, None) => Target::Home(kind, u), (Some(u), Some(c), None, None) => Target::Collection(kind, u, c), (Some(u), Some(c), Some(o), None) => Target::Object(kind, u, c, o), _ => return None, }) } fn kind_segment(kind: PimKind) -> &'static str { match kind { PimKind::Calendar => "calendars", PimKind::AddressBook => "addressbooks", } } fn seg(s: &str) -> String { utf8_percent_encode(s, SEGMENT).to_string() } fn status(code: StatusCode) -> Response { code.into_response() } fn xml_response(code: StatusCode, body: String) -> Response { ( code, [(CONTENT_TYPE, "application/xml; charset=utf-8")], body, ) .into_response() } /// A failed precondition, named in a `` body. fn error(code: StatusCode, condition: Element) -> Response { xml_response(code, xml::error(condition)) } fn options() -> Response { ( StatusCode::OK, [ ("dav", "1, 3, calendar-access, addressbook, extended-mkcol"), ( ALLOW.as_str(), "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT", ), ], ) .into_response() } async fn read_body(body: Body, limit: usize) -> Option { axum::body::to_bytes(body, limit).await.ok() } // --------------------------------------------------------------------------- // PROPFIND // --------------------------------------------------------------------------- /// A resource PROPFIND can describe. enum Res { Root, Principal, Home, Collection(PimKind, PimCollection), Object(PimKind, PimObject), } async fn propfind( state: &AppState, me: &Me, target: &Target, headers: &HeaderMap, body: Body, ) -> Reply { // Missing means infinity to RFC 4918, but clients that omit it mean 0. let deep = match headers.get("depth").and_then(|v| v.to_str().ok()) { None | Some("0") => false, Some("1") => true, Some(_) => { return Ok(error( StatusCode::FORBIDDEN, el(DAV, "propfind-finite-depth"), )); } }; let Some(body) = read_body(body, MAX_XML_SIZE).await else { return Ok(status(StatusCode::PAYLOAD_TOO_LARGE)); }; let Ok(request) = xml::propfind(&body) else { return Ok(status(StatusCode::BAD_REQUEST)); }; let mut list: Vec<(String, Res)> = Vec::new(); match target { Target::Root => list.push((format!("{PIM}/"), Res::Root)), Target::Principal(_) => list.push((me.principal(), Res::Principal)), Target::Home(kind, _) => { list.push((me.home(*kind), Res::Home)); if deep { for c in state.db.pim_collections(me.id, *kind).await? { list.push((me.collection(*kind, &c.slug), Res::Collection(*kind, c))); } } } Target::Collection(kind, _, slug) => { let Some(c) = state.db.pim_collection(me.id, *kind, slug).await? else { return Ok(status(StatusCode::NOT_FOUND)); }; if deep { for o in state.db.pim_objects(c.id).await? { let href = me.object(*kind, &c.slug, &o.name); list.push((href, Res::Object(*kind, o))); } } list.insert( 0, (me.collection(*kind, &c.slug), Res::Collection(*kind, c)), ); } Target::Object(kind, _, slug, name) => { let found = match state.db.pim_collection(me.id, *kind, slug).await? { Some(c) => state.db.pim_object(c.id, name).await?, None => None, }; let Some((o, _)) = found else { return Ok(status(StatusCode::NOT_FOUND)); }; list.push((me.object(*kind, slug, name), Res::Object(*kind, o))); } } let responses: Vec = list .into_iter() .map(|(href, res)| { let mut r = xml::Response::new(href); let all = props(me, &res); match &request { Propfind::AllProp(_) => all.into_iter().for_each(|p| r.push(200, p)), Propfind::PropName => all.iter().for_each(|p| r.push(200, Name::of(p).element())), Propfind::Prop(names) => { for n in names { match all.iter().find(|p| Name::of(p) == *n) { Some(p) => r.push(200, p.clone()), None => r.push(404, n.element()), } } } } r }) .collect(); Ok(xml_response( StatusCode::MULTI_STATUS, xml::multistatus(&Name::new(DAV, "multistatus"), &responses), )) } /// Every live property of a resource, with its value. fn props(me: &Me, res: &Res) -> Vec { let text = |ns: &str, local: &str, v: &str| with_text(el(ns, local), v); let href_prop = |ns: &str, local: &str, h: &str| with_children(el(ns, local), hrefs([h])); let resourcetype = |types: &[(&str, &str)]| { with_children( el(DAV, "resourcetype"), types.iter().map(|(ns, l)| el(ns, l)), ) }; let mut out = vec![href_prop(DAV, "current-user-principal", &me.principal())]; match res { Res::Root => out.push(resourcetype(&[(DAV, "collection")])), Res::Principal => { let principal = me.principal(); let addresses = [ format!("mailto:{}@{MAIL_DOMAIN}", seg(&me.name)), principal.clone(), format!("urn:uuid:{}", principal_uuid(me.id)), ]; out.extend([ resourcetype(&[(DAV, "collection"), (DAV, "principal")]), text(DAV, "displayname", &me.name), href_prop(DAV, "principal-URL", &principal), href_prop(CALDAV, "calendar-home-set", &me.home(PimKind::Calendar)), href_prop( CARDDAV, "addressbook-home-set", &me.home(PimKind::AddressBook), ), with_children( el(CALDAV, "calendar-user-address-set"), hrefs(addresses.iter().map(String::as_str)), ), text(CALDAV, "calendar-user-type", "INDIVIDUAL"), privileges(), ]); } Res::Home => out.extend([ resourcetype(&[(DAV, "collection")]), href_prop(DAV, "owner", &me.principal()), privileges(), ]), Res::Collection(kind, c) => { let (types, desc) = match kind { PimKind::Calendar => ((CALDAV, "calendar"), (CALDAV, "calendar-description")), PimKind::AddressBook => ( (CARDDAV, "addressbook"), (CARDDAV, "addressbook-description"), ), }; out.extend([ resourcetype(&[(DAV, "collection"), types]), href_prop(DAV, "owner", &me.principal()), privileges(), // Empty until the REPORTs exist. el(DAV, "supported-report-set"), text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)), text(DAV, "sync-token", &sync_token(c)), text( if *kind == PimKind::Calendar { CALDAV } else { CARDDAV }, "max-resource-size", &MAX_RESOURCE_SIZE.to_string(), ), ]); if let Some(v) = &c.displayname { out.push(text(DAV, "displayname", v)); } if let Some(v) = &c.description { out.push(text(desc.0, desc.1, v)); } match kind { PimKind::Calendar => { out.push(with_children( el(CALDAV, "supported-calendar-component-set"), c.components .split(',') .map(|comp| with_attr(el(CALDAV, "comp"), "name", comp)), )); out.push(with_children( el(CALDAV, "supported-calendar-data"), [with_attr( with_attr(el(CALDAV, "calendar-data"), "content-type", "text/calendar"), "version", "2.0", )], )); if let Some(v) = &c.color { out.push(text(APPLE, "calendar-color", v)); } if let Some(v) = &c.sort_order { out.push(text(APPLE, "calendar-order", v)); } if let Some(v) = &c.timezone { out.push(text(CALDAV, "calendar-timezone", v)); } } PimKind::AddressBook => out.push(with_children( el(CARDDAV, "supported-address-data"), ["3.0", "4.0"].map(|v| { with_attr( with_attr( el(CARDDAV, "address-data-type"), "content-type", "text/vcard", ), "version", v, ) }), )), } } Res::Object(kind, o) => { out.extend([ resourcetype(&[]), text(DAV, "getetag", &o.etag), text(DAV, "getcontenttype", &content_type(*kind, &o.component)), text(DAV, "getcontentlength", &o.size.to_string()), ]); if let Ok(t) = chrono::DateTime::parse_from_rfc3339(&o.modified_at) { let http_date = t.to_utc().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); out.push(text(DAV, "getlastmodified", &http_date)); } } } out } fn privileges() -> Element { let names = [ "all", "read", "write", "write-properties", "write-content", "bind", "unbind", "read-current-user-privilege-set", ]; with_children( el(DAV, "current-user-privilege-set"), names.map(|n| with_children(el(DAV, "privilege"), [el(DAV, n)])), ) } /// Carries the collection id, so a token handed out for a deleted /// collection never matches the one that later takes its URL. fn sync_token(c: &PimCollection) -> String { format!("urn:fbng:sync:{}-{}", c.id, c.seq) } /// A stable UUID per account, for the `urn:uuid:` calendar user address. fn principal_uuid(user_id: i64) -> String { let h = crate::hex(&Sha256::digest(format!("filebrowser-ng principal {user_id}"))[..16]); format!( "{}-{}-{}-{}-{}", &h[..8], &h[8..12], &h[12..16], &h[16..20], &h[20..] ) } fn content_type(kind: PimKind, component: &str) -> String { match kind { PimKind::Calendar => format!("text/calendar; charset=utf-8; component={component}"), PimKind::AddressBook => "text/vcard; charset=utf-8".to_string(), } } // --------------------------------------------------------------------------- // PROPPATCH, MKCALENDAR, MKCOL // --------------------------------------------------------------------------- async fn proppatch(state: &AppState, me: &Me, target: &Target, body: Body) -> Reply { let Target::Collection(kind, _, slug) = target else { return Ok(status(StatusCode::FORBIDDEN)); }; let Some(mut col) = state.db.pim_collection(me.id, *kind, slug).await? else { return Ok(status(StatusCode::NOT_FOUND)); }; let Some(body) = read_body(body, MAX_XML_SIZE).await else { return Ok(status(StatusCode::PAYLOAD_TOO_LARGE)); }; let Ok(update) = xml::update(&body) else { return Ok(status(StatusCode::BAD_REQUEST)); }; let (ok, results) = apply(*kind, &mut col, &update, false); if ok { state.db.pim_update_collection(&col).await?; } let mut r = xml::Response::new(me.collection(*kind, slug)); for (code, prop) in results { r.push(code, prop); } Ok(xml_response( StatusCode::MULTI_STATUS, xml::multistatus(&Name::new(DAV, "multistatus"), &[r]), )) } async fn mkcol(state: &AppState, me: &Me, target: &Target, method: &str, body: Body) -> Reply { let Target::Collection(kind, _, slug) = target else { return Ok(status(StatusCode::FORBIDDEN)); }; let calendar = method == "MKCALENDAR"; if calendar && *kind != PimKind::Calendar { return Ok(status(StatusCode::FORBIDDEN)); } if state.db.pim_collection(me.id, *kind, slug).await?.is_some() { return Ok(status(StatusCode::METHOD_NOT_ALLOWED)); } let Some(body) = read_body(body, MAX_XML_SIZE).await else { return Ok(status(StatusCode::PAYLOAD_TOO_LARGE)); }; let Ok(update) = xml::update(&body) else { return Ok(status(StatusCode::BAD_REQUEST)); }; // A plain MKCOL makes a plain collection, which a calendar home cannot // hold. An address book home takes it as an address book. let typed = update .set .iter() .any(|p| Name::of(p).is(DAV, "resourcetype")); if !calendar && *kind == PimKind::Calendar && !typed { return Ok(status(StatusCode::FORBIDDEN)); } let mut col = PimCollection { slug: slug.clone(), components: match kind { PimKind::Calendar => "VEVENT,VTODO".to_string(), PimKind::AddressBook => String::new(), }, ..Default::default() }; let (ok, results) = apply(*kind, &mut col, &update, true); if !ok { let root = match calendar { true => Name::new(CALDAV, "mkcalendar-response"), false => Name::new(DAV, "mkcol-response"), }; let propstats = group(results); return Ok(xml_response( StatusCode::FORBIDDEN, xml::propstat_document(&root, &propstats), )); } if !state.db.pim_create_collection(me.id, *kind, &col).await? { return Ok(status(StatusCode::METHOD_NOT_ALLOWED)); } Ok(status(StatusCode::CREATED)) } fn group(results: Vec<(u16, Element)>) -> Vec<(u16, Vec)> { let mut r = xml::Response::default(); for (code, prop) in results { r.push(code, prop); } r.propstats } /// Applies property changes to `col`. Returns whether all of them are /// allowed, and each property with its status. Nothing may be stored unless /// all are: RFC 4918 makes PROPPATCH atomic. fn apply( kind: PimKind, col: &mut PimCollection, update: &Update, creating: bool, ) -> (bool, Vec<(u16, Element)>) { let cal = kind == PimKind::Calendar; let mut results = Vec::new(); for p in &update.set { let name = Name::of(p); let value = || Some(xml::text(p)).filter(|v| !v.is_empty()); let ok = match (name.ns.as_str(), name.local.as_str()) { (DAV, "displayname") => { col.displayname = value(); true } (CALDAV, "calendar-description") if cal => { col.description = value(); true } (CARDDAV, "addressbook-description") if !cal => { col.description = value(); true } (APPLE, "calendar-color") if cal => { col.color = value(); true } (APPLE, "calendar-order") if cal => { col.sort_order = value(); true } (CALDAV, "calendar-timezone") if cal => { let tz = value(); let valid = tz.as_deref().is_none_or(is_timezone); if valid { col.timezone = tz; } valid } (DAV, "resourcetype") if creating => { let wanted = match kind { PimKind::Calendar => (CALDAV, "calendar"), PimKind::AddressBook => (CARDDAV, "addressbook"), }; xml::child(p, wanted.0, wanted.1).is_some() } (CALDAV, "supported-calendar-component-set") if creating && cal => { let comps: Vec<_> = xml::elements(p) .filter(|c| Name::of(c).is(CALDAV, "comp")) .filter_map(|c| c.attributes.get("name")) .map(|n| n.to_ascii_uppercase()) .collect(); let valid = !comps.is_empty() && comps .iter() .all(|c| ["VEVENT", "VTODO", "VJOURNAL"].contains(&c.as_str())); if valid { col.components = comps.join(","); } valid } _ => false, }; results.push((if ok { 200 } else { 403 }, name.element())); } for name in &update.remove { let field = match (name.ns.as_str(), name.local.as_str()) { (DAV, "displayname") => Some(&mut col.displayname), (CALDAV, "calendar-description") if cal => Some(&mut col.description), (CARDDAV, "addressbook-description") if !cal => Some(&mut col.description), (APPLE, "calendar-color") if cal => Some(&mut col.color), (APPLE, "calendar-order") if cal => Some(&mut col.sort_order), (CALDAV, "calendar-timezone") if cal => Some(&mut col.timezone), _ => None, }; let ok = field.map(|f| *f = None).is_some(); results.push((if ok { 200 } else { 403 }, name.element())); } let ok = results.iter().all(|(code, _)| *code == 200); if !ok { for (code, _) in &mut results { if *code == 200 { *code = 424; } } } (ok, results) } /// A VCALENDAR holding a VTIMEZONE, as `calendar-timezone` must be. fn is_timezone(v: &str) -> bool { use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType}; ICalendar::parse(v).is_ok_and(|c| { c.components .iter() .any(|c| c.component_type == ICalendarComponentType::VTimezone) }) } // --------------------------------------------------------------------------- // Objects // --------------------------------------------------------------------------- async fn get(state: &AppState, me: &Me, target: &Target, head: bool) -> Reply { let Target::Object(kind, _, slug, name) = target else { return Ok(status(StatusCode::METHOD_NOT_ALLOWED)); }; let found = match state.db.pim_collection(me.id, *kind, slug).await? { Some(c) => state.db.pim_object(c.id, name).await?, None => None, }; let Some((o, data)) = found else { return Ok(status(StatusCode::NOT_FOUND)); }; let body = if head { Body::empty() } else { Body::from(data) }; Ok(( StatusCode::OK, [ (CONTENT_TYPE, content_type(*kind, &o.component)), (ETAG, o.etag), ], body, ) .into_response()) } async fn put(state: &AppState, me: &Me, target: &Target, headers: &HeaderMap, body: Body) -> Reply { let Target::Object(kind, _, slug, name) = target else { return Ok(status(StatusCode::METHOD_NOT_ALLOWED)); }; let Some(col) = state.db.pim_collection(me.id, *kind, slug).await? else { return Ok(status(StatusCode::CONFLICT)); }; let ns = match kind { PimKind::Calendar => CALDAV, PimKind::AddressBook => CARDDAV, }; let Some(data) = read_body(body, MAX_RESOURCE_SIZE).await else { return Ok(error(StatusCode::FORBIDDEN, el(ns, "max-resource-size"))); }; let parsed = match kind { PimKind::Calendar => { let supported: Vec<&str> = col.components.split(',').collect(); object::calendar(&data, &supported).map(|o| (o.uid, o.component.to_string())) } PimKind::AddressBook => { object::vcard(&data).map(|uid| (uid.unwrap_or_else(|| name.clone()), "VCARD".into())) } }; let (uid, component) = match parsed { Ok(v) => v, Err(invalid) => return Ok(error(StatusCode::FORBIDDEN, invalid.condition())), }; let etag = format!("\"{}\"", crate::hex(&Sha256::digest(&data)[..16])); let obj = PimObject { name: name.clone(), uid, component, etag: etag.clone(), ..Default::default() }; // The stored bytes are the request bytes, so the ETag may be returned. match state .db .pim_put_object(col.id, &obj, &data, &precondition(headers)) .await? { PimWrite::Created => Ok((StatusCode::CREATED, [(ETAG, etag)]).into_response()), PimWrite::Updated => Ok((StatusCode::NO_CONTENT, [(ETAG, etag)]).into_response()), PimWrite::PreconditionFailed => Ok(status(StatusCode::PRECONDITION_FAILED)), PimWrite::UidConflict(holder) => Ok(error( StatusCode::FORBIDDEN, with_children( el(ns, "no-uid-conflict"), hrefs([me.object(*kind, slug, &holder).as_str()]), ), )), PimWrite::Deleted | PimWrite::NotFound => Ok(status(StatusCode::INTERNAL_SERVER_ERROR)), } } async fn delete(state: &AppState, me: &Me, target: &Target, headers: &HeaderMap) -> Reply { let (kind, slug, name) = match target { Target::Collection(k, _, s) => (k, s, None), Target::Object(k, _, s, n) => (k, s, Some(n)), _ => return Ok(status(StatusCode::FORBIDDEN)), }; let Some(col) = state.db.pim_collection(me.id, *kind, slug).await? else { return Ok(status(StatusCode::NOT_FOUND)); }; let Some(name) = name else { state.db.pim_delete_collection(col.id).await?; return Ok(status(StatusCode::NO_CONTENT)); }; Ok( match state .db .pim_delete_object(col.id, name, &precondition(headers)) .await? { PimWrite::Deleted => status(StatusCode::NO_CONTENT), PimWrite::NotFound => status(StatusCode::NOT_FOUND), PimWrite::PreconditionFailed => status(StatusCode::PRECONDITION_FAILED), _ => status(StatusCode::INTERNAL_SERVER_ERROR), }, ) } fn precondition(headers: &HeaderMap) -> Precondition { let header = |name: &str| { headers .get(name) .and_then(|v| v.to_str().ok()) .map(str::to_string) }; Precondition { if_match: header("if-match"), if_none_match: header("if-none-match"), } }