Skip to main content

jmap_metadata_client/
lib.rs

1//! jmap-metadata-client — JMAP Object Metadata extension client helpers.
2//!
3//! Implements the client-side helpers for
4//! [draft-ietf-jmap-metadata-02](https://datatracker.ietf.org/doc/draft-ietf-jmap-metadata/).
5//!
6//! ## Architecture change from -01 to -02
7//!
8//! The -02 revision eliminates the standalone `Metadata` object type.
9//! Metadata becomes `metadata` and `privateMetadata` properties on
10//! each opted-in JMAP data type. Clients read and write metadata
11//! through the existing `Foo/get`, `Foo/set`, `Foo/changes`, and
12//! `Foo/query` methods — there are no separate `Metadata/*` methods.
13//!
14//! This crate no longer provides a `SessionClient` or method bindings.
15//! Instead, it provides **pure helper functions** that consumers use
16//! alongside their existing extension-client crate calls:
17//!
18//! - **Property constants** — [`METADATA_PROPERTY`], [`PRIVATE_METADATA_PROPERTY`]
19//!   for inclusion in `/get` `properties` lists.
20//! - **Filter-condition builders** — [`metadata_exists_condition`],
21//!   [`metadata_text_contains_condition`], [`metadata_text_equals_condition`],
22//!   and their `private_metadata_*` counterparts, producing JSON values
23//!   suitable for merging into per-type `FilterCondition` objects.
24//! - **`/changes` helpers** — [`IGNORE_METADATA_ONLY_CHANGES`] arg name,
25//!   [`is_metadata_property`], [`is_metadata_only_change`] for classifying
26//!   `updatedProperties` in `/changes` responses.
27//!
28//! All functions are stateless and operate on string/JSON values.
29//!
30//! # Migration from -01
31//!
32//! The -01 crate exposed `JmapMetadataExt`, `SessionClient`,
33//! `metadata_get`, `metadata_set`, `metadata_changes`, `metadata_query`,
34//! `metadata_query_changes`, five `*Params` structs, and `MetadataChangesParams`.
35//! All of those are removed in -02. Extension-client crates that previously
36//! depended on this crate for standalone `Metadata/*` methods should instead
37//! use the helper functions from this crate alongside their own typed method
38//! calls (e.g. `email_get` with metadata properties, `email_query` with
39//! metadata filter conditions).
40//!
41//! # Example
42//!
43//! ```rust
44//! use jmap_metadata_client::{
45//!     METADATA_PROPERTY,
46//!     PRIVATE_METADATA_PROPERTY,
47//!     metadata_exists_condition,
48//!     metadata_text_contains_condition,
49//! };
50//!
51//! // Include metadata properties in a /get properties list
52//! let properties = vec!["id", "subject", METADATA_PROPERTY, PRIVATE_METADATA_PROPERTY];
53//! assert!(properties.contains(&"metadata"));
54//!
55//! // Build filter conditions for a /query request
56//! let exists = metadata_exists_condition("photography");
57//! assert_eq!(exists, serde_json::json!({ "metadataExists": "photography" }));
58//!
59//! let text_match = metadata_text_contains_condition("photography/caption", "sunset");
60//! assert_eq!(text_match, serde_json::json!({
61//!     "metadataTextContains": { "path": "photography/caption", "value": "sunset" }
62//! }));
63//! ```
64
65#![forbid(unsafe_code)]
66
67// ---------------------------------------------------------------------------
68// Re-exports from jmap-metadata-types
69// ---------------------------------------------------------------------------
70
71/// Capability URI for `urn:ietf:params:jmap:metadata`.
72pub use jmap_metadata_types::JMAP_METADATA_URI;
73
74/// Per-data-type metadata capability advertisement (§1.2.1).
75pub use jmap_metadata_types::DataTypeMetadataInfo;
76
77/// Value shape for text-matching filter conditions (§3.5).
78pub use jmap_metadata_types::MetadataTextMatch;
79
80/// Namespace identifier validation helpers.
81pub use jmap_metadata_types::{is_registered_namespace, is_valid_namespace, is_vendor_namespace};
82
83// ---------------------------------------------------------------------------
84// Property name constants (§2)
85// ---------------------------------------------------------------------------
86
87/// Wire name of the shared metadata property on opted-in data types (§2).
88///
89/// Include in the `properties` list of a `/get` request to fetch the
90/// per-namespace shared metadata map.
91pub const METADATA_PROPERTY: &str = "metadata";
92
93/// Wire name of the per-user private metadata property on opted-in data
94/// types (§2).
95///
96/// Include in the `properties` list of a `/get` request to fetch the
97/// per-namespace per-user metadata map. Only available when the account's
98/// [`DataTypeMetadataInfo::supports_private`] is `true`.
99pub const PRIVATE_METADATA_PROPERTY: &str = "privateMetadata";
100
101// ---------------------------------------------------------------------------
102// /changes helpers (§2.4, §2.5)
103// ---------------------------------------------------------------------------
104
105/// Wire name of the `ignoreMetadataOnlyChanges` argument for `/changes`
106/// requests (§2.4).
107///
108/// When set to `true` in a `/changes` request, objects whose only changes
109/// are to `metadata` and/or `privateMetadata` properties are excluded from
110/// the `updated` array in the response.
111pub const IGNORE_METADATA_ONLY_CHANGES: &str = "ignoreMetadataOnlyChanges";
112
113/// Returns `true` if `property` is one of the metadata-extension properties
114/// (`metadata` or `privateMetadata`).
115///
116/// Useful for classifying the `updatedProperties` array in a `/changes`
117/// response (§2.5) to determine whether a change is metadata-only.
118///
119/// # Examples
120///
121/// ```
122/// use jmap_metadata_client::is_metadata_property;
123///
124/// assert!(is_metadata_property("metadata"));
125/// assert!(is_metadata_property("privateMetadata"));
126/// assert!(!is_metadata_property("subject"));
127/// assert!(!is_metadata_property("id"));
128/// ```
129pub fn is_metadata_property(property: &str) -> bool {
130    property == METADATA_PROPERTY || property == PRIVATE_METADATA_PROPERTY
131}
132
133/// Returns `true` if ALL properties in `updated_properties` are metadata
134/// properties (§2.5).
135///
136/// A `/changes` response with `updatedProperties` present indicates that
137/// only those properties changed. If every property in the list is a
138/// metadata property, the change is metadata-only and may be skipped by
139/// clients that don't need metadata state updates.
140///
141/// Returns `false` for an empty slice (no properties ≠ metadata-only).
142///
143/// # Examples
144///
145/// ```
146/// use jmap_metadata_client::is_metadata_only_change;
147///
148/// assert!(is_metadata_only_change(&["metadata".to_string()]));
149/// assert!(is_metadata_only_change(&["metadata".to_string(), "privateMetadata".to_string()]));
150/// assert!(!is_metadata_only_change(&["metadata".to_string(), "subject".to_string()]));
151/// assert!(!is_metadata_only_change(&[]));
152/// ```
153pub fn is_metadata_only_change(updated_properties: &[String]) -> bool {
154    !updated_properties.is_empty() && updated_properties.iter().all(|p| is_metadata_property(p))
155}
156
157// ---------------------------------------------------------------------------
158// Filter-condition builders (§3.5)
159// ---------------------------------------------------------------------------
160
161/// Build a `metadataExists` filter condition (§3.5).
162///
163/// Returns a JSON object `{ "metadataExists": "<path>" }` suitable for
164/// merging into a per-type `FilterCondition` object in a `/query` request.
165///
166/// `path` is a namespace identifier or `<namespace>/<key>` path with
167/// RFC 6901 escaping where applicable.
168///
169/// # Examples
170///
171/// ```
172/// use jmap_metadata_client::metadata_exists_condition;
173/// use serde_json::json;
174///
175/// assert_eq!(
176///     metadata_exists_condition("photography"),
177///     json!({ "metadataExists": "photography" })
178/// );
179/// assert_eq!(
180///     metadata_exists_condition("acme.example.com/memo"),
181///     json!({ "metadataExists": "acme.example.com/memo" })
182/// );
183/// ```
184pub fn metadata_exists_condition(path: &str) -> serde_json::Value {
185    serde_json::json!({ "metadataExists": path })
186}
187
188/// Build a `privateMetadataExists` filter condition (§3.5).
189///
190/// Same as [`metadata_exists_condition`] but targets `privateMetadata`.
191///
192/// # Examples
193///
194/// ```
195/// use jmap_metadata_client::private_metadata_exists_condition;
196/// use serde_json::json;
197///
198/// assert_eq!(
199///     private_metadata_exists_condition("photography"),
200///     json!({ "privateMetadataExists": "photography" })
201/// );
202/// ```
203pub fn private_metadata_exists_condition(path: &str) -> serde_json::Value {
204    serde_json::json!({ "privateMetadataExists": path })
205}
206
207/// Build a `metadataTextContains` filter condition (§3.5).
208///
209/// Returns a JSON object suitable for merging into a per-type
210/// `FilterCondition`. Matches objects where the string value at `path`
211/// in the `metadata` map contains `value` as a substring.
212///
213/// # Examples
214///
215/// ```
216/// use jmap_metadata_client::metadata_text_contains_condition;
217/// use serde_json::json;
218///
219/// assert_eq!(
220///     metadata_text_contains_condition("photography/caption", "sunset"),
221///     json!({ "metadataTextContains": { "path": "photography/caption", "value": "sunset" } })
222/// );
223/// ```
224pub fn metadata_text_contains_condition(path: &str, value: &str) -> serde_json::Value {
225    serde_json::json!({
226        "metadataTextContains": {
227            "path": path,
228            "value": value,
229        }
230    })
231}
232
233/// Build a `privateMetadataTextContains` filter condition (§3.5).
234///
235/// Same as [`metadata_text_contains_condition`] but targets `privateMetadata`.
236///
237/// # Examples
238///
239/// ```
240/// use jmap_metadata_client::private_metadata_text_contains_condition;
241/// use serde_json::json;
242///
243/// assert_eq!(
244///     private_metadata_text_contains_condition("workflow/note", "urgent"),
245///     json!({ "privateMetadataTextContains": { "path": "workflow/note", "value": "urgent" } })
246/// );
247/// ```
248pub fn private_metadata_text_contains_condition(path: &str, value: &str) -> serde_json::Value {
249    serde_json::json!({
250        "privateMetadataTextContains": {
251            "path": path,
252            "value": value,
253        }
254    })
255}
256
257/// Build a `metadataTextEquals` filter condition (§3.5).
258///
259/// Returns a JSON object suitable for merging into a per-type
260/// `FilterCondition`. Matches objects where the string value at `path`
261/// in the `metadata` map is exactly equal to `value`.
262///
263/// # Examples
264///
265/// ```
266/// use jmap_metadata_client::metadata_text_equals_condition;
267/// use serde_json::json;
268///
269/// assert_eq!(
270///     metadata_text_equals_condition("photography/rating", "5"),
271///     json!({ "metadataTextEquals": { "path": "photography/rating", "value": "5" } })
272/// );
273/// ```
274pub fn metadata_text_equals_condition(path: &str, value: &str) -> serde_json::Value {
275    serde_json::json!({
276        "metadataTextEquals": {
277            "path": path,
278            "value": value,
279        }
280    })
281}
282
283/// Build a `privateMetadataTextEquals` filter condition (§3.5).
284///
285/// Same as [`metadata_text_equals_condition`] but targets `privateMetadata`.
286///
287/// # Examples
288///
289/// ```
290/// use jmap_metadata_client::private_metadata_text_equals_condition;
291/// use serde_json::json;
292///
293/// assert_eq!(
294///     private_metadata_text_equals_condition("workflow/status", "approved"),
295///     json!({ "privateMetadataTextEquals": { "path": "workflow/status", "value": "approved" } })
296/// );
297/// ```
298pub fn private_metadata_text_equals_condition(path: &str, value: &str) -> serde_json::Value {
299    serde_json::json!({
300        "privateMetadataTextEquals": {
301            "path": path,
302            "value": value,
303        }
304    })
305}
306
307// ---------------------------------------------------------------------------
308// Tests
309// ---------------------------------------------------------------------------
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use serde_json::json;
315
316    // -- Property constants ------------------------------------------------
317
318    #[test]
319    fn property_constants_match_spec_wire_names() {
320        assert_eq!(METADATA_PROPERTY, "metadata");
321        assert_eq!(PRIVATE_METADATA_PROPERTY, "privateMetadata");
322    }
323
324    // -- is_metadata_property ----------------------------------------------
325
326    #[test]
327    fn is_metadata_property_metadata() {
328        assert!(is_metadata_property("metadata"));
329    }
330
331    #[test]
332    fn is_metadata_property_private() {
333        assert!(is_metadata_property("privateMetadata"));
334    }
335
336    #[test]
337    fn is_metadata_property_non_metadata() {
338        assert!(!is_metadata_property("subject"));
339        assert!(!is_metadata_property("id"));
340        assert!(!is_metadata_property(""));
341    }
342
343    // -- is_metadata_only_change -------------------------------------------
344
345    #[test]
346    fn metadata_only_change_single() {
347        assert!(is_metadata_only_change(&["metadata".into()]));
348    }
349
350    #[test]
351    fn metadata_only_change_both() {
352        assert!(is_metadata_only_change(&[
353            "metadata".into(),
354            "privateMetadata".into()
355        ]));
356    }
357
358    #[test]
359    fn metadata_only_change_mixed() {
360        assert!(!is_metadata_only_change(&[
361            "metadata".into(),
362            "subject".into()
363        ]));
364    }
365
366    #[test]
367    fn metadata_only_change_empty() {
368        assert!(!is_metadata_only_change(&[]));
369    }
370
371    #[test]
372    fn metadata_only_change_non_metadata() {
373        assert!(!is_metadata_only_change(&["subject".into()]));
374    }
375
376    // -- Filter-condition builders -----------------------------------------
377
378    #[test]
379    fn metadata_exists_condition_namespace_only() {
380        assert_eq!(
381            metadata_exists_condition("photography"),
382            json!({ "metadataExists": "photography" })
383        );
384    }
385
386    #[test]
387    fn metadata_exists_condition_with_key() {
388        assert_eq!(
389            metadata_exists_condition("acme.example.com/memo"),
390            json!({ "metadataExists": "acme.example.com/memo" })
391        );
392    }
393
394    #[test]
395    fn private_metadata_exists_condition_spec() {
396        assert_eq!(
397            private_metadata_exists_condition("photography"),
398            json!({ "privateMetadataExists": "photography" })
399        );
400    }
401
402    #[test]
403    fn metadata_text_contains_wire_shape() {
404        let v = metadata_text_contains_condition("photography/caption", "sunset");
405        assert_eq!(v["metadataTextContains"]["path"], "photography/caption");
406        assert_eq!(v["metadataTextContains"]["value"], "sunset");
407    }
408
409    #[test]
410    fn private_metadata_text_contains_wire_shape() {
411        let v = private_metadata_text_contains_condition("workflow/note", "urgent");
412        assert_eq!(v["privateMetadataTextContains"]["path"], "workflow/note");
413        assert_eq!(v["privateMetadataTextContains"]["value"], "urgent");
414    }
415
416    #[test]
417    fn metadata_text_equals_wire_shape() {
418        let v = metadata_text_equals_condition("photography/rating", "5");
419        assert_eq!(v["metadataTextEquals"]["path"], "photography/rating");
420        assert_eq!(v["metadataTextEquals"]["value"], "5");
421    }
422
423    #[test]
424    fn private_metadata_text_equals_wire_shape() {
425        let v = private_metadata_text_equals_condition("workflow/status", "approved");
426        assert_eq!(v["privateMetadataTextEquals"]["path"], "workflow/status");
427        assert_eq!(v["privateMetadataTextEquals"]["value"], "approved");
428    }
429
430    // -- MetadataTextMatch round-trip via re-export -------------------------
431
432    #[test]
433    fn metadata_text_match_re_export_round_trip() {
434        let m: MetadataTextMatch = serde_json::from_value(json!({
435            "path": "acme.example.com/memo",
436            "value": "follow up"
437        }))
438        .unwrap();
439        let v = serde_json::to_value(&m).unwrap();
440        assert_eq!(
441            v,
442            json!({ "path": "acme.example.com/memo", "value": "follow up" })
443        );
444        let back: MetadataTextMatch = serde_json::from_value(v).unwrap();
445        assert_eq!(back, m);
446    }
447
448    // -- DataTypeMetadataInfo re-export ------------------------------------
449
450    #[test]
451    fn data_type_metadata_info_re_export_deserializes() {
452        let info: DataTypeMetadataInfo = serde_json::from_str(
453            r#"{
454                "namespaces": ["photography"],
455                "supportsVendorNamespaces": false,
456                "supportsPrivate": true,
457                "maxDepth": 3
458            }"#,
459        )
460        .unwrap();
461        assert_eq!(info.namespaces, vec!["photography"]);
462        assert!(!info.supports_vendor_namespaces);
463        assert!(info.supports_private);
464        assert_eq!(info.max_depth, Some(3));
465    }
466
467    // -- JMAP_METADATA_URI re-export ---------------------------------------
468
469    #[test]
470    fn jmap_metadata_uri_value() {
471        assert_eq!(JMAP_METADATA_URI, "urn:ietf:params:jmap:metadata");
472    }
473
474    // -- IGNORE_METADATA_ONLY_CHANGES constant -----------------------------
475
476    #[test]
477    fn ignore_metadata_only_changes_wire_name() {
478        assert_eq!(IGNORE_METADATA_ONLY_CHANGES, "ignoreMetadataOnlyChanges");
479    }
480}