jmap_types/methods.rs
1//! RFC 8620 §5 generic method-response shapes shared by all JMAP method types.
2//!
3//! These wire types are normative — every JMAP `/get`, `/set`, `/changes`,
4//! `/query`, and `/queryChanges` method (across mail, calendars, contacts,
5//! chat, etc.) returns one of these shapes. Centralising them here avoids
6//! drift between the seven `jmap-*-client` crates that previously each
7//! defined their own copies. Server crates may still hand-build wire JSON
8//! for their `/set` responses (so they can use the typed
9//! [`SetErrorType`](crate::backend) enum at construction time); this crate's
10//! [`SetError`] is the deserialization target on the client side.
11//!
12//! All types use camelCase JSON via `#[serde(rename_all = "camelCase")]` and
13//! are marked `#[non_exhaustive]` so future RFC errata or extensions can add
14//! fields without a SemVer break.
15//!
16//! # Spec references
17//!
18//! | Type | Spec |
19//! |---|---|
20//! | [`GetResponse`] | RFC 8620 §5.1 |
21//! | [`ChangesResponse`] | RFC 8620 §5.2 |
22//! | [`SetResponse`], [`SetError`] | RFC 8620 §5.3 |
23//! | [`QueryResponse`] | RFC 8620 §5.5 |
24//! | [`QueryChangesResponse`], [`AddedItem`] | RFC 8620 §5.6 |
25
26use std::collections::HashMap;
27
28use serde::{Deserialize, Serialize};
29
30use crate::{Id, State};
31
32// ---------------------------------------------------------------------------
33// /get
34// ---------------------------------------------------------------------------
35
36/// RFC 8620 §5.1 — `Foo/get` response shape.
37///
38/// `T` is the type of object being fetched (e.g. `Mailbox`, `CalendarEvent`).
39/// `state` is the opaque state token the server returns alongside the result;
40/// it advances every time any object of the requested type changes in the
41/// account. `not_found` lists ids the client requested that the server could
42/// not find — `null` is treated as an empty list per RFC 8620 §5.1.
43#[non_exhaustive]
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct GetResponse<T> {
47 /// The account the response refers to.
48 pub account_id: Id,
49 /// Opaque state token for this object type at the time of the response.
50 pub state: State,
51 /// The fetched objects, one per id that was found.
52 pub list: Vec<T>,
53 /// Ids that were requested but not found. `null` on the wire is treated
54 /// as an empty list per RFC 8620 §5.1.
55 pub not_found: Option<Vec<Id>>,
56 /// Catch-all for vendor / site / private extension fields not covered
57 /// by the typed fields above. Preserves unknown fields across
58 /// deserialize/serialize round-trip per workspace extras-preservation
59 /// policy (see workspace AGENTS.md).
60 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
61 pub extra: serde_json::Map<String, serde_json::Value>,
62}
63
64// ---------------------------------------------------------------------------
65// /changes
66// ---------------------------------------------------------------------------
67
68/// RFC 8620 §5.2 — `Foo/changes` response shape.
69///
70/// Reports the ids of objects created, updated, or destroyed since
71/// `old_state`. If `has_more_changes` is `true`, the client should call
72/// `/changes` again with `since_state = new_state` to retrieve the next
73/// page; otherwise `new_state` is the current state.
74///
75/// # Extension fields
76///
77/// Some JMAP data-type extensions add an `updatedProperties` field to
78/// their `/changes` response shape:
79///
80/// - RFC 8621 §2.2 (`Mailbox/changes`): set when only `totalEmails` /
81/// `unreadEmails` / `totalThreads` / `unreadThreads` changed.
82/// - RFC 9425 §5 (`Quota/changes`): set when only the `used` property
83/// changed.
84///
85/// For all other `/changes` methods (RFC 8621 §3.2 `Thread/changes`,
86/// §4.3 `Email/changes`, plus every extension `/changes` method not
87/// listed above) the server omits the field, and clients deserialize
88/// it as `None`. Carrying the field on the base type avoids duplicating
89/// the `ChangesResponse` shape into per-extension newtypes.
90#[non_exhaustive]
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct ChangesResponse {
94 /// The account the response refers to.
95 pub account_id: Id,
96 /// The state token the client passed in.
97 pub old_state: State,
98 /// The current (or next-page) state token.
99 pub new_state: State,
100 /// `true` if there are more changes the client must page through.
101 pub has_more_changes: bool,
102 /// Ids of objects created since `old_state`.
103 pub created: Vec<Id>,
104 /// Ids of objects updated since `old_state`.
105 pub updated: Vec<Id>,
106 /// Ids of objects destroyed since `old_state`.
107 pub destroyed: Vec<Id>,
108 /// Optional list of property names that changed (RFC 8621 §2.2,
109 /// RFC 9425 §5). Servers MAY set this for `Mailbox/changes` and
110 /// `Quota/changes` responses when the only changes are to a small
111 /// known subset of properties; clients can then back-reference
112 /// `/updatedProperties` into a follow-up `Mailbox/get` or
113 /// `Quota/get` to fetch only those fields. For all other `/changes`
114 /// methods the field is absent on the wire and `None` here.
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub updated_properties: Option<Vec<String>>,
117 /// Catch-all for vendor / site / private extension fields not covered
118 /// by the typed fields above. Preserves unknown fields across
119 /// deserialize/serialize round-trip per workspace extras-preservation
120 /// policy (see workspace AGENTS.md).
121 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
122 pub extra: serde_json::Map<String, serde_json::Value>,
123}
124
125// ---------------------------------------------------------------------------
126// /set
127// ---------------------------------------------------------------------------
128
129/// A per-item failure in a `/set` response (RFC 8620 §5.3).
130///
131/// Appears as the value type in the `notCreated`, `notUpdated`, and
132/// `notDestroyed` maps of [`SetResponse`]. The `error_type` field uses
133/// `String` rather than a typed enum so extension errors (e.g.
134/// `"calendarHasEvent"`, `"noSupportedScheduleMethods"`) round-trip
135/// cleanly without requiring a version-bump on every new spec extension.
136///
137/// All fields beyond `error_type` are optional and present only when the
138/// corresponding error type calls for them per RFC 8620 §5.3 / RFC 8621
139/// §5.5, §5.7, §7.5:
140///
141/// | Field | Set when error_type is | Spec |
142/// |---|---|---|
143/// | `description` | any (optional human-readable detail) | RFC 8620 §5.3 |
144/// | `properties` | `invalidProperties` | RFC 8620 §5.3 |
145/// | `existing_id` | `alreadyExists` | RFC 8620 §5.4, RFC 8621 §5.7 |
146/// | `not_found` | `blobNotFound` | RFC 8621 §5.5 |
147/// | `max_recipients` | `tooManyRecipients` | RFC 8621 §7.5 |
148/// | `invalid_recipients` | `invalidRecipients` | RFC 8621 §7.5 |
149/// | `max_size` | `tooLarge` | RFC 8621 §7.5 |
150///
151/// # Extension fields
152///
153/// JMAP extensions (e.g. JMAP Chat's `serverRetryAfter` for slow-mode
154/// rate limiting) MAY add additional SetError fields beyond the RFC 8620
155/// base set. The `extra` field captures any such field via
156/// `#[serde(flatten)]` so it round-trips losslessly. Extension crates
157/// (e.g. `jmap-chat-client`) provide typed accessor helpers that read
158/// from `extra` — the base type stays free of extension-specific fields.
159#[non_exhaustive]
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct SetError {
163 /// The machine-readable error type (e.g. `"forbidden"`, `"notFound"`,
164 /// `"alreadyExists"`, or an extension-defined string).
165 #[serde(rename = "type")]
166 pub error_type: String,
167 /// Human-readable description of the error. Optional per RFC 8620 §5.3.
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub description: Option<String>,
170 /// Property names that caused the error (for `invalidProperties`).
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub properties: Option<Vec<String>>,
173 /// The existing object id (for `alreadyExists`).
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub existing_id: Option<Id>,
176 /// Missing blob ids (for `blobNotFound`).
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub not_found: Option<Vec<Id>>,
179 /// Maximum recipients allowed (for `tooManyRecipients`).
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub max_recipients: Option<u64>,
182 /// Invalid recipient addresses (for `invalidRecipients`).
183 #[serde(skip_serializing_if = "Option::is_none")]
184 pub invalid_recipients: Option<Vec<String>>,
185 /// Maximum message size in octets (for `tooLarge`).
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub max_size: Option<u64>,
188 /// Catch-all for extension SetError fields not in the RFC 8620 base
189 /// set. Captured via `#[serde(flatten)]` so they round-trip losslessly.
190 /// Extension crates provide typed accessors (e.g.
191 /// `jmap-chat-client`'s helper for reading `serverRetryAfter`).
192 ///
193 /// Uses `serde_json::Map` (which preserves insertion order) rather than
194 /// `HashMap` to match the workspace extras-preservation policy (see
195 /// workspace `AGENTS.md`) and to give callers deterministic serialized
196 /// output.
197 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
198 pub extra: serde_json::Map<String, serde_json::Value>,
199}
200
201impl SetError {
202 /// Construct a `SetError` with the given type string and all optional
203 /// fields `None` / empty. Use this when deserializing tests or when
204 /// constructing a wire-shaped error from a typed source. Server crates
205 /// that want a typed enum for construction should use
206 /// `jmap_server::backend::SetError` (declared in the `jmap-server`
207 /// crate, not linkable from here since `jmap-types` does not depend on
208 /// `jmap-server`) — this type is deliberately String-typed for
209 /// client-side parsing flexibility.
210 pub fn new(error_type: impl Into<String>) -> Self {
211 Self {
212 error_type: error_type.into(),
213 description: None,
214 properties: None,
215 existing_id: None,
216 not_found: None,
217 max_recipients: None,
218 invalid_recipients: None,
219 max_size: None,
220 extra: serde_json::Map::new(),
221 }
222 }
223}
224
225impl std::fmt::Display for SetError {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 match &self.description {
228 Some(desc) => write!(f, "{}: {}", self.error_type, desc),
229 None => write!(f, "{}", self.error_type),
230 }
231 }
232}
233
234/// RFC 8620 §5.3 — `Foo/set` response shape.
235///
236/// Wire shape per RFC 8620 §5.3 (rfc8620.txt §5.3 around line 2033):
237///
238/// ```text
239/// created Id[Foo] | null
240/// updated Id[Foo|null] | null ← inner null is REQUIRED
241/// destroyed Id[] | null
242/// notCreated Id[SetError] | null
243/// notUpdated Id[SetError] | null
244/// notDestroyed Id[SetError] | null
245/// ```
246///
247/// The inner `null` in `updated` is the server's signal that the patch was
248/// applied verbatim with no server-set property deltas to report; a typed
249/// `SetResponse<Foo>` MUST accept this rather than failing because `null`
250/// cannot become `Foo`.
251///
252/// `created` and `not_created` keys are caller-supplied creation ids
253/// (`String`); `updated`, `not_updated`, `not_destroyed` keys are
254/// server-assigned record ids ([`Id`]) — typed differently so callers can
255/// use `updated`/`destroyed` keys interchangeably with ids from any
256/// `/get` response.
257#[non_exhaustive]
258#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260#[serde(bound(
261 deserialize = "T: serde::de::DeserializeOwned",
262 serialize = "T: Serialize"
263))]
264pub struct SetResponse<T = serde_json::Value> {
265 /// The account the response refers to.
266 pub account_id: Id,
267 /// State token before this `/set` was applied. Optional because some
268 /// servers omit it on no-op responses (per RFC 8620 §5.3 the field is
269 /// nullable).
270 pub old_state: Option<State>,
271 /// State token after this `/set`.
272 pub new_state: State,
273 /// Successfully created objects, keyed by caller-supplied creation id.
274 pub created: Option<HashMap<String, T>>,
275 /// Successfully updated objects, keyed by record id. The value is
276 /// `Some(T)` when the server reports server-set property deltas, or
277 /// `None` when the patch was applied verbatim with nothing to echo.
278 pub updated: Option<HashMap<Id, Option<T>>>,
279 /// Ids of successfully destroyed objects.
280 pub destroyed: Option<Vec<Id>>,
281 /// Failed creates, keyed by caller-supplied creation id.
282 pub not_created: Option<HashMap<String, SetError>>,
283 /// Failed updates, keyed by record id.
284 pub not_updated: Option<HashMap<Id, SetError>>,
285 /// Failed destroys, keyed by record id.
286 pub not_destroyed: Option<HashMap<Id, SetError>>,
287 /// Catch-all for vendor / site / private extension fields not covered
288 /// by the typed fields above. Preserves unknown fields across
289 /// deserialize/serialize round-trip per workspace extras-preservation
290 /// policy (see workspace AGENTS.md).
291 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
292 pub extra: serde_json::Map<String, serde_json::Value>,
293}
294
295// ---------------------------------------------------------------------------
296// /query
297// ---------------------------------------------------------------------------
298
299/// RFC 8620 §5.5 — `Foo/query` response shape.
300///
301/// Returns the ids of objects matching a filter, in sort order. The
302/// `query_state` token can be passed to `Foo/queryChanges` to retrieve only
303/// the delta against this snapshot.
304#[non_exhaustive]
305#[derive(Debug, Clone, Serialize, Deserialize)]
306#[serde(rename_all = "camelCase")]
307pub struct QueryResponse {
308 /// The account the response refers to.
309 pub account_id: Id,
310 /// Opaque state token for this query result; pass to `/queryChanges`.
311 pub query_state: State,
312 /// `true` if `/queryChanges` will give incremental updates against this
313 /// `query_state`; `false` if the client must re-run `/query` to refresh.
314 pub can_calculate_changes: bool,
315 /// Zero-based offset within the full result set of the first id in
316 /// `ids`. Per RFC 8620 §5.5, may differ from the requested `position`
317 /// when the requested offset exceeds the result count.
318 pub position: u64,
319 /// The matching ids in sort order.
320 pub ids: Vec<Id>,
321 /// Total number of matching objects, or `None` when the request did not
322 /// set `calculateTotal: true`.
323 pub total: Option<u64>,
324 /// Server's max page size; `None` when not advertised.
325 pub limit: Option<u64>,
326 /// Catch-all for vendor / site / private extension fields not covered
327 /// by the typed fields above. Preserves unknown fields across
328 /// deserialize/serialize round-trip per workspace extras-preservation
329 /// policy (see workspace AGENTS.md).
330 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
331 pub extra: serde_json::Map<String, serde_json::Value>,
332}
333
334// ---------------------------------------------------------------------------
335// /queryChanges
336// ---------------------------------------------------------------------------
337
338/// A single item added to a query result set (RFC 8620 §5.6).
339///
340/// The `index` is the position the new item occupies in the post-change
341/// result set, accounting for items also added in this batch.
342#[non_exhaustive]
343#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345pub struct AddedItem {
346 /// The id of the new item in the result set.
347 pub id: Id,
348 /// Zero-based position of the new item in the post-change result set.
349 pub index: u64,
350 /// Catch-all for vendor / site / private extension fields not covered
351 /// by the typed fields above. Preserves unknown fields across
352 /// deserialize/serialize round-trip per workspace extras-preservation
353 /// policy (see workspace AGENTS.md).
354 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
355 pub extra: serde_json::Map<String, serde_json::Value>,
356}
357
358/// RFC 8620 §5.6 — `Foo/queryChanges` response shape.
359///
360/// Reports the ids removed from and added to a query result set since
361/// `old_query_state`. Combined with the previous result, the client can
362/// reconstruct the new result without re-fetching all ids.
363#[non_exhaustive]
364#[derive(Debug, Clone, Serialize, Deserialize)]
365#[serde(rename_all = "camelCase")]
366pub struct QueryChangesResponse {
367 /// The account the response refers to.
368 pub account_id: Id,
369 /// The state token the client passed in.
370 pub old_query_state: State,
371 /// The current state token.
372 pub new_query_state: State,
373 /// Total number of matching objects (only when
374 /// `calculateTotal: true` was set in the request).
375 pub total: Option<u64>,
376 /// Ids removed from the result set since `old_query_state`.
377 pub removed: Vec<Id>,
378 /// Items added to the result set, with their new positions.
379 pub added: Vec<AddedItem>,
380 /// Catch-all for vendor / site / private extension fields not covered
381 /// by the typed fields above. Preserves unknown fields across
382 /// deserialize/serialize round-trip per workspace extras-preservation
383 /// policy (see workspace AGENTS.md).
384 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
385 pub extra: serde_json::Map<String, serde_json::Value>,
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use serde_json::json;
392
393 // Independent oracles: hand-written JSON shapes from the RFC 8620
394 // examples and prose descriptions, NOT derived from the types being
395 // tested. Wire round-trips ensure the serde rename rules and field
396 // shapes match the spec.
397
398 #[test]
399 fn get_response_round_trips() {
400 let raw = json!({
401 "accountId": "A1",
402 "state": "s42",
403 "list": [{"id": "x", "name": "First"}],
404 "notFound": ["missing1"]
405 });
406 let resp = GetResponse::<serde_json::Value>::deserialize(&raw).unwrap();
407 assert_eq!(resp.account_id.as_ref(), "A1");
408 assert_eq!(resp.state, "s42");
409 assert_eq!(resp.list.len(), 1);
410 assert_eq!(resp.list[0]["name"], "First");
411 let nf = resp.not_found.as_ref().unwrap();
412 assert_eq!(nf.len(), 1);
413 assert_eq!(nf[0].as_ref(), "missing1");
414 // Round-trip back to JSON and confirm the camelCase keys.
415 let back = serde_json::to_value(&resp).unwrap();
416 assert_eq!(back["accountId"], "A1");
417 assert_eq!(back["notFound"][0], "missing1");
418 }
419
420 #[test]
421 fn get_response_null_not_found() {
422 // §5.1 allows notFound to be null when the request did not specify
423 // ids (null is treated as the empty list).
424 let raw = json!({
425 "accountId": "A1",
426 "state": "s1",
427 "list": [],
428 "notFound": null
429 });
430 let resp: GetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
431 assert!(resp.not_found.is_none());
432 }
433
434 #[test]
435 fn changes_response_round_trips() {
436 let raw = json!({
437 "accountId": "A1",
438 "oldState": "s0",
439 "newState": "s1",
440 "hasMoreChanges": false,
441 "created": ["a"],
442 "updated": ["b"],
443 "destroyed": ["c"]
444 });
445 let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
446 assert_eq!(resp.old_state, "s0");
447 assert_eq!(resp.new_state, "s1");
448 assert!(!resp.has_more_changes);
449 assert_eq!(resp.created[0].as_ref(), "a");
450 assert_eq!(resp.updated[0].as_ref(), "b");
451 assert_eq!(resp.destroyed[0].as_ref(), "c");
452 // RFC 8620 §5.2 base `/changes` does not define `updatedProperties`;
453 // the field must default to `None` when absent from the wire.
454 assert!(resp.updated_properties.is_none());
455 }
456
457 /// Oracle: RFC 8621 §2.2 example response (lines 1015-1031 of rfc8621.txt
458 /// in this repo) — `Mailbox/changes` carries `updatedProperties` listing
459 /// `totalEmails`, `unreadEmails`, `totalThreads`, `unreadThreads`.
460 #[test]
461 fn changes_response_deserializes_mailbox_updated_properties() {
462 let raw = json!({
463 "accountId": "A1",
464 "oldState": "78541",
465 "newState": "78542",
466 "hasMoreChanges": false,
467 "updatedProperties": [
468 "totalEmails", "unreadEmails",
469 "totalThreads", "unreadThreads"
470 ],
471 "created": [],
472 "updated": ["B"],
473 "destroyed": []
474 });
475 let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
476 let props = resp
477 .updated_properties
478 .expect("updatedProperties must be present");
479 assert_eq!(
480 props,
481 vec![
482 "totalEmails".to_string(),
483 "unreadEmails".to_string(),
484 "totalThreads".to_string(),
485 "unreadThreads".to_string()
486 ]
487 );
488 }
489
490 /// Oracle: RFC 9425 §5 example response — `Quota/changes` carries
491 /// `updatedProperties: ["used"]` when only quota usage changed.
492 #[test]
493 fn changes_response_deserializes_quota_updated_properties() {
494 let raw = json!({
495 "accountId": "A1",
496 "oldState": "78541",
497 "newState": "78542",
498 "hasMoreChanges": false,
499 "updatedProperties": ["used"],
500 "created": [],
501 "updated": ["2a06df0d-9865-4e74-a92f-74dcc814270e"],
502 "destroyed": []
503 });
504 let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
505 let props = resp
506 .updated_properties
507 .expect("updatedProperties must be present");
508 assert_eq!(props, vec!["used".to_string()]);
509 }
510
511 /// `updatedProperties: null` on the wire (RFC 8621 §2.2: "If the server
512 /// is unable to tell if only counts have changed, it MUST just be null")
513 /// must also deserialize as `None` — distinct from omitted but
514 /// semantically equivalent on the typed side.
515 #[test]
516 fn changes_response_accepts_explicit_null_updated_properties() {
517 let raw = json!({
518 "accountId": "A1",
519 "oldState": "s0",
520 "newState": "s1",
521 "hasMoreChanges": false,
522 "updatedProperties": null,
523 "created": [],
524 "updated": ["B"],
525 "destroyed": []
526 });
527 let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
528 assert!(resp.updated_properties.is_none());
529 }
530
531 /// Serializing a `ChangesResponse` without `updated_properties` must
532 /// NOT emit a `"updatedProperties": null` key — the
533 /// `skip_serializing_if = "Option::is_none"` attribute keeps the wire
534 /// shape minimal and matches the RFC 8620 §5.2 base envelope for
535 /// methods that don't define the extension field.
536 #[test]
537 fn changes_response_omits_updated_properties_when_none() {
538 let resp = ChangesResponse {
539 account_id: Id::from("A1"),
540 old_state: "s0".into(),
541 new_state: "s1".into(),
542 has_more_changes: false,
543 created: vec![],
544 updated: vec![],
545 destroyed: vec![],
546 updated_properties: None,
547 extra: serde_json::Map::new(),
548 };
549 let serialized = serde_json::to_value(&resp).expect("must serialize");
550 assert!(
551 serialized.get("updatedProperties").is_none(),
552 "updatedProperties must be omitted when None"
553 );
554 }
555
556 #[test]
557 fn set_response_updated_accepts_null_value() {
558 // §5.3 wire type: updated is Id[Foo|null]|null. The inner null
559 // signals "patch applied verbatim, no server-set fields to echo".
560 let raw = json!({
561 "accountId": "A1",
562 "oldState": "s1",
563 "newState": "s2",
564 "updated": { "ev1": null, "ev2": null }
565 });
566 let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
567 let upd = resp.updated.unwrap();
568 assert!(upd.get(&Id::from("ev1")).unwrap().is_none());
569 assert!(upd.get(&Id::from("ev2")).unwrap().is_none());
570 }
571
572 #[test]
573 fn set_response_updated_accepts_object_value() {
574 let raw = json!({
575 "accountId": "A1",
576 "oldState": "s1",
577 "newState": "s2",
578 "updated": { "ev1": { "id": "ev1", "title": "Meeting" } }
579 });
580 let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
581 let upd = resp.updated.unwrap();
582 let ev1 = upd.get(&Id::from("ev1")).unwrap().as_ref().unwrap();
583 assert_eq!(ev1["title"], "Meeting");
584 }
585
586 #[test]
587 fn set_response_not_updated_keys_are_ids() {
588 // §5.3: notUpdated is Id[SetError]|null. Keys are server-assigned
589 // ids, not creation ids — typing them as Id (not String) lets
590 // callers use the keys interchangeably with /get response ids.
591 let raw = json!({
592 "accountId": "A1",
593 "oldState": "s1",
594 "newState": "s1",
595 "notUpdated": {
596 "ev1": { "type": "stateMismatch" }
597 }
598 });
599 let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
600 let nu = resp.not_updated.unwrap();
601 assert_eq!(
602 nu.get(&Id::from("ev1")).unwrap().error_type,
603 "stateMismatch"
604 );
605 }
606
607 #[test]
608 fn set_error_full_8_fields_round_trip() {
609 // §5.3 + RFC 8621 §5.5/§5.7/§7.5: SetError carries up to 8 fields
610 // depending on the error type. The client side must preserve all
611 // of them on deserialize, otherwise callers cannot recover the
612 // existingId / blobNotFound / recipient-list payload that the
613 // server relied on to make the error actionable.
614 let raw = json!({
615 "type": "alreadyExists",
616 "description": "conflict",
617 "properties": ["name"],
618 "existingId": "obj-7",
619 "notFound": ["blob-1", "blob-2"],
620 "maxRecipients": 50,
621 "invalidRecipients": ["bad@", "no@no"],
622 "maxSize": 10485760
623 });
624 let err = SetError::deserialize(&raw).unwrap();
625 assert_eq!(err.error_type, "alreadyExists");
626 assert_eq!(err.description.as_deref(), Some("conflict"));
627 assert_eq!(err.properties.as_ref().unwrap()[0], "name");
628 assert_eq!(err.existing_id.as_ref().unwrap().as_ref(), "obj-7");
629 assert_eq!(err.not_found.as_ref().unwrap().len(), 2);
630 assert_eq!(err.max_recipients, Some(50));
631 assert_eq!(err.invalid_recipients.as_ref().unwrap().len(), 2);
632 assert_eq!(err.max_size, Some(10_485_760));
633 // Round-trip preserves wire field names.
634 let back = serde_json::to_value(&err).unwrap();
635 assert_eq!(back, raw);
636 }
637
638 #[test]
639 fn set_error_minimal_omits_optional_fields_on_serialize() {
640 // §5.3: only `type` is required. Optional fields MUST be omitted
641 // (not serialized as `null`) so the wire matches the server's
642 // construction shape exactly.
643 let err = SetError::new("forbidden");
644 let json = serde_json::to_value(&err).unwrap();
645 assert_eq!(json["type"], "forbidden");
646 assert!(json.get("description").is_none());
647 assert!(json.get("properties").is_none());
648 assert!(json.get("existingId").is_none());
649 assert!(json.get("notFound").is_none());
650 assert!(json.get("maxRecipients").is_none());
651 assert!(json.get("invalidRecipients").is_none());
652 assert!(json.get("maxSize").is_none());
653 // Empty extra map must not appear at all in the wire output.
654 let obj = json.as_object().unwrap();
655 assert_eq!(
656 obj.len(),
657 1,
658 "minimal SetError must serialize to exactly {{type}}: {json}"
659 );
660 }
661
662 #[test]
663 fn set_error_extension_fields_round_trip_via_extra() {
664 // JMAP Chat's serverRetryAfter is a per-extension SetError field
665 // that must round-trip losslessly through extra without the base
666 // type knowing about it. Pin both directions.
667 let raw = json!({
668 "type": "rateLimited",
669 "description": "slow-mode active",
670 "serverRetryAfter": "2026-01-01T00:00:00Z"
671 });
672 let err = SetError::deserialize(&raw).unwrap();
673 assert_eq!(err.error_type, "rateLimited");
674 assert_eq!(err.description.as_deref(), Some("slow-mode active"));
675 assert_eq!(
676 err.extra.get("serverRetryAfter").and_then(|v| v.as_str()),
677 Some("2026-01-01T00:00:00Z"),
678 "extension field must land in extra map: {err:?}"
679 );
680 let back = serde_json::to_value(&err).unwrap();
681 assert_eq!(back, raw, "round-trip must preserve extension field");
682 }
683
684 #[test]
685 fn set_error_extension_type_round_trips() {
686 // Extension errors (e.g. calendars draft §10.7.2) MUST round-trip
687 // through the String error_type without a new variant being added.
688 let err = SetError::new("noSupportedScheduleMethods");
689 let json = serde_json::to_value(&err).unwrap();
690 assert_eq!(json["type"], "noSupportedScheduleMethods");
691 let back: SetError = serde_json::from_value(json).unwrap();
692 assert_eq!(back.error_type, "noSupportedScheduleMethods");
693 }
694
695 #[test]
696 fn set_error_display_with_description() {
697 let err = SetError {
698 error_type: "forbidden".to_owned(),
699 description: Some("not your calendar".to_owned()),
700 ..SetError::new("forbidden")
701 };
702 assert_eq!(err.to_string(), "forbidden: not your calendar");
703 }
704
705 #[test]
706 fn set_error_display_without_description() {
707 let err = SetError::new("forbidden");
708 assert_eq!(err.to_string(), "forbidden");
709 }
710
711 #[test]
712 fn query_response_round_trips() {
713 let raw = json!({
714 "accountId": "A1",
715 "queryState": "qs1",
716 "canCalculateChanges": true,
717 "position": 0,
718 "ids": ["a", "b", "c"],
719 "total": 3,
720 "limit": 100
721 });
722 let resp: QueryResponse = serde_json::from_value(raw).unwrap();
723 assert_eq!(resp.query_state, "qs1");
724 assert!(resp.can_calculate_changes);
725 assert_eq!(resp.ids.len(), 3);
726 assert_eq!(resp.total, Some(3));
727 assert_eq!(resp.limit, Some(100));
728 }
729
730 #[test]
731 fn query_response_omits_optional_total_and_limit() {
732 let raw = json!({
733 "accountId": "A1",
734 "queryState": "qs1",
735 "canCalculateChanges": false,
736 "position": 0,
737 "ids": [],
738 "total": null,
739 "limit": null
740 });
741 let resp: QueryResponse = serde_json::from_value(raw).unwrap();
742 assert!(resp.total.is_none());
743 assert!(resp.limit.is_none());
744 }
745
746 #[test]
747 fn query_changes_response_round_trips() {
748 let raw = json!({
749 "accountId": "A1",
750 "oldQueryState": "qs0",
751 "newQueryState": "qs1",
752 "total": 5,
753 "removed": ["x"],
754 "added": [
755 {"id": "y", "index": 2}
756 ]
757 });
758 let resp: QueryChangesResponse = serde_json::from_value(raw).unwrap();
759 assert_eq!(resp.old_query_state, "qs0");
760 assert_eq!(resp.new_query_state, "qs1");
761 assert_eq!(resp.total, Some(5));
762 assert_eq!(resp.removed[0].as_ref(), "x");
763 assert_eq!(resp.added.len(), 1);
764 assert_eq!(resp.added[0].id.as_ref(), "y");
765 assert_eq!(resp.added[0].index, 2);
766 }
767
768 #[test]
769 fn added_item_round_trips() {
770 let raw = json!({"id": "foo", "index": 7});
771 let item = AddedItem::deserialize(&raw).unwrap();
772 assert_eq!(item.id.as_ref(), "foo");
773 assert_eq!(item.index, 7);
774 assert_eq!(serde_json::to_value(&item).unwrap(), raw);
775 }
776
777 // ── Extras-preservation policy tests (JMAP-lbdy.1) ───────────────────
778 //
779 // One round-trip preservation test per migrated type. Each test
780 // asserts that an unknown vendor / site / private-extension field
781 // survives deserialize/serialize unchanged. Per workspace
782 // AGENTS.md "Extras-preservation policy for vendor/site fields".
783
784 /// `GetResponse.extra` captures vendor fields and preserves them on
785 /// re-serialize.
786 #[test]
787 fn get_response_preserves_vendor_extras() {
788 let raw = json!({
789 "accountId": "A1",
790 "state": "s1",
791 "list": [],
792 "notFound": null,
793 "acmeCorpAuditTrail": {"sequence": 42}
794 });
795 let resp = GetResponse::<serde_json::Value>::deserialize(&raw).unwrap();
796 assert_eq!(
797 resp.extra
798 .get("acmeCorpAuditTrail")
799 .and_then(|v| v["sequence"].as_u64()),
800 Some(42),
801 "vendor field must land in extra: {:?}",
802 resp.extra
803 );
804 let back = serde_json::to_value(&resp).unwrap();
805 assert_eq!(
806 back["acmeCorpAuditTrail"]["sequence"], 42,
807 "vendor field must survive serialize: {back}"
808 );
809 }
810
811 /// `ChangesResponse.extra` captures vendor fields and preserves them.
812 #[test]
813 fn changes_response_preserves_vendor_extras() {
814 let raw = json!({
815 "accountId": "A1",
816 "oldState": "s0",
817 "newState": "s1",
818 "hasMoreChanges": false,
819 "created": [],
820 "updated": [],
821 "destroyed": [],
822 "acmeCorpReplayToken": "rt-99"
823 });
824 let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
825 assert_eq!(
826 resp.extra
827 .get("acmeCorpReplayToken")
828 .and_then(|v| v.as_str()),
829 Some("rt-99")
830 );
831 let back = serde_json::to_value(&resp).unwrap();
832 assert_eq!(back["acmeCorpReplayToken"], "rt-99");
833 }
834
835 /// `SetResponse.extra` captures vendor fields and preserves them.
836 #[test]
837 fn set_response_preserves_vendor_extras() {
838 let raw = json!({
839 "accountId": "A1",
840 "oldState": "s1",
841 "newState": "s2",
842 "acmeCorpTransactionId": "txn-abc"
843 });
844 let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
845 assert_eq!(
846 resp.extra
847 .get("acmeCorpTransactionId")
848 .and_then(|v| v.as_str()),
849 Some("txn-abc")
850 );
851 let back = serde_json::to_value(&resp).unwrap();
852 assert_eq!(back["acmeCorpTransactionId"], "txn-abc");
853 }
854
855 /// `QueryResponse.extra` captures vendor fields and preserves them.
856 #[test]
857 fn query_response_preserves_vendor_extras() {
858 let raw = json!({
859 "accountId": "A1",
860 "queryState": "qs1",
861 "canCalculateChanges": false,
862 "position": 0,
863 "ids": [],
864 "total": null,
865 "limit": null,
866 "acmeCorpSearchTimingMs": 17
867 });
868 let resp: QueryResponse = serde_json::from_value(raw).unwrap();
869 assert_eq!(
870 resp.extra
871 .get("acmeCorpSearchTimingMs")
872 .and_then(|v| v.as_u64()),
873 Some(17)
874 );
875 let back = serde_json::to_value(&resp).unwrap();
876 assert_eq!(back["acmeCorpSearchTimingMs"], 17);
877 }
878
879 /// `QueryChangesResponse.extra` captures vendor fields and preserves them.
880 #[test]
881 fn query_changes_response_preserves_vendor_extras() {
882 let raw = json!({
883 "accountId": "A1",
884 "oldQueryState": "qs0",
885 "newQueryState": "qs1",
886 "total": null,
887 "removed": [],
888 "added": [],
889 "acmeCorpDeltaToken": "dt-2"
890 });
891 let resp: QueryChangesResponse = serde_json::from_value(raw).unwrap();
892 assert_eq!(
893 resp.extra
894 .get("acmeCorpDeltaToken")
895 .and_then(|v| v.as_str()),
896 Some("dt-2")
897 );
898 let back = serde_json::to_value(&resp).unwrap();
899 assert_eq!(back["acmeCorpDeltaToken"], "dt-2");
900 }
901
902 /// `AddedItem.extra` captures vendor fields and preserves them.
903 #[test]
904 fn added_item_preserves_vendor_extras() {
905 let raw = json!({
906 "id": "x",
907 "index": 0,
908 "acmeCorpHighlight": true
909 });
910 let item = AddedItem::deserialize(&raw).unwrap();
911 assert_eq!(
912 item.extra
913 .get("acmeCorpHighlight")
914 .and_then(|v| v.as_bool()),
915 Some(true)
916 );
917 let back = serde_json::to_value(&item).unwrap();
918 assert_eq!(back["acmeCorpHighlight"], true);
919 }
920
921 /// Empty extras must NOT serialize as a key on the wire — the
922 /// `skip_serializing_if = "serde_json::Map::is_empty"` attribute keeps
923 /// the wire shape byte-identical to the pre-migration form when no
924 /// vendor fields are present.
925 #[test]
926 fn empty_extras_omitted_from_wire() {
927 let resp = AddedItem {
928 id: Id::from("z"),
929 index: 1,
930 extra: serde_json::Map::new(),
931 };
932 let serialized = serde_json::to_value(&resp).expect("must serialize");
933 let obj = serialized.as_object().expect("must be object");
934 assert_eq!(
935 obj.len(),
936 2,
937 "empty extras must not add any wire keys; got {serialized}"
938 );
939 assert!(obj.contains_key("id"));
940 assert!(obj.contains_key("index"));
941 }
942}