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}