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