Skip to main content

rusmes_jmap/
back_reference.rs

1//! RFC 8620 §3.7 back-reference resolution for JMAP method call batches.
2//!
3//! When a JMAP request contains multiple method calls, later calls may
4//! reference the results of earlier calls using `ResultReference` objects.
5//! Any argument value that is a JSON object of the form
6//!
7//! ```json
8//! { "#refKey": { "resultOf": "call-id", "name": "Method/name", "path": "/json/pointer" } }
9//! ```
10//!
11//! is resolved by the dispatcher before invoking the method. The `#refKey`
12//! argument is replaced with the value extracted from the referenced response
13//! body using the RFC 6901 JSON Pointer in `path`.
14
15use serde::Deserialize;
16use serde_json::{Map, Value};
17
18// ── Public types ─────────────────────────────────────────────────────────────
19
20/// A RFC 8620 §3.7 result reference embedded inside a method call argument.
21#[derive(Debug, Deserialize, PartialEq, Eq)]
22pub struct ResultReference {
23    /// The `id` of the earlier method call whose response is referenced.
24    #[serde(rename = "resultOf")]
25    pub result_of: String,
26
27    /// The method name of the earlier call (must match to guard against
28    /// accidentally picking up a different method's response for the same ID).
29    pub name: String,
30
31    /// RFC 6901 JSON Pointer into the response body, e.g. `/list/0/id`.
32    pub path: String,
33}
34
35/// Error variants returned when a back-reference cannot be resolved.
36#[derive(thiserror::Error, Debug)]
37pub enum BackRefError {
38    /// No completed call with the requested `id` (and `name`) was found.
39    #[error("result not found: no completed call with id={0:?} and method={1:?}")]
40    ResultNotFound(String, String),
41
42    /// The referenced call completed with an error response.
43    #[error("referenced call id={0:?} returned an error response")]
44    ResultWasError(String),
45
46    /// The JSON Pointer path did not resolve to any value in the response body.
47    #[error("path {0:?} resolved to nothing in the response for call id={1:?}")]
48    PathNotFound(String, String),
49}
50
51// ── Core algorithm ────────────────────────────────────────────────────────────
52
53/// Walk `args` and resolve every `#key` result-reference in-place.
54///
55/// `completed` is the list of already-executed calls represented as
56/// `(call_id, method_name, response_body)` triples.
57///
58/// The function only considers top-level keys in `args` that start with `#`.
59/// It attempts to deserialise each such value as a `ResultReference`. If the
60/// shape does not match (i.e. the value is not an object with `resultOf`,
61/// `name`, and `path` fields), the key is left unchanged — it may be a
62/// legitimate `#`-prefixed JSON key from a protocol extension.
63///
64/// On success, the `#key` entry is removed from `args` and replaced with
65/// `key` (without the leading `#`) set to the extracted value.
66///
67/// # Errors
68///
69/// Returns the *first* error encountered if any reference fails to resolve.
70pub fn resolve_back_references(
71    args: &mut Map<String, Value>,
72    completed: &[(String, String, Value)],
73) -> Result<(), BackRefError> {
74    // Collect the keys we need to process. Avoid mutating while iterating.
75    let ref_keys: Vec<String> = args
76        .keys()
77        .filter(|k| k.starts_with('#'))
78        .cloned()
79        .collect();
80
81    for hash_key in ref_keys {
82        let raw = match args.get(&hash_key) {
83            Some(v) => v.clone(),
84            None => continue,
85        };
86
87        // Try to deserialise the value as a ResultReference.
88        // If it doesn't match the shape, leave the key alone.
89        let reference: ResultReference = match serde_json::from_value(raw) {
90            Ok(r) => r,
91            Err(_) => continue,
92        };
93
94        // Locate the completed response with matching id + name.
95        let (_id, _name, response_body) = match completed
96            .iter()
97            .find(|(id, name, _)| id == &reference.result_of && name == &reference.name)
98        {
99            Some(entry) => entry,
100            None => {
101                return Err(BackRefError::ResultNotFound(
102                    reference.result_of,
103                    reference.name,
104                ));
105            }
106        };
107
108        // Reject if the response body represents a method-level error.
109        // RFC 8620 §3.7.2: it is an error if the referenced call returned
110        // a method error (the response name would be "error").
111        //
112        // We detect this by checking whether the response body has a
113        // top-level "type" key whose value starts with the JMAP error URN
114        // prefix — consistent with the way JmapError is serialised in this
115        // codebase.
116        if is_error_response(response_body) {
117            return Err(BackRefError::ResultWasError(reference.result_of.clone()));
118        }
119
120        // Apply the JSON Pointer to extract the target value.
121        let extracted = match response_body.pointer(&reference.path) {
122            Some(v) => v.clone(),
123            None => {
124                return Err(BackRefError::PathNotFound(
125                    reference.path.clone(),
126                    reference.result_of.clone(),
127                ));
128            }
129        };
130
131        // Replace #key with key (sans leading #).
132        let plain_key = hash_key[1..].to_string();
133        args.remove(&hash_key);
134        args.insert(plain_key, extracted);
135    }
136
137    Ok(())
138}
139
140// ── Private helpers ──────────────────────────────────────────────────────────
141
142/// Returns `true` if `body` looks like a serialised JMAP method error.
143///
144/// The heuristic checks for a top-level `"type"` key whose string value
145/// contains the JMAP error URN prefix `urn:ietf:params:jmap:error:`.
146fn is_error_response(body: &Value) -> bool {
147    body.get("type")
148        .and_then(Value::as_str)
149        .map(|t| t.starts_with("urn:ietf:params:jmap:error:"))
150        .unwrap_or(false)
151}
152
153// ── Unit tests ────────────────────────────────────────────────────────────────
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use serde_json::json;
159
160    // ── helper ────────────────────────────────────────────────────────────────
161
162    fn completed_entry(id: &str, name: &str, body: Value) -> (String, String, Value) {
163        (id.to_string(), name.to_string(), body)
164    }
165
166    // ── test_back_ref_resolves_correctly ─────────────────────────────────────
167
168    /// A two-call batch where call 1 uses a ResultReference into call 0's
169    /// response.  The `#ids` argument must be resolved to the `/ids` array
170    /// from the Email/query response.
171    #[test]
172    fn test_back_ref_resolves_correctly() {
173        // Simulate call 0 having returned an Email/query response.
174        let query_response = json!({
175            "accountId": "acc1",
176            "queryState": "s1",
177            "canCalculateChanges": false,
178            "position": 0,
179            "ids": ["email-1", "email-2", "email-3"]
180        });
181
182        let completed = vec![completed_entry("c0", "Email/query", query_response)];
183
184        // Build arguments for call 1 that back-reference call 0.
185        let mut args = serde_json::Map::new();
186        args.insert("accountId".to_string(), json!("acc1"));
187        // The # key references call 0's /ids array.
188        args.insert(
189            "#ids".to_string(),
190            json!({
191                "resultOf": "c0",
192                "name": "Email/query",
193                "path": "/ids"
194            }),
195        );
196
197        resolve_back_references(&mut args, &completed).expect("should resolve");
198
199        // #ids was removed and ids was inserted with the extracted array.
200        assert!(!args.contains_key("#ids"), "#ids should have been removed");
201        assert_eq!(
202            args["ids"],
203            json!(["email-1", "email-2", "email-3"]),
204            "ids should equal the extracted array"
205        );
206        // accountId should be untouched.
207        assert_eq!(args["accountId"], json!("acc1"));
208    }
209
210    /// Back-reference to a specific nested value inside the response body.
211    #[test]
212    fn test_back_ref_resolves_nested_path() {
213        let email_get_response = json!({
214            "accountId": "acc1",
215            "state": "s2",
216            "list": [
217                { "id": "email-1", "threadId": "T1", "subject": "Hello" }
218            ],
219            "notFound": []
220        });
221
222        let completed = vec![completed_entry("c0", "Email/get", email_get_response)];
223
224        let mut args = serde_json::Map::new();
225        args.insert(
226            "#threadId".to_string(),
227            json!({
228                "resultOf": "c0",
229                "name": "Email/get",
230                "path": "/list/0/threadId"
231            }),
232        );
233
234        resolve_back_references(&mut args, &completed).expect("should resolve nested");
235
236        assert!(!args.contains_key("#threadId"));
237        assert_eq!(args["threadId"], json!("T1"));
238    }
239
240    // ── test_back_ref_result_not_found ────────────────────────────────────────
241
242    /// A reference to a non-existent call ID must return `ResultNotFound`.
243    #[test]
244    fn test_back_ref_result_not_found() {
245        let completed: Vec<(String, String, Value)> = vec![];
246
247        let mut args = serde_json::Map::new();
248        args.insert(
249            "#ids".to_string(),
250            json!({
251                "resultOf": "ghost-call",
252                "name": "Email/query",
253                "path": "/ids"
254            }),
255        );
256
257        let err =
258            resolve_back_references(&mut args, &completed).expect_err("should return an error");
259
260        assert!(
261            matches!(err, BackRefError::ResultNotFound(ref id, _) if id == "ghost-call"),
262            "unexpected error variant: {err}"
263        );
264    }
265
266    /// A reference whose method name does not match the completed call must
267    /// also return `ResultNotFound`.
268    #[test]
269    fn test_back_ref_result_not_found_wrong_name() {
270        let completed = vec![completed_entry(
271            "c0",
272            "Email/get",
273            json!({ "list": [], "notFound": [] }),
274        )];
275
276        let mut args = serde_json::Map::new();
277        args.insert(
278            "#ids".to_string(),
279            json!({
280                "resultOf": "c0",
281                "name": "Email/query",   // wrong method name
282                "path": "/ids"
283            }),
284        );
285
286        let err = resolve_back_references(&mut args, &completed)
287            .expect_err("should return an error for method-name mismatch");
288
289        assert!(matches!(err, BackRefError::ResultNotFound(..)));
290    }
291
292    // ── test_back_ref_path_not_found ──────────────────────────────────────────
293
294    /// A valid call ID but an invalid (non-existent) JSON Pointer path must
295    /// return `PathNotFound`.
296    #[test]
297    fn test_back_ref_path_not_found() {
298        let completed = vec![completed_entry(
299            "c0",
300            "Email/query",
301            json!({
302                "accountId": "acc1",
303                "ids": ["e1"]
304            }),
305        )];
306
307        let mut args = serde_json::Map::new();
308        args.insert(
309            "#missingKey".to_string(),
310            json!({
311                "resultOf": "c0",
312                "name": "Email/query",
313                "path": "/doesNotExist/deeply/nested"
314            }),
315        );
316
317        let err =
318            resolve_back_references(&mut args, &completed).expect_err("should return PathNotFound");
319
320        assert!(
321            matches!(err, BackRefError::PathNotFound(ref p, _) if p == "/doesNotExist/deeply/nested"),
322            "unexpected error: {err}"
323        );
324    }
325
326    // ── error-response detection ──────────────────────────────────────────────
327
328    /// A reference whose earlier call returned a method-level error must
329    /// return `ResultWasError`.
330    #[test]
331    fn test_back_ref_result_was_error() {
332        let error_body = json!({
333            "type": "urn:ietf:params:jmap:error:serverFail",
334            "detail": "something went wrong"
335        });
336        let completed = vec![completed_entry("c0", "Email/query", error_body)];
337
338        let mut args = serde_json::Map::new();
339        args.insert(
340            "#ids".to_string(),
341            json!({
342                "resultOf": "c0",
343                "name": "Email/query",
344                "path": "/ids"
345            }),
346        );
347
348        let err = resolve_back_references(&mut args, &completed)
349            .expect_err("should return ResultWasError");
350
351        assert!(
352            matches!(err, BackRefError::ResultWasError(ref id) if id == "c0"),
353            "unexpected error: {err}"
354        );
355    }
356
357    // ── non-ResultReference shaped # keys are left alone ─────────────────────
358
359    /// A `#`-prefixed key whose value is not a ResultReference object must
360    /// be left untouched (it may be a legitimate protocol-extension key).
361    #[test]
362    fn test_back_ref_non_ref_shape_left_alone() {
363        let completed: Vec<(String, String, Value)> = vec![];
364
365        let mut args = serde_json::Map::new();
366        args.insert("#notARef".to_string(), json!("plain string value"));
367        args.insert("#alsoNotARef".to_string(), json!({ "someOtherField": 42 }));
368
369        resolve_back_references(&mut args, &completed).expect("should succeed");
370
371        // Neither key was touched.
372        assert_eq!(args["#notARef"], json!("plain string value"));
373        assert_eq!(args["#alsoNotARef"], json!({ "someOtherField": 42 }));
374    }
375}