riglr_web_tools/
client.rs

1//! Web client for interacting with various web APIs
2
3use crate::error::{Result, WebToolError};
4use reqwest::{Client, ClientBuilder};
5use serde::Serialize;
6use std::collections::HashMap;
7use std::time::Duration;
8use tracing::{debug, info, warn};
9
10/// Configuration for HTTP client
11#[derive(Debug, Clone)]
12pub struct HttpConfig {
13    /// Request timeout
14    pub timeout: Duration,
15    /// Maximum retries
16    pub max_retries: u32,
17    /// Retry delay
18    pub retry_delay: Duration,
19    /// User agent
20    pub user_agent: String,
21    /// Enable exponential backoff
22    pub exponential_backoff: bool,
23    /// Jitter for retry delays (0.0 to 1.0)
24    pub jitter_factor: f32,
25}
26
27impl Default for HttpConfig {
28    fn default() -> Self {
29        Self {
30            timeout: Duration::from_secs(30),
31            max_retries: 3,
32            retry_delay: Duration::from_millis(500),
33            user_agent: "riglr-web-tools/0.1.0".to_string(),
34            exponential_backoff: true,
35            jitter_factor: 0.1,
36        }
37    }
38}
39
40/// Type-safe API keys configuration
41#[derive(Debug, Clone, Default)]
42pub struct ApiKeys {
43    /// Twitter/X Bearer Token
44    pub twitter: Option<String>,
45    /// Exa API key
46    pub exa: Option<String>,
47    /// DexScreener API key (optional)
48    pub dexscreener: Option<String>,
49    /// NewsAPI key
50    pub newsapi: Option<String>,
51    /// CryptoPanic API key
52    pub cryptopanic: Option<String>,
53    /// LunarCrush API key
54    pub lunarcrush: Option<String>,
55    /// Alternative data API key
56    pub alternative: Option<String>,
57    /// Generic fallback for other services
58    pub other: HashMap<String, String>,
59}
60
61impl ApiKeys {
62    /// Check if all API keys are empty
63    pub fn is_empty(&self) -> bool {
64        self.twitter.is_none()
65            && self.exa.is_none()
66            && self.dexscreener.is_none()
67            && self.newsapi.is_none()
68            && self.cryptopanic.is_none()
69            && self.lunarcrush.is_none()
70            && self.alternative.is_none()
71            && self.other.is_empty()
72    }
73
74    /// Get an API key by name
75    pub fn get(&self, key: &str) -> Option<&String> {
76        match key {
77            "twitter" => self.twitter.as_ref(),
78            "exa" => self.exa.as_ref(),
79            "dexscreener" => self.dexscreener.as_ref(),
80            "newsapi" => self.newsapi.as_ref(),
81            "cryptopanic" => self.cryptopanic.as_ref(),
82            "lunarcrush" => self.lunarcrush.as_ref(),
83            "alternative" => self.alternative.as_ref(),
84            other => self.other.get(other),
85        }
86    }
87
88    /// Get the number of configured API keys
89    pub fn len(&self) -> usize {
90        let mut count = 0;
91        if self.twitter.is_some() {
92            count += 1;
93        }
94        if self.exa.is_some() {
95            count += 1;
96        }
97        if self.dexscreener.is_some() {
98            count += 1;
99        }
100        if self.newsapi.is_some() {
101            count += 1;
102        }
103        if self.cryptopanic.is_some() {
104            count += 1;
105        }
106        if self.lunarcrush.is_some() {
107            count += 1;
108        }
109        if self.alternative.is_some() {
110            count += 1;
111        }
112        count + self.other.len()
113    }
114
115    /// Check if an API key exists
116    pub fn contains_key(&self, key: &str) -> bool {
117        self.get(key).is_some()
118    }
119
120    /// Insert a new API key
121    pub fn insert(&mut self, key: String, value: String) {
122        match key.as_str() {
123            "twitter" => self.twitter = Some(value),
124            "exa" => self.exa = Some(value),
125            "dexscreener" => self.dexscreener = Some(value),
126            "newsapi" => self.newsapi = Some(value),
127            "cryptopanic" => self.cryptopanic = Some(value),
128            "lunarcrush" => self.lunarcrush = Some(value),
129            "alternative" => self.alternative = Some(value),
130            other => {
131                self.other.insert(other.to_string(), value);
132            }
133        }
134    }
135}
136
137/// Type-safe client configuration
138#[derive(Debug, Clone, Default)]
139pub struct ClientConfig {
140    /// Base URL overrides for testing
141    pub base_urls: BaseUrls,
142    /// Rate limiting settings
143    pub rate_limits: RateLimits,
144}
145
146impl ClientConfig {
147    /// Check if the config is empty
148    pub fn is_empty(&self) -> bool {
149        false // Config always has default values
150    }
151
152    /// Get a configuration value by key
153    pub fn get(&self, key: &str) -> Option<String> {
154        match key {
155            "dexscreener_url" => Some(self.base_urls.dexscreener.clone()),
156            "exa_url" => Some(self.base_urls.exa.clone()),
157            "newsapi_url" => Some(self.base_urls.newsapi.clone()),
158            "cryptopanic_url" => Some(self.base_urls.cryptopanic.clone()),
159            "lunarcrush_url" => Some(self.base_urls.lunarcrush.clone()),
160            "twitter_url" => Some(self.base_urls.twitter.clone()),
161            _ => None,
162        }
163    }
164
165    /// Get the number of configuration entries
166    pub fn len(&self) -> usize {
167        6 // Fixed number of base URLs
168    }
169
170    /// Insert a configuration value
171    pub fn insert(&mut self, key: String, value: String) {
172        match key.as_str() {
173            "dexscreener_url" => self.base_urls.dexscreener = value,
174            "exa_url" => self.base_urls.exa = value,
175            "newsapi_url" => self.base_urls.newsapi = value,
176            "cryptopanic_url" => self.base_urls.cryptopanic = value,
177            "lunarcrush_url" => self.base_urls.lunarcrush = value,
178            "twitter_url" => self.base_urls.twitter = value,
179            _ => {}
180        }
181    }
182}
183
184/// Base URL configuration for various services
185#[derive(Debug, Clone)]
186pub struct BaseUrls {
187    /// DexScreener API base URL
188    pub dexscreener: String,
189    /// Exa API base URL
190    pub exa: String,
191    /// News API base URL
192    pub newsapi: String,
193    /// CryptoPanic API base URL
194    pub cryptopanic: String,
195    /// LunarCrush API base URL
196    pub lunarcrush: String,
197    /// Twitter API base URL
198    pub twitter: String,
199}
200
201impl Default for BaseUrls {
202    fn default() -> Self {
203        Self {
204            dexscreener: "https://api.dexscreener.com/latest".to_string(),
205            exa: "https://api.exa.ai".to_string(),
206            newsapi: "https://newsapi.org/v2".to_string(),
207            cryptopanic: "https://cryptopanic.com/api/v1".to_string(),
208            lunarcrush: "https://lunarcrush.com/api/3".to_string(),
209            twitter: "https://api.twitter.com/2".to_string(),
210        }
211    }
212}
213
214/// Rate limiting configuration
215#[derive(Debug, Clone)]
216pub struct RateLimits {
217    /// DexScreener requests per minute limit
218    pub dexscreener_per_minute: u32,
219    /// Twitter requests per minute limit
220    pub twitter_per_minute: u32,
221    /// News API requests per minute limit
222    pub newsapi_per_minute: u32,
223    /// Exa API requests per minute limit
224    pub exa_per_minute: u32,
225}
226
227impl Default for RateLimits {
228    fn default() -> Self {
229        Self {
230            dexscreener_per_minute: 300,
231            twitter_per_minute: 300,
232            newsapi_per_minute: 500,
233            exa_per_minute: 100,
234        }
235    }
236}
237
238/// A client for interacting with various web APIs and services
239#[derive(Debug, Clone)]
240pub struct WebClient {
241    /// HTTP client for making requests
242    pub http_client: Client,
243    /// Type-safe API keys
244    pub api_keys: ApiKeys,
245    /// Type-safe configuration
246    pub config: ClientConfig,
247    /// HTTP configuration
248    pub http_config: HttpConfig,
249}
250
251impl Default for WebClient {
252    fn default() -> Self {
253        let http_config = HttpConfig::default();
254        let http_client = ClientBuilder::new()
255            .timeout(http_config.timeout)
256            .user_agent(&http_config.user_agent)
257            .build()
258            .expect("Failed to create default HTTP client");
259
260        Self {
261            http_client,
262            api_keys: ApiKeys::default(),
263            config: ClientConfig::default(),
264            http_config,
265        }
266    }
267}
268
269impl WebClient {
270    /// Create with custom HTTP configuration
271    pub fn with_config(http_config: HttpConfig) -> Result<Self> {
272        let http_client = ClientBuilder::new()
273            .timeout(http_config.timeout)
274            .user_agent(&http_config.user_agent)
275            .build()
276            .map_err(|e| WebToolError::Client(format!("Failed to create HTTP client: {}", e)))?;
277
278        Ok(Self {
279            http_client,
280            api_keys: ApiKeys::default(),
281            config: ClientConfig::default(),
282            http_config,
283        })
284    }
285
286    /// Set API key for a service (for backwards compatibility)
287    pub fn with_api_key<S1: Into<String>, S2: Into<String>>(
288        mut self,
289        service: S1,
290        api_key: S2,
291    ) -> Self {
292        let service = service.into();
293        let api_key = api_key.into();
294
295        match service.as_str() {
296            "twitter" => self.api_keys.twitter = Some(api_key),
297            "exa" => self.api_keys.exa = Some(api_key),
298            "dexscreener" => self.api_keys.dexscreener = Some(api_key),
299            "newsapi" => self.api_keys.newsapi = Some(api_key),
300            "cryptopanic" => self.api_keys.cryptopanic = Some(api_key),
301            "lunarcrush" => self.api_keys.lunarcrush = Some(api_key),
302            "alternative" => self.api_keys.alternative = Some(api_key),
303            _ => {
304                self.api_keys.other.insert(service, api_key);
305            }
306        }
307        self
308    }
309
310    /// Set Twitter/X Bearer Token
311    pub fn with_twitter_token<S: Into<String>>(mut self, token: S) -> Self {
312        self.api_keys.twitter = Some(token.into());
313        self
314    }
315
316    /// Set Exa API key
317    pub fn with_exa_key<S: Into<String>>(mut self, key: S) -> Self {
318        self.api_keys.exa = Some(key.into());
319        self
320    }
321
322    /// Set DexScreener API key (if required)
323    pub fn with_dexscreener_key<S: Into<String>>(mut self, key: S) -> Self {
324        self.api_keys.dexscreener = Some(key.into());
325        self
326    }
327
328    /// Set News API key
329    pub fn with_news_api_key<S: Into<String>>(mut self, key: S) -> Self {
330        self.api_keys.newsapi = Some(key.into());
331        self
332    }
333
334    /// Set configuration option (for backwards compatibility)
335    pub fn set_config<S: Into<String>>(&mut self, key: S, value: S) {
336        let key = key.into();
337        let value = value.into();
338
339        // Map old config keys to new structure
340        match key.as_str() {
341            "base_url" | "dexscreener_base_url" => self.config.base_urls.dexscreener = value,
342            "exa_base_url" => self.config.base_urls.exa = value,
343            "newsapi_base_url" => self.config.base_urls.newsapi = value,
344            "cryptopanic_base_url" => self.config.base_urls.cryptopanic = value,
345            "lunarcrush_base_url" => self.config.base_urls.lunarcrush = value,
346            "twitter_base_url" => self.config.base_urls.twitter = value,
347            _ => {
348                // Log unrecognized config keys
349                warn!("Unrecognized config key: {}", key);
350            }
351        }
352    }
353
354    /// Get API key for a service
355    pub fn get_api_key(&self, service: &str) -> Option<&String> {
356        match service {
357            "twitter" => self.api_keys.twitter.as_ref(),
358            "exa" => self.api_keys.exa.as_ref(),
359            "dexscreener" => self.api_keys.dexscreener.as_ref(),
360            "newsapi" => self.api_keys.newsapi.as_ref(),
361            "cryptopanic" => self.api_keys.cryptopanic.as_ref(),
362            "lunarcrush" => self.api_keys.lunarcrush.as_ref(),
363            "alternative" => self.api_keys.alternative.as_ref(),
364            _ => self.api_keys.other.get(service),
365        }
366    }
367
368    /// Get config value (for backwards compatibility)
369    pub fn get_config(&self, key: &str) -> Option<String> {
370        match key {
371            "dexscreener_base_url" | "base_url" => Some(self.config.base_urls.dexscreener.clone()),
372            "exa_base_url" => Some(self.config.base_urls.exa.clone()),
373            "newsapi_base_url" => Some(self.config.base_urls.newsapi.clone()),
374            "cryptopanic_base_url" => Some(self.config.base_urls.cryptopanic.clone()),
375            "lunarcrush_base_url" => Some(self.config.base_urls.lunarcrush.clone()),
376            "twitter_base_url" => Some(self.config.base_urls.twitter.clone()),
377            _ => None,
378        }
379    }
380
381    /// Calculate retry delay with exponential backoff and jitter
382    fn calculate_retry_delay(&self, attempt: u32) -> Duration {
383        let base_delay = self.http_config.retry_delay;
384
385        let delay = if self.http_config.exponential_backoff {
386            // Exponential backoff: delay * 2^(attempt - 1)
387            base_delay * (2_u32.pow(attempt.saturating_sub(1)))
388        } else {
389            // Linear backoff: delay * attempt
390            base_delay * attempt
391        };
392
393        // Add jitter if configured
394        if self.http_config.jitter_factor > 0.0 {
395            use rand::Rng;
396            let mut rng = rand::rng();
397            let jitter_range = delay.as_millis() as f32 * self.http_config.jitter_factor;
398            let jitter = rng.random_range(-jitter_range..=jitter_range) as u64;
399            let final_delay = (delay.as_millis() as i64 + jitter as i64).max(0) as u64;
400            Duration::from_millis(final_delay)
401        } else {
402            delay
403        }
404    }
405
406    /// Execute HTTP request with retry logic (internal helper)
407    async fn execute_with_retry<F, Fut>(&self, url: &str, request_fn: F) -> Result<String>
408    where
409        F: Fn() -> Fut,
410        Fut: std::future::Future<Output = reqwest::Result<reqwest::Response>>,
411    {
412        let mut attempts = 0;
413        let mut last_error = None;
414
415        while attempts < self.http_config.max_retries {
416            attempts += 1;
417
418            match request_fn().await {
419                Ok(response) => {
420                    let status = response.status();
421
422                    if status.is_success() {
423                        let text = response.text().await.map_err(|e| {
424                            WebToolError::Network(format!("Failed to read response: {}", e))
425                        })?;
426
427                        info!("Successfully fetched {} bytes from {}", text.len(), url);
428                        return Ok(text);
429                    } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
430                        // Immediately return a rate limit error for 429
431                        let error_text = response.text().await.unwrap_or_default();
432                        return Err(WebToolError::RateLimit(format!(
433                            "HTTP 429 from {}: {}",
434                            url, error_text
435                        )));
436                    } else if [
437                        reqwest::StatusCode::BAD_GATEWAY,
438                        reqwest::StatusCode::SERVICE_UNAVAILABLE,
439                        reqwest::StatusCode::GATEWAY_TIMEOUT,
440                    ]
441                    .contains(&status)
442                        && attempts < self.http_config.max_retries
443                    {
444                        // Retry only on specific server errors (502, 503, 504)
445                        warn!(
446                            "Server error {} from {}, attempt {}/{}",
447                            status, url, attempts, self.http_config.max_retries
448                        );
449                        last_error = Some(format!("HTTP {}", status));
450
451                        let delay = self.calculate_retry_delay(attempts);
452                        debug!("Retrying after {:?}", delay);
453                        tokio::time::sleep(delay).await;
454                    } else {
455                        // All other errors (e.g., 4xx) are permanent API errors
456                        let error_text = response.text().await.unwrap_or_default();
457                        return Err(WebToolError::Api(format!(
458                            "HTTP {} from {}: {}",
459                            status, url, error_text
460                        )));
461                    }
462                }
463                Err(e) => {
464                    if attempts < self.http_config.max_retries {
465                        warn!(
466                            "Request failed for {}, attempt {}/{}: {}",
467                            url, attempts, self.http_config.max_retries, e
468                        );
469                        last_error = Some(e.to_string());
470
471                        let delay = self.calculate_retry_delay(attempts);
472                        debug!("Retrying after {:?}", delay);
473                        tokio::time::sleep(delay).await;
474                    } else {
475                        return Err(WebToolError::Api(format!(
476                            "Request failed after {} attempts: {}",
477                            attempts, e
478                        )));
479                    }
480                }
481            }
482        }
483
484        Err(WebToolError::Api(format!(
485            "Request failed after {} attempts: {}",
486            attempts,
487            last_error.unwrap_or_else(|| "Unknown error".to_string())
488        )))
489    }
490
491    /// Execute HTTP POST request with retry logic returning JSON (internal helper)
492    async fn execute_post_with_retry<F, Fut>(
493        &self,
494        url: &str,
495        request_fn: F,
496    ) -> Result<serde_json::Value>
497    where
498        F: Fn() -> Fut,
499        Fut: std::future::Future<Output = reqwest::Result<reqwest::Response>>,
500    {
501        let mut attempts = 0;
502        let mut last_error = None;
503
504        while attempts < self.http_config.max_retries {
505            attempts += 1;
506
507            match request_fn().await {
508                Ok(response) => {
509                    let status = response.status();
510
511                    if status.is_success() {
512                        let json = response.json::<serde_json::Value>().await.map_err(|e| {
513                            WebToolError::Parsing(format!("Failed to parse JSON response: {}", e))
514                        })?;
515
516                        info!("Successfully posted to {}", url);
517                        return Ok(json);
518                    } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
519                        // Immediately return a rate limit error for 429
520                        let error_text = response.text().await.unwrap_or_default();
521                        return Err(WebToolError::RateLimit(format!(
522                            "HTTP 429 from {}: {}",
523                            url, error_text
524                        )));
525                    } else if [
526                        reqwest::StatusCode::BAD_GATEWAY,
527                        reqwest::StatusCode::SERVICE_UNAVAILABLE,
528                        reqwest::StatusCode::GATEWAY_TIMEOUT,
529                    ]
530                    .contains(&status)
531                        && attempts < self.http_config.max_retries
532                    {
533                        // Retry only on specific server errors (502, 503, 504)
534                        warn!(
535                            "Server error {} from {}, attempt {}/{}",
536                            status, url, attempts, self.http_config.max_retries
537                        );
538                        last_error = Some(format!("HTTP {}", status));
539
540                        let delay = self.calculate_retry_delay(attempts);
541                        debug!("Retrying after {:?}", delay);
542                        tokio::time::sleep(delay).await;
543                    } else {
544                        // All other errors (e.g., 4xx) are permanent API errors
545                        let error_text = response.text().await.unwrap_or_default();
546                        return Err(WebToolError::Api(format!(
547                            "HTTP {} from {}: {}",
548                            status, url, error_text
549                        )));
550                    }
551                }
552                Err(e) => {
553                    if attempts < self.http_config.max_retries {
554                        warn!(
555                            "Request failed for {}, attempt {}/{}: {}",
556                            url, attempts, self.http_config.max_retries, e
557                        );
558                        last_error = Some(e.to_string());
559
560                        let delay = self.calculate_retry_delay(attempts);
561                        debug!("Retrying after {:?}", delay);
562                        tokio::time::sleep(delay).await;
563                    } else {
564                        return Err(WebToolError::Api(format!(
565                            "Request failed after {} attempts: {}",
566                            attempts, e
567                        )));
568                    }
569                }
570            }
571        }
572
573        Err(WebToolError::Api(format!(
574            "Request failed after {} attempts: {}",
575            attempts,
576            last_error.unwrap_or_else(|| "Unknown error".to_string())
577        )))
578    }
579
580    /// Make a GET request with retry logic
581    pub async fn get(&self, url: &str) -> Result<String> {
582        self.get_with_headers(url, HashMap::new()).await
583    }
584
585    /// Make a GET request with headers and retry logic
586    pub async fn get_with_headers(
587        &self,
588        url: &str,
589        headers: HashMap<String, String>,
590    ) -> Result<String> {
591        debug!("GET request to: {}", url);
592
593        self.execute_with_retry(url, || {
594            let mut request = self.http_client.get(url);
595
596            // Add headers
597            for (key, value) in &headers {
598                request = request.header(key, value);
599            }
600
601            request.send()
602        })
603        .await
604    }
605
606    /// Make GET request with query parameters
607    pub async fn get_with_params(
608        &self,
609        url: &str,
610        params: &HashMap<String, String>,
611    ) -> Result<String> {
612        self.get_with_params_and_headers(url, params, HashMap::new())
613            .await
614    }
615
616    /// Make GET request with query parameters and headers
617    pub async fn get_with_params_and_headers(
618        &self,
619        url: &str,
620        params: &HashMap<String, String>,
621        headers: HashMap<String, String>,
622    ) -> Result<String> {
623        debug!("GET request to: {} with params: {:?}", url, params);
624
625        self.execute_with_retry(url, || {
626            let mut request = self.http_client.get(url);
627
628            // Add query parameters
629            for (key, value) in params {
630                request = request.query(&[(key, value)]);
631            }
632
633            // Add headers
634            for (key, value) in &headers {
635                request = request.header(key, value);
636            }
637
638            request.send()
639        })
640        .await
641    }
642
643    /// Make a POST request with JSON body
644    pub async fn post<T: Serialize>(&self, url: &str, body: &T) -> Result<serde_json::Value> {
645        self.post_with_headers(url, body, HashMap::new()).await
646    }
647
648    /// Make a POST request with JSON body and headers
649    pub async fn post_with_headers<T: Serialize>(
650        &self,
651        url: &str,
652        body: &T,
653        headers: HashMap<String, String>,
654    ) -> Result<serde_json::Value> {
655        debug!("POST request to: {}", url);
656
657        self.execute_post_with_retry(url, || {
658            let mut request = self.http_client.post(url).json(body);
659
660            // Add headers
661            for (key, value) in &headers {
662                request = request.header(key, value);
663            }
664
665            request.send()
666        })
667        .await
668    }
669
670    /// Make a DELETE request
671    pub async fn delete(&self, url: &str) -> Result<()> {
672        debug!("DELETE request to: {}", url);
673
674        let response = self
675            .http_client
676            .delete(url)
677            .send()
678            .await
679            .map_err(|e| WebToolError::Network(format!("DELETE request failed: {}", e)))?;
680
681        if !response.status().is_success() {
682            let status = response.status();
683            let error_text = response.text().await.unwrap_or_default();
684            return Err(WebToolError::Api(format!(
685                "HTTP {} from {}: {}",
686                status, url, error_text
687            )));
688        }
689
690        info!("Successfully deleted: {}", url);
691        Ok(())
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    #[test]
700    fn test_web_client_creation() {
701        let client = WebClient::default();
702        assert!(client.api_keys.is_empty());
703        assert!(!client.config.is_empty()); // Config always has default values
704    }
705
706    #[test]
707    fn test_with_api_key() {
708        let client = WebClient::default()
709            .with_twitter_token("test_token")
710            .with_exa_key("exa_key");
711
712        assert_eq!(
713            client.get_api_key("twitter"),
714            Some(&"test_token".to_string())
715        );
716        assert_eq!(client.get_api_key("exa"), Some(&"exa_key".to_string()));
717        assert_eq!(client.get_api_key("unknown"), None);
718    }
719
720    #[test]
721    fn test_config() {
722        let mut client = WebClient::default();
723        client.set_config("test_key", "test_value");
724
725        // Config no longer stores arbitrary test keys, only predefined URLs
726        assert_eq!(client.get_config("unknown"), None);
727    }
728
729    #[test]
730    fn test_http_config_default() {
731        let config = HttpConfig::default();
732        assert_eq!(config.timeout, Duration::from_secs(30));
733        assert_eq!(config.max_retries, 3);
734        assert_eq!(config.retry_delay, Duration::from_millis(500));
735        assert_eq!(config.user_agent, "riglr-web-tools/0.1.0");
736        assert!(config.exponential_backoff);
737        assert_eq!(config.jitter_factor, 0.1);
738    }
739
740    #[test]
741    fn test_api_keys_default() {
742        let keys = ApiKeys::default();
743        assert!(keys.is_empty());
744        assert!(keys.twitter.is_none());
745        assert!(keys.exa.is_none());
746        assert!(keys.dexscreener.is_none());
747        assert!(keys.newsapi.is_none());
748        assert!(keys.cryptopanic.is_none());
749        assert!(keys.lunarcrush.is_none());
750        assert!(keys.alternative.is_none());
751        assert!(keys.other.is_empty());
752    }
753
754    #[test]
755    fn test_api_keys_is_empty() {
756        let mut keys = ApiKeys::default();
757        assert!(keys.is_empty());
758
759        keys.twitter = Some("token".to_string());
760        assert!(!keys.is_empty());
761
762        keys = ApiKeys::default();
763        keys.other.insert("custom".to_string(), "value".to_string());
764        assert!(!keys.is_empty());
765    }
766
767    #[test]
768    fn test_api_keys_get() {
769        let mut keys = ApiKeys::default();
770        keys.twitter = Some("twitter_token".to_string());
771        keys.exa = Some("exa_key".to_string());
772        keys.dexscreener = Some("dex_key".to_string());
773        keys.newsapi = Some("news_key".to_string());
774        keys.cryptopanic = Some("crypto_key".to_string());
775        keys.lunarcrush = Some("lunar_key".to_string());
776        keys.alternative = Some("alt_key".to_string());
777        keys.other
778            .insert("custom".to_string(), "custom_key".to_string());
779
780        assert_eq!(keys.get("twitter"), Some(&"twitter_token".to_string()));
781        assert_eq!(keys.get("exa"), Some(&"exa_key".to_string()));
782        assert_eq!(keys.get("dexscreener"), Some(&"dex_key".to_string()));
783        assert_eq!(keys.get("newsapi"), Some(&"news_key".to_string()));
784        assert_eq!(keys.get("cryptopanic"), Some(&"crypto_key".to_string()));
785        assert_eq!(keys.get("lunarcrush"), Some(&"lunar_key".to_string()));
786        assert_eq!(keys.get("alternative"), Some(&"alt_key".to_string()));
787        assert_eq!(keys.get("custom"), Some(&"custom_key".to_string()));
788        assert_eq!(keys.get("unknown"), None);
789    }
790
791    #[test]
792    fn test_api_keys_len() {
793        let mut keys = ApiKeys::default();
794        assert_eq!(keys.len(), 0);
795
796        keys.twitter = Some("token".to_string());
797        assert_eq!(keys.len(), 1);
798
799        keys.exa = Some("key".to_string());
800        keys.dexscreener = Some("key".to_string());
801        keys.newsapi = Some("key".to_string());
802        keys.cryptopanic = Some("key".to_string());
803        keys.lunarcrush = Some("key".to_string());
804        keys.alternative = Some("key".to_string());
805        assert_eq!(keys.len(), 7);
806
807        keys.other
808            .insert("custom1".to_string(), "value1".to_string());
809        keys.other
810            .insert("custom2".to_string(), "value2".to_string());
811        assert_eq!(keys.len(), 9);
812    }
813
814    #[test]
815    fn test_api_keys_contains_key() {
816        let mut keys = ApiKeys::default();
817        assert!(!keys.contains_key("twitter"));
818
819        keys.twitter = Some("token".to_string());
820        assert!(keys.contains_key("twitter"));
821        assert!(!keys.contains_key("exa"));
822
823        keys.other.insert("custom".to_string(), "value".to_string());
824        assert!(keys.contains_key("custom"));
825        assert!(!keys.contains_key("unknown"));
826    }
827
828    #[test]
829    fn test_api_keys_insert() {
830        let mut keys = ApiKeys::default();
831
832        keys.insert("twitter".to_string(), "token".to_string());
833        assert_eq!(keys.twitter, Some("token".to_string()));
834
835        keys.insert("exa".to_string(), "key".to_string());
836        assert_eq!(keys.exa, Some("key".to_string()));
837
838        keys.insert("dexscreener".to_string(), "key".to_string());
839        assert_eq!(keys.dexscreener, Some("key".to_string()));
840
841        keys.insert("newsapi".to_string(), "key".to_string());
842        assert_eq!(keys.newsapi, Some("key".to_string()));
843
844        keys.insert("cryptopanic".to_string(), "key".to_string());
845        assert_eq!(keys.cryptopanic, Some("key".to_string()));
846
847        keys.insert("lunarcrush".to_string(), "key".to_string());
848        assert_eq!(keys.lunarcrush, Some("key".to_string()));
849
850        keys.insert("alternative".to_string(), "key".to_string());
851        assert_eq!(keys.alternative, Some("key".to_string()));
852
853        keys.insert("custom".to_string(), "value".to_string());
854        assert_eq!(keys.other.get("custom"), Some(&"value".to_string()));
855    }
856
857    #[test]
858    fn test_client_config_default() {
859        let config = ClientConfig::default();
860        assert!(!config.is_empty()); // Always returns false
861        assert_eq!(config.len(), 6); // Fixed number of URLs
862    }
863
864    #[test]
865    fn test_client_config_get() {
866        let config = ClientConfig::default();
867        assert!(config.get("dexscreener_url").is_some());
868        assert!(config.get("exa_url").is_some());
869        assert!(config.get("newsapi_url").is_some());
870        assert!(config.get("cryptopanic_url").is_some());
871        assert!(config.get("lunarcrush_url").is_some());
872        assert!(config.get("twitter_url").is_some());
873        assert_eq!(config.get("unknown"), None);
874    }
875
876    #[test]
877    fn test_client_config_insert() {
878        let mut config = ClientConfig::default();
879        let old_dex_url = config.base_urls.dexscreener.clone();
880
881        config.insert(
882            "dexscreener_url".to_string(),
883            "https://custom.com".to_string(),
884        );
885        assert_eq!(config.base_urls.dexscreener, "https://custom.com");
886        assert_ne!(config.base_urls.dexscreener, old_dex_url);
887
888        config.insert("exa_url".to_string(), "https://exa.custom.com".to_string());
889        assert_eq!(config.base_urls.exa, "https://exa.custom.com");
890
891        config.insert(
892            "newsapi_url".to_string(),
893            "https://news.custom.com".to_string(),
894        );
895        assert_eq!(config.base_urls.newsapi, "https://news.custom.com");
896
897        config.insert(
898            "cryptopanic_url".to_string(),
899            "https://crypto.custom.com".to_string(),
900        );
901        assert_eq!(config.base_urls.cryptopanic, "https://crypto.custom.com");
902
903        config.insert(
904            "lunarcrush_url".to_string(),
905            "https://lunar.custom.com".to_string(),
906        );
907        assert_eq!(config.base_urls.lunarcrush, "https://lunar.custom.com");
908
909        config.insert(
910            "twitter_url".to_string(),
911            "https://twitter.custom.com".to_string(),
912        );
913        assert_eq!(config.base_urls.twitter, "https://twitter.custom.com");
914
915        // Test unknown key does nothing
916        let old_dex_url = config.base_urls.dexscreener.clone();
917        config.insert("unknown".to_string(), "value".to_string());
918        assert_eq!(config.base_urls.dexscreener, old_dex_url);
919    }
920
921    #[test]
922    fn test_base_urls_default() {
923        let urls = BaseUrls::default();
924        assert_eq!(urls.dexscreener, "https://api.dexscreener.com/latest");
925        assert_eq!(urls.exa, "https://api.exa.ai");
926        assert_eq!(urls.newsapi, "https://newsapi.org/v2");
927        assert_eq!(urls.cryptopanic, "https://cryptopanic.com/api/v1");
928        assert_eq!(urls.lunarcrush, "https://lunarcrush.com/api/3");
929        assert_eq!(urls.twitter, "https://api.twitter.com/2");
930    }
931
932    #[test]
933    fn test_rate_limits_default() {
934        let limits = RateLimits::default();
935        assert_eq!(limits.dexscreener_per_minute, 300);
936        assert_eq!(limits.twitter_per_minute, 300);
937        assert_eq!(limits.newsapi_per_minute, 500);
938        assert_eq!(limits.exa_per_minute, 100);
939    }
940
941    #[test]
942    fn test_web_client_default() {
943        let client = WebClient::default();
944        assert!(client.api_keys.is_empty());
945        assert!(!client.config.is_empty());
946        assert_eq!(client.http_config.max_retries, 3);
947    }
948
949    #[test]
950    fn test_web_client_with_config() {
951        let config = HttpConfig {
952            timeout: Duration::from_secs(60),
953            max_retries: 5,
954            retry_delay: Duration::from_millis(1000),
955            user_agent: "custom-agent".to_string(),
956            exponential_backoff: false,
957            jitter_factor: 0.2,
958        };
959
960        let client = WebClient::with_config(config.clone()).unwrap();
961        assert_eq!(client.http_config.timeout, Duration::from_secs(60));
962        assert_eq!(client.http_config.max_retries, 5);
963        assert_eq!(client.http_config.retry_delay, Duration::from_millis(1000));
964        assert_eq!(client.http_config.user_agent, "custom-agent");
965        assert!(!client.http_config.exponential_backoff);
966        assert_eq!(client.http_config.jitter_factor, 0.2);
967    }
968
969    #[test]
970    fn test_web_client_with_api_key() {
971        let client = WebClient::default()
972            .with_api_key("twitter", "twitter_key")
973            .with_api_key("exa", "exa_key")
974            .with_api_key("dexscreener", "dex_key")
975            .with_api_key("newsapi", "news_key")
976            .with_api_key("cryptopanic", "crypto_key")
977            .with_api_key("lunarcrush", "lunar_key")
978            .with_api_key("alternative", "alt_key")
979            .with_api_key("custom", "custom_key");
980
981        assert_eq!(client.api_keys.twitter, Some("twitter_key".to_string()));
982        assert_eq!(client.api_keys.exa, Some("exa_key".to_string()));
983        assert_eq!(client.api_keys.dexscreener, Some("dex_key".to_string()));
984        assert_eq!(client.api_keys.newsapi, Some("news_key".to_string()));
985        assert_eq!(client.api_keys.cryptopanic, Some("crypto_key".to_string()));
986        assert_eq!(client.api_keys.lunarcrush, Some("lunar_key".to_string()));
987        assert_eq!(client.api_keys.alternative, Some("alt_key".to_string()));
988        assert_eq!(
989            client.api_keys.other.get("custom"),
990            Some(&"custom_key".to_string())
991        );
992    }
993
994    #[test]
995    fn test_web_client_builder_methods() {
996        let client = WebClient::default()
997            .with_twitter_token("twitter_token")
998            .with_exa_key("exa_key")
999            .with_dexscreener_key("dex_key")
1000            .with_news_api_key("news_key");
1001
1002        assert_eq!(client.api_keys.twitter, Some("twitter_token".to_string()));
1003        assert_eq!(client.api_keys.exa, Some("exa_key".to_string()));
1004        assert_eq!(client.api_keys.dexscreener, Some("dex_key".to_string()));
1005        assert_eq!(client.api_keys.newsapi, Some("news_key".to_string()));
1006    }
1007
1008    #[test]
1009    fn test_web_client_set_config() {
1010        let mut client = WebClient::default();
1011        let original_dex_url = client.config.base_urls.dexscreener.clone();
1012
1013        client.set_config("base_url", "https://custom-dex.com");
1014        assert_eq!(
1015            client.config.base_urls.dexscreener,
1016            "https://custom-dex.com"
1017        );
1018        assert_ne!(client.config.base_urls.dexscreener, original_dex_url);
1019
1020        client.set_config("dexscreener_base_url", "https://dex.custom.com");
1021        assert_eq!(
1022            client.config.base_urls.dexscreener,
1023            "https://dex.custom.com"
1024        );
1025
1026        client.set_config("exa_base_url", "https://exa.custom.com");
1027        assert_eq!(client.config.base_urls.exa, "https://exa.custom.com");
1028
1029        client.set_config("newsapi_base_url", "https://news.custom.com");
1030        assert_eq!(client.config.base_urls.newsapi, "https://news.custom.com");
1031
1032        client.set_config("cryptopanic_base_url", "https://crypto.custom.com");
1033        assert_eq!(
1034            client.config.base_urls.cryptopanic,
1035            "https://crypto.custom.com"
1036        );
1037
1038        client.set_config("lunarcrush_base_url", "https://lunar.custom.com");
1039        assert_eq!(
1040            client.config.base_urls.lunarcrush,
1041            "https://lunar.custom.com"
1042        );
1043
1044        client.set_config("twitter_base_url", "https://twitter.custom.com");
1045        assert_eq!(
1046            client.config.base_urls.twitter,
1047            "https://twitter.custom.com"
1048        );
1049
1050        // Test unknown key (should log warning)
1051        let old_dex_url = client.config.base_urls.dexscreener.clone();
1052        client.set_config("unknown_key", "value");
1053        assert_eq!(client.config.base_urls.dexscreener, old_dex_url);
1054    }
1055
1056    #[test]
1057    fn test_web_client_get_api_key() {
1058        let client = WebClient::default()
1059            .with_api_key("twitter", "twitter_key")
1060            .with_api_key("exa", "exa_key")
1061            .with_api_key("dexscreener", "dex_key")
1062            .with_api_key("newsapi", "news_key")
1063            .with_api_key("cryptopanic", "crypto_key")
1064            .with_api_key("lunarcrush", "lunar_key")
1065            .with_api_key("alternative", "alt_key")
1066            .with_api_key("custom", "custom_key");
1067
1068        assert_eq!(
1069            client.get_api_key("twitter"),
1070            Some(&"twitter_key".to_string())
1071        );
1072        assert_eq!(client.get_api_key("exa"), Some(&"exa_key".to_string()));
1073        assert_eq!(
1074            client.get_api_key("dexscreener"),
1075            Some(&"dex_key".to_string())
1076        );
1077        assert_eq!(client.get_api_key("newsapi"), Some(&"news_key".to_string()));
1078        assert_eq!(
1079            client.get_api_key("cryptopanic"),
1080            Some(&"crypto_key".to_string())
1081        );
1082        assert_eq!(
1083            client.get_api_key("lunarcrush"),
1084            Some(&"lunar_key".to_string())
1085        );
1086        assert_eq!(
1087            client.get_api_key("alternative"),
1088            Some(&"alt_key".to_string())
1089        );
1090        assert_eq!(
1091            client.get_api_key("custom"),
1092            Some(&"custom_key".to_string())
1093        );
1094        assert_eq!(client.get_api_key("unknown"), None);
1095    }
1096
1097    #[test]
1098    fn test_web_client_get_config() {
1099        let client = WebClient::default();
1100
1101        assert!(client.get_config("dexscreener_base_url").is_some());
1102        assert!(client.get_config("base_url").is_some());
1103        assert!(client.get_config("exa_base_url").is_some());
1104        assert!(client.get_config("newsapi_base_url").is_some());
1105        assert!(client.get_config("cryptopanic_base_url").is_some());
1106        assert!(client.get_config("lunarcrush_base_url").is_some());
1107        assert!(client.get_config("twitter_base_url").is_some());
1108        assert_eq!(client.get_config("unknown"), None);
1109
1110        // Test that base_url and dexscreener_base_url return the same value
1111        assert_eq!(
1112            client.get_config("base_url"),
1113            client.get_config("dexscreener_base_url")
1114        );
1115    }
1116
1117    #[test]
1118    fn test_calculate_retry_delay_exponential_backoff() {
1119        let config = HttpConfig {
1120            retry_delay: Duration::from_millis(100),
1121            exponential_backoff: true,
1122            jitter_factor: 0.0, // No jitter for predictable testing
1123            ..Default::default()
1124        };
1125        let client = WebClient::with_config(config).unwrap();
1126
1127        // Test exponential backoff: delay * 2^(attempt - 1)
1128        let delay1 = client.calculate_retry_delay(1);
1129        assert_eq!(delay1, Duration::from_millis(100)); // 100 * 2^0 = 100
1130
1131        let delay2 = client.calculate_retry_delay(2);
1132        assert_eq!(delay2, Duration::from_millis(200)); // 100 * 2^1 = 200
1133
1134        let delay3 = client.calculate_retry_delay(3);
1135        assert_eq!(delay3, Duration::from_millis(400)); // 100 * 2^2 = 400
1136    }
1137
1138    #[test]
1139    fn test_calculate_retry_delay_linear_backoff() {
1140        let config = HttpConfig {
1141            retry_delay: Duration::from_millis(100),
1142            exponential_backoff: false,
1143            jitter_factor: 0.0, // No jitter for predictable testing
1144            ..Default::default()
1145        };
1146        let client = WebClient::with_config(config).unwrap();
1147
1148        // Test linear backoff: delay * attempt
1149        let delay1 = client.calculate_retry_delay(1);
1150        assert_eq!(delay1, Duration::from_millis(100)); // 100 * 1 = 100
1151
1152        let delay2 = client.calculate_retry_delay(2);
1153        assert_eq!(delay2, Duration::from_millis(200)); // 100 * 2 = 200
1154
1155        let delay3 = client.calculate_retry_delay(3);
1156        assert_eq!(delay3, Duration::from_millis(300)); // 100 * 3 = 300
1157    }
1158
1159    #[test]
1160    fn test_calculate_retry_delay_with_jitter() {
1161        let config = HttpConfig {
1162            retry_delay: Duration::from_millis(100),
1163            exponential_backoff: false,
1164            jitter_factor: 0.5, // 50% jitter
1165            ..Default::default()
1166        };
1167        let client = WebClient::with_config(config).unwrap();
1168
1169        // With jitter, the delay should vary but be within expected range
1170        let delay = client.calculate_retry_delay(1);
1171        // The delay should be around 100ms but with jitter (50ms range)
1172        assert!(delay.as_millis() >= 50 && delay.as_millis() <= 150);
1173    }
1174
1175    #[test]
1176    fn test_calculate_retry_delay_saturating_sub() {
1177        let config = HttpConfig {
1178            retry_delay: Duration::from_millis(100),
1179            exponential_backoff: true,
1180            jitter_factor: 0.0,
1181            ..Default::default()
1182        };
1183        let client = WebClient::with_config(config).unwrap();
1184
1185        // Test with attempt = 0 (should not panic due to saturating_sub)
1186        let delay = client.calculate_retry_delay(0);
1187        assert_eq!(delay, Duration::from_millis(100)); // 100 * 2^0 = 100
1188    }
1189}