jmap_chat_client/methods/
blob.rs1use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11use jmap_types::Id;
12
13const USING_BLOB: &[&str] = &["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:blob2"];
15
16#[non_exhaustive]
20#[derive(Debug, Clone, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct BlobLookupEntry {
23 pub id: String,
25 pub matched_ids: HashMap<String, Vec<String>>,
28 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
33 pub extra: serde_json::Map<String, serde_json::Value>,
34}
35
36#[non_exhaustive]
40#[derive(Debug, Clone, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct BlobLookupResponse {
43 pub account_id: Id,
45 pub list: Vec<BlobLookupEntry>,
47 #[serde(default)]
50 pub not_found: Vec<String>,
51 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
56 pub extra: serde_json::Map<String, serde_json::Value>,
57}
58
59#[non_exhaustive]
63#[derive(Debug, Clone, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct BlobObject {
66 pub id: Id,
68 #[serde(rename = "type")]
70 pub content_type: Option<String>,
71 pub size: Option<u64>,
75 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
80 pub extra: serde_json::Map<String, serde_json::Value>,
81}
82
83#[non_exhaustive]
89#[derive(Debug, Clone, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct BlobConvertResponse {
92 pub account_id: Id,
94 #[serde(default)]
96 pub created: Option<HashMap<String, BlobObject>>,
97 #[serde(default)]
99 pub not_created: Option<HashMap<String, super::SetError>>,
100 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
105 pub extra: serde_json::Map<String, serde_json::Value>,
106}
107
108#[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#[derive(Debug, Serialize)]
125#[serde(rename_all = "camelCase")]
126struct BlobConvertCreate<'a> {
127 image_convert: ImageConvertRecipe<'a>,
128}
129
130impl super::SessionClient {
131 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 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#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[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 assert!(
250 ic.get("height").is_none(),
251 "height must be omitted when None"
252 );
253 }
254
255 #[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 #[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 #[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 #[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 #[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}