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(¶ms).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(¶ms).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(¶ms).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(¶ms).expect("serialize MetadataChangesParams");
396 assert_eq!(v["acmeCorpCursor"], json!("opaque-token"));
397 }
398}