firebase_rs_sdk/storage/request/
builders.rs

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}