1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Duration;
4
5use rand::{distributions::Alphanumeric, thread_rng, Rng};
6use reqwest::Method;
7use serde_json::{Map, Value};
8use url::form_urlencoded;
9
10use crate::storage::error::internal_error;
11use crate::storage::list::{build_list_options, ListOptions};
12use crate::storage::location::Location;
13use crate::storage::metadata::serde::ObjectMetadata;
14use crate::storage::service::FirebaseStorageImpl;
15use crate::storage::{SetMetadataRequest, UploadMetadata};
16
17use super::{RequestBody, RequestInfo, ResponseHandler};
18
19pub fn get_metadata_request(
20 storage: &FirebaseStorageImpl,
21 location: &Location,
22) -> RequestInfo<Value> {
23 let base_url = format!("{}/v0{}", storage.host(), location.full_server_url());
24 let timeout = Duration::from_millis(storage.max_operation_retry_time());
25
26 let handler: ResponseHandler<Value> = Arc::new(|payload| {
27 serde_json::from_slice(&payload.body)
28 .map_err(|err| internal_error(format!("failed to parse metadata: {err}")))
29 });
30
31 RequestInfo::new(base_url, Method::GET, timeout, handler)
32 .with_query_param("alt", "json")
33 .with_headers(default_json_headers())
34}
35
36pub fn update_metadata_request(
37 storage: &FirebaseStorageImpl,
38 location: &Location,
39 metadata: SetMetadataRequest,
40) -> RequestInfo<Value> {
41 let base_url = format!("{}/v0{}", storage.host(), location.full_server_url());
42 let timeout = Duration::from_millis(storage.max_operation_retry_time());
43
44 let handler: ResponseHandler<Value> = Arc::new(|payload| {
45 serde_json::from_slice(&payload.body)
46 .map_err(|err| internal_error(format!("failed to parse metadata: {err}")))
47 });
48
49 RequestInfo::new(base_url, Method::PATCH, timeout, handler)
50 .with_query_param("alt", "json")
51 .with_headers(default_json_headers())
52 .with_body(RequestBody::Text(
53 serde_json::to_string(&metadata).expect("metadata serialization should never fail"),
54 ))
55}
56
57pub fn list_request(
58 storage: &FirebaseStorageImpl,
59 location: &Location,
60 options: &ListOptions,
61) -> RequestInfo<Value> {
62 let base_url = format!("{}/v0{}", storage.host(), location.bucket_only_server_url());
63 let timeout = Duration::from_millis(storage.max_operation_retry_time());
64 let handler: ResponseHandler<Value> = Arc::new(|payload| {
65 serde_json::from_slice(&payload.body)
66 .map_err(|err| internal_error(format!("failed to parse list response: {err}")))
67 });
68
69 let mut request = RequestInfo::new(base_url, Method::GET, timeout, handler)
70 .with_query_param("alt", "json")
71 .with_headers(default_json_headers());
72
73 for (key, value) in build_list_options(location, options) {
74 request = request.with_query_param(key, value);
75 }
76
77 request
78}
79
80pub fn download_bytes_request(
81 storage: &FirebaseStorageImpl,
82 location: &Location,
83 max_download_size_bytes: Option<u64>,
84) -> RequestInfo<Vec<u8>> {
85 let base_url = format!("{}/v0{}", storage.host(), location.full_server_url());
86 let timeout = Duration::from_millis(storage.max_operation_retry_time());
87
88 let handler: ResponseHandler<Vec<u8>> = Arc::new(|payload| Ok(payload.body));
89
90 let mut request = RequestInfo::new(base_url, Method::GET, timeout, handler);
91 request
92 .query_params
93 .insert("alt".to_string(), "media".to_string());
94
95 if let Some(limit) = max_download_size_bytes {
96 request
97 .headers
98 .insert("Range".to_string(), format!("bytes=0-{limit}"));
99 request.success_codes = vec![200, 206];
100 }
101
102 request
103}
104
105pub fn download_url_request(
106 storage: &FirebaseStorageImpl,
107 location: &Location,
108) -> RequestInfo<Option<String>> {
109 let url_part = location.full_server_url();
110 let base_url = format!("{}/v0{}", storage.host(), url_part.clone());
111 let timeout = Duration::from_millis(storage.max_operation_retry_time());
112
113 let download_base = format!("{}://{}/v0{}", storage.protocol(), storage.host(), url_part);
114
115 let handler: ResponseHandler<Option<String>> = Arc::new(move |payload| {
116 let value: Value = serde_json::from_slice(&payload.body)
117 .map_err(|err| internal_error(format!("failed to parse download metadata: {err}")))?;
118
119 if let Some(tokens) = value
120 .get("downloadTokens")
121 .and_then(|v| v.as_str())
122 .filter(|s| !s.is_empty())
123 {
124 if let Some(token) = tokens.split(',').find(|segment| !segment.is_empty()) {
125 let encoded_token: String =
126 form_urlencoded::byte_serialize(token.as_bytes()).collect();
127 return Ok(Some(format!(
128 "{download_base}?alt=media&token={encoded_token}"
129 )));
130 }
131 }
132
133 Ok(None)
134 });
135
136 let mut request = RequestInfo::new(base_url, Method::GET, timeout, handler);
137 request.headers = default_json_headers();
138 request
139}
140
141pub fn delete_object_request(
142 storage: &FirebaseStorageImpl,
143 location: &Location,
144) -> RequestInfo<()> {
145 let base_url = format!("{}/v0{}", storage.host(), location.full_server_url());
146 let timeout = Duration::from_millis(storage.max_operation_retry_time());
147
148 let handler: ResponseHandler<()> = Arc::new(|_| Ok(()));
149
150 let mut request = RequestInfo::new(base_url, Method::DELETE, timeout, handler);
151 request.success_codes = vec![200, 204];
152 request
153}
154
155pub const RESUMABLE_UPLOAD_CHUNK_SIZE: usize = 256 * 1024;
156
157#[derive(Clone, Debug, Default)]
158pub struct ResumableUploadStatus {
159 pub current: u64,
160 pub total: u64,
161 pub finalized: bool,
162 pub metadata: Option<ObjectMetadata>,
163}
164
165impl ResumableUploadStatus {
166 pub fn new(
167 current: u64,
168 total: u64,
169 finalized: bool,
170 metadata: Option<ObjectMetadata>,
171 ) -> Self {
172 Self {
173 current,
174 total,
175 finalized,
176 metadata,
177 }
178 }
179}
180
181pub fn multipart_upload_request(
182 storage: &FirebaseStorageImpl,
183 location: &Location,
184 data: Vec<u8>,
185 metadata: Option<UploadMetadata>,
186) -> RequestInfo<ObjectMetadata> {
187 let base_url = format!("{}/v0{}", storage.host(), location.bucket_only_server_url());
188 let timeout = Duration::from_millis(storage.max_upload_retry_time());
189
190 let total_size = data.len() as u64;
191 let (resource, content_type) = build_upload_resource(location, metadata.clone(), total_size);
192 let resource_json =
193 serde_json::to_string(&resource).expect("upload metadata serialization should never fail");
194
195 let boundary = generate_boundary();
196 let mut body = Vec::with_capacity(resource_json.len() + data.len() + boundary.len() * 4 + 200);
197 push_multipart_segment(
198 &mut body,
199 &boundary,
200 "Content-Type: application/json; charset=utf-8",
201 resource_json.as_bytes(),
202 );
203 push_multipart_segment(
204 &mut body,
205 &boundary,
206 &format!("Content-Type: {content_type}"),
207 &data,
208 );
209 finalize_multipart(&mut body, &boundary);
210
211 let handler: ResponseHandler<ObjectMetadata> = Arc::new(|payload| {
212 let value: Value = serde_json::from_slice(&payload.body)
213 .map_err(|err| internal_error(format!("failed to parse upload metadata: {err}")))?;
214 Ok(ObjectMetadata::from_value(value))
215 });
216
217 let mut request = RequestInfo::new(base_url, Method::POST, timeout, handler)
218 .with_headers(default_json_headers())
219 .with_body(RequestBody::Bytes(body))
220 .with_query_param("uploadType", "multipart")
221 .with_query_param("name", location.path());
222
223 request.headers.insert(
224 "Content-Type".to_string(),
225 format!("multipart/related; boundary={boundary}"),
226 );
227 request.headers.insert(
228 "X-Goog-Upload-Protocol".to_string(),
229 "multipart".to_string(),
230 );
231
232 request
233}
234
235pub fn create_resumable_upload_request(
236 storage: &FirebaseStorageImpl,
237 location: &Location,
238 metadata: Option<UploadMetadata>,
239 total_size: u64,
240) -> RequestInfo<String> {
241 let base_url = format!("{}/v0{}", storage.host(), location.bucket_only_server_url());
242 let timeout = Duration::from_millis(storage.max_upload_retry_time());
243
244 let (resource, content_type) = build_upload_resource(location, metadata.clone(), total_size);
245 let resource_json =
246 serde_json::to_string(&resource).expect("upload metadata serialization should never fail");
247
248 let handler: ResponseHandler<String> = Arc::new(|payload| {
249 let status = header_value(&payload.headers, "X-Goog-Upload-Status")
250 .ok_or_else(|| internal_error("missing resumable upload status header"))?;
251 if !matches!(status.to_ascii_lowercase().as_str(), "active" | "final") {
252 return Err(internal_error(format!(
253 "unexpected resumable upload status: {status}"
254 )));
255 }
256
257 let upload_url = header_value(&payload.headers, "X-Goog-Upload-URL")
258 .ok_or_else(|| internal_error("missing resumable upload url"))?;
259 Ok(upload_url.to_string())
260 });
261
262 let mut request = RequestInfo::new(base_url, Method::POST, timeout, handler)
263 .with_query_param("uploadType", "resumable")
264 .with_query_param("name", location.path())
265 .with_headers(default_json_headers())
266 .with_body(RequestBody::Text(resource_json));
267
268 request.headers.insert(
269 "X-Goog-Upload-Protocol".to_string(),
270 "resumable".to_string(),
271 );
272 request
273 .headers
274 .insert("X-Goog-Upload-Command".to_string(), "start".to_string());
275 request.headers.insert(
276 "X-Goog-Upload-Header-Content-Length".to_string(),
277 total_size.to_string(),
278 );
279 request.headers.insert(
280 "X-Goog-Upload-Header-Content-Type".to_string(),
281 content_type,
282 );
283
284 request
285}
286
287pub fn get_resumable_upload_status_request(
288 storage: &FirebaseStorageImpl,
289 _location: &Location,
290 upload_url: &str,
291 total_size: u64,
292) -> RequestInfo<ResumableUploadStatus> {
293 let timeout = Duration::from_millis(storage.max_upload_retry_time());
294 let handler: ResponseHandler<ResumableUploadStatus> = Arc::new(move |payload| {
295 let status = header_value(&payload.headers, "X-Goog-Upload-Status")
296 .ok_or_else(|| internal_error("missing resumable upload status header"))?;
297 if !matches!(status.to_ascii_lowercase().as_str(), "active" | "final") {
298 return Err(internal_error(format!(
299 "unexpected resumable upload status: {status}"
300 )));
301 }
302 let received = header_value(&payload.headers, "X-Goog-Upload-Size-Received")
303 .ok_or_else(|| internal_error("missing upload size header"))?;
304 let current = received
305 .parse::<u64>()
306 .map_err(|_| internal_error("invalid upload size header"))?;
307
308 Ok(ResumableUploadStatus::new(
309 current,
310 total_size,
311 status.eq_ignore_ascii_case("final"),
312 None,
313 ))
314 });
315
316 let mut request = RequestInfo::new(upload_url, Method::POST, timeout, handler);
317 request
318 .headers
319 .insert("X-Goog-Upload-Command".to_string(), "query".to_string());
320 request.headers.insert(
321 "X-Goog-Upload-Protocol".to_string(),
322 "resumable".to_string(),
323 );
324 request
325}
326
327pub fn continue_resumable_upload_request(
328 storage: &FirebaseStorageImpl,
329 _location: &Location,
330 upload_url: &str,
331 start_offset: u64,
332 total_size: u64,
333 chunk: Vec<u8>,
334 finalize: bool,
335) -> RequestInfo<ResumableUploadStatus> {
336 let timeout = Duration::from_millis(storage.max_upload_retry_time());
337 let bytes_to_upload = chunk.len() as u64;
338 let empty_chunk = chunk.is_empty();
339
340 let handler: ResponseHandler<ResumableUploadStatus> = Arc::new(move |payload| {
341 let status = header_value(&payload.headers, "X-Goog-Upload-Status")
342 .ok_or_else(|| internal_error("missing resumable upload status header"))?;
343 if !matches!(status.to_ascii_lowercase().as_str(), "active" | "final") {
344 return Err(internal_error(format!(
345 "unexpected resumable upload status: {status}"
346 )));
347 }
348
349 let new_current = (start_offset + bytes_to_upload).min(total_size);
350
351 let metadata = if status.eq_ignore_ascii_case("final") {
352 if payload.body.is_empty() {
353 return Err(internal_error(
354 "final resumable response missing metadata payload",
355 ));
356 }
357 let value: Value = serde_json::from_slice(&payload.body)
358 .map_err(|err| internal_error(format!("failed to parse upload metadata: {err}")))?;
359 Some(ObjectMetadata::from_value(value))
360 } else {
361 None
362 };
363
364 Ok(ResumableUploadStatus::new(
365 new_current,
366 total_size,
367 status.eq_ignore_ascii_case("final"),
368 metadata,
369 ))
370 });
371
372 let mut request = RequestInfo::new(upload_url, Method::POST, timeout, handler)
373 .with_body(RequestBody::Bytes(chunk));
374
375 let mut command = String::from("upload");
376 if finalize && empty_chunk {
377 command = "finalize".to_string();
378 } else if finalize {
379 command.push_str(", finalize");
380 }
381
382 request.headers.insert(
383 "X-Goog-Upload-Protocol".to_string(),
384 "resumable".to_string(),
385 );
386 request
387 .headers
388 .insert("X-Goog-Upload-Command".to_string(), command);
389 request
390 .headers
391 .insert("X-Goog-Upload-Offset".to_string(), start_offset.to_string());
392 request.headers.insert(
393 "Content-Type".to_string(),
394 "application/octet-stream".to_string(),
395 );
396
397 request.success_codes = vec![200, 201, 308];
398 request
399 .additional_retry_codes
400 .extend_from_slice(&[308_u16, 500, 502, 503, 504]);
401
402 request
403}
404
405fn default_json_headers() -> HashMap<String, String> {
406 let mut headers = HashMap::new();
407 headers.insert("Accept".to_string(), "application/json".to_string());
408 headers.insert("Content-Type".to_string(), "application/json".to_string());
409 headers
410}
411
412fn generate_boundary() -> String {
413 thread_rng()
414 .sample_iter(&Alphanumeric)
415 .take(30)
416 .map(char::from)
417 .collect()
418}
419
420fn push_multipart_segment(body: &mut Vec<u8>, boundary: &str, header: &str, data: &[u8]) {
421 body.extend_from_slice(b"--");
422 body.extend_from_slice(boundary.as_bytes());
423 body.extend_from_slice(b"\r\n");
424 body.extend_from_slice(header.as_bytes());
425 body.extend_from_slice(b"\r\n\r\n");
426 body.extend_from_slice(data);
427 body.extend_from_slice(b"\r\n");
428}
429
430fn finalize_multipart(body: &mut Vec<u8>, boundary: &str) {
431 body.extend_from_slice(b"--");
432 body.extend_from_slice(boundary.as_bytes());
433 body.extend_from_slice(b"--");
434}
435
436fn build_upload_resource(
437 location: &Location,
438 metadata: Option<UploadMetadata>,
439 total_size: u64,
440) -> (Value, String) {
441 let mut map = Map::new();
442 map.insert(
443 "name".to_string(),
444 Value::String(location.path().to_string()),
445 );
446 map.insert(
447 "fullPath".to_string(),
448 Value::String(location.path().to_string()),
449 );
450 map.insert("size".to_string(), Value::String(total_size.to_string()));
451
452 let mut content_type = String::from("application/octet-stream");
453
454 if let Some(meta) = metadata {
455 if let Some(value) = meta.cache_control {
456 map.insert("cacheControl".to_string(), Value::String(value));
457 }
458 if let Some(value) = meta.content_disposition {
459 map.insert("contentDisposition".to_string(), Value::String(value));
460 }
461 if let Some(value) = meta.content_encoding {
462 map.insert("contentEncoding".to_string(), Value::String(value));
463 }
464 if let Some(value) = meta.content_language {
465 map.insert("contentLanguage".to_string(), Value::String(value));
466 }
467 if let Some(value) = meta.content_type {
468 content_type = value.clone();
469 map.insert("contentType".to_string(), Value::String(value));
470 }
471 if let Some(custom) = meta.custom_metadata {
472 let mut custom_map = Map::new();
473 for (k, v) in custom {
474 custom_map.insert(k, Value::String(v));
475 }
476 map.insert("metadata".to_string(), Value::Object(custom_map));
477 }
478 if let Some(value) = meta.md5_hash {
479 map.insert("md5Hash".to_string(), Value::String(value));
480 }
481 if let Some(value) = meta.crc32c {
482 map.insert("crc32c".to_string(), Value::String(value));
483 }
484 }
485
486 if !map.contains_key("contentType") {
487 map.insert(
488 "contentType".to_string(),
489 Value::String(content_type.clone()),
490 );
491 }
492
493 (Value::Object(map), content_type)
494}
495
496fn header_value<'a>(headers: &'a HashMap<String, String>, name: &str) -> Option<&'a str> {
497 if let Some(value) = headers.get(name) {
498 return Some(value);
499 }
500 headers
501 .iter()
502 .find(|(key, _)| key.eq_ignore_ascii_case(name))
503 .map(|(_, value)| value.as_str())
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::app::initialize_app;
510 use crate::app::{FirebaseAppSettings, FirebaseOptions};
511 use crate::storage::metadata::serde::{SetMetadataRequest, UploadMetadata};
512 use crate::storage::request::{RequestBody, ResponsePayload};
513 use reqwest::StatusCode;
514
515 fn unique_settings() -> FirebaseAppSettings {
516 use std::sync::atomic::{AtomicUsize, Ordering};
517 static COUNTER: AtomicUsize = AtomicUsize::new(0);
518 FirebaseAppSettings {
519 name: Some(format!(
520 "storage-request-{}",
521 COUNTER.fetch_add(1, Ordering::SeqCst)
522 )),
523 ..Default::default()
524 }
525 }
526
527 async fn build_storage() -> FirebaseStorageImpl {
528 let options = FirebaseOptions {
529 storage_bucket: Some("my-bucket".into()),
530 ..Default::default()
531 };
532 let app = initialize_app(options, Some(unique_settings()))
533 .await
534 .unwrap();
535 let container = app.container();
536 let auth_provider = container.get_provider("auth-internal");
537 let app_check_provider = container.get_provider("app-check-internal");
538 FirebaseStorageImpl::new(app, auth_provider, app_check_provider, None, None).unwrap()
539 }
540
541 #[tokio::test]
542 async fn builds_get_metadata_request() {
543 let storage = build_storage().await;
544 let location = Location::new("my-bucket", "photos/cat.png");
545 let request = get_metadata_request(&storage, &location);
546 assert_eq!(
547 request.url,
548 "firebasestorage.googleapis.com/v0/b/my%2Dbucket/o/photos%2Fcat%2Epng"
549 );
550 assert_eq!(request.method, Method::GET);
551 assert_eq!(request.query_params.get("alt"), Some(&"json".to_string()));
552 }
553
554 #[tokio::test]
555 async fn builds_update_metadata_request() {
556 let storage = build_storage().await;
557 let location = Location::new("my-bucket", "docs/file.txt");
558 let mut metadata = SetMetadataRequest::default();
559 metadata.content_type = Some("text/plain".into());
560 let request = update_metadata_request(&storage, &location, metadata);
561 assert_eq!(request.method, Method::PATCH);
562 assert_eq!(
563 request.url,
564 "firebasestorage.googleapis.com/v0/b/my%2Dbucket/o/docs%2Ffile%2Etxt"
565 );
566 assert_eq!(request.query_params.get("alt"), Some(&"json".to_string()));
567 match &request.body {
568 RequestBody::Text(body) => {
569 assert!(body.contains("\"contentType\":\"text/plain\""));
570 }
571 _ => panic!("expected text body"),
572 }
573 }
574
575 #[tokio::test]
576 async fn builds_list_request() {
577 let storage = build_storage().await;
578 let location = Location::new("my-bucket", "");
579 let mut options = ListOptions::default();
580 options.max_results = Some(25);
581 options.page_token = Some("token123".into());
582 let request = list_request(&storage, &location, &options);
583 assert_eq!(request.method, Method::GET);
584 assert_eq!(
585 request.query_params.get("delimiter"),
586 Some(&"/".to_string())
587 );
588 assert_eq!(request.query_params.get("prefix"), Some(&"".to_string()));
589 assert_eq!(
590 request.query_params.get("maxResults"),
591 Some(&"25".to_string())
592 );
593 assert_eq!(
594 request.query_params.get("pageToken"),
595 Some(&"token123".to_string())
596 );
597 }
598
599 #[tokio::test]
600 async fn download_bytes_request_sets_range_header() {
601 let storage = build_storage().await;
602 let location = Location::new("my-bucket", "docs/file.txt");
603 let request = download_bytes_request(&storage, &location, Some(1024));
604 assert_eq!(request.method, Method::GET);
605 assert_eq!(request.query_params.get("alt"), Some(&"media".to_string()));
606 assert_eq!(
607 request.headers.get("Range"),
608 Some(&"bytes=0-1024".to_string())
609 );
610 assert_eq!(request.success_codes, vec![200, 206]);
611 }
612
613 #[tokio::test]
614 async fn download_url_request_builds_signed_url() {
615 let storage = build_storage().await;
616 let location = Location::new("my-bucket", "photos/cat.jpg");
617 let request = download_url_request(&storage, &location);
618
619 let payload = ResponsePayload {
620 status: StatusCode::OK,
621 headers: HashMap::new(),
622 body: serde_json::to_vec(&serde_json::json!({
623 "downloadTokens": "token123"
624 }))
625 .unwrap(),
626 };
627
628 let handler = request.response_handler.clone();
629 let url = handler(payload).unwrap().unwrap();
630 assert!(url.contains("token=token123"));
631 assert!(url.starts_with("https://"));
632 assert!(url.contains("/v0/b/my%2Dbucket"));
633 }
634
635 #[tokio::test]
636 async fn delete_object_request_accepts_empty_response() {
637 let storage = build_storage().await;
638 let location = Location::new("my-bucket", "docs/file.txt");
639 let request = delete_object_request(&storage, &location);
640 assert_eq!(request.method, Method::DELETE);
641 assert!(request.success_codes.contains(&204));
642 }
643
644 #[tokio::test]
645 async fn multipart_upload_request_sets_protocol_and_body() {
646 let storage = build_storage().await;
647 let location = Location::new("my-bucket", "photos/dog.jpg");
648 let mut metadata = UploadMetadata::new();
649 metadata.content_type = Some("image/jpeg".into());
650 metadata.md5_hash = Some("abc123".into());
651 metadata.insert_custom_metadata("role", "cover");
652 let bytes = vec![1_u8, 2, 3, 4, 5];
653
654 let request = multipart_upload_request(&storage, &location, bytes.clone(), Some(metadata));
655 assert_eq!(request.method, Method::POST);
656 assert_eq!(
657 request.query_params.get("uploadType"),
658 Some(&"multipart".to_string())
659 );
660 assert_eq!(
661 request.query_params.get("name"),
662 Some(&"photos/dog.jpg".to_string())
663 );
664 let content_type = request.headers.get("Content-Type").unwrap();
665 assert!(content_type.starts_with("multipart/related; boundary="));
666 assert_eq!(
667 request.headers.get("X-Goog-Upload-Protocol"),
668 Some(&"multipart".to_string())
669 );
670
671 match &request.body {
672 RequestBody::Bytes(body) => {
673 assert!(body
674 .windows(bytes.len())
675 .any(|window| window == bytes.as_slice()));
676 }
677 other => panic!("unexpected request body: {other:?}"),
678 }
679 }
680
681 #[tokio::test]
682 async fn create_resumable_upload_request_extracts_upload_url() {
683 let storage = build_storage().await;
684 let location = Location::new("my-bucket", "videos/clip.mp4");
685 let mut metadata = UploadMetadata::new();
686 metadata.content_type = Some("video/mp4".into());
687 metadata.crc32c = Some("deadbeef".into());
688 let request = create_resumable_upload_request(&storage, &location, Some(metadata), 2048);
689
690 assert_eq!(
691 request.query_params.get("uploadType"),
692 Some(&"resumable".to_string())
693 );
694
695 let mut headers = HashMap::new();
696 headers.insert("X-Goog-Upload-Status".to_string(), "active".to_string());
697 headers.insert(
698 "X-Goog-Upload-URL".to_string(),
699 "https://example.com/upload/session".to_string(),
700 );
701 let payload = ResponsePayload {
702 status: StatusCode::OK,
703 headers,
704 body: Vec::new(),
705 };
706
707 let handler = request.response_handler.clone();
708 let url = handler(payload).unwrap();
709 assert_eq!(url, "https://example.com/upload/session");
710 }
711
712 #[tokio::test]
713 async fn get_resumable_upload_status_reads_headers() {
714 let storage = build_storage().await;
715 let location = Location::new("my-bucket", "videos/clip.mp4");
716 let request = get_resumable_upload_status_request(
717 &storage,
718 &location,
719 "https://example.com/upload/session",
720 4096,
721 );
722
723 let mut headers = HashMap::new();
724 headers.insert("X-Goog-Upload-Status".to_string(), "active".to_string());
725 headers.insert(
726 "X-Goog-Upload-Size-Received".to_string(),
727 "1024".to_string(),
728 );
729 let payload = ResponsePayload {
730 status: StatusCode::OK,
731 headers,
732 body: Vec::new(),
733 };
734
735 let handler = request.response_handler.clone();
736 let status = handler(payload).unwrap();
737 assert_eq!(status.current, 1024);
738 assert_eq!(status.total, 4096);
739 assert!(!status.finalized);
740 }
741
742 #[tokio::test]
743 async fn continue_resumable_upload_handles_final_response() {
744 let storage = build_storage().await;
745 let location = Location::new("my-bucket", "videos/clip.mp4");
746 let chunk = vec![0_u8, 1, 2, 3];
747 let request = continue_resumable_upload_request(
748 &storage,
749 &location,
750 "https://example.com/upload/session",
751 0,
752 4,
753 chunk,
754 true,
755 );
756
757 let mut headers = HashMap::new();
758 headers.insert("X-Goog-Upload-Status".to_string(), "final".to_string());
759 let payload = ResponsePayload {
760 status: StatusCode::OK,
761 headers,
762 body: serde_json::to_vec(&serde_json::json!({
763 "name": "videos/clip.mp4",
764 "bucket": "my-bucket"
765 }))
766 .unwrap(),
767 };
768
769 let handler = request.response_handler.clone();
770 let status = handler(payload).unwrap();
771 assert!(status.finalized);
772 assert_eq!(status.current, 4);
773 assert!(status.metadata.is_some());
774 }
775}