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}