1use crate::error::RepoError;
4use crate::types::{BlobLink, BlobRef};
5
6pub 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
77pub 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 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 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 #[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 #[tokio::test]
333 async fn upload_blob_from_url_success() {
334 let server = MockServer::start().await;
335
336 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::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 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}