Skip to main content

atrg_repo/
blob.rs

1//! Blob upload helpers for AT Protocol PDS endpoints.
2
3use crate::error::RepoError;
4use crate::types::{BlobLink, BlobRef};
5
6/// Upload a blob to the authenticated user's PDS.
7///
8/// Calls `com.atproto.repo.uploadBlob` with the given data and MIME type.
9/// Returns a [`BlobRef`] that can be embedded in record fields.
10pub async fn upload_blob(
11    http: &reqwest::Client,
12    pds_endpoint: &str,
13    access_token: &str,
14    data: Vec<u8>,
15    mime_type: &str,
16) -> Result<BlobRef, RepoError> {
17    let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", pds_endpoint);
18
19    tracing::debug!(
20        pds = pds_endpoint,
21        mime = mime_type,
22        size = data.len(),
23        "uploading blob"
24    );
25
26    let response = http
27        .post(&url)
28        .header("Authorization", format!("Bearer {}", access_token))
29        .header("Content-Type", mime_type)
30        .body(data)
31        .send()
32        .await?;
33
34    if !response.status().is_success() {
35        let status = response.status();
36        let body = response
37            .text()
38            .await
39            .unwrap_or_else(|_| "<no body>".to_string());
40        return Err(RepoError::Pds(format!(
41            "uploadBlob failed ({}): {}",
42            status, body
43        )));
44    }
45
46    let body: serde_json::Value = response.json().await?;
47
48    let blob = body
49        .get("blob")
50        .ok_or_else(|| RepoError::Pds("uploadBlob response missing 'blob' field".to_string()))?;
51
52    let cid = blob
53        .get("ref")
54        .and_then(|r| r.get("$link"))
55        .and_then(|l| l.as_str())
56        .ok_or_else(|| {
57            RepoError::Pds("uploadBlob response missing 'ref.$link' field".to_string())
58        })?;
59
60    let mime = blob
61        .get("mimeType")
62        .and_then(|m| m.as_str())
63        .unwrap_or("application/octet-stream");
64
65    let size = blob.get("size").and_then(|s| s.as_u64()).unwrap_or(0);
66
67    Ok(BlobRef {
68        blob_type: "blob".to_string(),
69        reference: BlobLink {
70            link: cid.to_string(),
71        },
72        mime_type: mime.to_string(),
73        size,
74    })
75}
76
77/// Fetch an image from a URL and upload it as a blob to the user's PDS.
78///
79/// Downloads the resource at `image_url`, determines its MIME type from the
80/// `Content-Type` response header (defaulting to `application/octet-stream`),
81/// and uploads it via [`upload_blob`].
82pub async fn upload_blob_from_url(
83    http: &reqwest::Client,
84    pds_endpoint: &str,
85    access_token: &str,
86    image_url: &str,
87) -> Result<BlobRef, RepoError> {
88    tracing::debug!(url = image_url, "fetching image for blob upload");
89
90    let response = http.get(image_url).send().await?;
91
92    if !response.status().is_success() {
93        return Err(RepoError::Pds(format!(
94            "failed to fetch image from {}: {}",
95            image_url,
96            response.status()
97        )));
98    }
99
100    let mime_type = response
101        .headers()
102        .get("content-type")
103        .and_then(|v| v.to_str().ok())
104        .unwrap_or("application/octet-stream")
105        .to_string();
106
107    let data = response.bytes().await?.to_vec();
108
109    upload_blob(http, pds_endpoint, access_token, data, &mime_type).await
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    // Both `upload_blob` and `upload_blob_from_url` are thin HTTP wrappers around
117    // `com.atproto.repo.uploadBlob`. Full integration tests require a mock PDS server
118    // (e.g. wiremock). The tests below cover the non-HTTP logic and response parsing paths.
119
120    /// Helper: build a fake `uploadBlob` JSON response body.
121    fn fake_upload_response(link: &str, mime: &str, size: u64) -> serde_json::Value {
122        serde_json::json!({
123            "blob": {
124                "ref": { "$link": link },
125                "mimeType": mime,
126                "size": size
127            }
128        })
129    }
130
131    #[test]
132    fn parse_blob_ref_from_valid_response() {
133        let json = fake_upload_response("bafkrei1234", "image/png", 4096);
134
135        let blob = json.get("blob").unwrap();
136        let cid = blob
137            .get("ref")
138            .and_then(|r| r.get("$link"))
139            .and_then(|l| l.as_str())
140            .unwrap();
141        let mime = blob
142            .get("mimeType")
143            .and_then(|m| m.as_str())
144            .unwrap_or("application/octet-stream");
145        let size = blob.get("size").and_then(|s| s.as_u64()).unwrap_or(0);
146
147        let blob_ref = BlobRef {
148            blob_type: "blob".to_string(),
149            reference: BlobLink {
150                link: cid.to_string(),
151            },
152            mime_type: mime.to_string(),
153            size,
154        };
155
156        assert_eq!(blob_ref.reference.link, "bafkrei1234");
157        assert_eq!(blob_ref.mime_type, "image/png");
158        assert_eq!(blob_ref.size, 4096);
159    }
160
161    #[test]
162    fn parse_blob_ref_missing_blob_field() {
163        let json = serde_json::json!({});
164        let result = json.get("blob");
165        assert!(result.is_none(), "missing 'blob' field should yield None");
166    }
167
168    #[test]
169    fn parse_blob_ref_missing_ref_link() {
170        let json = serde_json::json!({ "blob": { "mimeType": "image/png", "size": 100 } });
171        let blob = json.get("blob").unwrap();
172        let cid = blob
173            .get("ref")
174            .and_then(|r| r.get("$link"))
175            .and_then(|l| l.as_str());
176        assert!(cid.is_none(), "missing ref.$link should yield None");
177    }
178
179    #[test]
180    fn parse_blob_ref_defaults_mime_and_size() {
181        // Response with ref but missing mimeType and size
182        let json = serde_json::json!({
183            "blob": {
184                "ref": { "$link": "bafkreiblob" }
185            }
186        });
187        let blob = json.get("blob").unwrap();
188        let mime = blob
189            .get("mimeType")
190            .and_then(|m| m.as_str())
191            .unwrap_or("application/octet-stream");
192        let size = blob.get("size").and_then(|s| s.as_u64()).unwrap_or(0);
193
194        assert_eq!(mime, "application/octet-stream");
195        assert_eq!(size, 0);
196    }
197
198    #[test]
199    fn upload_blob_url_construction() {
200        let endpoint = "https://pds.example.com";
201        let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", endpoint);
202        assert_eq!(
203            url,
204            "https://pds.example.com/xrpc/com.atproto.repo.uploadBlob"
205        );
206    }
207
208    use wiremock::matchers::{header, method, path};
209    use wiremock::{Mock, MockServer, ResponseTemplate};
210
211    // ---- upload_blob ----
212
213    #[tokio::test]
214    async fn upload_blob_success() {
215        let server = MockServer::start().await;
216        Mock::given(method("POST"))
217            .and(path("/xrpc/com.atproto.repo.uploadBlob"))
218            .and(header("Authorization", "Bearer tok123"))
219            .and(header("Content-Type", "image/png"))
220            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
221                "blob": {
222                    "ref": { "$link": "bafkreiblob" },
223                    "mimeType": "image/png",
224                    "size": 64
225                }
226            })))
227            .mount(&server)
228            .await;
229
230        let http = reqwest::Client::new();
231        let result = upload_blob(&http, &server.uri(), "tok123", vec![0u8; 64], "image/png").await;
232
233        let blob_ref = result.unwrap();
234        assert_eq!(blob_ref.reference.link, "bafkreiblob");
235        assert_eq!(blob_ref.mime_type, "image/png");
236        assert_eq!(blob_ref.size, 64);
237        assert_eq!(blob_ref.blob_type, "blob");
238    }
239
240    #[tokio::test]
241    async fn upload_blob_pds_error() {
242        let server = MockServer::start().await;
243        Mock::given(method("POST"))
244            .and(path("/xrpc/com.atproto.repo.uploadBlob"))
245            .respond_with(ResponseTemplate::new(413).set_body_string("payload too large"))
246            .mount(&server)
247            .await;
248
249        let http = reqwest::Client::new();
250        let result = upload_blob(&http, &server.uri(), "tok", vec![0u8; 10], "image/png").await;
251
252        match result {
253            Err(RepoError::Pds(msg)) => {
254                assert!(
255                    msg.contains("413"),
256                    "error should contain status code: {msg}"
257                );
258                assert!(msg.contains("payload too large"));
259            }
260            other => panic!("expected Pds error, got {:?}", other),
261        }
262    }
263
264    #[tokio::test]
265    async fn upload_blob_missing_blob_field() {
266        let server = MockServer::start().await;
267        Mock::given(method("POST"))
268            .and(path("/xrpc/com.atproto.repo.uploadBlob"))
269            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
270            .mount(&server)
271            .await;
272
273        let http = reqwest::Client::new();
274        let result = upload_blob(&http, &server.uri(), "tok", vec![1], "image/png").await;
275
276        match result {
277            Err(RepoError::Pds(msg)) => assert!(msg.contains("missing 'blob'")),
278            other => panic!("expected Pds error for missing blob field, got {:?}", other),
279        }
280    }
281
282    #[tokio::test]
283    async fn upload_blob_missing_ref_link() {
284        let server = MockServer::start().await;
285        Mock::given(method("POST"))
286            .and(path("/xrpc/com.atproto.repo.uploadBlob"))
287            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
288                "blob": { "mimeType": "image/png", "size": 10 }
289            })))
290            .mount(&server)
291            .await;
292
293        let http = reqwest::Client::new();
294        let result = upload_blob(&http, &server.uri(), "tok", vec![1], "image/png").await;
295
296        match result {
297            Err(RepoError::Pds(msg)) => assert!(msg.contains("ref.$link")),
298            other => panic!("expected Pds error for missing ref.$link, got {:?}", other),
299        }
300    }
301
302    #[tokio::test]
303    async fn upload_blob_defaults_mime_and_size() {
304        let server = MockServer::start().await;
305        Mock::given(method("POST"))
306            .and(path("/xrpc/com.atproto.repo.uploadBlob"))
307            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
308                "blob": {
309                    "ref": { "$link": "bafkrei999" }
310                }
311            })))
312            .mount(&server)
313            .await;
314
315        let http = reqwest::Client::new();
316        let blob_ref = upload_blob(
317            &http,
318            &server.uri(),
319            "tok",
320            vec![1],
321            "application/octet-stream",
322        )
323        .await
324        .unwrap();
325
326        assert_eq!(blob_ref.mime_type, "application/octet-stream");
327        assert_eq!(blob_ref.size, 0);
328    }
329
330    // ---- upload_blob_from_url ----
331
332    #[tokio::test]
333    async fn upload_blob_from_url_success() {
334        let server = MockServer::start().await;
335
336        // Mock the image fetch endpoint
337        Mock::given(method("GET"))
338            .and(path("/images/photo.jpg"))
339            .respond_with(
340                ResponseTemplate::new(200)
341                    .insert_header("Content-Type", "image/jpeg")
342                    .set_body_bytes(vec![0xFFu8, 0xD8, 0xFF, 0xE0]),
343            )
344            .mount(&server)
345            .await;
346
347        // Mock the upload endpoint
348        Mock::given(method("POST"))
349            .and(path("/xrpc/com.atproto.repo.uploadBlob"))
350            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
351                "blob": {
352                    "ref": { "$link": "bafkreiimg" },
353                    "mimeType": "image/jpeg",
354                    "size": 4
355                }
356            })))
357            .mount(&server)
358            .await;
359
360        let http = reqwest::Client::new();
361        let image_url = format!("{}/images/photo.jpg", server.uri());
362        let blob_ref = upload_blob_from_url(&http, &server.uri(), "tok", &image_url)
363            .await
364            .unwrap();
365
366        assert_eq!(blob_ref.reference.link, "bafkreiimg");
367        assert_eq!(blob_ref.mime_type, "image/jpeg");
368        assert_eq!(blob_ref.size, 4);
369    }
370
371    #[tokio::test]
372    async fn upload_blob_from_url_fetch_failure() {
373        let server = MockServer::start().await;
374
375        Mock::given(method("GET"))
376            .and(path("/images/gone.jpg"))
377            .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
378            .mount(&server)
379            .await;
380
381        let http = reqwest::Client::new();
382        let image_url = format!("{}/images/gone.jpg", server.uri());
383        let result = upload_blob_from_url(&http, &server.uri(), "tok", &image_url).await;
384
385        match result {
386            Err(RepoError::Pds(msg)) => {
387                assert!(msg.contains("failed to fetch image"));
388                assert!(msg.contains("404"));
389            }
390            other => panic!("expected Pds error, got {:?}", other),
391        }
392    }
393
394    #[tokio::test]
395    async fn upload_blob_from_url_defaults_mime_type() {
396        let server = MockServer::start().await;
397
398        // Return response without Content-Type header
399        Mock::given(method("GET"))
400            .and(path("/data/blob"))
401            .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0u8; 8]))
402            .mount(&server)
403            .await;
404
405        Mock::given(method("POST"))
406            .and(path("/xrpc/com.atproto.repo.uploadBlob"))
407            .and(header("Content-Type", "application/octet-stream"))
408            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
409                "blob": {
410                    "ref": { "$link": "bafkreidefault" },
411                    "mimeType": "application/octet-stream",
412                    "size": 8
413                }
414            })))
415            .mount(&server)
416            .await;
417
418        let http = reqwest::Client::new();
419        let url = format!("{}/data/blob", server.uri());
420        let blob_ref = upload_blob_from_url(&http, &server.uri(), "tok", &url)
421            .await
422            .unwrap();
423
424        assert_eq!(blob_ref.mime_type, "application/octet-stream");
425    }
426}