Skip to main content

jmap_chat_client/methods/
blob.rs

1//! Blob/lookup and Blob/convert — urn:ietf:params:jmap:blob2
2//!
3//! Spec: draft-ietf-jmap-blobext-01 §6 (Blob/lookup), §8 (Blob/convert)
4//!
5//! These methods use the blob2 capability, NOT USING_CHAT.
6
7use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11use jmap_types::Id;
12
13/// Capability URIs for Blob extension method calls.
14const USING_BLOB: &[&str] = &["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:blob2"];
15
16/// A single entry in a `Blob/lookup` response.
17///
18/// Spec: draft-ietf-jmap-blobext-01 §6
19#[non_exhaustive]
20#[derive(Debug, Clone, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct BlobLookupEntry {
23    /// The blobId that was queried.
24    pub id: String,
25    /// Per-type reverse lookup: keys are data type names (e.g. `"Message"`),
26    /// values are object IDs that reference this blob.
27    pub matched_ids: HashMap<String, Vec<String>>,
28    /// Catch-all for vendor / site / private extension fields not covered
29    /// by the typed fields above. Preserves unknown fields across
30    /// deserialize/serialize round-trip per workspace extras-preservation
31    /// policy (see workspace AGENTS.md).
32    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
33    pub extra: serde_json::Map<String, serde_json::Value>,
34}
35
36/// Response to a `Blob/lookup` call.
37///
38/// Spec: draft-ietf-jmap-blobext-01 §6
39#[non_exhaustive]
40#[derive(Debug, Clone, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct BlobLookupResponse {
43    /// Account the query was run against.
44    pub account_id: Id,
45    /// One entry per queried blobId.
46    pub list: Vec<BlobLookupEntry>,
47    /// blobIds that were not found or not accessible (access-control safe).
48    /// An absent field and an empty array are semantically identical.
49    #[serde(default)]
50    pub not_found: Vec<String>,
51    /// Catch-all for vendor / site / private extension fields not covered
52    /// by the typed fields above. Preserves unknown fields across
53    /// deserialize/serialize round-trip per workspace extras-preservation
54    /// policy (see workspace AGENTS.md).
55    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
56    pub extra: serde_json::Map<String, serde_json::Value>,
57}
58
59/// A blob object returned in a `Blob/convert` (or `Blob/set`) response.
60///
61/// Spec: draft-ietf-jmap-blobext-01 §4 (BlobObject properties)
62#[non_exhaustive]
63#[derive(Debug, Clone, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct BlobObject {
66    /// The blobId of the created blob.
67    pub id: Id,
68    /// MIME type of the blob, or `None` if the server did not report it.
69    #[serde(rename = "type")]
70    pub content_type: Option<String>,
71    /// Size in octets.  `None` if the server deferred generation and does
72    /// not yet know the final size (spec §8 allows omitting size for
73    /// deferred conversions).
74    pub size: Option<u64>,
75    /// Catch-all for vendor / site / private extension fields not covered
76    /// by the typed fields above. Preserves unknown fields across
77    /// deserialize/serialize round-trip per workspace extras-preservation
78    /// policy (see workspace AGENTS.md).
79    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
80    pub extra: serde_json::Map<String, serde_json::Value>,
81}
82
83/// Response to a `Blob/convert` call.
84///
85/// Spec: draft-ietf-jmap-blobext-01 §8 — response has the same structure as
86/// Blob/set: a `created` map of creation id → `BlobObject` for each
87/// successful conversion, and a `notCreated` map for failures.
88#[non_exhaustive]
89#[derive(Debug, Clone, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct BlobConvertResponse {
92    /// Account the conversion was run against.
93    pub account_id: Id,
94    /// Successful conversions: maps each creation id to the resulting blob.
95    #[serde(default)]
96    pub created: Option<HashMap<String, BlobObject>>,
97    /// Failed conversions: maps each creation id to a SetError.
98    #[serde(default)]
99    pub not_created: Option<HashMap<String, super::SetError>>,
100    /// Catch-all for vendor / site / private extension fields not covered
101    /// by the typed fields above. Preserves unknown fields across
102    /// deserialize/serialize round-trip per workspace extras-preservation
103    /// policy (see workspace AGENTS.md).
104    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
105    pub extra: serde_json::Map<String, serde_json::Value>,
106}
107
108/// `imageConvert` recipe for a `Blob/convert` request.
109///
110/// Spec: draft-ietf-jmap-blobext-01 §8.1 (ImageConvertRecipe)
111#[derive(Debug, Serialize)]
112#[serde(rename_all = "camelCase")]
113struct ImageConvertRecipe<'a> {
114    blob_id: &'a Id,
115    #[serde(rename = "type")]
116    content_type: &'a str,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    width: Option<u32>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    height: Option<u32>,
121}
122
123/// One entry in the `create` map of a `Blob/convert` request.
124#[derive(Debug, Serialize)]
125#[serde(rename_all = "camelCase")]
126struct BlobConvertCreate<'a> {
127    image_convert: ImageConvertRecipe<'a>,
128}
129
130impl super::SessionClient {
131    /// Reverse-lookup blobs: given a list of blob IDs and data type names,
132    /// returns which objects of those types reference each blob.
133    ///
134    /// Uses capability `urn:ietf:params:jmap:blob2`; the server MUST advertise
135    /// it in the Session for this method to succeed (RFC 8620 §3.3).
136    ///
137    /// `type_names` filters which data types to search. `None` queries all
138    /// types registered on the server. For JMAP Chat, `"Message"` is the
139    /// expected type.
140    ///
141    /// Security: blobs that are inaccessible or nonexistent are returned with
142    /// empty `matchedIds` arrays rather than an error (draft-ietf-jmap-blobext
143    /// §6), to avoid information leakage.
144    pub async fn blob_lookup(
145        &self,
146        blob_ids: &[Id],
147        type_names: Option<&[&str]>,
148    ) -> Result<BlobLookupResponse, jmap_base_client::ClientError> {
149        if blob_ids.is_empty() {
150            return Err(jmap_base_client::ClientError::InvalidArgument(
151                "blob_lookup: blob_ids may not be empty".into(),
152            ));
153        }
154        let (api_url, account_id) = self.session_parts()?;
155        let args = serde_json::json!({
156            "accountId": account_id,
157            "ids": blob_ids,
158            "typeNames": type_names,
159        });
160        let req = super::build_request("Blob/lookup", args, USING_BLOB);
161        let resp = self.call_internal(api_url, &req).await?;
162        jmap_base_client::extract_response(&resp, super::CALL_ID)
163    }
164
165    /// Convert a blob to a different MIME type via an `imageConvert` recipe
166    /// (JMAP-BLOBEXT §8 / blob2 capability).
167    ///
168    /// Typical use: request a thumbnail (`image/webp`) from an image blob
169    /// without downloading the original. The server MUST advertise
170    /// `urn:ietf:params:jmap:blob2` in Session capabilities.
171    ///
172    /// `width` and `height` are optional maximum-dimension hints; the server
173    /// may ignore or clamp them. Pass `None` to omit.
174    ///
175    /// On success the converted blob is in
176    /// `response.created[CALL_ID]`.  On failure the error is in
177    /// `response.not_created[CALL_ID]`.
178    pub async fn blob_convert(
179        &self,
180        from_blob_id: &Id,
181        content_type: &str,
182        width: Option<u32>,
183        height: Option<u32>,
184    ) -> Result<BlobConvertResponse, jmap_base_client::ClientError> {
185        if content_type.is_empty() {
186            return Err(jmap_base_client::ClientError::InvalidArgument(
187                "blob_convert: content_type may not be empty".into(),
188            ));
189        }
190        let (api_url, account_id) = self.session_parts()?;
191        let create_entry = BlobConvertCreate {
192            image_convert: ImageConvertRecipe {
193                blob_id: from_blob_id,
194                content_type,
195                width,
196                height,
197            },
198        };
199        let create_map = {
200            let mut m = serde_json::Map::new();
201            m.insert(
202                super::CALL_ID.to_owned(),
203                serde_json::to_value(&create_entry)
204                    .map_err(|e| jmap_base_client::ClientError::InvalidArgument(e.to_string()))?,
205            );
206            m
207        };
208        let args = serde_json::json!({
209            "accountId": account_id,
210            "create": serde_json::Value::Object(create_map),
211        });
212        let req = super::build_request("Blob/convert", args, USING_BLOB);
213        let resp = self.call_internal(api_url, &req).await?;
214        jmap_base_client::extract_response(&resp, super::CALL_ID)
215    }
216}
217
218// ---------------------------------------------------------------------------
219// Tests
220// ---------------------------------------------------------------------------
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    /// Oracle: the `create` map serialises to the correct wire format.
227    ///
228    /// Expected JSON is hand-derived from draft-ietf-jmap-blobext-01 §8.1.
229    /// We do NOT use blob_convert() itself as the oracle — that would make the
230    /// test circular.
231    #[test]
232    fn image_convert_recipe_wire_format() {
233        let blob_id = Id::from("Bxxx");
234        let recipe = ImageConvertRecipe {
235            blob_id: &blob_id,
236            content_type: "image/webp",
237            width: Some(100),
238            height: None,
239        };
240        let entry = BlobConvertCreate {
241            image_convert: recipe,
242        };
243        let v = serde_json::to_value(&entry).expect("serialization must not fail");
244        let ic = v.get("imageConvert").expect("must have imageConvert key");
245        assert_eq!(ic.get("blobId").and_then(|v| v.as_str()), Some("Bxxx"));
246        assert_eq!(ic.get("type").and_then(|v| v.as_str()), Some("image/webp"));
247        assert_eq!(ic.get("width").and_then(|v| v.as_u64()), Some(100));
248        // height must be absent when None (skip_serializing_if)
249        assert!(
250            ic.get("height").is_none(),
251            "height must be omitted when None"
252        );
253    }
254
255    /// Oracle: `BlobConvertResponse` deserialises the spec-shaped response correctly.
256    ///
257    /// Expected JSON is hand-written from the draft-ietf-jmap-blobext-01 §8 example.
258    #[test]
259    fn blob_convert_response_deserialise() {
260        let json = serde_json::json!({
261            "accountId": "abc",
262            "created": {
263                "r1": {
264                    "id": "Bnew",
265                    "type": "image/webp",
266                    "size": 12345
267                }
268            },
269            "notCreated": {}
270        });
271        let resp: BlobConvertResponse =
272            serde_json::from_value(json).expect("deserialisation must not fail");
273        assert_eq!(resp.account_id, "abc");
274        let created = resp.created.expect("created must be Some");
275        let obj = created.get("r1").expect("r1 key must be present");
276        assert_eq!(obj.id.as_ref(), "Bnew");
277        assert_eq!(obj.content_type.as_deref(), Some("image/webp"));
278        assert_eq!(obj.size, Some(12345));
279    }
280
281    // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
282    //
283    // Each test deserialises wire JSON containing a synthetic `acmeCorp*`
284    // vendor field and asserts it survives in `extra`. The vendor field
285    // names cannot collide with any field defined in
286    // draft-ietf-jmap-blobext-01 §4, §6, or §8, so the tests are
287    // independent of the code under test (workspace test-integrity rule).
288
289    /// `BlobLookupEntry.extra` captures unknown fields on deserialize.
290    #[test]
291    fn blob_lookup_entry_preserves_vendor_extras() {
292        let raw = serde_json::json!({
293            "id": "B1",
294            "matchedIds": {
295                "Message": ["M1", "M2"]
296            },
297            "acmeCorpCacheHit": true
298        });
299        let obj: BlobLookupEntry =
300            serde_json::from_value(raw).expect("BlobLookupEntry must deserialize");
301        assert_eq!(
302            obj.extra.get("acmeCorpCacheHit").and_then(|v| v.as_bool()),
303            Some(true)
304        );
305    }
306
307    /// `BlobLookupResponse.extra` captures unknown fields on deserialize.
308    #[test]
309    fn blob_lookup_response_preserves_vendor_extras() {
310        let raw = serde_json::json!({
311            "accountId": "acc1",
312            "list": [],
313            "acmeCorpRequestId": "req-42"
314        });
315        let obj: BlobLookupResponse =
316            serde_json::from_value(raw).expect("BlobLookupResponse must deserialize");
317        assert_eq!(
318            obj.extra.get("acmeCorpRequestId").and_then(|v| v.as_str()),
319            Some("req-42")
320        );
321    }
322
323    /// `BlobObject.extra` captures unknown fields on deserialize.
324    #[test]
325    fn blob_object_preserves_vendor_extras() {
326        let raw = serde_json::json!({
327            "id": "Bnew",
328            "type": "image/webp",
329            "size": 12345,
330            "acmeCorpCdnUrl": "https://cdn.example.com/Bnew"
331        });
332        let obj: BlobObject = serde_json::from_value(raw).expect("BlobObject must deserialize");
333        assert_eq!(
334            obj.extra.get("acmeCorpCdnUrl").and_then(|v| v.as_str()),
335            Some("https://cdn.example.com/Bnew")
336        );
337    }
338
339    /// `BlobConvertResponse.extra` captures unknown fields on deserialize.
340    #[test]
341    fn blob_convert_response_preserves_vendor_extras() {
342        let raw = serde_json::json!({
343            "accountId": "acc1",
344            "created": {},
345            "notCreated": {},
346            "acmeCorpJobId": "job-7"
347        });
348        let obj: BlobConvertResponse =
349            serde_json::from_value(raw).expect("BlobConvertResponse must deserialize");
350        assert_eq!(
351            obj.extra.get("acmeCorpJobId").and_then(|v| v.as_str()),
352            Some("job-7")
353        );
354    }
355}