Skip to main content

jmap_metadata_client/methods/
mod.rs

1//! Typed JMAP Metadata method wrappers — response types, SessionClient,
2//! constants, and helpers.
3//!
4//! Response types mirror RFC 8620 standard shapes (§5.1 /get, §5.5 /query,
5//! §5.2 /changes, §5.3 /set, §5.6 /queryChanges). Method implementations
6//! live in sub-modules and operate on `SessionClient`.
7
8pub mod metadata;
9
10// ---------------------------------------------------------------------------
11// Response types (RFC 8620 §5)
12// ---------------------------------------------------------------------------
13//
14// Re-exported from `jmap-types::methods` so all `jmap-*-client` crates share
15// one canonical set of /get, /set, /changes, /query, /queryChanges shapes.
16// The wire format is identical to the previous local definitions.
17
18pub use jmap_types::{
19    AddedItem, ChangesResponse, GetResponse, QueryChangesResponse, QueryResponse, SetError,
20    SetResponse,
21};
22
23// ---------------------------------------------------------------------------
24// MetadataChangesParams — extra arguments for Metadata/changes
25// (draft-ietf-jmap-metadata-01 §3.3)
26// ---------------------------------------------------------------------------
27
28/// Extra method-level arguments for `Metadata/changes`
29/// (draft-ietf-jmap-metadata-01 §3.3).
30///
31/// Both fields are optional. Pass `None` (or `Default::default()`) to fetch
32/// all Metadata changes regardless of `relatedType` or `@type`.
33///
34/// Per §3.3, when both filters are specified the server MUST return only
35/// changes to Metadata objects that satisfy both criteria (logical AND).
36/// The `state` token returned in the response represents the complete
37/// account state and is independent of the filters; clients that use these
38/// filters MUST re-use the same filter values across subsequent
39/// `Metadata/changes` calls to ensure consistent synchronisation.
40#[derive(Debug, Default, serde::Serialize)]
41#[serde(rename_all = "camelCase")]
42pub struct MetadataChangesParams {
43    /// Restrict the `created`/`updated`/`destroyed` arrays in the response
44    /// to Metadata objects whose `relatedType` equals this value
45    /// (draft-ietf-jmap-metadata-01 §3.3).
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub filter_related_type: Option<String>,
48
49    /// Restrict the `created`/`updated`/`destroyed` arrays in the response
50    /// to Metadata objects whose `@type` is in this list
51    /// (draft-ietf-jmap-metadata-01 §3.3).
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub filter_metadata_type: Option<Vec<String>>,
54
55    /// Catch-all for vendor / site / private extension fields not covered
56    /// by the typed fields above. Preserves unknown fields across
57    /// deserialize/serialize round-trip per workspace extras-preservation
58    /// policy (see workspace AGENTS.md).
59    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
60    pub extra: serde_json::Map<String, serde_json::Value>,
61}
62
63// ---------------------------------------------------------------------------
64// Constants
65// ---------------------------------------------------------------------------
66
67/// The call-id embedded in every single-method JMAP request produced by
68/// [`build_request`]. Pass directly to `jmap_base_client::extract_response`.
69pub(crate) const CALL_ID: &str = "r1";
70
71/// Capability URIs for JMAP Metadata method calls
72/// (draft-ietf-jmap-metadata-01 §1.2).
73pub(crate) const USING_METADATA: &[&str] = &[
74    "urn:ietf:params:jmap:core",
75    jmap_metadata_types::JMAP_METADATA_URI,
76];
77
78// ---------------------------------------------------------------------------
79// build_request helper
80// ---------------------------------------------------------------------------
81
82/// Build a single-method JMAP request.
83///
84/// `using` is the complete `using` array for the request (RFC 8620 §3.3).
85/// Use the pre-defined constant [`USING_METADATA`] for standard calls.
86///
87/// The embedded call-id is [`CALL_ID`]; pass it directly to
88/// `jmap_base_client::extract_response`.
89pub(crate) fn build_request(
90    method: &str,
91    args: serde_json::Value,
92    using: &[&str],
93) -> jmap_types::JmapRequest {
94    let using_vec: Vec<String> = using.iter().map(|&s| s.to_owned()).collect();
95    let invocation: jmap_types::Invocation = (method.to_owned(), args, CALL_ID.to_owned());
96    jmap_types::JmapRequest::new(using_vec, vec![invocation], None)
97}
98
99// ---------------------------------------------------------------------------
100// SessionClient — session-bound client
101// ---------------------------------------------------------------------------
102
103/// A `JmapClient` bound to a JMAP session.
104///
105/// Obtain via [`JmapMetadataExt::with_metadata_session`](crate::JmapMetadataExt::with_metadata_session).
106/// All JMAP Metadata methods are available on this type without needing to
107/// pass `&Session` on every call.
108///
109/// # Session lifecycle
110///
111/// `SessionClient` captures the `Session` at construction time. After
112/// re-fetching the session via `JmapClient::fetch_session`, construct a new
113/// `SessionClient` with the updated session. Reusing a stale `SessionClient`
114/// after session expiry will result in `unknownAccount` or similar errors
115/// from the server.
116///
117/// `Clone` is derived because `JmapClient` is itself cheap-to-clone (it
118/// already implements `Clone` and `with_metadata_session` clones one
119/// internally), enabling parallel-task fan-out with one bound session.
120///
121/// `Debug` is implemented manually to redact the inner `JmapClient` (which
122/// holds an HTTP client and is intentionally not `Debug` in
123/// `jmap-base-client`); only the `Session` is shown. This lets callers
124/// embed a `SessionClient` in a `#[derive(Debug)]` struct without manual
125/// impls of their own.
126#[non_exhaustive]
127#[derive(Clone)]
128pub struct SessionClient {
129    pub(crate) client: jmap_base_client::JmapClient,
130    pub(crate) session: jmap_base_client::Session,
131}
132
133impl std::fmt::Debug for SessionClient {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.debug_struct("SessionClient")
136            // The inner JmapClient is not Debug — show a placeholder so
137            // callers know it is present without leaking HTTP-client
138            // internals.
139            .field("client", &"<JmapClient>")
140            .field("session", &self.session)
141            .finish()
142    }
143}
144
145impl SessionClient {
146    /// Extract `(api_url, metadata_account_id)` from the bound session.
147    ///
148    /// Returns `Err(InvalidSession)` if there is no primary account for
149    /// `urn:ietf:params:jmap:metadata`.
150    pub(crate) fn session_parts(&self) -> Result<(&str, &str), jmap_base_client::ClientError> {
151        let api_url = self.session.api_url.as_str();
152        let account_id = self
153            .session
154            .primary_account_id(jmap_metadata_types::JMAP_METADATA_URI)
155            .ok_or_else(|| {
156                jmap_base_client::ClientError::InvalidSession(
157                    "no primary account for urn:ietf:params:jmap:metadata".into(),
158                )
159            })?;
160        Ok((api_url, account_id))
161    }
162
163    /// Forward a JMAP request to the underlying HTTP client.
164    pub(crate) async fn call_internal(
165        &self,
166        api_url: &str,
167        req: &jmap_types::JmapRequest,
168    ) -> Result<jmap_types::JmapResponse, jmap_base_client::ClientError> {
169        self.client.call(api_url, req).await
170    }
171}
172
173// ---------------------------------------------------------------------------
174// Tests
175// ---------------------------------------------------------------------------
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use serde_json::json;
181
182    /// Oracle: USING_METADATA contains exactly the two capability URIs from
183    /// draft-ietf-jmap-metadata-01 §1.2.
184    /// Expected values are taken directly from the draft.
185    #[test]
186    fn using_metadata_contains_correct_uris() {
187        let req = build_request("Metadata/get", json!({}), USING_METADATA);
188        let v = serde_json::to_value(&req).expect("serialize");
189        let using = v["using"].as_array().expect("using must be array");
190        assert_eq!(using.len(), 2, "must have exactly 2 capability URIs");
191        assert!(
192            using.contains(&json!("urn:ietf:params:jmap:core")),
193            "must include jmap:core"
194        );
195        assert!(
196            using.contains(&json!("urn:ietf:params:jmap:metadata")),
197            "must include jmap:metadata"
198        );
199    }
200
201    /// Oracle: build_request produces correct method name and CALL_ID.
202    /// Expected: invocation[0] == method, invocation[2] == CALL_ID constant.
203    #[test]
204    fn build_request_method_name_and_call_id() {
205        let req = build_request(
206            "Metadata/get",
207            json!({"accountId": "acc1", "ids": null}),
208            USING_METADATA,
209        );
210        let v = serde_json::to_value(&req).expect("serialize JmapRequest");
211
212        let calls = v["methodCalls"]
213            .as_array()
214            .expect("methodCalls must be array");
215        assert_eq!(calls.len(), 1, "must have exactly 1 method call");
216        assert_eq!(calls[0][0], json!("Metadata/get"), "method name must match");
217        assert_eq!(calls[0][2], json!("r1"), "call_id must be CALL_ID constant");
218    }
219
220    /// Oracle: session_parts returns None when metadata capability absent.
221    #[test]
222    fn session_parts_err_no_primary_account() {
223        let session_json = json!({
224            "capabilities": {},
225            "accounts": {},
226            "primaryAccounts": {},
227            "username": "user@example.com",
228            "apiUrl": "https://jmap.example.com/api/",
229            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
230            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
231            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
232            "state": "s1"
233        });
234        let session: jmap_base_client::Session =
235            serde_json::from_value(session_json).expect("session must deserialize");
236        let result = session.primary_account_id("urn:ietf:params:jmap:metadata");
237        assert!(
238            result.is_none(),
239            "must return None when metadata capability is not in primaryAccounts"
240        );
241    }
242
243    /// Oracle: GetResponse<T> deserializes from RFC 8620 §5.1 shape.
244    #[test]
245    fn get_response_deserializes() {
246        let json = json!({
247            "accountId": "acc1",
248            "state": "s42",
249            "list": [],
250            "notFound": ["missing1"]
251        });
252        let resp: GetResponse<serde_json::Value> =
253            serde_json::from_value(json).expect("GetResponse must deserialize");
254        assert_eq!(resp.account_id, "acc1");
255        assert_eq!(resp.state, "s42");
256        assert!(resp.list.is_empty());
257        assert_eq!(
258            resp.not_found.as_deref(),
259            Some(["missing1".into()].as_slice())
260        );
261    }
262
263    /// Oracle: ChangesResponse deserializes from RFC 8620 §5.2 shape.
264    #[test]
265    fn changes_response_deserializes() {
266        let json = json!({
267            "accountId": "acc1",
268            "oldState": "s10",
269            "newState": "s11",
270            "hasMoreChanges": false,
271            "created": ["id1"],
272            "updated": ["id2"],
273            "destroyed": []
274        });
275        let resp: ChangesResponse =
276            serde_json::from_value(json).expect("ChangesResponse must deserialize");
277        assert_eq!(resp.old_state, "s10");
278        assert_eq!(resp.new_state, "s11");
279        assert!(!resp.has_more_changes);
280        assert_eq!(resp.created.len(), 1);
281        assert_eq!(resp.updated.len(), 1);
282        assert!(resp.destroyed.is_empty());
283    }
284
285    /// Oracle: SetResponse deserializes from RFC 8620 §5.3 shape.
286    #[test]
287    fn set_response_deserializes() {
288        let json = json!({
289            "accountId": "acc1",
290            "oldState": "s10",
291            "newState": "s11",
292            "created": null,
293            "updated": null,
294            "destroyed": ["id1"],
295            "notCreated": null,
296            "notUpdated": null,
297            "notDestroyed": null
298        });
299        let resp: SetResponse = serde_json::from_value(json).expect("SetResponse must deserialize");
300        assert_eq!(resp.new_state, "s11");
301        assert_eq!(resp.destroyed.as_deref(), Some(["id1".into()].as_slice()));
302    }
303
304    /// Oracle: QueryChangesResponse deserializes from RFC 8620 §5.6 shape.
305    #[test]
306    fn query_changes_response_deserializes() {
307        let json = json!({
308            "accountId": "acc1",
309            "oldQueryState": "qs1",
310            "newQueryState": "qs2",
311            "total": 5,
312            "removed": ["id3"],
313            "added": [{"id": "id4", "index": 0}]
314        });
315        let resp: QueryChangesResponse =
316            serde_json::from_value(json).expect("QueryChangesResponse must deserialize");
317        assert_eq!(resp.old_query_state, "qs1");
318        assert_eq!(resp.new_query_state, "qs2");
319        assert_eq!(resp.total, Some(5));
320        assert_eq!(resp.removed.len(), 1);
321        assert_eq!(resp.added.len(), 1);
322        assert_eq!(resp.added[0].index, 0);
323    }
324
325    /// Oracle: MetadataChangesParams default (all None) serializes to `{}`.
326    /// Expected: skip_serializing_if omits both None fields.
327    #[test]
328    fn metadata_changes_params_default_is_empty_object() {
329        let params = MetadataChangesParams::default();
330        let v = serde_json::to_value(&params).expect("serialize");
331        assert_eq!(
332            v,
333            json!({}),
334            "default params must serialize to empty object"
335        );
336    }
337
338    /// Oracle: MetadataChangesParams serializes filterRelatedType in camelCase.
339    /// Expected field name from draft-ietf-jmap-metadata-01 §3.3.
340    #[test]
341    fn metadata_changes_params_serializes_filter_related_type() {
342        let params = MetadataChangesParams {
343            filter_related_type: Some("Email".into()),
344            filter_metadata_type: None,
345            extra: serde_json::Map::new(),
346        };
347        let v = serde_json::to_value(&params).expect("serialize");
348        assert_eq!(
349            v["filterRelatedType"],
350            json!("Email"),
351            "filterRelatedType must serialize in camelCase"
352        );
353        assert!(
354            v.get("filterMetadataType").is_none(),
355            "filterMetadataType must be absent when None"
356        );
357    }
358
359    /// Oracle: MetadataChangesParams serializes filterMetadataType array.
360    /// Expected field name from draft-ietf-jmap-metadata-01 §3.3.
361    #[test]
362    fn metadata_changes_params_serializes_filter_metadata_type() {
363        let params = MetadataChangesParams {
364            filter_related_type: None,
365            filter_metadata_type: Some(vec!["Annotation".into(), "ImapMetadata".into()]),
366            extra: serde_json::Map::new(),
367        };
368        let v = serde_json::to_value(&params).expect("serialize");
369        assert!(
370            v.get("filterRelatedType").is_none(),
371            "filterRelatedType must be absent when None"
372        );
373        assert_eq!(
374            v["filterMetadataType"],
375            json!(["Annotation", "ImapMetadata"]),
376            "filterMetadataType must serialize as array"
377        );
378    }
379
380    // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
381    //
382    // For Serialize-only method-argument structs, the test constructs a
383    // struct with a vendor field in `extra` and asserts that the field
384    // flattens into the serialized JSON. Uses synthetic `acmeCorp*` keys
385    // that are guaranteed not to appear in any draft-ietf-jmap-metadata
386    // typed field — so the tests are independent of the crate under test.
387
388    /// `MetadataChangesParams.extra` flattens into serialized JSON.
389    #[test]
390    fn metadata_changes_params_propagates_vendor_extras() {
391        let mut params = MetadataChangesParams::default();
392        params
393            .extra
394            .insert("acmeCorpCursor".into(), json!("opaque-token"));
395        let v = serde_json::to_value(&params).expect("serialize MetadataChangesParams");
396        assert_eq!(v["acmeCorpCursor"], json!("opaque-token"));
397    }
398}