Skip to main content

rust_genai/
files.rs

1//! Files API surface.
2
3use 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    /// 上传文件(直接上传字节数据)。
37    ///
38    /// # Errors
39    /// 当配置无效、请求失败或响应解析失败时返回错误。
40    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    /// 初始化一个文件的 resumable upload,并返回原始 HTTP headers(含 `x-goog-upload-url`)。
49    ///
50    /// 该方法只执行 `start` 请求,不会上传文件内容。
51    ///
52    /// # Errors
53    /// 当配置无效、请求失败或响应解析失败时返回错误。
54    pub async fn create(&self, file: File) -> Result<CreateFileResponse> {
55        self.create_with_config(file, CreateFileConfig::default())
56            .await
57    }
58
59    /// 初始化一个文件的 resumable upload(自定义配置)。
60    ///
61    /// # Errors
62    /// 当配置无效、请求失败或响应解析失败时返回错误。
63    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    /// 上传文件(自定义配置)。
106    ///
107    /// # Errors
108    /// 当配置无效、请求失败或响应解析失败时返回错误。
109    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    /// 从文件路径上传。
139    ///
140    /// # Errors
141    /// 当文件无效、请求失败或响应解析失败时返回错误。
142    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    /// 从文件路径上传(自定义配置)。
148    ///
149    /// # Errors
150    /// 当文件无效、请求失败或响应解析失败时返回错误。
151    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    /// 下载文件(返回字节内容)。
197    ///
198    /// # Errors
199    /// 当请求失败或响应解析失败时返回错误。
200    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    /// 下载文件(自定义配置)。
206    ///
207    /// # Errors
208    /// 当请求失败或响应解析失败时返回错误。
209    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    /// 列出文件。
236    ///
237    /// # Errors
238    /// 当请求失败或响应解析失败时返回错误。
239    pub async fn list(&self) -> Result<ListFilesResponse> {
240        self.list_with_config(ListFilesConfig::default()).await
241    }
242
243    /// 列出文件(自定义配置)。
244    ///
245    /// # Errors
246    /// 当请求失败或响应解析失败时返回错误。
247    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    /// 列出所有文件(自动翻页)。
270    ///
271    /// # Errors
272    /// 当请求失败或响应解析失败时返回错误。
273    pub async fn all(&self) -> Result<Vec<File>> {
274        self.all_with_config(ListFilesConfig::default()).await
275    }
276
277    /// 列出所有文件(带配置,自动翻页)。
278    ///
279    /// # Errors
280    /// 当请求失败或响应解析失败时返回错误。
281    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    /// 获取文件元数据。
299    ///
300    /// # Errors
301    /// 当请求失败或响应解析失败时返回错误。
302    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    /// 获取文件元数据(自定义配置)。
308    ///
309    /// # Errors
310    /// 当请求失败或响应解析失败时返回错误。
311    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    /// 删除文件。
337    ///
338    /// # Errors
339    /// 当请求失败或响应解析失败时返回错误。
340    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    /// 删除文件(自定义配置)。
346    ///
347    /// # Errors
348    /// 当请求失败或响应解析失败时返回错误。
349    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    /// 注册 Google Cloud Storage 文件(使其可用于 Gemini Developer API)。
383    ///
384    /// 该方法要求客户端使用 OAuth/ADC(即 `Credentials::OAuth` 或 `Credentials::ApplicationDefault`),
385    /// **不支持** API key 认证。
386    ///
387    /// # Errors
388    /// 当配置无效、请求失败或响应解析失败时返回错误。
389    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    /// 注册 Google Cloud Storage 文件(自定义配置)。
395    ///
396    /// # Errors
397    /// 当配置无效、请求失败或响应解析失败时返回错误。
398    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    /// 轮询直到文件状态变为 ACTIVE。
452    ///
453    /// # Errors
454    /// 当请求失败、文件失败或超时返回错误。
455    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}