libjmap/
lib.rs

1// Copyright 2025 Hugo Osvaldo Barrera
2//
3// SPDX-License-Identifier: ISC
4
5use std::collections::HashMap;
6
7use http::{
8    Request, Response, StatusCode, Uri,
9    response::Parts,
10    uri::{InvalidUri, PathAndQuery},
11};
12use http_body_util::BodyExt as _;
13use hyper::body::{Bytes, Incoming};
14use log::trace;
15use serde::{Deserialize, Serialize, de::DeserializeOwned};
16use serde_json::Value as JsonValue;
17use tokio::sync::Mutex;
18use tower_service::Service;
19
20use crate::{
21    addressbook::JMAP_CONTACTS,
22    calendar::JMAP_CALENDARS,
23    error::{Error, Result},
24};
25
26/// JMAP core capability URN.
27pub const JMAP_CORE: &str = "urn:ietf:params:jmap:core";
28
29pub mod addressbook;
30pub mod calendar;
31pub mod error;
32
33/// JMAP response structure containing method responses and session state.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct JmapResponse<T> {
36    #[serde(rename = "methodResponses")]
37    pub method_responses: Vec<MethodResponse<T>>,
38    #[serde(rename = "sessionState")]
39    pub session_state: String,
40}
41
42/// JMAP method response data.
43/// Either a success response or an error.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(untagged)]
46pub enum MethodResponseData<T> {
47    /// Error response.
48    // Must be first for untagged deserialization.
49    Error(JmapError),
50    /// Successful response.
51    Success(T),
52}
53
54impl<T> MethodResponseData<T> {
55    /// Convert to Result, transforming Error variant into Error::ServerError
56    pub fn into_result(self) -> Result<T> {
57        match self {
58            MethodResponseData::Error(error) => Err(Error::ServerError {
59                error_type: error.error_type,
60                description: error.description,
61            }),
62            MethodResponseData::Success(data) => Ok(data),
63        }
64    }
65}
66
67/// JMAP method response tuple: [`method_name`, `response_data`, `call_id`]
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct MethodResponse<T>(pub String, pub MethodResponseData<T>, pub String);
70
71/// Response structure for Get requests.
72///
73/// See: <https://www.rfc-editor.org/rfc/rfc8620#section-5.1>
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct GetResponse<T> {
76    pub state: String,
77    pub list: Vec<T>,
78    #[serde(rename = "notFound")]
79    pub not_found: Vec<String>,
80    #[serde(rename = "accountId")]
81    pub account_id: String,
82}
83
84/// Response structure for query method calls
85///
86/// See: <https://www.rfc-editor.org/rfc/rfc8620#section-5.5>
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct QueryResponse {
89    pub filter: Option<JsonValue>,
90    pub sort: Option<JsonValue>,
91    #[serde(rename = "queryState")]
92    pub query_state: String,
93    #[serde(rename = "canCalculateChanges")]
94    pub can_calculate_changes: bool,
95    pub position: u32,
96    pub ids: Vec<String>,
97    pub total: Option<u32>,
98    #[serde(rename = "accountId")]
99    pub account_id: String,
100}
101
102/// Response structure for changes method calls.
103///
104/// See: <https://www.rfc-editor.org/rfc/rfc8620#section-5.2>
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct ChangesResponse {
107    #[serde(rename = "oldState")]
108    pub old_state: String,
109    #[serde(rename = "newState")]
110    pub new_state: String,
111    #[serde(rename = "hasMoreChanges")]
112    pub has_more_changes: bool,
113    pub created: Vec<String>,
114    pub updated: Vec<String>,
115    pub destroyed: Vec<String>,
116    #[serde(rename = "accountId")]
117    pub account_id: String,
118}
119
120/// Status of an item relative to a previous state.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum ChangeStatus {
123    /// No change since the given state.
124    NotChanged,
125    /// Modified since the given state.
126    Changed,
127    /// Deleted since the given state.
128    Deleted,
129}
130
131/// JMAP set response structure for create/update/delete operations.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct SetResponse<T> {
134    #[serde(rename = "oldState")]
135    pub old_state: String,
136    #[serde(rename = "newState")]
137    pub new_state: String,
138    pub created: Option<HashMap<String, T>>,
139    pub updated: Option<HashMap<String, T>>,
140    pub destroyed: Option<Vec<String>>,
141    #[serde(rename = "notCreated")]
142    pub not_created: Option<HashMap<String, JmapError>>,
143    #[serde(rename = "notUpdated")]
144    pub not_updated: Option<HashMap<String, JmapError>>,
145    #[serde(rename = "notDestroyed")]
146    pub not_destroyed: Option<HashMap<String, JmapError>>,
147    #[serde(rename = "accountId")]
148    pub account_id: String,
149}
150
151/// JMAP error structure
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct JmapError {
154    #[serde(rename = "type")]
155    pub error_type: String,
156    pub properties: Option<Vec<String>>,
157    pub description: Option<String>,
158}
159
160/// JMAP request structure (RFC 8620 section 3.3)
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct RequestObject {
163    /// Capabilities used by the client
164    pub using: Vec<String>,
165    /// Method calls to execute
166    #[serde(rename = "methodCalls")]
167    pub method_calls: Vec<Invocation>,
168    /// Created object IDs mapping (optional)
169    #[serde(rename = "createdIds", skip_serializing_if = "Option::is_none")]
170    pub created_ids: Option<HashMap<String, String>>,
171}
172
173/// JMAP method invocation: [methodName, arguments, methodCallId]
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct Invocation(pub String, pub JsonValue, pub String);
176
177impl RequestObject {
178    /// Create a new JMAP request with basic capabilities
179    #[must_use]
180    pub fn new(capabilities: Vec<String>) -> Self {
181        Self {
182            using: capabilities,
183            method_calls: Vec::new(),
184            created_ids: None,
185        }
186    }
187
188    /// Add a method call to the request
189    #[must_use]
190    pub fn with_method_call(
191        mut self,
192        method_name: String,
193        arguments: JsonValue,
194        call_id: String,
195    ) -> Self {
196        self.method_calls
197            .push(Invocation(method_name, arguments, call_id));
198        self
199    }
200
201    /// Helper to create a standard JMAP request with core capabilities
202    #[must_use]
203    pub fn with_core_capability(capability: &str) -> Self {
204        Self::new(vec![JMAP_CORE.to_string(), capability.to_string()])
205    }
206}
207
208/// Fetch the JMAP session resource.
209///
210/// Returns the URL for the session resource and its contents.
211/// `base_url` should be a domain's base URL, e.g.: `https://example.com`.
212///
213/// See: <https://www.rfc-editor.org/rfc/rfc8620#section-2>
214///
215/// The provided `base_url` should point to the domain where the server is located.
216///
217/// # Errors
218///
219/// See [`DiscoverSessionError`].
220pub async fn discover_session_resource<C, E>(
221    http_client: &mut C,
222    base_url: &Uri,
223) -> Result<(Uri, SessionResource), DiscoverSessionError<E>>
224where
225    // TODO: static?
226    C: Service<http::Request<String>, Response = Response<Incoming>, Error = E> + 'static,
227    E: std::error::Error + Send + Sync,
228{
229    let mut uri: Uri = make_relative_url(base_url.clone(), "/.well-known/jmap")
230        .map_err(DiscoverSessionError::InvalidInput)?;
231    let (mut head, mut raw_body);
232
233    loop {
234        let request = Request::builder()
235            .method("GET")
236            .uri(&uri)
237            .body(String::new())
238            .expect("All provided arguments are propertly validated.");
239        let response = http_client
240            .call(request)
241            .await
242            .map_err(DiscoverSessionError::ClientError)?;
243        (head, raw_body) = response.into_parts();
244        if !head.status.is_redirection() {
245            break;
246        }
247
248        let location = head
249            .headers
250            .get(hyper::header::LOCATION)
251            .ok_or(DiscoverSessionError::MissingLocation)?
252            .as_bytes();
253
254        let location_uri =
255            Uri::try_from(location).map_err(DiscoverSessionError::InvalidLocation)?;
256
257        uri = if location_uri.host().is_some() {
258            location_uri
259        } else {
260            let loc_parts = location_uri.into_parts();
261
262            let mut parts = uri.into_parts();
263            parts.path_and_query = loc_parts.path_and_query;
264            Uri::from_parts(parts).expect("Building from validated parts must succeed")
265        }
266    }
267
268    let body = raw_body
269        .collect()
270        .await
271        .map_err(DiscoverSessionError::StreamingError)?
272        .to_bytes();
273    trace!("{body:?}");
274    check_status(head.status).map_err(DiscoverSessionError::BadStatus)?;
275    let session_resource: SessionResource =
276        serde_json::from_slice(body.as_ref()).map_err(DiscoverSessionError::ResponseNotJson)?;
277    Ok((uri, session_resource))
278}
279
280/// JMAP client for interacting with JMAP servers.
281pub struct JmapClient<C>
282where
283    C: Service<http::Request<String>, Response = Response<Incoming>> + Sync + Send + 'static,
284{
285    http_client: Mutex<C>,
286    session_url: Uri,
287    api_url: Uri,
288}
289
290/// JMAP collection types (calendars, address books, etc.).
291pub trait RecordType: DeserializeOwned + Sized {
292    const ITEM_NAME: &'static str;
293    const COLLECTION_NAME: &'static str;
294    const CAPABILITY: &'static str;
295
296    fn id(&self) -> &str;
297}
298
299/// JMAP record with ID, state, and data.
300#[derive(Debug)]
301pub struct Record {
302    // XXX: might want to drop this—it's always present in `data`.
303    pub id: String,
304    pub state: String,
305    pub data: JsonValue,
306}
307
308/// Reference to a specific version of a JMAP record.
309#[derive(Debug)]
310pub struct RecordRef {
311    pub id: String,
312    pub state: String,
313}
314
315impl<C> JmapClient<C>
316where
317    C: Service<http::Request<String>, Response = Response<Incoming>> + Sync + Send + 'static,
318    <C as Service<http::Request<String>>>::Error: std::error::Error + Send + Sync,
319{
320    /// Create a new JMAP client.
321    // TODO: I'll want a builder pattern, where one can explicitly supply an api_url, or have it resolved.
322    pub fn new(client: C, session_url: Uri, api_url: Uri) -> JmapClient<C> {
323        JmapClient {
324            http_client: Mutex::new(client),
325            session_url,
326            api_url,
327        }
328    }
329
330    /// Discover the JMAP session URL for a domain.
331    ///
332    /// The provided `client: C` MUST handle authentication as required by the server.
333    ///
334    /// # Errors
335    ///
336    /// Returns an error if the domain is invalid or the well-known resource cannot be retrieved.
337    // TODO: domain as string?
338    // TODO: allow plain-text http?
339    pub async fn discover_session_url(
340        mut client: C,
341        scheme: Scheme,
342        domain: String,
343        // TODO: port?
344    ) -> Result<Uri, DiscoverError> {
345        // TODO: SRV discovery: _jmap._tcp.example.com
346
347        // If the client has a username in the form of an email address, it MAY use the domain
348        // portion of this to attempt autodiscovery of the JMAP server.
349        let uri: Uri = format!("{scheme}://{domain}:443/.well-known/jmap")
350            .parse()
351            .map_err(DiscoverError::InvalidDomain)?;
352
353        let request = Request::builder()
354            .method("GET")
355            .uri(uri)
356            .body(String::new())
357            .expect("All provided arguments are propertly validated.");
358        let response_future = client.call(request).await;
359        let response = response_future.unwrap();
360        let (head, body) = response.into_parts();
361        check_status(head.status).unwrap();
362        let _body = body.collect().await.unwrap().to_bytes();
363        todo!()
364    }
365
366    /// Returns the server's `apiUrl`.
367    pub fn api_url(&self) -> &Uri {
368        &self.api_url
369    }
370
371    /// Returns the URL for the JMAP session resource.
372    pub fn session_context_url(&self) -> &Uri {
373        &self.session_url
374    }
375
376    /// Fetch the current session resource.
377    ///
378    /// # Errors
379    ///
380    /// Returns an error if the HTTP request fails, the server returns an error status,
381    /// or the response cannot be parsed as JSON.
382    pub async fn get_session_resource(&self) -> Result<SessionResource> {
383        let request = Request::builder()
384            .method("GET")
385            .uri(&self.session_url)
386            .body(String::new())?;
387
388        let mut http_client = self.http_client.lock().await;
389        let response = http_client
390            .call(request)
391            .await
392            .map_err(|e| Error::ClientConnection(Box::new(e)))?;
393        let (head, body) = response.into_parts();
394        check_status(head.status)?;
395        let body_bytes = body.collect().await?.to_bytes();
396        let session_resource: SessionResource = serde_json::from_slice(body_bytes.as_ref())?;
397        Ok(session_resource)
398    }
399
400    async fn jmap_request(&self, request: RequestObject) -> Result<Bytes> {
401        let body = serde_json::to_string(&request)?;
402        let uri = make_relative_url(self.session_url.clone(), &self.api_url.to_string())?;
403        let request = Request::builder()
404            .method("POST")
405            .header("Content-Type", "application/json; charset=utf8")
406            .uri(uri)
407            .body(body)?;
408        let (head, body) = self.request(request).await?;
409        check_status(head.status)?;
410        Ok(body)
411    }
412
413    /// Send a request to the server.
414    ///
415    /// Sends a request, applying any necessary authentication and logging the response.
416    ///
417    /// # Errors
418    ///
419    /// Returns an error if the underlying http request fails or if streaming the response fails.
420    async fn request(&self, request: Request<String>) -> Result<(Parts, Bytes), RequestError> {
421        let mut client = self.http_client.lock().await;
422        let response_future = client.call(request);
423        drop(client); // E.g.: unlock http_client.
424
425        let response = response_future
426            .await
427            .map_err(|e| RequestError::Client(Box::from(e)))?;
428        let (head, body) = response.into_parts();
429        let body = body.collect().await?.to_bytes();
430
431        // log::trace!("Response ({}): {:?}", head.status, body);
432        Ok((head, body))
433    }
434
435    // let body = r#"[[ "Core/echo", { "hello": true, "high": 5 }, "b3ff" ]] "#;
436
437    async fn create(
438        &self,
439        capability: &str,
440        data_type: &str,
441        data: JsonValue,
442        if_state: Option<&str>,
443    ) -> Result<Record> {
444        let mut arguments = serde_json::json!({
445            "create": {
446                "new0": data
447            }
448        });
449        if let Some(state) = if_state {
450            arguments["ifInState"] = serde_json::json!(state);
451        }
452        let request = RequestObject::new(vec![JMAP_CORE.to_string(), capability.to_string()])
453            .with_method_call(format!("{data_type}/set"), arguments, "0".to_string());
454        let body = self.jmap_request(request).await?;
455        let response_json: JmapResponse<SetResponse<JsonValue>> =
456            serde_json::from_slice(body.as_ref())?;
457        let mut responses = response_json.method_responses;
458        let method_response = responses.pop().ok_or(Error::InvalidResponse {
459            reason: "No method responses found".to_string(),
460        })?;
461
462        let set_response = method_response.1.into_result()?;
463
464        if let Some(not_created) = set_response.not_created
465            && let Some(error) = not_created.get("new0")
466        {
467            return Err(Error::ServerError {
468                error_type: error.error_type.clone(),
469                description: error.description.clone(),
470            });
471        }
472
473        let created_item = set_response
474            .created
475            .ok_or(Error::InvalidResponse {
476                reason: "No items were created".to_string(),
477            })?
478            .get("new0")
479            .ok_or(Error::InvalidResponse {
480                reason: "Created item 'new0' missing from response".to_string(),
481            })?
482            .clone();
483
484        let id = created_item["id"]
485            .as_str()
486            .ok_or(Error::InvalidResponse {
487                reason: "Created item missing 'id' field".to_string(),
488            })?
489            .to_string();
490
491        let created = Record {
492            id,
493            state: set_response.new_state,
494            data: created_item,
495        };
496        Ok(created)
497    }
498
499    async fn update(
500        &self,
501        capability: &str,
502        data_type: &str,
503        id: &str,
504        data: JsonValue,
505        if_state: Option<&str>,
506    ) -> Result<Record> {
507        let arguments = serde_json::json!({
508            "update": { id.to_string(): data},
509            "ifInState": if_state,
510        });
511        let request = RequestObject::new(vec![JMAP_CORE.to_string(), capability.to_string()])
512            .with_method_call(format!("{data_type}/set"), arguments, "0".to_string());
513        let body = self.jmap_request(request).await?;
514        let response_json: JmapResponse<SetResponse<JsonValue>> =
515            serde_json::from_slice(body.as_ref())?;
516        let mut responses = response_json.method_responses;
517        let method_response = responses.pop().ok_or(Error::InvalidResponse {
518            reason: "No method responses found".to_string(),
519        })?;
520
521        let set_response = method_response.1.into_result()?;
522
523        if let Some(not_updated) = set_response.not_updated
524            && let Some(error) = not_updated.get(id)
525        {
526            return Err(Error::ServerError {
527                error_type: error.error_type.clone(),
528                description: error.description.clone(),
529            });
530        }
531
532        let updated_item = set_response
533            .updated
534            .ok_or(Error::InvalidResponse {
535                reason: "No items were updated".to_string(),
536            })?
537            .get(id)
538            .ok_or_else(|| Error::InvalidResponse {
539                reason: format!("Updated item '{id}' missing from response"),
540            })?
541            .clone();
542
543        let updated = Record {
544            id: id.to_string(),
545            state: set_response.new_state,
546            data: updated_item,
547        };
548        Ok(updated)
549    }
550
551    /// Create a new collection.
552    ///
553    /// # Errors
554    ///
555    /// Returns an error if the server request fails or the server rejects the operation.
556    pub async fn create_collection<T: RecordType>(&self, name: &str) -> Result<Record> {
557        let collection = serde_json::json!({
558            "name": name
559            // TODO: optional props?
560        });
561        self.create(T::CAPABILITY, T::COLLECTION_NAME, collection, None)
562            .await
563        // TODO: return id
564    }
565
566    /// Create a new record in a collection.
567    ///
568    /// # Errors
569    ///
570    /// Returns an error if the record is not a JSON object, the server request fails,
571    /// or the server rejects the operation.
572    pub async fn create_record<T: RecordType>(
573        &self,
574        calendar_id: &str,
575        record: &JsonValue,
576        if_state: Option<&str>,
577    ) -> Result<Record> {
578        let mut record = record.clone();
579        if let JsonValue::Object(ref mut map) = record {
580            map.insert(
581                "calendarIds".to_string(),
582                serde_json::json!({
583                    calendar_id: true
584                }),
585            );
586        } else {
587            return Err(Error::InvalidData {
588                message: "Record must be a JSON object".to_string(),
589            });
590        }
591
592        self.create(T::CAPABILITY, T::ITEM_NAME, record, if_state)
593            .await
594    }
595
596    /// Update an existing record in a collection.
597    ///
598    /// # Errors
599    ///
600    /// Returns an error if the record is not a JSON object, the server request fails,
601    /// or the server rejects the operation.
602    pub async fn update_record<T: RecordType>(
603        &self,
604        record_id: &str,
605        calendar_id: &str,
606        record: &JsonValue,
607        if_state: Option<&str>,
608    ) -> Result<Record> {
609        let mut record = record.clone();
610        if let JsonValue::Object(ref mut map) = record {
611            map.insert(
612                "calendarIds".to_string(),
613                serde_json::json!({
614                    calendar_id: true
615                }),
616            );
617        } else {
618            return Err(Error::InvalidData {
619                message: "Record must be a JSON object".to_string(),
620            });
621        }
622
623        self.update(T::CAPABILITY, T::ITEM_NAME, record_id, record, if_state)
624            .await
625    }
626
627    /// Returns the new server state.
628    async fn delete(
629        &self,
630        capability: &str,
631        data_type: &str,
632        id: &str,
633        if_state: Option<&str>,
634    ) -> Result<String> {
635        let arguments = serde_json::json!({
636            "destroy": [id],
637            "ifInState": if_state,
638        });
639        let request = RequestObject::new(vec![JMAP_CORE.to_string(), capability.to_string()])
640            .with_method_call(format!("{data_type}/set"), arguments, "d0".to_string());
641        let body = self.jmap_request(request).await?;
642        let response_json: JmapResponse<SetResponse<JsonValue>> =
643            serde_json::from_slice(body.as_ref())?;
644        let mut responses = response_json.method_responses;
645        let method_response = responses.pop().ok_or(Error::InvalidResponse {
646            reason: "No method responses found".to_string(),
647        })?;
648
649        let set_response = method_response.1.into_result()?;
650
651        if let Some(not_destroyed) = set_response.not_destroyed
652            && let Some(error) = not_destroyed.get(id)
653        {
654            return Err(Error::ServerError {
655                error_type: error.error_type.clone(),
656                description: error.description.clone(),
657            });
658        }
659
660        let destroyed_items = set_response.destroyed.ok_or(Error::InvalidResponse {
661            reason: "No items were destroyed".to_string(),
662        })?;
663
664        if !destroyed_items.contains(&id.to_string()) {
665            return Err(Error::IncompleteOperation {
666                message: format!("Item '{id}' was not destroyed"),
667            });
668        }
669
670        Ok(set_response.new_state)
671    }
672
673    /// Delete a collection.
674    ///
675    /// # Errors
676    ///
677    /// Returns an error if the server request fails or the server rejects the operation.
678    pub async fn delete_collection<T: RecordType>(
679        &self,
680        collection_id: &str,
681        if_state: Option<&str>,
682    ) -> Result<String> {
683        self.delete(T::CAPABILITY, T::COLLECTION_NAME, collection_id, if_state)
684            .await
685    }
686
687    /// Returns the new server state.
688    /// Delete a record.
689    ///
690    /// # Errors
691    ///
692    /// Returns an error if the server request fails or the server rejects the operation.
693    pub async fn delete_record<T: RecordType>(
694        &self,
695        record_id: &str,
696        if_state: Option<&str>,
697    ) -> Result<String> {
698        self.delete(T::CAPABILITY, T::ITEM_NAME, record_id, if_state)
699            .await
700    }
701
702    /// Get all collections of a specific type.
703    ///
704    /// # Errors
705    ///
706    /// Returns an error if the server request fails or the response cannot be parsed.
707    pub async fn get_collections<T>(&self) -> Result<Vec<T>>
708    where
709        T: RecordType,
710    {
711        let arguments = serde_json::json!({ "ids": null });
712        let request = RequestObject::new(vec![JMAP_CORE.to_string(), T::CAPABILITY.to_string()])
713            .with_method_call(
714                format!("{}/get", T::COLLECTION_NAME),
715                arguments,
716                "0".to_string(),
717            );
718        let body = self.jmap_request(request).await?;
719
720        let parsed_body: JmapResponse<GetResponse<T>> = serde_json::from_slice(body.as_ref())?;
721        let mut responses = parsed_body.method_responses;
722        let method_response = responses.pop().ok_or(Error::InvalidResponse {
723            reason: "No method responses found".to_string(),
724        })?;
725
726        let get_response = method_response.1.into_result()?;
727
728        Ok(get_response.list)
729    }
730
731    /// Get all records in a collection.
732    ///
733    /// # Errors
734    ///
735    /// Returns an error if the server request fails or the response cannot be parsed.
736    pub async fn get_records<T: RecordType>(&self, calendar_id: &str) -> Result<Vec<Record>> {
737        // First method call: query for items in calendar
738        let query_args = serde_json::json!({
739            "filter": { "inCalendars": [calendar_id] }
740        });
741        // Second method call: get the actual items
742        let get_args = serde_json::json!({
743            "#ids": {
744                "resultOf": "Q1",
745                "name": "CalendarEvent/query",
746                "path": "/ids"
747            }
748        });
749
750        let request = RequestObject::new(vec![JMAP_CORE.to_string(), T::CAPABILITY.to_string()])
751            .with_method_call(
752                format!("{}/query", T::ITEM_NAME),
753                query_args,
754                "Q1".to_string(),
755            )
756            .with_method_call(format!("{}/get", T::ITEM_NAME), get_args, "Q2".to_string());
757
758        let body = self.jmap_request(request).await?;
759        // Using JsonValue because this response contains mixed types (QueryResponse + GetRecordsResponse)
760        let response_json: JmapResponse<JsonValue> = serde_json::from_slice(body.as_ref())?;
761
762        let method_response = response_json
763            .method_responses
764            .into_iter()
765            .find(|m| m.2 == "Q2")
766            .ok_or(Error::InvalidResponse {
767                reason: "Method response Q2 not found".to_string(),
768            })?;
769
770        let response_data = method_response.1.into_result()?;
771
772        let get_response: GetResponse<JsonValue> = serde_json::from_value(response_data.clone())?;
773
774        let records = get_response
775            .list
776            .iter()
777            .map(|item| Record {
778                id: item["id"].as_str().unwrap_or("").to_string(),
779                state: get_response.state.clone(),
780                data: item.clone(),
781            })
782            .collect::<Vec<_>>();
783
784        Ok(records)
785    }
786
787    /// Get changes for a data type since a given state.
788    ///
789    /// Uses the JMAP `Foo/changes` method to retrieve all changes (created, updated, destroyed)
790    /// since the provided state.
791    ///
792    /// # Errors
793    ///
794    /// Returns an error if the server request fails or the response cannot be parsed.
795    pub async fn changes<T: RecordType>(
796        &self,
797        since_state: &str,
798        max_changes: Option<u32>,
799    ) -> Result<ChangesResponse> {
800        let mut arguments = serde_json::json!({
801            "sinceState": since_state,
802        });
803
804        if let Some(max) = max_changes {
805            arguments["maxChanges"] = serde_json::json!(max);
806        }
807
808        let request = RequestObject::new(vec![JMAP_CORE.to_string(), T::CAPABILITY.to_string()])
809            .with_method_call(
810                format!("{}/changes", T::ITEM_NAME),
811                arguments,
812                "c0".to_string(),
813            );
814
815        let body = self.jmap_request(request).await?;
816        let response_json: JmapResponse<ChangesResponse> = serde_json::from_slice(body.as_ref())?;
817
818        let mut responses = response_json.method_responses;
819        let method_response = responses.pop().ok_or(Error::InvalidResponse {
820            reason: "No method responses found".to_string(),
821        })?;
822
823        method_response.1.into_result()
824    }
825
826    /// Check if an item has changed since a given state.
827    ///
828    /// Uses the JMAP `Foo/changes` method to determine if an item has been created, updated, or
829    /// deleted since the provided state. Handles pagination via `hasMoreChanges`.
830    ///
831    /// Returns a tuple of `(ChangeStatus, new_state)` where `new_state` is the latest state
832    /// from the server that can be used for subsequent operations.
833    ///
834    /// This method is rather inefficient, and not advisable for JMAP-native applications.
835    ///
836    /// # Errors
837    ///
838    /// Returns an error if the server request fails or the response cannot be parsed.
839    pub async fn changed_since<T: RecordType>(
840        &self,
841        id: &str,
842        old_state: &str,
843    ) -> Result<(ChangeStatus, String)> {
844        let mut current_state = old_state.to_string();
845        let id_str = id.to_string();
846
847        let mut latest_status = ChangeStatus::NotChanged;
848        loop {
849            let changes_response = self.changes::<T>(&current_state, Some(500)).await?;
850
851            // Order matters: check destroyed first, then updated, then created
852            #[allow(clippy::collapsible_if)] // improves readability.
853            if changes_response.destroyed.contains(&id_str) {
854                latest_status = ChangeStatus::Deleted;
855            } else if changes_response.updated.contains(&id_str) {
856                if latest_status != ChangeStatus::Deleted {
857                    latest_status = ChangeStatus::Changed;
858                }
859            } else if changes_response.created.contains(&id_str) {
860                if latest_status == ChangeStatus::NotChanged {
861                    latest_status = ChangeStatus::Changed;
862                }
863            }
864
865            if !changes_response.has_more_changes {
866                break;
867            }
868
869            current_state = changes_response.new_state;
870        }
871
872        Ok((latest_status, current_state))
873    }
874}
875
876/// Error returned by [`discover_session_resource`].
877#[derive(Debug)]
878pub enum DiscoverSessionError<E>
879where
880    E: std::fmt::Debug,
881{
882    InvalidInput(http::Error),
883    ClientError(E),
884    MissingLocation,
885    InvalidLocation(InvalidUri),
886    BadStatus(StatusCode),
887    StreamingError(hyper::Error),
888    ResponseNotJson(serde_json::Error),
889}
890
891impl<E> std::fmt::Display for DiscoverSessionError<E>
892where
893    E: std::fmt::Debug,
894{
895    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
896        std::fmt::Debug::fmt(self, f)
897    }
898}
899
900impl<E> std::error::Error for DiscoverSessionError<E> where E: std::fmt::Debug {}
901
902/// Uri schemes for known transport protocols.
903pub enum Scheme {
904    /// Plain-text HTTP.
905    HTTP,
906    /// HTTP over TLS.
907    HTTPS,
908}
909
910impl std::fmt::Display for Scheme {
911    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
912        match self {
913            Scheme::HTTP => f.write_str("http"),
914            Scheme::HTTPS => f.write_str("https"),
915        }
916    }
917}
918
919#[derive(thiserror::Error, Debug)]
920pub enum DiscoverError {
921    #[error("Invalid domain provided as input")]
922    InvalidDomain(InvalidUri),
923
924    #[error("Received error retrieveing well-known resource.")]
925    WellKnownError(StatusCode),
926}
927
928/// Error executing an HTTP request.
929// XXX: copy-pasted from libdav
930#[derive(thiserror::Error, Debug)]
931pub enum RequestError {
932    /// Error handling the HTTP stream.
933    #[error("error executing http request: {0}")]
934    Http(#[from] hyper::Error),
935
936    /// Error from the underlying HTTP client.
937    #[error("client error executing request: {0}")]
938    // TODO: remove dyn, make generic over client error type
939    Client(Box<dyn std::error::Error + Send + Sync>),
940}
941
942#[derive(Debug, PartialEq, Deserialize)]
943pub struct SessionResource {
944    #[serde(rename = "apiUrl")]
945    pub api_url: Option<String>,
946    pub capabilities: std::collections::HashMap<String, JsonValue>,
947    #[serde(flatten)]
948    pub other_fields: JsonValue,
949}
950
951impl SessionResource {
952    #[must_use]
953    pub fn api_url(&self) -> Option<&str> {
954        self.api_url.as_deref()
955    }
956
957    #[must_use]
958    pub fn supports_calendars(&self) -> bool {
959        self.capabilities.contains_key(JMAP_CALENDARS)
960    }
961
962    #[must_use]
963    pub fn supports_address_books(&self) -> bool {
964        self.capabilities.contains_key(JMAP_CONTACTS)
965    }
966
967    // - session has:
968    //   - accountCapabilities: urn:ietf:params:jmap:calendars
969    //   - accountCapabilities: urn:ietf:params:jmap:contacts
970    //   - it also defines rate limits and maxCallsInRequest, etc.
971}
972
973/// Checks if the status code is success. If it is not, return it as an error.
974#[inline]
975pub(crate) fn check_status(status: StatusCode) -> Result<(), StatusCode> {
976    if status.is_success() {
977        Ok(())
978    } else {
979        Err(status)
980    }
981}
982
983fn make_relative_url(base: Uri, path: &str) -> Result<Uri, http::Error> {
984    // let path = strict_percent_encoded(path);
985    let mut parts = base.into_parts();
986    parts.path_and_query = Some(PathAndQuery::try_from(path)?);
987    Uri::from_parts(parts).map_err(http::Error::from)
988}
989
990// # API
991//
992// - The request MUST be of type application/json
993//
994// I need to add the appropriate `using: urn:ietf:params:jmap:calendar` to
995// requests, but this depends on the type of objects with which i am interacting.
996//
997//
998// ---
999//
1000// uri templates: https://www.rfc-editor.org/rfc/rfc6570
1001
1002#[cfg(test)]
1003mod test {
1004    use http::Uri;
1005    use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
1006    use hyper_util::{
1007        client::legacy::{Client, connect::HttpConnector},
1008        rt::TokioExecutor,
1009    };
1010    use serde_json::json;
1011    use tower_http::auth::AddAuthorization;
1012
1013    use crate::{
1014        ChangeStatus, GetResponse, JmapClient, JmapResponse, JsonValue, MethodResponseData,
1015        SetResponse, addressbook::AddressBook, calendar::Calendar, discover_session_resource,
1016    };
1017
1018    // TODO: localhost for non-containerised tests.
1019    const TEST_SERVER_BASE_URL: &str = "http://host.containers.internal:8080";
1020
1021    type TestClient = AddAuthorization<Client<HttpsConnector<HttpConnector>, String>>;
1022
1023    /// Test helper to set up a JMAP client connection
1024    struct TestSetup {
1025        pub client: JmapClient<TestClient>,
1026        pub session: crate::SessionResource,
1027    }
1028
1029    async fn setup_test_client() -> TestSetup {
1030        let connector = HttpsConnectorBuilder::new()
1031            .with_native_roots()
1032            .unwrap()
1033            .https_or_http()
1034            .enable_http1()
1035            .build();
1036        let base_url = TEST_SERVER_BASE_URL.parse::<Uri>().unwrap();
1037
1038        let http_client = Client::builder(TokioExecutor::new()).build(connector);
1039        let mut auth_client = AddAuthorization::basic(http_client, "user1", "");
1040        let (session_uri, session) = discover_session_resource(&mut auth_client, &base_url)
1041            .await
1042            .unwrap();
1043
1044        // FIXME: library should return an absolute URI?
1045        let api_url = session.api_url().unwrap().parse().unwrap();
1046        let client = JmapClient::new(auth_client, session_uri, api_url);
1047
1048        TestSetup { client, session }
1049    }
1050
1051    /// Ugly test. Requires a Cyrus server running locally.
1052    ///
1053    /// See: <https://github.com/cyrusimap/cyrus-docker-test-server>
1054    #[tokio::test]
1055    async fn test_collection_methods() {
1056        let setup = setup_test_client().await;
1057        let jmap = setup.client;
1058        let session = setup.session;
1059
1060        assert!(session.supports_calendars());
1061        assert!(session.supports_address_books());
1062        assert_eq!(session.api_url(), Some("/jmap/"));
1063
1064        let initial = jmap.get_collections::<Calendar>().await.unwrap();
1065        let initial_count = initial.len();
1066
1067        let created = jmap.create_collection::<Calendar>("Created").await.unwrap();
1068        let post_create_count = jmap.get_collections::<Calendar>().await.unwrap().len();
1069
1070        assert_eq!(initial_count + 1, post_create_count);
1071
1072        let created_id = created.data["id"].as_str().unwrap();
1073        jmap.delete_collection::<Calendar>(created_id, None)
1074            .await
1075            .unwrap();
1076
1077        let post_delete_count = jmap.get_collections::<Calendar>().await.unwrap().len();
1078        assert_eq!(initial_count, post_delete_count);
1079    }
1080
1081    #[tokio::test]
1082    #[ignore = "Cyrus returns internal error"]
1083    async fn test_address_book_methods() {
1084        let setup = setup_test_client().await;
1085        let jmap = setup.client;
1086        let session = setup.session;
1087
1088        assert!(session.supports_calendars());
1089        assert!(session.supports_address_books());
1090        assert_eq!(session.api_url(), Some("/jmap/"));
1091
1092        let initial = jmap.get_collections::<AddressBook>().await.unwrap();
1093        let initial_count = initial.len();
1094
1095        let created = jmap.create_collection::<AddressBook>("Blah").await.unwrap();
1096        let post_create_count = jmap.get_collections::<AddressBook>().await.unwrap().len();
1097
1098        assert_eq!(initial_count + 1, post_create_count);
1099
1100        let created_id = created.data["id"].as_str().unwrap();
1101        jmap.delete_collection::<AddressBook>(created_id, None)
1102            .await
1103            .unwrap();
1104
1105        let post_delete_count = jmap.get_collections::<AddressBook>().await.unwrap().len();
1106        assert_eq!(initial_count, post_delete_count);
1107    }
1108
1109    #[tokio::test]
1110    async fn get_session_resource() {
1111        let setup = setup_test_client().await;
1112        let jmap = setup.client;
1113        let session = setup.session;
1114
1115        let session2 = jmap.get_session_resource().await.unwrap();
1116
1117        assert_eq!(session.api_url(), Some("/jmap/"));
1118        assert!(session.supports_calendars());
1119        assert!(session.supports_address_books());
1120
1121        assert_eq!(session, session2);
1122    }
1123
1124    #[tokio::test]
1125    async fn test_record_methods() {
1126        let setup = setup_test_client().await;
1127        let jmap = setup.client;
1128        let session = setup.session;
1129
1130        assert!(session.supports_calendars());
1131        assert!(session.supports_address_books());
1132        assert_eq!(session.api_url(), Some("/jmap/"));
1133
1134        let initial_count = jmap.get_records::<Calendar>("Default").await.unwrap().len();
1135
1136        let event = json!({
1137            // "@type": "CalendarEvent",
1138            "title": "Meeting with Bob",
1139            "start": "2025-07-10T10:00:00",
1140            "duration": "PT1H"
1141        });
1142        let created = jmap
1143            .create_record::<Calendar>("Default", &event, None)
1144            .await
1145            .unwrap();
1146
1147        let records = jmap.get_records::<Calendar>("Default").await.unwrap();
1148        let post_create_count = records.len();
1149        assert!(records.iter().any(|r| r.id == created.id));
1150        assert_eq!(initial_count + 1, post_create_count);
1151
1152        // Update with bogus state.
1153        let updated_event = json!({
1154            "title": "Updated Meeting with Bob",
1155            "start": "2025-07-10T10:00:00",
1156            "duration": "PT1H"
1157        });
1158        jmap.update_record::<Calendar>(&created.id, "Default", &updated_event, Some("0"))
1159            .await
1160            .unwrap_err();
1161
1162        // Update with no state.
1163        let updated = jmap
1164            .update_record::<Calendar>(&created.id, "Default", &updated_event, None)
1165            .await
1166            .unwrap();
1167        assert_eq!(updated.id, created.id);
1168        assert_ne!(updated.state, created.state); // State should change after update
1169
1170        // Update with correct state.
1171        let updated_event = json!({
1172            "title": "Updated Meeting with Bobby",
1173            "start": "2025-07-10T10:00:00",
1174            "duration": "PT1H"
1175        });
1176        let updated = jmap
1177            .update_record::<Calendar>(&created.id, "Default", &updated_event, Some(&updated.state))
1178            .await
1179            .unwrap();
1180        assert_eq!(updated.id, created.id);
1181        assert_ne!(updated.state, created.state); // State should change after update
1182
1183        let records_after_update = jmap.get_records::<Calendar>("Default").await.unwrap();
1184        let updated_record = records_after_update
1185            .iter()
1186            .find(|r| r.id == created.id)
1187            .unwrap();
1188        assert_eq!(updated_record.data["title"], "Updated Meeting with Bobby");
1189
1190        // Deletion with bogus state.
1191        jmap.delete_record::<Calendar>(&created.id, Some("0"))
1192            .await
1193            .unwrap_err();
1194        let records = jmap.get_records::<Calendar>("Default").await.unwrap();
1195        let failed_delete_count = records.len();
1196        assert!(records.iter().any(|r| r.id == created.id));
1197        assert_eq!(post_create_count, failed_delete_count);
1198
1199        // Deletion with no state.
1200        jmap.delete_record::<Calendar>(&created.id, None)
1201            .await
1202            .unwrap();
1203        let records = jmap.get_records::<Calendar>("Default").await.unwrap();
1204        let post_delete_count = records.len();
1205        assert!(!records.iter().any(|r| r.id == created.id));
1206        assert_eq!(initial_count, post_delete_count);
1207    }
1208
1209    #[tokio::test]
1210    async fn changes() {
1211        let setup = setup_test_client().await;
1212        let jmap = setup.client;
1213
1214        // Create first event.
1215        let event1 = json!({
1216            "title": "Initial Event",
1217            "start": "2025-07-09T10:00:00",
1218            "duration": "PT1H"
1219        });
1220        let created1 = jmap
1221            .create_record::<Calendar>("Default", &event1, None)
1222            .await
1223            .unwrap();
1224
1225        // Keep baseline state.
1226        let baseline_state = created1.state.clone();
1227
1228        // Create second event.
1229        let event2 = json!({
1230            "title": "Test Event for Changes",
1231            "start": "2025-07-10T10:00:00",
1232            "duration": "PT1H"
1233        });
1234        let created2 = jmap
1235            .create_record::<Calendar>("Default", &event2, None)
1236            .await
1237            .unwrap();
1238
1239        // Get changes since baseline.
1240        let changes = jmap
1241            .changes::<Calendar>(&baseline_state, None)
1242            .await
1243            .unwrap();
1244        assert!(changes.created.contains(&created2.id));
1245        assert!(!changes.has_more_changes);
1246        assert_ne!(changes.old_state, changes.new_state);
1247
1248        // Update second event.
1249        let updated_event = json!({
1250            "title": "Updated Test Event",
1251            "start": "2025-07-10T11:00:00",
1252            "duration": "PT2H"
1253        });
1254        let state_before_update = changes.new_state.clone();
1255        jmap.update_record::<Calendar>(&created2.id, "Default", &updated_event, None)
1256            .await
1257            .unwrap();
1258
1259        // Get changes since baseline.
1260        let changes = jmap
1261            .changes::<Calendar>(&state_before_update, Some(100))
1262            .await
1263            .unwrap();
1264        assert!(changes.updated.contains(&created2.id));
1265        assert!(!changes.created.contains(&created2.id));
1266
1267        // Delete second event.
1268        let state_before_delete = changes.new_state.clone();
1269        jmap.delete_record::<Calendar>(&created2.id, None)
1270            .await
1271            .unwrap();
1272
1273        // Get changes since before deletion.
1274        let changes = jmap
1275            .changes::<Calendar>(&state_before_delete, None)
1276            .await
1277            .unwrap();
1278        assert!(changes.destroyed.contains(&created2.id));
1279        assert!(!changes.updated.contains(&created2.id));
1280    }
1281
1282    #[tokio::test]
1283    async fn changed_since() {
1284        let setup = setup_test_client().await;
1285        let jmap = setup.client;
1286
1287        // Create an event.
1288        let event1 = json!({
1289            "title": "Test Event for Changes",
1290            "start": "2025-07-10T10:00:00",
1291            "duration": "PT1H"
1292        });
1293        let created1 = jmap
1294            .create_record::<Calendar>("Default", &event1, None)
1295            .await
1296            .unwrap();
1297        let state_after_create = created1.state.clone();
1298
1299        // Check that the item shows as NotChanged when checking against its creation state.
1300        let (status, _new_state) = jmap
1301            .changed_since::<Calendar>(&created1.id, &state_after_create)
1302            .await
1303            .unwrap();
1304        assert_eq!(status, ChangeStatus::NotChanged);
1305
1306        // Create another event to change the state.
1307        let event2 = json!({
1308            "title": "Another Event",
1309            "start": "2025-07-11T10:00:00",
1310            "duration": "PT1H"
1311        });
1312        let _created2 = jmap
1313            .create_record::<Calendar>("Default", &event2, None)
1314            .await
1315            .unwrap();
1316
1317        // Original item should still be NotChanged relative to its creation state.
1318        let (status, _new_state) = jmap
1319            .changed_since::<Calendar>(&created1.id, &state_after_create)
1320            .await
1321            .unwrap();
1322        assert_eq!(status, ChangeStatus::NotChanged);
1323
1324        // Update the first event.
1325        let updated_event = json!({
1326            "title": "Updated Test Event",
1327            "start": "2025-07-10T10:00:00",
1328            "duration": "PT1H"
1329        });
1330        let _updated1 = jmap
1331            .update_record::<Calendar>(&created1.id, "Default", &updated_event, None)
1332            .await
1333            .unwrap();
1334
1335        // Now it should show as Changed.
1336        let (status, _new_state) = jmap
1337            .changed_since::<Calendar>(&created1.id, &state_after_create)
1338            .await
1339            .unwrap();
1340        assert_eq!(status, ChangeStatus::Changed);
1341
1342        // Delete the first event.
1343        jmap.delete_record::<Calendar>(&created1.id, None)
1344            .await
1345            .unwrap();
1346
1347        // Now it should show as Deleted.
1348        let (status, _new_state) = jmap
1349            .changed_since::<Calendar>(&created1.id, &state_after_create)
1350            .await
1351            .unwrap();
1352        assert_eq!(status, ChangeStatus::Deleted);
1353    }
1354
1355    #[test]
1356    fn parse_get_calendar_success() {
1357        let body = r#"
1358{
1359  "methodResponses": [
1360    [
1361      "Calendar/get",
1362      {
1363        "state": "18",
1364        "list": [
1365          {
1366            "id": "Default",
1367            "name": "personal",
1368            "description": null,
1369            "sortOrder": 0,
1370            "isVisible": true,
1371            "isSubscribed": true,
1372            "includeInAvailability": "all",
1373            "timeZone": null,
1374            "myRights": {
1375              "mayReadFreeBusy": true,
1376              "mayReadItems": true,
1377              "mayWriteAll": true,
1378              "mayWriteOwn": true,
1379              "mayUpdatePrivate": true,
1380              "mayRSVP": true,
1381              "mayDelete": true,
1382              "mayAdmin": true
1383            },
1384            "shareWith": null
1385          }
1386        ],
1387        "notFound": [],
1388        "accountId": "user1"
1389      },
1390      "0"
1391    ]
1392  ],
1393  "sessionState": "0"
1394}
1395"#;
1396
1397        let expected = vec![Calendar {
1398            id: "Default".into(),
1399            name: "personal".into(),
1400            description: None,
1401            color: None,
1402            sort_order: 0,
1403        }];
1404        let mut parsed_body: JmapResponse<GetResponse<Calendar>> =
1405            serde_json::from_slice(body.as_bytes()).unwrap();
1406        let method_response = parsed_body.method_responses.pop().unwrap();
1407        let got = method_response.1.into_result().unwrap().list;
1408
1409        assert_eq!(expected, got);
1410    }
1411
1412    #[test]
1413    fn parse_error_response_state_mismatch() {
1414        let body = serde_json::json!( {
1415            "methodResponses": [
1416                [ "error", { "type": "stateMismatch" }, "0" ]
1417            ],
1418            "sessionState": "0"
1419        })
1420        .to_string();
1421
1422        let parsed_body: JmapResponse<SetResponse<JsonValue>> =
1423            serde_json::from_str(&body).unwrap();
1424
1425        assert_eq!(parsed_body.method_responses.len(), 1);
1426        let method_response = &parsed_body.method_responses[0];
1427
1428        assert_eq!(method_response.0, "error");
1429        assert_eq!(method_response.2, "0");
1430
1431        match &method_response.1 {
1432            MethodResponseData::Error(error) => {
1433                assert_eq!(error.error_type, "stateMismatch");
1434                assert_eq!(error.properties, None);
1435                assert_eq!(error.description, None);
1436            }
1437            MethodResponseData::Success(_) => panic!("Expected error response, got success"),
1438        }
1439    }
1440
1441    #[test]
1442    fn parse_error_response_unknown_method() {
1443        let body = serde_json::json!( {
1444            "methodResponses": [
1445                [ "error", { "type": "unknownMethod" }, "call-id" ]
1446            ],
1447            "sessionState": "0"
1448        })
1449        .to_string();
1450
1451        let parsed_body: JmapResponse<JsonValue> = serde_json::from_str(&body).unwrap();
1452
1453        assert_eq!(parsed_body.method_responses.len(), 1);
1454        let method_response = &parsed_body.method_responses[0];
1455
1456        assert_eq!(method_response.0, "error");
1457        assert_eq!(method_response.2, "call-id");
1458
1459        match &method_response.1 {
1460            MethodResponseData::Error(error) => {
1461                assert_eq!(error.error_type, "unknownMethod");
1462            }
1463            MethodResponseData::Success(_) => panic!("Expected error response, got success"),
1464        }
1465    }
1466}