1use 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#[derive(Debug, Clone)]
12pub struct HttpConfig {
13 pub timeout: Duration,
15 pub max_retries: u32,
17 pub retry_delay: Duration,
19 pub user_agent: String,
21 pub exponential_backoff: bool,
23 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#[derive(Debug, Clone, Default)]
42pub struct ApiKeys {
43 pub twitter: Option<String>,
45 pub exa: Option<String>,
47 pub dexscreener: Option<String>,
49 pub newsapi: Option<String>,
51 pub cryptopanic: Option<String>,
53 pub lunarcrush: Option<String>,
55 pub alternative: Option<String>,
57 pub other: HashMap<String, String>,
59}
60
61impl ApiKeys {
62 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 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 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 pub fn contains_key(&self, key: &str) -> bool {
117 self.get(key).is_some()
118 }
119
120 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#[derive(Debug, Clone, Default)]
139pub struct ClientConfig {
140 pub base_urls: BaseUrls,
142 pub rate_limits: RateLimits,
144}
145
146impl ClientConfig {
147 pub fn is_empty(&self) -> bool {
149 false }
151
152 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 pub fn len(&self) -> usize {
167 6 }
169
170 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#[derive(Debug, Clone)]
186pub struct BaseUrls {
187 pub dexscreener: String,
189 pub exa: String,
191 pub newsapi: String,
193 pub cryptopanic: String,
195 pub lunarcrush: String,
197 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#[derive(Debug, Clone)]
216pub struct RateLimits {
217 pub dexscreener_per_minute: u32,
219 pub twitter_per_minute: u32,
221 pub newsapi_per_minute: u32,
223 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#[derive(Debug, Clone)]
240pub struct WebClient {
241 pub http_client: Client,
243 pub api_keys: ApiKeys,
245 pub config: ClientConfig,
247 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 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 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 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 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 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 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 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 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 warn!("Unrecognized config key: {}", key);
350 }
351 }
352 }
353
354 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 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 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 base_delay * (2_u32.pow(attempt.saturating_sub(1)))
388 } else {
389 base_delay * attempt
391 };
392
393 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 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 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 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 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 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 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 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 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 pub async fn get(&self, url: &str) -> Result<String> {
582 self.get_with_headers(url, HashMap::new()).await
583 }
584
585 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 for (key, value) in &headers {
598 request = request.header(key, value);
599 }
600
601 request.send()
602 })
603 .await
604 }
605
606 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 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 for (key, value) in params {
630 request = request.query(&[(key, value)]);
631 }
632
633 for (key, value) in &headers {
635 request = request.header(key, value);
636 }
637
638 request.send()
639 })
640 .await
641 }
642
643 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 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 for (key, value) in &headers {
662 request = request.header(key, value);
663 }
664
665 request.send()
666 })
667 .await
668 }
669
670 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()); }
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 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()); assert_eq!(config.len(), 6); }
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 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 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 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, ..Default::default()
1124 };
1125 let client = WebClient::with_config(config).unwrap();
1126
1127 let delay1 = client.calculate_retry_delay(1);
1129 assert_eq!(delay1, Duration::from_millis(100)); let delay2 = client.calculate_retry_delay(2);
1132 assert_eq!(delay2, Duration::from_millis(200)); let delay3 = client.calculate_retry_delay(3);
1135 assert_eq!(delay3, Duration::from_millis(400)); }
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, ..Default::default()
1145 };
1146 let client = WebClient::with_config(config).unwrap();
1147
1148 let delay1 = client.calculate_retry_delay(1);
1150 assert_eq!(delay1, Duration::from_millis(100)); let delay2 = client.calculate_retry_delay(2);
1153 assert_eq!(delay2, Duration::from_millis(200)); let delay3 = client.calculate_retry_delay(3);
1156 assert_eq!(delay3, Duration::from_millis(300)); }
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, ..Default::default()
1166 };
1167 let client = WebClient::with_config(config).unwrap();
1168
1169 let delay = client.calculate_retry_delay(1);
1171 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 let delay = client.calculate_retry_delay(0);
1187 assert_eq!(delay, Duration::from_millis(100)); }
1189}