models_dev/
client.rs

1//! Smart HTTP client for interacting with the models.dev API with caching.
2
3use std::time::Duration;
4use std::sync::{Arc, Mutex};
5
6use crate::error::ModelsDevError;
7
8/// Cache metadata for tracking ETags and last modified times.
9#[derive(Debug, Clone)]
10struct CacheMetadata {
11    etag: Option<String>,
12    last_modified: Option<String>,
13}
14
15/// Smart HTTP client for the models.dev API with intelligent caching.
16///
17/// This client provides HTTP communication with automatic caching that only
18/// updates when the API data has actually changed, using conditional requests.
19#[derive(Debug, Clone)]
20pub struct ModelsDevClient {
21    /// The HTTP client for making API requests.
22    http_client: reqwest::Client,
23    /// Base URL for the models.dev API.
24    api_base_url: String,
25    /// Request timeout.
26    timeout: Duration,
27    /// Cache metadata for conditional requests.
28    cache_metadata: Arc<Mutex<Option<CacheMetadata>>>,
29}
30
31impl ModelsDevClient {
32    /// Create a new ModelsDevClient with default settings.
33    ///
34    /// Uses the default API base URL and 30-second timeout.
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Create a new ModelsDevClient with a custom API base URL.
40    ///
41    /// # Arguments
42    /// * `api_base_url` - The base URL for the models.dev API
43    ///
44    /// # Examples
45    /// ```
46    /// use models_dev::ModelsDevClient;
47    /// let client = ModelsDevClient::with_base_url("https://custom.api.models.dev");
48    /// ```
49    pub fn with_base_url(api_base_url: impl Into<String>) -> Self {
50        Self {
51            http_client: reqwest::Client::builder()
52                .timeout(Duration::from_secs(30))
53                .build()
54                .expect("Failed to build HTTP client"),
55            api_base_url: api_base_url.into(),
56            timeout: Duration::from_secs(30),
57            cache_metadata: Arc::new(Mutex::new(None)),
58        }
59    }
60
61    /// Fetch providers with smart caching - only updates if API changed.
62    ///
63    /// This method uses conditional HTTP requests to check if the API data
64    /// has changed before fetching the full response. It combines disk caching
65    /// with conditional requests for optimal performance.
66    ///
67    /// # Returns
68    /// * `Ok(ModelsDevResponse)` - The API response containing providers
69    /// * `Err(ModelsDevError)` - If the request fails or the response is invalid
70    ///
71    /// # Examples
72    /// ```
73    /// # async fn example() -> Result<(), models_dev::ModelsDevError> {
74    /// use models_dev::ModelsDevClient;
75    /// let client = ModelsDevClient::new();
76    /// let response = client.fetch_providers().await?;
77    /// println!("Found {} providers", response.providers.len());
78    /// # Ok(())
79    /// # }
80    /// ```
81    pub async fn fetch_providers(&self) -> Result<crate::types::ModelsDevResponse, ModelsDevError> {
82        let url = format!("{}/api.json", self.api_base_url);
83
84        // Try HEAD request first to check if modified
85        let head_response = self.http_client.head(&url).timeout(Duration::from_secs(5)).send().await;
86
87        if let Ok(head_resp) = head_response {
88            if let Some(etag) = head_resp.headers().get("etag") {
89                let cached_etag = self.get_cached_etag().await?;
90
91                if cached_etag == Some(etag.to_str().unwrap_or("").to_string()) {
92                    // Not modified, return cached data
93                    return self.load_cached_response().await;
94                }
95            }
96        }
97
98        // Either HEAD failed or data changed, fetch full response
99        let response = self.http_client.get(&url).timeout(self.timeout).send().await?;
100        let api_response = self.process_response(response).await?;
101
102        // Update cache metadata
103        self.update_cache_metadata(&api_response).await?;
104
105        Ok(api_response)
106    }
107
108    /// Process HTTP response and parse JSON.
109    async fn process_response(
110        &self,
111        response: reqwest::Response,
112    ) -> Result<crate::types::ModelsDevResponse, ModelsDevError> {
113        if !response.status().is_success() {
114            let error_msg = response
115                .text()
116                .await
117                .unwrap_or_else(|_| "Unknown error".to_string());
118            return Err(ModelsDevError::ApiError(error_msg));
119        }
120
121        let response_text = response.text().await?;
122        let api_response: crate::types::ModelsDevResponse =
123            serde_json::from_str(&response_text).map_err(ModelsDevError::JsonError)?;
124
125        Ok(api_response)
126    }
127
128    /// Get cached ETag for conditional requests.
129    async fn get_cached_etag(&self) -> Result<Option<String>, ModelsDevError> {
130        let cache_meta = self.cache_metadata.lock().unwrap();
131        Ok(cache_meta.as_ref().and_then(|m| m.etag.clone()))
132    }
133
134    /// Update cache metadata from response headers.
135    async fn update_cache_metadata(
136        &self,
137        _response: &crate::types::ModelsDevResponse,
138    ) -> Result<(), ModelsDevError> {
139        // For now, we'll just store a placeholder ETag
140        // In a real implementation, you'd extract this from HTTP headers
141        // or calculate a hash of the response content
142        let metadata = CacheMetadata {
143            etag: Some(format!("\"{}\"", std::time::SystemTime::now()
144                .duration_since(std::time::UNIX_EPOCH)
145                .unwrap()
146                .as_secs())), // Simple timestamp-based ETag
147            last_modified: None,
148        };
149
150        let mut cache_meta = self.cache_metadata.lock().unwrap();
151        *cache_meta = Some(metadata);
152
153        Ok(())
154    }
155
156    /// Load cached response from disk.
157    async fn load_cached_response(&self) -> Result<crate::types::ModelsDevResponse, ModelsDevError> {
158        // For now, this is a placeholder
159        // In a real implementation, you'd load from disk cache
160        Err(ModelsDevError::CacheError(
161            "Cached response not available".to_string(),
162        ))
163    }
164
165    /// Get the API base URL.
166    ///
167    /// # Returns
168    /// The base URL used for API requests
169    pub fn api_base_url(&self) -> &str {
170        &self.api_base_url
171    }
172
173    /// Get the request timeout.
174    ///
175    /// # Returns
176    /// The timeout duration for HTTP requests
177    pub fn timeout(&self) -> Duration {
178        self.timeout
179    }
180
181    /// Clear cache metadata.
182    ///
183    /// Clears the cache metadata, forcing a fresh API request on next call.
184    ///
185    /// # Examples
186    /// ```
187    /// use models_dev::ModelsDevClient;
188    /// let client = ModelsDevClient::new();
189    /// client.clear_cache().unwrap();
190    /// ```
191    pub fn clear_cache(&self) -> Result<(), ModelsDevError> {
192        let mut cache_meta = self.cache_metadata.lock().unwrap();
193        *cache_meta = None;
194        Ok(())
195    }
196
197    /// Get cache information for debugging.
198    ///
199    /// Returns information about the current cache state.
200    pub fn cache_info(&self) -> CacheInfo {
201        let cache_meta = self.cache_metadata.lock().unwrap();
202        CacheInfo {
203            has_metadata: cache_meta.is_some(),
204            etag: cache_meta.as_ref().and_then(|m| m.etag.clone()),
205            last_modified: cache_meta.as_ref().and_then(|m| m.last_modified.clone()),
206        }
207    }
208}
209
210/// Cache information for debugging and monitoring.
211#[derive(Debug, Clone)]
212pub struct CacheInfo {
213    pub has_metadata: bool,
214    pub etag: Option<String>,
215    pub last_modified: Option<String>,
216}
217
218impl Default for ModelsDevClient {
219    fn default() -> Self {
220        Self {
221            http_client: reqwest::Client::builder()
222                .timeout(Duration::from_secs(30))
223                .build()
224                .expect("Failed to build HTTP client"),
225            api_base_url: "https://models.dev".to_string(),
226            timeout: Duration::from_secs(30),
227            cache_metadata: Arc::new(Mutex::new(None)),
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_client_creation() {
238        let client = ModelsDevClient::new();
239        assert_eq!(client.api_base_url(), "https://models.dev");
240        assert_eq!(client.timeout(), Duration::from_secs(30));
241    }
242
243    #[test]
244    fn test_client_with_custom_base_url() {
245        let client = ModelsDevClient::with_base_url("https://custom.api.com");
246        assert_eq!(client.api_base_url(), "https://custom.api.com");
247    }
248
249    #[test]
250    fn test_client_default() {
251        let client = ModelsDevClient::default();
252        assert_eq!(client.api_base_url(), "https://models.dev");
253        assert_eq!(client.timeout(), Duration::from_secs(30));
254    }
255
256    #[test]
257    fn test_client_is_send_and_sync() {
258        fn assert_send_sync<T: Send + Sync>() {}
259        assert_send_sync::<ModelsDevClient>();
260    }
261
262    #[test]
263    fn test_client_debug_format() {
264        let client = ModelsDevClient::new();
265        let debug_str = format!("{:?}", client);
266        assert!(debug_str.contains("ModelsDevClient"));
267        assert!(debug_str.contains("models.dev"));
268    }
269
270    #[test]
271    fn test_cache_info() {
272        let client = ModelsDevClient::new();
273        let info = client.cache_info();
274        assert!(!info.has_metadata);
275        assert!(info.etag.is_none());
276        assert!(info.last_modified.is_none());
277    }
278
279    #[test]
280    fn test_clear_cache() {
281        let client = ModelsDevClient::new();
282        assert!(client.clear_cache().is_ok());
283    }
284
285    #[test]
286    fn test_cache_metadata() {
287        let metadata = CacheMetadata {
288            etag: Some("\"abc123\"".to_string()),
289            last_modified: Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()),
290        };
291        
292        assert_eq!(metadata.etag, Some("\"abc123\"".to_string()));
293        assert_eq!(metadata.last_modified, Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()));
294    }
295}