Skip to main content

fetchr_http/
client.rs

1use std::time::Duration;
2use reqwest::header::{HeaderMap, RANGE};
3use reqwest::{Client, StatusCode};
4use url::Url;
5
6use crate::error::{HttpError, Result};
7use crate::metadata::RemoteMetadata;
8
9#[derive(Clone, Debug)]
10pub struct HttpClientConfig {
11    pub user_agent: String,
12    pub connect_timeout: Duration,
13    pub timeout: Duration,
14}
15
16impl Default for HttpClientConfig {
17    fn default() -> Self {
18        Self {
19            user_agent: format!("Fetchr/{}", env!("CARGO_PKG_VERSION")),
20            connect_timeout: Duration::from_secs(10),
21            timeout: Duration::from_secs(30),
22        }
23    }
24}
25
26#[derive(Clone, Debug)]
27pub struct HttpClient {
28    inner: Client,
29}
30
31impl HttpClient {
32    pub fn new(config: HttpClientConfig) -> Result<Self> {
33        let client = Client::builder()
34            .user_agent(config.user_agent)
35            .connect_timeout(config.connect_timeout)
36            .timeout(config.timeout)
37            .build()?;
38
39        Ok(Self { inner: client })
40    }
41
42    /// Inspects the remote URL via HEAD or range GET request to extract headers & metadata.
43    pub async fn probe_metadata(&self, url: &str) -> Result<RemoteMetadata> {
44        let parsed_url = Url::parse(url).map_err(|_| HttpError::InvalidUrl(url.to_string()))?;
45
46        // 1. Try HEAD request first
47        let head_res = self.inner.head(parsed_url.clone()).send().await;
48        if let Ok(res) = head_res {
49            if res.status().is_success() {
50                let final_url = res.url().to_string();
51                let headers = res.headers();
52                return Ok(RemoteMetadata::from_headers(final_url, headers));
53            }
54        }
55
56        // 2. Fallback: Probe using Range: bytes=0-0 GET request if HEAD is rejected/unsupported
57        let get_res = self
58            .inner
59            .get(parsed_url)
60            .header(RANGE, "bytes=0-0")
61            .send()
62            .await?;
63
64        if !get_res.status().is_success() && get_res.status() != StatusCode::PARTIAL_CONTENT {
65            return Err(HttpError::HttpStatus {
66                status: get_res.status().as_u16(),
67                message: get_res.status().canonical_reason().unwrap_or("Unknown").to_string(),
68            });
69        }
70
71        let final_url = get_res.url().to_string();
72        let headers = get_res.headers();
73        let mut metadata = RemoteMetadata::from_headers(final_url, headers);
74
75        // If status was 206 Partial Content, Accept-Ranges is true
76        if get_res.status() == StatusCode::PARTIAL_CONTENT {
77            metadata.accept_ranges = true;
78            // Parse real Content-Length from Content-Range header if main Content-Length was 1
79            if let Some(content_range) = headers.get(reqwest::header::CONTENT_RANGE) {
80                if let Ok(range_str) = content_range.to_str() {
81                    // Example: bytes 0-0/10485760
82                    if let Some(total_str) = range_str.split('/').nth(1) {
83                        if let Ok(total_len) = total_str.parse::<u64>() {
84                            metadata.content_length = Some(total_len);
85                        }
86                    }
87                }
88            }
89        }
90
91        Ok(metadata)
92    }
93
94    /// Initiates a streaming HTTP GET request, optionally with a Range header.
95    pub async fn fetch_stream(
96        &self,
97        url: &str,
98        start_byte: u64,
99        end_byte: Option<u64>,
100    ) -> Result<(StatusCode, HeaderMap, reqwest::Response)> {
101        let parsed_url = Url::parse(url).map_err(|_| HttpError::InvalidUrl(url.to_string()))?;
102        let mut req = self.inner.get(parsed_url);
103
104        if start_byte > 0 || end_byte.is_some() {
105            let range_val = match end_byte {
106                Some(end) => format!("bytes={}-{}", start_byte, end),
107                None => format!("bytes={}-", start_byte),
108            };
109            req = req.header(RANGE, range_val);
110        }
111
112        let res = req.send().await?;
113        let status = res.status();
114        let headers = res.headers().clone();
115
116        if !status.is_success() && status != StatusCode::PARTIAL_CONTENT {
117            return Err(HttpError::HttpStatus {
118                status: status.as_u16(),
119                message: status.canonical_reason().unwrap_or("Unknown").to_string(),
120            });
121        }
122
123        Ok((status, headers, res))
124    }
125}
126
127impl Default for HttpClient {
128    fn default() -> Self {
129        Self::new(HttpClientConfig::default()).expect("Failed to build default HttpClient")
130    }
131}