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}