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}