Skip to main content

jmap_metadata_client/methods/
metadata.rs

1//! JMAP Metadata — Metadata/* method implementations on SessionClient
2//! (draft-ietf-jmap-metadata-01 §3).
3//!
4//! Each method follows the standard five-step pattern:
5//!   1. Validate arguments (defence-in-depth empty-state guards).
6//!   2. Call `self.session_parts()?` → `(api_url, account_id)`.
7//!   3. Build args JSON with `serde_json::json!({…})`.
8//!   4. Call `build_request(method_name, args, USING_METADATA)`.
9//!   5. Call `self.call_internal(api_url, &req).await?`.
10//!   6. Call `jmap_base_client::extract_response(&resp, CALL_ID)?`.
11
12use std::collections::HashMap;
13
14use jmap_types::{Id, PatchObject, State};
15
16use super::{
17    ChangesResponse, GetResponse, MetadataChangesParams, QueryChangesResponse, QueryResponse,
18    SetResponse,
19};
20
21impl super::SessionClient {
22    /// Fetch Metadata objects by IDs (draft-ietf-jmap-metadata-01 §3.2).
23    ///
24    /// If `ids` is `None`, the server returns all Metadata objects for the
25    /// account in one response. Pass `properties: None` to return all
26    /// fields.
27    pub async fn metadata_get(
28        &self,
29        ids: Option<&[Id]>,
30        properties: Option<&[&str]>,
31    ) -> Result<GetResponse<jmap_metadata_types::Metadata>, jmap_base_client::ClientError> {
32        let (api_url, account_id) = self.session_parts()?;
33        // Omit `ids` / `properties` entirely when None rather than sending
34        // an explicit JSON null. RFC 8620 §5.1 accepts both shapes, but the
35        // crate's other builders (set/changes/query) consistently use the
36        // conditional-add idiom; matching it here keeps the wire request
37        // canonical and avoids "present-but-null vs absent" interop quirks
38        // in proxies / audit loggers.
39        let mut args = serde_json::json!({ "accountId": account_id });
40        if let Some(id_slice) = ids {
41            args["ids"] = serde_json::to_value(id_slice).expect("Id slice Serialize is infallible");
42        }
43        if let Some(props) = properties {
44            args["properties"] = serde_json::Value::Array(
45                props.iter().copied().map(serde_json::Value::from).collect(),
46            );
47        }
48        let req = super::build_request("Metadata/get", args, super::USING_METADATA);
49        let resp = self.call_internal(api_url, &req).await?;
50        jmap_base_client::extract_response(&resp, super::CALL_ID)
51    }
52
53    /// Fetch changes to Metadata objects since `since_state`
54    /// (draft-ietf-jmap-metadata-01 §3.3).
55    ///
56    /// If `has_more_changes` is true in the response, call again with
57    /// `new_state` as `since_state` until the flag is false.
58    ///
59    /// `params` carries the Metadata-specific optional filters
60    /// `filterRelatedType` and `filterMetadataType`. Pass `None` (or
61    /// `Some(Default::default())`) to fetch unfiltered changes. Per §3.3,
62    /// clients that use these filters MUST re-use the same filter values
63    /// across subsequent calls to ensure consistent synchronisation,
64    /// because the returned state token represents the complete account
65    /// state and is independent of the filter selection.
66    pub async fn metadata_changes(
67        &self,
68        since_state: &State,
69        max_changes: Option<u64>,
70        params: Option<MetadataChangesParams>,
71    ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
72        // Defence-in-depth: even with the typed-`State` parameter (a transparent
73        // newtype around `String`), an empty state token is still a logically
74        // invalid value that should be caught client-side rather than producing
75        // a confusing server-side `cannotCalculateChanges` error.
76        if since_state.as_ref().is_empty() {
77            return Err(jmap_base_client::ClientError::InvalidArgument(
78                "metadata_changes: since_state may not be empty".into(),
79            ));
80        }
81        let (api_url, account_id) = self.session_parts()?;
82        let mut args = serde_json::json!({
83            "accountId": account_id,
84            "sinceState": since_state,
85        });
86        if let Some(mc) = max_changes {
87            args["maxChanges"] = mc.into();
88        }
89        if let Some(p) = params {
90            if let Some(v) = p.filter_related_type {
91                args["filterRelatedType"] = v.into();
92            }
93            if let Some(v) = p.filter_metadata_type {
94                args["filterMetadataType"] =
95                    serde_json::to_value(&v).expect("Vec<String> Serialize is infallible");
96            }
97            // Vendor extras: flatten any caller-supplied keys directly into
98            // the args object. Note that an extras key colliding with a
99            // typed-field wire name (`filterRelatedType`, `filterMetadataType`,
100            // `accountId`, `sinceState`, `maxChanges`) is the caller's
101            // responsibility to avoid; we do NOT overwrite typed fields here.
102            for (k, v) in p.extra {
103                args.as_object_mut()
104                    .expect("args is constructed as an Object")
105                    .entry(k)
106                    .or_insert(v);
107            }
108        }
109        let req = super::build_request("Metadata/changes", args, super::USING_METADATA);
110        let resp = self.call_internal(api_url, &req).await?;
111        jmap_base_client::extract_response(&resp, super::CALL_ID)
112    }
113
114    /// Create, update, or destroy Metadata objects
115    /// (draft-ietf-jmap-metadata-01 §3.1).
116    ///
117    /// Pass `create`, `update`, and/or `destroy` as needed. All three are
118    /// optional; pass `None` to omit any operation from the request.
119    ///
120    /// `update` is `Option<HashMap<Id, PatchObject>>` (RFC 8620 §5.3). Wire
121    /// format is unchanged from a plain JSON object because [`PatchObject`]
122    /// is `#[serde(transparent)]`; the typed parameter binds the JSON Pointer
123    /// key + null-leaf removal contract to the type system.
124    ///
125    /// The server enforces uniqueness on the tuple
126    /// `(relatedType, relatedId, @type, isPrivate)` and may return
127    /// `alreadyExists`, `forbidden` (if `maySetPrivate: false` and
128    /// `isPrivate: true` is requested), `overQuota`, or
129    /// `invalidProperties` SetErrors per §3.1.
130    pub async fn metadata_set(
131        &self,
132        create: Option<serde_json::Value>,
133        update: Option<HashMap<Id, PatchObject>>,
134        destroy: Option<Vec<Id>>,
135    ) -> Result<SetResponse<jmap_metadata_types::Metadata>, jmap_base_client::ClientError> {
136        let (api_url, account_id) = self.session_parts()?;
137        let mut args = serde_json::json!({
138            "accountId": account_id,
139        });
140        if let Some(c) = create {
141            args["create"] = c;
142        }
143        if let Some(u) = update {
144            args["update"] = serde_json::to_value(&u).map_err(|e| {
145                jmap_base_client::ClientError::InvalidArgument(format!(
146                    "metadata_set: serializing update map failed: {e}"
147                ))
148            })?;
149        }
150        if let Some(d) = destroy {
151            args["destroy"] = serde_json::to_value(&d).expect("Id Vec Serialize is infallible");
152        }
153        let req = super::build_request("Metadata/set", args, super::USING_METADATA);
154        let resp = self.call_internal(api_url, &req).await?;
155        jmap_base_client::extract_response(&resp, super::CALL_ID)
156    }
157
158    /// Query Metadata IDs with optional filter and sort
159    /// (draft-ietf-jmap-metadata-01 §3.4).
160    ///
161    /// Pass `filter: None` and `sort: None` to return all Metadata IDs with
162    /// server-default ordering. Use `position` and `limit` for pagination.
163    ///
164    /// The `filter` value is typed by the caller; the canonical type is
165    /// [`jmap_metadata_types::MetadataFilterCondition`] (§3.4.1) wrapped in
166    /// `jmap_types::query::Filter`. This builder accepts a `serde_json::Value`
167    /// so callers can mix filter conditions with `FilterOperator` algebra
168    /// without forcing a single type through the API.
169    ///
170    /// Sort comparators MUST set `property` to one of `id`, `@type`,
171    /// `relatedType`, `relatedId`, or `isPrivate` per §3.4.2; `id` MUST be
172    /// supported by every server, the others SHOULD be supported.
173    pub async fn metadata_query(
174        &self,
175        filter: Option<serde_json::Value>,
176        sort: Option<serde_json::Value>,
177        position: Option<u64>,
178        limit: Option<u64>,
179    ) -> Result<QueryResponse, jmap_base_client::ClientError> {
180        let (api_url, account_id) = self.session_parts()?;
181        let mut args = serde_json::json!({
182            "accountId": account_id,
183        });
184        if let Some(f) = filter {
185            args["filter"] = f;
186        }
187        if let Some(s) = sort {
188            args["sort"] = s;
189        }
190        if let Some(p) = position {
191            args["position"] = p.into();
192        }
193        if let Some(l) = limit {
194            args["limit"] = l.into();
195        }
196        let req = super::build_request("Metadata/query", args, super::USING_METADATA);
197        let resp = self.call_internal(api_url, &req).await?;
198        jmap_base_client::extract_response(&resp, super::CALL_ID)
199    }
200
201    /// Fetch query-result changes for Metadata since `since_query_state`
202    /// (draft-ietf-jmap-metadata-01 §3.5).
203    ///
204    /// Returns which Metadata IDs were removed from or added to the query
205    /// result set since the given state. `max_changes` may be `None`.
206    pub async fn metadata_query_changes(
207        &self,
208        since_query_state: &State,
209        max_changes: Option<u64>,
210    ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
211        // Defence-in-depth: see `metadata_changes`.
212        if since_query_state.as_ref().is_empty() {
213            return Err(jmap_base_client::ClientError::InvalidArgument(
214                "metadata_query_changes: since_query_state may not be empty".into(),
215            ));
216        }
217        let (api_url, account_id) = self.session_parts()?;
218        let mut args = serde_json::json!({
219            "accountId": account_id,
220            "sinceQueryState": since_query_state,
221        });
222        if let Some(mc) = max_changes {
223            args["maxChanges"] = mc.into();
224        }
225        let req = super::build_request("Metadata/queryChanges", args, super::USING_METADATA);
226        let resp = self.call_internal(api_url, &req).await?;
227        jmap_base_client::extract_response(&resp, super::CALL_ID)
228    }
229}
230
231// ---------------------------------------------------------------------------
232// Tests
233// ---------------------------------------------------------------------------
234
235#[cfg(test)]
236mod tests {
237    use serde_json::json;
238
239    // Production-path coverage lives in tests/metadata_tests.rs
240    // (wiremock-backed end-to-end). The tests here exercise the typed
241    // wire-shape oracles for response types using JSON literals taken
242    // from draft-ietf-jmap-metadata-01 and RFC 8620.
243
244    /// Oracle: Annotation deserialization from draft-ietf-jmap-metadata-01
245    /// §1.6.1 (example 1). Expected JSON taken verbatim from the draft.
246    #[test]
247    fn annotation_deserializes_from_spec_example() {
248        let json = json!({
249            "@type": "Annotation",
250            "id": "MD789",
251            "relatedType": "Email",
252            "relatedId": "EM456",
253            "isPrivate": true,
254            "acme.example.com:workflowState": "pending-review"
255        });
256        let meta: jmap_metadata_types::Metadata =
257            serde_json::from_value(json).expect("Annotation must deserialize");
258        match meta {
259            jmap_metadata_types::Metadata::Annotation(a) => {
260                assert_eq!(a.related_type, "Email");
261                assert_eq!(a.related_id.as_ref(), "EM456");
262                assert_eq!(a.is_private, Some(true));
263                assert_eq!(
264                    a.extra
265                        .get("acme.example.com:workflowState")
266                        .and_then(|v| v.as_str()),
267                    Some("pending-review")
268                );
269            }
270            _ => panic!("expected Annotation variant"),
271        }
272    }
273
274    /// Oracle: GetResponse<Metadata> deserializes from RFC 8620 §5.1 shape.
275    #[test]
276    fn get_response_metadata_deserializes() {
277        use super::super::GetResponse;
278
279        let json = json!({
280            "accountId": "acc1",
281            "state": "s42",
282            "list": [
283                {
284                    "@type": "Annotation",
285                    "id": "MD1",
286                    "relatedType": "Mailbox",
287                    "relatedId": "MB1",
288                    "isPrivate": false,
289                    "acme.example.com:color": "blue"
290                }
291            ],
292            "notFound": []
293        });
294        let resp: GetResponse<jmap_metadata_types::Metadata> =
295            serde_json::from_value(json).expect("GetResponse<Metadata> must deserialize");
296        assert_eq!(resp.account_id, "acc1");
297        assert_eq!(resp.state, "s42");
298        assert_eq!(resp.list.len(), 1);
299        assert_eq!(resp.list[0].related_type(), "Mailbox");
300    }
301
302    /// Oracle: SetResponse<Metadata> deserializes with a created entry.
303    #[test]
304    fn set_response_metadata_with_created_deserializes() {
305        use super::super::SetResponse;
306
307        let json = json!({
308            "accountId": "acc1",
309            "oldState": "s1",
310            "newState": "s2",
311            "created": {
312                "newMeta": {
313                    "@type": "Annotation",
314                    "id": "server-md-id",
315                    "relatedType": "Email",
316                    "relatedId": "EM1",
317                    "isPrivate": false,
318                    "acme.example.com:tag": "important"
319                }
320            },
321            "updated": null,
322            "destroyed": null,
323            "notCreated": null,
324            "notUpdated": null,
325            "notDestroyed": null
326        });
327        let resp: SetResponse<jmap_metadata_types::Metadata> =
328            serde_json::from_value(json).expect("SetResponse<Metadata> must deserialize");
329        assert_eq!(resp.new_state, "s2");
330        let created = resp.created.expect("created must be present");
331        assert!(
332            created.contains_key("newMeta"),
333            "created must contain 'newMeta' key"
334        );
335        let meta = &created["newMeta"];
336        assert_eq!(meta.id().map(|id| id.as_ref()), Some("server-md-id"));
337        assert_eq!(meta.related_type(), "Email");
338    }
339}