Skip to main content

scirs2_io/network/
http.rs

1//! HTTP client functionality for network I/O operations
2//!
3//! This module provides HTTP/HTTPS capabilities for downloading and uploading files,
4//! with support for streaming, authentication, retries, and caching.
5
6use crate::error::{IoError, Result};
7use crate::network::NetworkConfig;
8use std::collections::HashMap;
9use std::path::Path;
10use std::time::Duration;
11#[cfg(feature = "reqwest")]
12use std::time::Instant;
13
14/// HTTP request method
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub enum HttpMethod {
17    /// GET method
18    GET,
19    /// POST method
20    POST,
21    /// PUT method
22    PUT,
23    /// DELETE method
24    DELETE,
25    /// HEAD method
26    HEAD,
27}
28
29impl std::fmt::Display for HttpMethod {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            HttpMethod::GET => write!(f, "GET"),
33            HttpMethod::POST => write!(f, "POST"),
34            HttpMethod::PUT => write!(f, "PUT"),
35            HttpMethod::DELETE => write!(f, "DELETE"),
36            HttpMethod::HEAD => write!(f, "HEAD"),
37        }
38    }
39}
40
41/// HTTP response information
42#[derive(Debug, Clone)]
43pub struct HttpResponse {
44    /// HTTP status code
45    pub status: u16,
46    /// Response headers
47    pub headers: HashMap<String, String>,
48    /// Content length if available
49    pub content_length: Option<u64>,
50    /// Content type if available
51    pub content_type: Option<String>,
52    /// Response body as bytes
53    pub body: Vec<u8>,
54}
55
56/// Progress callback for long-running operations
57pub type ProgressCallback = Box<dyn Fn(u64, Option<u64>) + Send + Sync>;
58
59/// HTTP client for network operations
60///
61/// # TLS crypto provider
62///
63/// When built with the `reqwest` feature, [`HttpClient::new`] auto-calls [`HttpClient::init`],
64/// which builds a [`reqwest::Client`] eagerly -- this constructs rustls' `ClientConfig` inside
65/// `.build()` even for a client that never issues an HTTPS request. Since the workspace's
66/// `reqwest` dependency uses `rustls-no-provider` (no aws-lc-rs/ring bundled -- see the
67/// "PURE RUST BLOCKER (reqwest)" note in the workspace root `Cargo.toml`), this crate installs
68/// the pure-Rust OxiTLS provider (`oxitls-rustcrypto-provider`, COOLJAPAN's RUSTSEC-2026-0104-fixed
69/// fork of the abandoned upstream `rustls-rustcrypto` 0.0.2-alpha, resolved under the
70/// `rustls-rustcrypto` name) as the process-default rustls `CryptoProvider` automatically,
71/// right before the first client is constructed -- unless the application has already installed
72/// a provider of its own. Applications that want a different provider (e.g. a C-linked
73/// aws-lc-rs/ring one) simply call `rustls::crypto::CryptoProvider::install_default(...)`
74/// before the first use of SciRS2 networking; an existing process default is never overridden.
75#[derive(Debug)]
76pub struct HttpClient {
77    #[allow(dead_code)]
78    config: NetworkConfig,
79    #[cfg(feature = "reqwest")]
80    client: Option<reqwest::Client>,
81}
82
83impl HttpClient {
84    /// Create a new HTTP client with the given configuration
85    pub fn new(config: NetworkConfig) -> Self {
86        let mut client = Self {
87            config,
88            #[cfg(feature = "reqwest")]
89            client: None,
90        };
91
92        // Auto-initialize if reqwest feature is enabled
93        #[cfg(feature = "reqwest")]
94        {
95            let _ = client.init();
96        }
97
98        client
99    }
100
101    /// Initialize the HTTP client (creates underlying reqwest client)
102    #[cfg(feature = "reqwest")]
103    pub fn init(&mut self) -> Result<()> {
104        crate::tls::ensure_default_tls_provider();
105        let mut client_builder = reqwest::Client::builder()
106            .connect_timeout(self.config.connect_timeout)
107            .timeout(self.config.read_timeout)
108            .user_agent(&self.config.user_agent);
109
110        // Add default headers
111        let mut headers = reqwest::header::HeaderMap::new();
112        for (key, value) in &self.config.headers {
113            if let (Ok(header_name), Ok(header_value)) = (
114                reqwest::header::HeaderName::from_bytes(key.as_bytes()),
115                reqwest::header::HeaderValue::from_str(value),
116            ) {
117                headers.insert(header_name, header_value);
118            }
119        }
120        client_builder = client_builder.default_headers(headers);
121
122        self.client =
123            Some(client_builder.build().map_err(|e| {
124                IoError::NetworkError(format!("Failed to create HTTP client: {}", e))
125            })?);
126
127        Ok(())
128    }
129
130    /// Download a file from URL to local path
131    #[cfg(all(feature = "reqwest", feature = "async"))]
132    pub async fn download<P: AsRef<Path>>(&self, url: &str, localpath: P) -> Result<()> {
133        let client = self.get_client()?;
134        let local_path = localpath.as_ref();
135
136        // Create parent directories if they don't exist
137        if let Some(parent) = local_path.parent() {
138            std::fs::create_dir_all(parent)
139                .map_err(|e| IoError::FileError(format!("Failed to create directory: {}", e)))?;
140        }
141
142        let mut retries = 0;
143        loop {
144            let start_time = Instant::now();
145
146            match self.download_with_retry(client, url, local_path).await {
147                Ok(_) => {
148                    let duration = start_time.elapsed();
149                    log::info!("Downloaded {} in {:.2}s", url, duration.as_secs_f64());
150                    return Ok(());
151                }
152                Err(e) => {
153                    retries += 1;
154                    if retries > self.config.max_retries {
155                        return Err(e);
156                    }
157
158                    let delay = Duration::from_millis(100 * 2_u64.pow(retries - 1));
159                    log::warn!(
160                        "Download failed, retrying in {}ms: {}",
161                        delay.as_millis(),
162                        e
163                    );
164                    #[cfg(feature = "async")]
165                    tokio::time::sleep(delay).await;
166                }
167            }
168        }
169    }
170
171    #[cfg(all(feature = "reqwest", feature = "async"))]
172    async fn download_with_retry(
173        &self,
174        client: &reqwest::Client,
175        url: &str,
176        local_path: &Path,
177    ) -> Result<()> {
178        let response = client
179            .get(url)
180            .send()
181            .await
182            .map_err(|e| IoError::NetworkError(format!("HTTP request failed: {}", e)))?;
183
184        if !response.status().is_success() {
185            return Err(IoError::NetworkError(format!(
186                "HTTP error {}: {}",
187                response.status().as_u16(),
188                response
189                    .status()
190                    .canonical_reason()
191                    .unwrap_or("Unknown error")
192            )));
193        }
194
195        let content_length = response.content_length();
196
197        let mut file = std::fs::File::create(local_path)
198            .map_err(|e| IoError::FileError(format!("Failed to create file: {}", e)))?;
199
200        use std::io::Write;
201
202        let bytes = response
203            .bytes()
204            .await
205            .map_err(|e| IoError::NetworkError(format!("Failed to read response body: {}", e)))?;
206
207        file.write_all(&bytes)
208            .map_err(|e| IoError::FileError(format!("Failed to write file: {}", e)))?;
209
210        let downloaded = bytes.len() as u64;
211
212        // Progress reporting could be added here
213        if let Some(total) = content_length {
214            let progress = (downloaded as f64 / total as f64 * 100.0) as u8;
215            log::debug!("Download progress: {}%", progress);
216        }
217
218        Ok(())
219    }
220
221    /// Upload a file from local path to URL
222    #[cfg(all(feature = "reqwest", feature = "async"))]
223    pub async fn upload<P: AsRef<Path>>(&self, localpath: P, url: &str) -> Result<()> {
224        let client = self.get_client()?;
225        let local_path = localpath.as_ref();
226
227        if !local_path.exists() {
228            return Err(IoError::FileError(format!(
229                "File does not exist: {}",
230                local_path.display()
231            )));
232        }
233
234        let file_content = std::fs::read(local_path)
235            .map_err(|e| IoError::FileError(format!("Failed to read file: {}", e)))?;
236
237        let mut retries = 0;
238        loop {
239            let start_time = Instant::now();
240
241            match self.upload_with_retry(client, &file_content, url).await {
242                Ok(_) => {
243                    let duration = start_time.elapsed();
244                    log::info!(
245                        "Uploaded {} in {:.2}s",
246                        local_path.display(),
247                        duration.as_secs_f64()
248                    );
249                    return Ok(());
250                }
251                Err(e) => {
252                    retries += 1;
253                    if retries > self.config.max_retries {
254                        return Err(e);
255                    }
256
257                    let delay = Duration::from_millis(100 * 2_u64.pow(retries - 1));
258                    log::warn!("Upload failed, retrying in {}ms: {}", delay.as_millis(), e);
259                    #[cfg(feature = "async")]
260                    tokio::time::sleep(delay).await;
261                }
262            }
263        }
264    }
265
266    #[cfg(all(feature = "reqwest", feature = "async"))]
267    async fn upload_with_retry(
268        &self,
269        client: &reqwest::Client,
270        content: &[u8],
271        url: &str,
272    ) -> Result<()> {
273        let response = client
274            .put(url)
275            .body(content.to_vec())
276            .send()
277            .await
278            .map_err(|e| IoError::NetworkError(format!("HTTP upload failed: {}", e)))?;
279
280        if !response.status().is_success() {
281            return Err(IoError::NetworkError(format!(
282                "HTTP upload error {}: {}",
283                response.status().as_u16(),
284                response
285                    .status()
286                    .canonical_reason()
287                    .unwrap_or("Unknown error")
288            )));
289        }
290
291        Ok(())
292    }
293
294    /// Make a custom HTTP request
295    #[cfg(all(feature = "reqwest", feature = "async"))]
296    pub async fn request(
297        &self,
298        method: HttpMethod,
299        url: &str,
300        body: Option<&[u8]>,
301    ) -> Result<HttpResponse> {
302        let client = self.get_client()?;
303
304        let mut request_builder = match method {
305            HttpMethod::GET => client.get(url),
306            HttpMethod::POST => client.post(url),
307            HttpMethod::PUT => client.put(url),
308            HttpMethod::DELETE => client.delete(url),
309            HttpMethod::HEAD => client.head(url),
310        };
311
312        if let Some(body_data) = body {
313            request_builder = request_builder.body(body_data.to_vec());
314        }
315
316        let response = request_builder
317            .send()
318            .await
319            .map_err(|e| IoError::NetworkError(format!("HTTP request failed: {}", e)))?;
320
321        let status = response.status().as_u16();
322        let headers = response
323            .headers()
324            .iter()
325            .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
326            .collect();
327
328        let content_length = response.content_length();
329        let content_type = response
330            .headers()
331            .get(reqwest::header::CONTENT_TYPE)
332            .and_then(|v| v.to_str().ok())
333            .map(|s| s.to_string());
334
335        let body = response
336            .bytes()
337            .await
338            .map_err(|e| IoError::NetworkError(format!("Failed to read response body: {}", e)))?
339            .to_vec();
340
341        Ok(HttpResponse {
342            status,
343            headers,
344            content_length,
345            content_type,
346            body,
347        })
348    }
349
350    /// Check if a URL is accessible (HEAD request)
351    #[cfg(all(feature = "reqwest", feature = "async"))]
352    pub async fn check_url(&self, url: &str) -> Result<bool> {
353        match self.request(HttpMethod::HEAD, url, None).await {
354            Ok(response) => Ok(response.status >= 200 && response.status < 300),
355            Err(_) => Ok(false),
356        }
357    }
358
359    /// Get file size from URL without downloading (HEAD request)
360    #[cfg(all(feature = "reqwest", feature = "async"))]
361    pub async fn get_remote_file_size(&self, url: &str) -> Result<Option<u64>> {
362        let response = self.request(HttpMethod::HEAD, url, None).await?;
363        Ok(response.content_length)
364    }
365
366    #[cfg(feature = "reqwest")]
367    fn get_client(&self) -> Result<&reqwest::Client> {
368        self.client
369            .as_ref()
370            .ok_or_else(|| IoError::ConfigError("HTTP client not initialized".to_string()))
371    }
372
373    // Fallback implementations when reqwest feature is not enabled
374    #[cfg(not(feature = "reqwest"))]
375    /// Download a file (fallback implementation when reqwest feature is disabled)
376    pub async fn download<P: AsRef<Path>>(url: &str, _localpath: P) -> Result<()> {
377        Err(IoError::ConfigError(
378            "HTTP support requires 'reqwest' feature".to_string(),
379        ))
380    }
381
382    #[cfg(not(feature = "reqwest"))]
383    /// Upload a file (fallback implementation when reqwest feature is disabled)
384    pub async fn upload<P: AsRef<Path>>(_local_path: P, path: P, url: &str) -> Result<()> {
385        Err(IoError::ConfigError(
386            "HTTP support requires 'reqwest' feature".to_string(),
387        ))
388    }
389
390    #[cfg(not(feature = "reqwest"))]
391    /// Make an HTTP request (fallback implementation when reqwest feature is disabled)
392    pub async fn request(
393        &self,
394        _method: HttpMethod,
395        _url: &str,
396        _body: Option<&[u8]>,
397    ) -> Result<HttpResponse> {
398        Err(IoError::ConfigError(
399            "HTTP support requires 'reqwest' feature".to_string(),
400        ))
401    }
402
403    #[cfg(not(feature = "reqwest"))]
404    /// Check if URL is reachable (fallback implementation when reqwest feature is disabled)
405    pub async fn check_url(url: &str) -> Result<bool> {
406        Err(IoError::ConfigError(
407            "HTTP support requires 'reqwest' feature".to_string(),
408        ))
409    }
410
411    #[cfg(not(feature = "reqwest"))]
412    /// Get remote file size (fallback implementation when reqwest feature is disabled)
413    pub async fn get_remote_file_size(url: &str) -> Result<Option<u64>> {
414        Err(IoError::ConfigError(
415            "HTTP support requires 'reqwest' feature".to_string(),
416        ))
417    }
418}
419
420/// Utility functions
421/// Download multiple files concurrently
422#[cfg(all(feature = "reqwest", feature = "async"))]
423pub async fn download_concurrent(
424    downloads: Vec<(String, String)>,
425    max_concurrent: usize,
426) -> Result<Vec<Result<()>>> {
427    use futures_util::stream::{FuturesUnordered, StreamExt};
428
429    let client = HttpClient::new(NetworkConfig::default());
430
431    #[cfg(feature = "async")]
432    let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent));
433    let mut futures = FuturesUnordered::new();
434
435    for (url, local_path) in downloads {
436        let client_clone = &client;
437        let semaphore_clone = semaphore.clone();
438
439        futures.push(async move {
440            #[cfg(feature = "async")]
441            let _permit = semaphore_clone.acquire().await.expect("Operation failed");
442            client_clone.download(&url, &local_path).await
443        });
444    }
445
446    let mut results = Vec::new();
447    while let Some(result) = futures.next().await {
448        results.push(result);
449    }
450
451    Ok(results)
452}
453
454/// Calculate download speed
455#[allow(dead_code)]
456pub fn calculate_speed(bytes: u64, duration: Duration) -> f64 {
457    if duration.as_secs_f64() > 0.0 {
458        bytes as f64 / duration.as_secs_f64()
459    } else {
460        0.0
461    }
462}
463
464/// Format file size in human-readable format
465#[allow(dead_code)]
466pub fn format_file_size(bytes: u64) -> String {
467    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
468    let mut size = bytes as f64;
469    let mut unit_index = 0;
470
471    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
472        size /= 1024.0;
473        unit_index += 1;
474    }
475
476    format!("{:.1} {}", size, UNITS[unit_index])
477}
478
479/// Format download speed in human-readable format
480#[allow(dead_code)]
481pub fn format_speed(bytes_per_second: f64) -> String {
482    format!("{}/s", format_file_size(bytes_per_second as u64))
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn test_http_method_display() {
491        assert_eq!(HttpMethod::GET.to_string(), "GET");
492        assert_eq!(HttpMethod::POST.to_string(), "POST");
493        assert_eq!(HttpMethod::PUT.to_string(), "PUT");
494        assert_eq!(HttpMethod::DELETE.to_string(), "DELETE");
495        assert_eq!(HttpMethod::HEAD.to_string(), "HEAD");
496    }
497
498    #[test]
499    fn test_http_response_creation() {
500        let mut headers = HashMap::new();
501        headers.insert("content-type".to_string(), "application/json".to_string());
502
503        let response = HttpResponse {
504            status: 200,
505            headers,
506            content_length: Some(1024),
507            content_type: Some("application/json".to_string()),
508            body: b"test data".to_vec(),
509        };
510
511        assert_eq!(response.status, 200);
512        assert_eq!(response.content_length, Some(1024));
513        assert_eq!(response.content_type, Some("application/json".to_string()));
514        assert_eq!(response.body, b"test data");
515    }
516
517    #[test]
518    fn test_http_client_creation() {
519        let config = NetworkConfig::default();
520        let _client = HttpClient::new(config);
521
522        // Client should be created successfully
523        // Test passes if no panic occurs during creation
524    }
525
526    #[test]
527    fn test_format_file_size() {
528        assert_eq!(format_file_size(512), "512.0 B");
529        assert_eq!(format_file_size(1024), "1.0 KB");
530        assert_eq!(format_file_size(1536), "1.5 KB");
531        assert_eq!(format_file_size(1024 * 1024), "1.0 MB");
532        assert_eq!(format_file_size(1024 * 1024 * 1024), "1.0 GB");
533    }
534
535    #[test]
536    fn test_calculate_speed() {
537        let duration = Duration::from_secs(1);
538        assert_eq!(calculate_speed(1024, duration), 1024.0);
539
540        let duration = Duration::from_secs(2);
541        assert_eq!(calculate_speed(2048, duration), 1024.0);
542
543        let duration = Duration::from_secs(0);
544        assert_eq!(calculate_speed(1024, duration), 0.0);
545    }
546
547    #[test]
548    fn test_format_speed() {
549        assert_eq!(format_speed(1024.0), "1.0 KB/s");
550        assert_eq!(format_speed(1024.0 * 1024.0), "1.0 MB/s");
551    }
552
553    #[cfg(feature = "async")]
554    #[tokio::test]
555    async fn test_http_client_without_reqwest_feature() {
556        let config = NetworkConfig::default();
557        let client = HttpClient::new(config);
558
559        // These should return feature errors when reqwest is not enabled
560        #[cfg(not(feature = "reqwest"))]
561        {
562            let download_result = client.download("http://example.com", "test.txt").await;
563            assert!(download_result.is_err());
564
565            let upload_result = client.upload("test.txt", "http://example.com").await;
566            assert!(upload_result.is_err());
567
568            let request_result = client
569                .request(HttpMethod::GET, "http://example.com", None)
570                .await;
571            assert!(request_result.is_err());
572
573            let check_result = client.check_url("http://example.com").await;
574            assert!(check_result.is_err());
575
576            let size_result = client.get_remote_file_size("http://example.com").await;
577            assert!(size_result.is_err());
578        }
579    }
580}