1use std::path::Path;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
8
9use crate::client::Credentials;
10use crate::client::{Backend, ClientInner};
11use crate::error::{Error, Result};
12use crate::http_response::{
13 sdk_http_response_from_headers, sdk_http_response_from_headers_and_body,
14};
15use crate::upload;
16#[cfg(test)]
17use crate::upload::CHUNK_SIZE;
18use rust_genai_types::enums::FileState;
19use rust_genai_types::files::{
20 CreateFileConfig, CreateFileResponse, DeleteFileConfig, DeleteFileResponse, DownloadFileConfig,
21 File, GetFileConfig, ListFilesConfig, ListFilesResponse, RegisterFilesConfig,
22 RegisterFilesResponse, UploadFileConfig,
23};
24use serde_json::Value;
25
26#[derive(Clone)]
27pub struct Files {
28 pub(crate) inner: Arc<ClientInner>,
29}
30
31impl Files {
32 pub(crate) const fn new(inner: Arc<ClientInner>) -> Self {
33 Self { inner }
34 }
35
36 pub async fn upload(&self, data: Vec<u8>, mime_type: impl Into<String>) -> Result<File> {
41 let config = UploadFileConfig {
42 mime_type: Some(mime_type.into()),
43 ..UploadFileConfig::default()
44 };
45 self.upload_with_config(data, config).await
46 }
47
48 pub async fn create(&self, file: File) -> Result<CreateFileResponse> {
55 self.create_with_config(file, CreateFileConfig::default())
56 .await
57 }
58
59 pub async fn create_with_config(
64 &self,
65 mut file: File,
66 mut config: CreateFileConfig,
67 ) -> Result<CreateFileResponse> {
68 ensure_gemini_backend(&self.inner)?;
69
70 let should_return_http_response = config.should_return_http_response.unwrap_or(false);
71 let http_options = config.http_options.take();
72 let mime_type = file.mime_type.clone().ok_or_else(|| Error::InvalidConfig {
73 message: "mime_type is required when creating a resumable upload session".into(),
74 })?;
75 let size_bytes = file
76 .size_bytes
77 .as_deref()
78 .map(str::trim)
79 .filter(|value| !value.is_empty())
80 .map(|value| {
81 value.parse::<u64>().map_err(|_| Error::InvalidConfig {
82 message: format!("Invalid size_bytes: {value}"),
83 })
84 })
85 .transpose()?;
86
87 if let Some(name) = file.name.take() {
88 file.name = Some(normalize_upload_name(&name));
89 }
90
91 let (_, headers, text) = self
92 .start_resumable_upload(file, size_bytes, &mime_type, None, http_options.as_ref())
93 .await?;
94
95 let response = CreateFileResponse {
96 sdk_http_response: Some(if should_return_http_response {
97 sdk_http_response_from_headers_and_body(&headers, text)
98 } else {
99 sdk_http_response_from_headers(&headers)
100 }),
101 };
102 Ok(response)
103 }
104
105 pub async fn upload_with_config(
110 &self,
111 data: Vec<u8>,
112 mut config: UploadFileConfig,
113 ) -> Result<File> {
114 ensure_gemini_backend(&self.inner)?;
115
116 let http_options = config.http_options.take();
117 let mime_type = config
118 .mime_type
119 .clone()
120 .ok_or_else(|| Error::InvalidConfig {
121 message: "mime_type is required when uploading raw bytes".into(),
122 })?;
123 let size_bytes = data.len() as u64;
124 let file = build_upload_file(config, size_bytes, &mime_type);
125 let (upload_url, _, _) = self
126 .start_resumable_upload(
127 file,
128 Some(size_bytes),
129 &mime_type,
130 None,
131 http_options.as_ref(),
132 )
133 .await?;
134 self.upload_bytes(&upload_url, &data, http_options.as_ref())
135 .await
136 }
137
138 pub async fn upload_from_path(&self, path: impl AsRef<Path>) -> Result<File> {
143 self.upload_from_path_with_config(path, UploadFileConfig::default())
144 .await
145 }
146
147 pub async fn upload_from_path_with_config(
152 &self,
153 path: impl AsRef<Path>,
154 mut config: UploadFileConfig,
155 ) -> Result<File> {
156 ensure_gemini_backend(&self.inner)?;
157
158 let path = path.as_ref();
159 let metadata = tokio::fs::metadata(path).await?;
160 if !metadata.is_file() {
161 return Err(Error::InvalidConfig {
162 message: format!("{} is not a valid file path", path.display()),
163 });
164 }
165
166 let http_options = config.http_options.take();
167 let size_bytes = metadata.len();
168 let mime_type = config.mime_type.take().unwrap_or_else(|| {
169 mime_guess::from_path(path)
170 .first_or_octet_stream()
171 .essence_str()
172 .to_string()
173 });
174
175 let file_name = path.file_name().and_then(|name| name.to_str());
176 let file = build_upload_file(config, size_bytes, &mime_type);
177 let (upload_url, _, _) = self
178 .start_resumable_upload(
179 file,
180 Some(size_bytes),
181 &mime_type,
182 file_name,
183 http_options.as_ref(),
184 )
185 .await?;
186 let mut file_handle = tokio::fs::File::open(path).await?;
187 self.upload_reader(
188 &upload_url,
189 &mut file_handle,
190 size_bytes,
191 http_options.as_ref(),
192 )
193 .await
194 }
195
196 pub async fn download(&self, name_or_uri: impl AsRef<str>) -> Result<Vec<u8>> {
201 self.download_with_config(name_or_uri, DownloadFileConfig::default())
202 .await
203 }
204
205 pub async fn download_with_config(
210 &self,
211 name_or_uri: impl AsRef<str>,
212 mut config: DownloadFileConfig,
213 ) -> Result<Vec<u8>> {
214 ensure_gemini_backend(&self.inner)?;
215
216 let http_options = config.http_options.take();
217 let file_name = normalize_file_name(name_or_uri.as_ref())?;
218 let url = build_file_download_url(&self.inner, &file_name, http_options.as_ref());
219 let mut request = self.inner.http.get(url);
220 request = apply_http_options(request, http_options.as_ref())?;
221 let response = self
222 .inner
223 .send_with_http_options(request, http_options.as_ref())
224 .await?;
225 if !response.status().is_success() {
226 return Err(Error::ApiError {
227 status: response.status().as_u16(),
228 message: response.text().await.unwrap_or_default(),
229 });
230 }
231 let bytes = response.bytes().await?;
232 Ok(bytes.to_vec())
233 }
234
235 pub async fn list(&self) -> Result<ListFilesResponse> {
240 self.list_with_config(ListFilesConfig::default()).await
241 }
242
243 pub async fn list_with_config(&self, config: ListFilesConfig) -> Result<ListFilesResponse> {
248 ensure_gemini_backend(&self.inner)?;
249 let http_options = config.http_options.as_ref();
250 let url = build_files_list_url(&self.inner, &config, http_options)?;
251 let mut request = self.inner.http.get(url);
252 request = apply_http_options(request, http_options)?;
253 let response = self
254 .inner
255 .send_with_http_options(request, http_options)
256 .await?;
257 if !response.status().is_success() {
258 return Err(Error::ApiError {
259 status: response.status().as_u16(),
260 message: response.text().await.unwrap_or_default(),
261 });
262 }
263 let headers = response.headers().clone();
264 let mut result = response.json::<ListFilesResponse>().await?;
265 result.sdk_http_response = Some(sdk_http_response_from_headers(&headers));
266 Ok(result)
267 }
268
269 pub async fn all(&self) -> Result<Vec<File>> {
274 self.all_with_config(ListFilesConfig::default()).await
275 }
276
277 pub async fn all_with_config(&self, mut config: ListFilesConfig) -> Result<Vec<File>> {
282 let mut files = Vec::new();
283 loop {
284 let response = self.list_with_config(config.clone()).await?;
285 if let Some(items) = response.files {
286 files.extend(items);
287 }
288 match response.next_page_token {
289 Some(token) if !token.is_empty() => {
290 config.page_token = Some(token);
291 }
292 _ => break,
293 }
294 }
295 Ok(files)
296 }
297
298 pub async fn get(&self, name_or_uri: impl AsRef<str>) -> Result<File> {
303 self.get_with_config(name_or_uri, GetFileConfig::default())
304 .await
305 }
306
307 pub async fn get_with_config(
312 &self,
313 name_or_uri: impl AsRef<str>,
314 mut config: GetFileConfig,
315 ) -> Result<File> {
316 ensure_gemini_backend(&self.inner)?;
317
318 let http_options = config.http_options.take();
319 let file_name = normalize_file_name(name_or_uri.as_ref())?;
320 let url = build_file_url(&self.inner, &file_name, http_options.as_ref());
321 let mut request = self.inner.http.get(url);
322 request = apply_http_options(request, http_options.as_ref())?;
323 let response = self
324 .inner
325 .send_with_http_options(request, http_options.as_ref())
326 .await?;
327 if !response.status().is_success() {
328 return Err(Error::ApiError {
329 status: response.status().as_u16(),
330 message: response.text().await.unwrap_or_default(),
331 });
332 }
333 Ok(response.json::<File>().await?)
334 }
335
336 pub async fn delete(&self, name_or_uri: impl AsRef<str>) -> Result<DeleteFileResponse> {
341 self.delete_with_config(name_or_uri, DeleteFileConfig::default())
342 .await
343 }
344
345 pub async fn delete_with_config(
350 &self,
351 name_or_uri: impl AsRef<str>,
352 mut config: DeleteFileConfig,
353 ) -> Result<DeleteFileResponse> {
354 ensure_gemini_backend(&self.inner)?;
355
356 let http_options = config.http_options.take();
357 let file_name = normalize_file_name(name_or_uri.as_ref())?;
358 let url = build_file_url(&self.inner, &file_name, http_options.as_ref());
359 let mut request = self.inner.http.delete(url);
360 request = apply_http_options(request, http_options.as_ref())?;
361 let response = self
362 .inner
363 .send_with_http_options(request, http_options.as_ref())
364 .await?;
365 if !response.status().is_success() {
366 return Err(Error::ApiError {
367 status: response.status().as_u16(),
368 message: response.text().await.unwrap_or_default(),
369 });
370 }
371 let headers = response.headers().clone();
372 let text = response.text().await.unwrap_or_default();
373 let mut result = if text.trim().is_empty() {
374 DeleteFileResponse::default()
375 } else {
376 serde_json::from_str::<DeleteFileResponse>(&text)?
377 };
378 result.sdk_http_response = Some(sdk_http_response_from_headers(&headers));
379 Ok(result)
380 }
381
382 pub async fn register_files(&self, uris: Vec<String>) -> Result<RegisterFilesResponse> {
390 self.register_files_with_config(uris, RegisterFilesConfig::default())
391 .await
392 }
393
394 pub async fn register_files_with_config(
399 &self,
400 uris: Vec<String>,
401 mut config: RegisterFilesConfig,
402 ) -> Result<RegisterFilesResponse> {
403 ensure_gemini_backend(&self.inner)?;
404 if matches!(self.inner.config.credentials, Credentials::ApiKey(_)) {
405 return Err(Error::InvalidConfig {
406 message: "register_files requires OAuth/ADC credentials, API key is not supported"
407 .into(),
408 });
409 }
410
411 let should_return_http_response = config.should_return_http_response.unwrap_or(false);
412 let http_options = config.http_options.take();
413 let url = build_files_register_url(&self.inner, http_options.as_ref());
414 let mut request = self
415 .inner
416 .http
417 .post(url)
418 .json(&serde_json::json!({ "uris": uris }));
419 request = apply_http_options(request, http_options.as_ref())?;
420
421 let response = self
422 .inner
423 .send_with_http_options(request, http_options.as_ref())
424 .await?;
425 if !response.status().is_success() {
426 return Err(Error::ApiError {
427 status: response.status().as_u16(),
428 message: response.text().await.unwrap_or_default(),
429 });
430 }
431
432 let headers = response.headers().clone();
433 let text = response.text().await.unwrap_or_default();
434 if should_return_http_response {
435 return Ok(RegisterFilesResponse {
436 sdk_http_response: Some(sdk_http_response_from_headers_and_body(&headers, text)),
437 ..Default::default()
438 });
439 }
440 if text.trim().is_empty() {
441 return Ok(RegisterFilesResponse {
442 sdk_http_response: Some(sdk_http_response_from_headers(&headers)),
443 ..Default::default()
444 });
445 }
446 let mut result: RegisterFilesResponse = serde_json::from_str(&text)?;
447 result.sdk_http_response = Some(sdk_http_response_from_headers(&headers));
448 Ok(result)
449 }
450
451 pub async fn wait_for_active(
456 &self,
457 name_or_uri: impl AsRef<str>,
458 config: WaitForFileConfig,
459 ) -> Result<File> {
460 ensure_gemini_backend(&self.inner)?;
461
462 let start = Instant::now();
463 loop {
464 let file = self.get(name_or_uri.as_ref()).await?;
465 match file.state {
466 Some(FileState::Active) => return Ok(file),
467 Some(FileState::Failed) => {
468 return Err(Error::ApiError {
469 status: 500,
470 message: "File processing failed".into(),
471 })
472 }
473 _ => {}
474 }
475
476 if let Some(timeout) = config.timeout {
477 if start.elapsed() >= timeout {
478 return Err(Error::Timeout {
479 message: "Timed out waiting for file to become ACTIVE".into(),
480 });
481 }
482 }
483
484 tokio::time::sleep(config.poll_interval).await;
485 }
486 }
487
488 async fn start_resumable_upload(
489 &self,
490 file: File,
491 size_bytes: Option<u64>,
492 mime_type: &str,
493 file_name: Option<&str>,
494 http_options: Option<&rust_genai_types::http::HttpOptions>,
495 ) -> Result<(String, HeaderMap, String)> {
496 let url = build_files_upload_url(&self.inner, http_options);
497 let mut request = self.inner.http.post(url);
498 request = apply_http_options(request, http_options)?;
499 request = request
500 .header("X-Goog-Upload-Protocol", "resumable")
501 .header("X-Goog-Upload-Command", "start")
502 .header("X-Goog-Upload-Header-Content-Type", mime_type);
503 if let Some(size_bytes) = size_bytes {
504 request = request.header(
505 "X-Goog-Upload-Header-Content-Length",
506 size_bytes.to_string(),
507 );
508 }
509
510 if let Some(file_name) = file_name {
511 request = request.header("X-Goog-Upload-File-Name", file_name);
512 }
513
514 let mut body = serde_json::json!({ "file": file });
515 if let Some(options) = http_options {
516 merge_extra_body(&mut body, options)?;
517 }
518 let request = request.json(&body);
519 let response = self
520 .inner
521 .send_with_http_options(request, http_options)
522 .await?;
523 if !response.status().is_success() {
524 return Err(Error::ApiError {
525 status: response.status().as_u16(),
526 message: response.text().await.unwrap_or_default(),
527 });
528 }
529
530 let headers = response.headers().clone();
531 let upload_url = headers
532 .get("x-goog-upload-url")
533 .and_then(|value| value.to_str().ok())
534 .ok_or_else(|| Error::Parse {
535 message: "Missing x-goog-upload-url header".into(),
536 })?
537 .to_string();
538 let text = response.text().await.unwrap_or_default();
539
540 Ok((upload_url, headers, text))
541 }
542
543 async fn upload_bytes(
544 &self,
545 upload_url: &str,
546 data: &[u8],
547 http_options: Option<&rust_genai_types::http::HttpOptions>,
548 ) -> Result<File> {
549 let validate_status = |status: &str| {
550 if status != "active" {
551 return Err(Error::Parse {
552 message: format!("Unexpected upload status: {status}"),
553 });
554 }
555 Ok(())
556 };
557
558 upload::upload_bytes_with(
559 data,
560 |chunk, offset, finalize| {
561 self.send_upload_chunk(upload_url, chunk, offset, finalize, http_options)
562 },
563 validate_status,
564 "Upload finished without final response",
565 )
566 .await
567 }
568
569 async fn upload_reader(
570 &self,
571 upload_url: &str,
572 reader: &mut tokio::fs::File,
573 total_size: u64,
574 http_options: Option<&rust_genai_types::http::HttpOptions>,
575 ) -> Result<File> {
576 let validate_status = |status: &str| {
577 if status != "active" {
578 return Err(Error::Parse {
579 message: format!("Unexpected upload status: {status}"),
580 });
581 }
582 Ok(())
583 };
584
585 upload::upload_reader_with(
586 reader,
587 total_size,
588 |chunk, offset, finalize| {
589 self.send_upload_chunk(upload_url, chunk, offset, finalize, http_options)
590 },
591 validate_status,
592 "Upload finished without final response",
593 )
594 .await
595 }
596
597 async fn send_upload_chunk(
598 &self,
599 upload_url: &str,
600 chunk: Vec<u8>,
601 offset: u64,
602 finalize: bool,
603 http_options: Option<&rust_genai_types::http::HttpOptions>,
604 ) -> Result<(String, Option<File>)> {
605 let command = if finalize {
606 "upload, finalize"
607 } else {
608 "upload"
609 };
610 let chunk_len = chunk.len();
611 let mut request = self.inner.http.post(upload_url);
612 request = apply_http_options(request, http_options)?;
613 let request = request
614 .header("X-Goog-Upload-Command", command)
615 .header("X-Goog-Upload-Offset", offset.to_string())
616 .header("Content-Length", chunk_len.to_string())
617 .body(chunk);
618
619 let response = self
620 .inner
621 .send_with_http_options(request, http_options)
622 .await?;
623
624 if !response.status().is_success() {
625 return Err(Error::ApiError {
626 status: response.status().as_u16(),
627 message: response.text().await.unwrap_or_default(),
628 });
629 }
630
631 let upload_status = response
632 .headers()
633 .get("x-goog-upload-status")
634 .and_then(|value| value.to_str().ok())
635 .ok_or_else(|| Error::Parse {
636 message: "Missing x-goog-upload-status header".into(),
637 })?
638 .to_string();
639
640 let body = response.bytes().await?;
641 if body.is_empty() {
642 return Ok((upload_status, None));
643 }
644
645 let value: Value = serde_json::from_slice(&body)?;
646 let file_value = value.get("file").cloned().unwrap_or(value);
647 let file: File = serde_json::from_value(file_value)?;
648
649 Ok((upload_status, Some(file)))
650 }
651}
652
653#[derive(Debug, Clone)]
654pub struct WaitForFileConfig {
655 pub poll_interval: Duration,
656 pub timeout: Option<Duration>,
657}
658
659impl Default for WaitForFileConfig {
660 fn default() -> Self {
661 Self {
662 poll_interval: Duration::from_secs(2),
663 timeout: Some(Duration::from_secs(300)),
664 }
665 }
666}
667
668#[cfg(test)]
669fn finalize_upload(status: &str, file: Option<File>) -> Result<File> {
670 upload::finalize_upload(status, file)
671}
672
673fn ensure_gemini_backend(inner: &ClientInner) -> Result<()> {
674 if inner.config.backend == Backend::VertexAi {
675 return Err(Error::InvalidConfig {
676 message: "Files API is only supported in Gemini API".into(),
677 });
678 }
679 Ok(())
680}
681
682fn build_upload_file(config: UploadFileConfig, size_bytes: u64, mime_type: &str) -> File {
683 let mut file = File::default();
684 if let Some(name) = config.name {
685 file.name = Some(normalize_upload_name(&name));
686 }
687 file.display_name = config.display_name;
688 file.mime_type = Some(mime_type.to_string());
689 file.size_bytes = Some(size_bytes.to_string());
690 file
691}
692
693fn normalize_upload_name(name: &str) -> String {
694 if name.starts_with("files/") {
695 name.to_string()
696 } else {
697 format!("files/{name}")
698 }
699}
700
701fn normalize_file_name(value: &str) -> Result<String> {
702 if value.starts_with("http://") || value.starts_with("https://") {
703 let marker = "files/";
704 let start = value.find(marker).ok_or_else(|| Error::InvalidConfig {
705 message: format!("Could not find 'files/' in URI: {value}"),
706 })?;
707 let suffix = &value[start + marker.len()..];
708 let name: String = suffix
709 .chars()
710 .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
711 .collect();
712 if name.is_empty() {
713 return Err(Error::InvalidConfig {
714 message: format!("Could not extract file name from URI: {value}"),
715 });
716 }
717 Ok(name)
718 } else if value.starts_with("files/") {
719 Ok(value.trim_start_matches("files/").to_string())
720 } else {
721 Ok(value.to_string())
722 }
723}
724
725fn build_files_upload_url(
726 inner: &ClientInner,
727 http_options: Option<&rust_genai_types::http::HttpOptions>,
728) -> String {
729 let base = http_options
730 .and_then(|opts| opts.base_url.as_deref())
731 .unwrap_or(&inner.api_client.base_url);
732 let version = http_options
733 .and_then(|opts| opts.api_version.as_deref())
734 .unwrap_or(&inner.api_client.api_version);
735 format!("{base}upload/{version}/files")
736}
737
738fn build_files_list_url(
739 inner: &ClientInner,
740 config: &ListFilesConfig,
741 http_options: Option<&rust_genai_types::http::HttpOptions>,
742) -> Result<String> {
743 let base = http_options
744 .and_then(|opts| opts.base_url.as_deref())
745 .unwrap_or(&inner.api_client.base_url);
746 let version = http_options
747 .and_then(|opts| opts.api_version.as_deref())
748 .unwrap_or(&inner.api_client.api_version);
749 let url = format!("{base}{version}/files");
750 add_list_query_params(&url, config)
751}
752
753fn build_files_register_url(
754 inner: &ClientInner,
755 http_options: Option<&rust_genai_types::http::HttpOptions>,
756) -> String {
757 let base = http_options
758 .and_then(|opts| opts.base_url.as_deref())
759 .unwrap_or(&inner.api_client.base_url);
760 let version = http_options
761 .and_then(|opts| opts.api_version.as_deref())
762 .unwrap_or(&inner.api_client.api_version);
763 format!("{base}{version}/files:register")
764}
765
766fn build_file_url(
767 inner: &ClientInner,
768 name: &str,
769 http_options: Option<&rust_genai_types::http::HttpOptions>,
770) -> String {
771 let base = http_options
772 .and_then(|opts| opts.base_url.as_deref())
773 .unwrap_or(&inner.api_client.base_url);
774 let version = http_options
775 .and_then(|opts| opts.api_version.as_deref())
776 .unwrap_or(&inner.api_client.api_version);
777 format!("{base}{version}/files/{name}")
778}
779
780fn build_file_download_url(
781 inner: &ClientInner,
782 name: &str,
783 http_options: Option<&rust_genai_types::http::HttpOptions>,
784) -> String {
785 let base = http_options
786 .and_then(|opts| opts.base_url.as_deref())
787 .unwrap_or(&inner.api_client.base_url);
788 let version = http_options
789 .and_then(|opts| opts.api_version.as_deref())
790 .unwrap_or(&inner.api_client.api_version);
791 format!("{base}{version}/files/{name}:download?alt=media")
792}
793
794fn add_list_query_params(url: &str, config: &ListFilesConfig) -> Result<String> {
795 let mut url = reqwest::Url::parse(url).map_err(|err| Error::InvalidConfig {
796 message: err.to_string(),
797 })?;
798 {
799 let mut pairs = url.query_pairs_mut();
800 if let Some(page_size) = config.page_size {
801 pairs.append_pair("pageSize", &page_size.to_string());
802 }
803 if let Some(page_token) = &config.page_token {
804 pairs.append_pair("pageToken", page_token);
805 }
806 }
807 Ok(url.to_string())
808}
809
810fn apply_http_options(
811 mut request: reqwest::RequestBuilder,
812 http_options: Option<&rust_genai_types::http::HttpOptions>,
813) -> Result<reqwest::RequestBuilder> {
814 if let Some(options) = http_options {
815 if let Some(timeout) = options.timeout {
816 request = request.timeout(Duration::from_millis(timeout));
817 }
818 if let Some(headers) = &options.headers {
819 for (key, value) in headers {
820 let name =
821 HeaderName::from_bytes(key.as_bytes()).map_err(|_| Error::InvalidConfig {
822 message: format!("Invalid header name: {key}"),
823 })?;
824 let value = HeaderValue::from_str(value).map_err(|_| Error::InvalidConfig {
825 message: format!("Invalid header value for {key}"),
826 })?;
827 request = request.header(name, value);
828 }
829 }
830 }
831 Ok(request)
832}
833
834fn merge_extra_body(
835 body: &mut Value,
836 http_options: &rust_genai_types::http::HttpOptions,
837) -> Result<()> {
838 if let Some(extra) = &http_options.extra_body {
839 match (body, extra) {
840 (Value::Object(body_map), Value::Object(extra_map)) => {
841 for (key, value) in extra_map {
842 body_map.insert(key.clone(), value.clone());
843 }
844 }
845 (_, _) => {
846 return Err(Error::InvalidConfig {
847 message: "HttpOptions.extra_body must be an object".into(),
848 });
849 }
850 }
851 }
852 Ok(())
853}
854
855#[cfg(test)]
856mod tests {
857 use super::*;
858 use crate::client::Client;
859 use crate::test_support::test_client_inner;
860 use serde_json::json;
861 use std::collections::HashMap;
862 use wiremock::matchers::{method, path};
863 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
864
865 #[test]
866 fn test_normalize_file_name() {
867 assert_eq!(normalize_file_name("files/abc-123").unwrap(), "abc-123");
868 assert_eq!(normalize_file_name("abc-123").unwrap(), "abc-123");
869 assert_eq!(
870 normalize_file_name("https://example.com/files/abc-123?foo=bar").unwrap(),
871 "abc-123"
872 );
873 }
874
875 #[test]
876 fn test_build_urls() {
877 let client = Client::new("test-key").unwrap();
878 let files = client.files();
879 let url = build_files_upload_url(&files.inner, None);
880 assert_eq!(
881 url,
882 "https://generativelanguage.googleapis.com/upload/v1beta/files"
883 );
884 let url = build_files_register_url(&files.inner, None);
885 assert_eq!(
886 url,
887 "https://generativelanguage.googleapis.com/v1beta/files:register"
888 );
889 }
890
891 #[test]
892 fn test_normalize_upload_and_list_params() {
893 assert_eq!(normalize_upload_name("files/abc"), "files/abc");
894 assert_eq!(normalize_upload_name("abc"), "files/abc");
895 assert!(normalize_file_name("https://example.com/no-files").is_err());
896 assert!(normalize_file_name("https://example.com/files/?x").is_err());
897
898 let url = add_list_query_params(
899 "https://example.com/files",
900 &ListFilesConfig {
901 http_options: None,
902 page_size: Some(3),
903 page_token: Some("t".to_string()),
904 },
905 )
906 .unwrap();
907 assert!(url.contains("pageSize=3"));
908 assert!(url.contains("pageToken=t"));
909 }
910
911 #[test]
912 fn test_build_upload_file_and_finalize_errors() {
913 let file = build_upload_file(
914 UploadFileConfig {
915 name: Some("abc".to_string()),
916 display_name: Some("d".to_string()),
917 ..Default::default()
918 },
919 5,
920 "text/plain",
921 );
922 assert_eq!(file.name.as_deref(), Some("files/abc"));
923 assert_eq!(file.size_bytes.as_deref(), Some("5"));
924
925 let err = finalize_upload("active", None).unwrap_err();
926 assert!(matches!(err, Error::Parse { .. }));
927 let err = finalize_upload("final", None).unwrap_err();
928 assert!(matches!(err, Error::Parse { .. }));
929 }
930
931 #[test]
932 fn test_ensure_gemini_backend_error() {
933 let vertex = test_client_inner(Backend::VertexAi);
934 let err = ensure_gemini_backend(&vertex).unwrap_err();
935 assert!(matches!(err, Error::InvalidConfig { .. }));
936 }
937
938 #[tokio::test]
939 async fn test_start_resumable_upload_and_send_chunk_errors() {
940 let server = MockServer::start().await;
941 Mock::given(method("POST"))
942 .and(path("/upload/v1beta/files"))
943 .respond_with(ResponseTemplate::new(200))
944 .mount(&server)
945 .await;
946
947 let client = Client::builder()
948 .api_key("test-key")
949 .base_url(server.uri())
950 .build()
951 .unwrap();
952 let files = client.files();
953 let file = build_upload_file(UploadFileConfig::default(), 1, "text/plain");
954 let err = files
955 .start_resumable_upload(file, Some(1), "text/plain", None, None)
956 .await
957 .unwrap_err();
958 assert!(matches!(err, Error::Parse { .. }));
959
960 Mock::given(method("POST"))
961 .and(path("/upload-chunk"))
962 .respond_with(ResponseTemplate::new(200))
963 .mount(&server)
964 .await;
965 let err = files
966 .send_upload_chunk(
967 &format!("{}/upload-chunk", server.uri()),
968 Vec::new(),
969 0,
970 true,
971 None,
972 )
973 .await
974 .unwrap_err();
975 assert!(matches!(err, Error::Parse { .. }));
976
977 Mock::given(method("POST"))
978 .and(path("/upload-fail"))
979 .respond_with(ResponseTemplate::new(400).set_body_string("bad"))
980 .mount(&server)
981 .await;
982 let err = files
983 .send_upload_chunk(
984 &format!("{}/upload-fail", server.uri()),
985 Vec::new(),
986 0,
987 true,
988 None,
989 )
990 .await
991 .unwrap_err();
992 assert!(matches!(err, Error::ApiError { .. }));
993 }
994
995 #[tokio::test]
996 async fn test_files_upload_errors() {
997 let client = Client::new("test-key").unwrap();
998 let files = client.files();
999
1000 let err = files
1001 .upload_with_config(vec![1, 2, 3], UploadFileConfig::default())
1002 .await
1003 .unwrap_err();
1004 assert!(matches!(err, Error::InvalidConfig { .. }));
1005
1006 let temp_dir = std::env::temp_dir().join("rust_genai_files_test_dir");
1007 let _ = tokio::fs::create_dir_all(&temp_dir).await;
1008 let err = files
1009 .upload_from_path_with_config(&temp_dir, UploadFileConfig::default())
1010 .await
1011 .unwrap_err();
1012 assert!(matches!(err, Error::InvalidConfig { .. }));
1013 let _ = tokio::fs::remove_dir_all(&temp_dir).await;
1014 }
1015
1016 #[tokio::test]
1017 async fn test_files_create_resumable_upload_sets_sdk_http_response() {
1018 let server = MockServer::start().await;
1019 Mock::given(method("POST"))
1020 .and(path("/upload/v1beta/files"))
1021 .respond_with(
1022 ResponseTemplate::new(200)
1023 .insert_header("x-goog-upload-url", format!("{}/upload-url", server.uri()))
1024 .set_body_string("raw-body"),
1025 )
1026 .mount(&server)
1027 .await;
1028
1029 let client = Client::builder()
1030 .api_key("test-key")
1031 .base_url(server.uri())
1032 .build()
1033 .unwrap();
1034 let files = client.files();
1035 let file = File {
1036 mime_type: Some("text/plain".to_string()),
1037 size_bytes: Some("3".to_string()),
1038 ..Default::default()
1039 };
1040
1041 let response = files
1042 .create_with_config(
1043 file,
1044 CreateFileConfig {
1045 http_options: Some(rust_genai_types::http::HttpOptions {
1046 headers: Some(HashMap::from([("x-test".to_string(), "1".to_string())])),
1047 extra_body: Some(json!({"extra": "value"})),
1048 ..Default::default()
1049 }),
1050 should_return_http_response: Some(true),
1051 },
1052 )
1053 .await
1054 .unwrap();
1055
1056 let sdk = response.sdk_http_response.unwrap();
1057 let headers = sdk.headers.unwrap();
1058 assert!(headers.contains_key("x-goog-upload-url"));
1059 assert_eq!(sdk.body.as_deref(), Some("raw-body"));
1060
1061 let received = server.received_requests().await.unwrap();
1062 let body = String::from_utf8_lossy(&received[0].body);
1063 assert!(body.contains(r#""extra":"value""#));
1064 assert!(received[0].headers.get("x-test").is_some());
1065 }
1066
1067 #[tokio::test]
1068 async fn test_start_resumable_upload_error_response() {
1069 let server = MockServer::start().await;
1070 Mock::given(method("POST"))
1071 .and(path("/upload/v1beta/files"))
1072 .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
1073 .mount(&server)
1074 .await;
1075
1076 let client = Client::builder()
1077 .api_key("test-key")
1078 .base_url(server.uri())
1079 .build()
1080 .unwrap();
1081 let files = client.files();
1082 let file = build_upload_file(UploadFileConfig::default(), 1, "text/plain");
1083 let err = files
1084 .start_resumable_upload(file, Some(1), "text/plain", None, None)
1085 .await
1086 .unwrap_err();
1087 assert!(matches!(err, Error::ApiError { .. }));
1088 }
1089
1090 #[tokio::test]
1091 async fn test_upload_bytes_empty_and_status_errors() {
1092 let server = MockServer::start().await;
1093
1094 Mock::given(method("POST"))
1095 .and(path("/upload-empty"))
1096 .respond_with(
1097 ResponseTemplate::new(200)
1098 .insert_header("x-goog-upload-status", "final")
1099 .set_body_json(json!({
1100 "file": {"name": "files/empty", "state": "ACTIVE"}
1101 })),
1102 )
1103 .mount(&server)
1104 .await;
1105
1106 Mock::given(method("POST"))
1107 .and(path("/upload-bad"))
1108 .respond_with(
1109 ResponseTemplate::new(200).insert_header("x-goog-upload-status", "paused"),
1110 )
1111 .mount(&server)
1112 .await;
1113
1114 let client = Client::builder()
1115 .api_key("test-key")
1116 .base_url(server.uri())
1117 .build()
1118 .unwrap();
1119 let files = client.files();
1120
1121 let file = files
1122 .upload_bytes(&format!("{}/upload-empty", server.uri()), &[], None)
1123 .await
1124 .unwrap();
1125 assert_eq!(file.name.as_deref(), Some("files/empty"));
1126
1127 let data = vec![0u8; CHUNK_SIZE + 1];
1128 let err = files
1129 .upload_bytes(&format!("{}/upload-bad", server.uri()), &data, None)
1130 .await
1131 .unwrap_err();
1132 assert!(matches!(err, Error::Parse { .. }));
1133 }
1134
1135 #[tokio::test]
1136 async fn test_upload_reader_empty_file() {
1137 let server = MockServer::start().await;
1138 Mock::given(method("POST"))
1139 .and(path("/upload-empty-file"))
1140 .respond_with(
1141 ResponseTemplate::new(200)
1142 .insert_header("x-goog-upload-status", "final")
1143 .set_body_json(json!({
1144 "file": {"name": "files/empty-file", "state": "ACTIVE"}
1145 })),
1146 )
1147 .mount(&server)
1148 .await;
1149
1150 let client = Client::builder()
1151 .api_key("test-key")
1152 .base_url(server.uri())
1153 .build()
1154 .unwrap();
1155 let files = client.files();
1156 let temp_path = std::env::temp_dir().join("rust_genai_empty_upload_file");
1157 let _ = tokio::fs::write(&temp_path, &[]).await;
1158 let mut handle = tokio::fs::File::open(&temp_path).await.unwrap();
1159
1160 let file = files
1161 .upload_reader(
1162 &format!("{}/upload-empty-file", server.uri()),
1163 &mut handle,
1164 0,
1165 None,
1166 )
1167 .await
1168 .unwrap();
1169 assert_eq!(file.name.as_deref(), Some("files/empty-file"));
1170 let _ = tokio::fs::remove_file(&temp_path).await;
1171 }
1172
1173 #[tokio::test]
1174 async fn test_upload_bytes_and_reader_active_then_final() {
1175 #[derive(Clone)]
1176 struct UploadResponder;
1177
1178 impl Respond for UploadResponder {
1179 fn respond(&self, request: &Request) -> ResponseTemplate {
1180 let finalize = request
1181 .headers
1182 .get("x-goog-upload-command")
1183 .and_then(|value| value.to_str().ok())
1184 .is_some_and(|value| value.contains("finalize"));
1185 if finalize {
1186 ResponseTemplate::new(200)
1187 .insert_header("x-goog-upload-status", "final")
1188 .set_body_json(json!({
1189 "file": {"name": "files/final", "state": "ACTIVE"}
1190 }))
1191 } else {
1192 ResponseTemplate::new(200).insert_header("x-goog-upload-status", "active")
1193 }
1194 }
1195 }
1196
1197 let server = MockServer::start().await;
1198 Mock::given(method("POST"))
1199 .and(path("/upload-active"))
1200 .respond_with(UploadResponder)
1201 .mount(&server)
1202 .await;
1203
1204 let client = Client::builder()
1205 .api_key("test-key")
1206 .base_url(server.uri())
1207 .build()
1208 .unwrap();
1209 let files = client.files();
1210 let data = vec![0u8; CHUNK_SIZE + 1];
1211 let file = files
1212 .upload_bytes(&format!("{}/upload-active", server.uri()), &data, None)
1213 .await
1214 .unwrap();
1215 assert_eq!(file.name.as_deref(), Some("files/final"));
1216
1217 Mock::given(method("POST"))
1218 .and(path("/upload-reader"))
1219 .respond_with(UploadResponder)
1220 .mount(&server)
1221 .await;
1222 let temp_path = std::env::temp_dir().join("rust_genai_reader_active");
1223 let _ = tokio::fs::write(&temp_path, vec![0u8; CHUNK_SIZE + 1]).await;
1224 let mut handle = tokio::fs::File::open(&temp_path).await.unwrap();
1225 let file = files
1226 .upload_reader(
1227 &format!("{}/upload-reader", server.uri()),
1228 &mut handle,
1229 (CHUNK_SIZE + 1) as u64,
1230 None,
1231 )
1232 .await
1233 .unwrap();
1234 assert_eq!(file.name.as_deref(), Some("files/final"));
1235 let _ = tokio::fs::remove_file(&temp_path).await;
1236 }
1237
1238 #[tokio::test]
1239 async fn test_upload_with_config_and_mime_guess() {
1240 let server = MockServer::start().await;
1241 Mock::given(method("POST"))
1242 .and(path("/upload/v1beta/files"))
1243 .respond_with(
1244 ResponseTemplate::new(200)
1245 .insert_header("x-goog-upload-url", format!("{}/upload-ok", server.uri())),
1246 )
1247 .mount(&server)
1248 .await;
1249 Mock::given(method("POST"))
1250 .and(path("/upload-ok"))
1251 .respond_with(
1252 ResponseTemplate::new(200)
1253 .insert_header("x-goog-upload-status", "final")
1254 .set_body_json(json!({
1255 "file": {"name": "files/ok", "state": "ACTIVE"}
1256 })),
1257 )
1258 .mount(&server)
1259 .await;
1260
1261 let client = Client::builder()
1262 .api_key("test-key")
1263 .base_url(server.uri())
1264 .build()
1265 .unwrap();
1266 let files = client.files();
1267 let file = files.upload(vec![1, 2, 3], "text/plain").await.unwrap();
1268 assert_eq!(file.name.as_deref(), Some("files/ok"));
1269
1270 let temp_path = std::env::temp_dir().join("rust_genai_upload_guess.txt");
1271 let _ = tokio::fs::write(&temp_path, b"hello").await;
1272 let file = files
1273 .upload_from_path_with_config(&temp_path, UploadFileConfig::default())
1274 .await
1275 .unwrap();
1276 assert_eq!(file.name.as_deref(), Some("files/ok"));
1277 let _ = tokio::fs::remove_file(&temp_path).await;
1278 }
1279
1280 #[tokio::test]
1281 async fn test_wait_for_active_timeout_after_sleep() {
1282 let server = MockServer::start().await;
1283 Mock::given(method("GET"))
1284 .and(path("/v1beta/files/slow"))
1285 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1286 "name": "files/slow",
1287 "state": "PROCESSING"
1288 })))
1289 .mount(&server)
1290 .await;
1291
1292 let client = Client::builder()
1293 .api_key("test-key")
1294 .base_url(server.uri())
1295 .build()
1296 .unwrap();
1297 let files = client.files();
1298 let err = files
1299 .wait_for_active(
1300 "slow",
1301 WaitForFileConfig {
1302 poll_interval: Duration::from_millis(1),
1303 timeout: Some(Duration::from_millis(2)),
1304 },
1305 )
1306 .await
1307 .unwrap_err();
1308 assert!(matches!(err, Error::Timeout { .. }));
1309 }
1310
1311 #[test]
1312 fn test_add_list_query_params_invalid_url() {
1313 let err = add_list_query_params("http://[::1", &ListFilesConfig::default()).unwrap_err();
1314 assert!(matches!(err, Error::InvalidConfig { .. }));
1315 }
1316}