1use crate::constants::DEFAULT_RPC_WEIGHT;
7use crate::utils::mask_url;
8use eyre::eyre;
9use serde::{
10 de::Error as DeError, ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer,
11};
12use std::hash::{Hash, Hasher};
13use thiserror::Error;
14use utoipa::ToSchema;
15
16#[derive(Debug, Error, PartialEq)]
17pub enum RpcConfigError {
18 #[error("Invalid weight: {value}. Must be between 0 and 100.")]
19 InvalidWeight { value: u8 },
20}
21
22fn default_rpc_weight() -> u8 {
24 DEFAULT_RPC_WEIGHT
25}
26
27#[derive(Clone, Debug, PartialEq, Eq, Default, ToSchema)]
32#[schema(example = json!({"url": "https://rpc.example.com", "weight": 100}))]
33pub struct RpcConfig {
34 pub url: String,
36 #[schema(default = default_rpc_weight, minimum = 0, maximum = 100)]
39 pub weight: u8,
40}
41
42impl Hash for RpcConfig {
43 fn hash<H: Hasher>(&self, state: &mut H) {
44 self.url.hash(state);
45 self.weight.hash(state);
46 }
47}
48
49impl Serialize for RpcConfig {
50 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
51 where
52 S: Serializer,
53 {
54 let mut state = serializer.serialize_struct("RpcConfig", 2)?;
55 state.serialize_field("url", &self.url)?;
56 state.serialize_field("weight", &self.weight)?;
57 state.end()
58 }
59}
60
61impl<'de> Deserialize<'de> for RpcConfig {
62 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
63 where
64 D: Deserializer<'de>,
65 {
66 #[derive(Deserialize)]
67 struct RpcConfigHelper {
68 url: String,
69 weight: Option<u8>,
70 }
71
72 let helper = RpcConfigHelper::deserialize(deserializer)?;
73 Ok(RpcConfig {
74 url: helper.url,
75 weight: helper.weight.unwrap_or(DEFAULT_RPC_WEIGHT),
76 })
77 }
78}
79
80impl RpcConfig {
81 pub fn new(url: String) -> Self {
87 Self {
88 url,
89 weight: DEFAULT_RPC_WEIGHT,
90 }
91 }
92
93 pub fn with_weight(url: String, weight: u8) -> Result<Self, RpcConfigError> {
105 if weight > 100 {
106 return Err(RpcConfigError::InvalidWeight { value: weight });
107 }
108 Ok(Self { url, weight })
109 }
110
111 pub fn get_weight(&self) -> u8 {
117 self.weight
118 }
119
120 fn validate_url_scheme(url: &str) -> Result<(), eyre::Report> {
123 if !url.starts_with("http://") && !url.starts_with("https://") {
124 return Err(eyre!(
125 "Invalid URL scheme for {}: Only HTTP and HTTPS are supported",
126 url
127 ));
128 }
129 Ok(())
130 }
131
132 pub fn validate_list(configs: &[RpcConfig]) -> Result<(), eyre::Report> {
151 for config in configs {
152 Self::validate_url_scheme(&config.url)?;
154 }
155 Ok(())
156 }
157}
158
159#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
166#[schema(example = json!({"url": "https://eth-mainnet.g.alchemy.com/***", "weight": 100}))]
167pub struct MaskedRpcConfig {
168 pub url: String,
170 #[schema(minimum = 0, maximum = 100)]
172 pub weight: u8,
173}
174
175impl From<&RpcConfig> for MaskedRpcConfig {
176 fn from(config: &RpcConfig) -> Self {
177 Self {
178 url: mask_url(&config.url),
179 weight: config.weight,
180 }
181 }
182}
183
184impl From<RpcConfig> for MaskedRpcConfig {
185 fn from(config: RpcConfig) -> Self {
186 Self::from(&config)
187 }
188}
189
190pub fn deserialize_rpc_urls<'de, D>(deserializer: D) -> Result<Option<Vec<RpcConfig>>, D::Error>
216where
217 D: Deserializer<'de>,
218{
219 let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
221
222 match value {
223 None => Ok(None),
224 Some(serde_json::Value::Array(arr)) => {
225 let mut configs = Vec::with_capacity(arr.len());
226 for item in arr {
227 match item {
228 serde_json::Value::String(url) => {
229 configs.push(RpcConfig::new(url));
231 }
232 serde_json::Value::Object(obj) => {
233 let config: RpcConfig =
235 serde_json::from_value(serde_json::Value::Object(obj))
236 .map_err(DeError::custom)?;
237 configs.push(config);
238 }
239 _ => {
240 return Err(DeError::custom(
241 "rpc_urls must be an array of strings or RpcConfig objects",
242 ));
243 }
244 }
245 }
246 Ok(Some(configs))
247 }
248 Some(_) => Err(DeError::custom(
249 "rpc_urls must be an array of strings or RpcConfig objects",
250 )),
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::constants::DEFAULT_RPC_WEIGHT;
258
259 #[test]
260 fn test_new_creates_config_with_default_weight() {
261 let url = "https://example.com".to_string();
262 let config = RpcConfig::new(url.clone());
263
264 assert_eq!(config.url, url);
265 assert_eq!(config.weight, DEFAULT_RPC_WEIGHT);
266 }
267
268 #[test]
269 fn test_with_weight_creates_config_with_custom_weight() {
270 let url = "https://example.com".to_string();
271 let weight: u8 = 5;
272 let result = RpcConfig::with_weight(url.clone(), weight);
273 assert!(result.is_ok());
274
275 let config = result.unwrap();
276 assert_eq!(config.url, url);
277 assert_eq!(config.weight, weight);
278 }
279
280 #[test]
281 fn test_get_weight_returns_weight_value() {
282 let url = "https://example.com".to_string();
283 let weight: u8 = 10;
284 let config = RpcConfig {
285 url,
286 weight,
287 ..Default::default()
288 };
289
290 assert_eq!(config.get_weight(), weight);
291 }
292
293 #[test]
294 fn test_equality_of_configs() {
295 let url = "https://example.com".to_string();
296 let config1 = RpcConfig::new(url.clone());
297 let config2 = RpcConfig::new(url.clone()); let config3 = RpcConfig::with_weight(url.clone(), 5u8).unwrap(); let config4 =
300 RpcConfig::with_weight("https://different.com".to_string(), DEFAULT_RPC_WEIGHT)
301 .unwrap(); assert_eq!(config1, config2);
304 assert_ne!(config1, config3);
305 assert_ne!(config1, config4);
306 }
307
308 #[test]
310 fn test_validate_url_scheme_with_http() {
311 let result = RpcConfig::validate_url_scheme("http://example.com");
312 assert!(result.is_ok(), "HTTP URL should be valid");
313 }
314
315 #[test]
316 fn test_validate_url_scheme_with_https() {
317 let result = RpcConfig::validate_url_scheme("https://secure.example.com");
318 assert!(result.is_ok(), "HTTPS URL should be valid");
319 }
320
321 #[test]
322 fn test_validate_url_scheme_with_query_params() {
323 let result =
324 RpcConfig::validate_url_scheme("https://example.com/api?param=value&other=123");
325 assert!(result.is_ok(), "URL with query parameters should be valid");
326 }
327
328 #[test]
329 fn test_validate_url_scheme_with_port() {
330 let result = RpcConfig::validate_url_scheme("http://localhost:8545");
331 assert!(result.is_ok(), "URL with port should be valid");
332 }
333
334 #[test]
335 fn test_validate_url_scheme_with_ftp() {
336 let result = RpcConfig::validate_url_scheme("ftp://example.com");
337 assert!(result.is_err(), "FTP URL should be invalid");
338 }
339
340 #[test]
341 fn test_validate_url_scheme_with_invalid_url() {
342 let result = RpcConfig::validate_url_scheme("invalid-url");
343 assert!(result.is_err(), "Invalid URL format should be rejected");
344 }
345
346 #[test]
347 fn test_validate_url_scheme_with_empty_string() {
348 let result = RpcConfig::validate_url_scheme("");
349 assert!(result.is_err(), "Empty string should be rejected");
350 }
351
352 #[test]
354 fn test_validate_list_with_empty_vec() {
355 let configs: Vec<RpcConfig> = vec![];
356 let result = RpcConfig::validate_list(&configs);
357 assert!(result.is_ok(), "Empty config vector should be valid");
358 }
359
360 #[test]
361 fn test_validate_list_with_valid_urls() {
362 let configs = vec![
363 RpcConfig::new("https://api.example.com".to_string()),
364 RpcConfig::new("http://localhost:8545".to_string()),
365 ];
366 let result = RpcConfig::validate_list(&configs);
367 assert!(result.is_ok(), "All URLs are valid, should return Ok");
368 }
369
370 #[test]
371 fn test_validate_list_with_one_invalid_url() {
372 let configs = vec![
373 RpcConfig::new("https://api.example.com".to_string()),
374 RpcConfig::new("ftp://invalid-scheme.com".to_string()),
375 RpcConfig::new("http://another-valid.com".to_string()),
376 ];
377 let result = RpcConfig::validate_list(&configs);
378 assert!(result.is_err(), "Should fail on first invalid URL");
379 }
380
381 #[test]
382 fn test_validate_list_with_all_invalid_urls() {
383 let configs = vec![
384 RpcConfig::new("ws://websocket.example.com".to_string()),
385 RpcConfig::new("ftp://invalid-scheme.com".to_string()),
386 ];
387 let result = RpcConfig::validate_list(&configs);
388 assert!(result.is_err(), "Should fail with all invalid URLs");
389 }
390
391 #[derive(Deserialize, Debug)]
397 struct TestRpcUrlsContainer {
398 #[serde(default, deserialize_with = "super::deserialize_rpc_urls")]
399 rpc_urls: Option<Vec<RpcConfig>>,
400 }
401
402 #[test]
403 fn test_deserialize_rpc_urls_simple_format_single_url() {
404 let json = r#"{"rpc_urls": ["https://rpc.example.com"]}"#;
405 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
406
407 let urls = result.rpc_urls.unwrap();
408 assert_eq!(urls.len(), 1);
409 assert_eq!(urls[0].url, "https://rpc.example.com");
410 assert_eq!(urls[0].weight, DEFAULT_RPC_WEIGHT);
411 }
412
413 #[test]
414 fn test_deserialize_rpc_urls_simple_format_multiple_urls() {
415 let json = r#"{"rpc_urls": ["https://rpc1.com", "https://rpc2.com", "https://rpc3.com"]}"#;
416 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
417
418 let urls = result.rpc_urls.unwrap();
419 assert_eq!(urls.len(), 3);
420 assert_eq!(urls[0].url, "https://rpc1.com");
421 assert_eq!(urls[1].url, "https://rpc2.com");
422 assert_eq!(urls[2].url, "https://rpc3.com");
423 for url in &urls {
425 assert_eq!(url.weight, DEFAULT_RPC_WEIGHT);
426 }
427 }
428
429 #[test]
430 fn test_deserialize_rpc_urls_extended_format_single_config() {
431 let json = r#"{"rpc_urls": [{"url": "https://rpc.example.com", "weight": 50}]}"#;
432 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
433
434 let urls = result.rpc_urls.unwrap();
435 assert_eq!(urls.len(), 1);
436 assert_eq!(urls[0].url, "https://rpc.example.com");
437 assert_eq!(urls[0].weight, 50);
438 }
439
440 #[test]
441 fn test_deserialize_rpc_urls_extended_format_multiple_configs() {
442 let json = r#"{"rpc_urls": [
443 {"url": "https://primary.com", "weight": 80},
444 {"url": "https://secondary.com", "weight": 15},
445 {"url": "https://fallback.com", "weight": 5}
446 ]}"#;
447 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
448
449 let urls = result.rpc_urls.unwrap();
450 assert_eq!(urls.len(), 3);
451 assert_eq!(urls[0].url, "https://primary.com");
452 assert_eq!(urls[0].weight, 80);
453 assert_eq!(urls[1].url, "https://secondary.com");
454 assert_eq!(urls[1].weight, 15);
455 assert_eq!(urls[2].url, "https://fallback.com");
456 assert_eq!(urls[2].weight, 5);
457 }
458
459 #[test]
460 fn test_deserialize_rpc_urls_extended_format_without_weight() {
461 let json = r#"{"rpc_urls": [{"url": "https://rpc.example.com"}]}"#;
463 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
464
465 let urls = result.rpc_urls.unwrap();
466 assert_eq!(urls.len(), 1);
467 assert_eq!(urls[0].url, "https://rpc.example.com");
468 assert_eq!(urls[0].weight, DEFAULT_RPC_WEIGHT);
469 }
470
471 #[test]
472 fn test_deserialize_rpc_urls_mixed_format() {
473 let json = r#"{"rpc_urls": [
474 "https://simple.com",
475 {"url": "https://weighted.com", "weight": 75},
476 "https://another-simple.com"
477 ]}"#;
478 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
479
480 let urls = result.rpc_urls.unwrap();
481 assert_eq!(urls.len(), 3);
482
483 assert_eq!(urls[0].url, "https://simple.com");
485 assert_eq!(urls[0].weight, DEFAULT_RPC_WEIGHT);
486
487 assert_eq!(urls[1].url, "https://weighted.com");
489 assert_eq!(urls[1].weight, 75);
490
491 assert_eq!(urls[2].url, "https://another-simple.com");
493 assert_eq!(urls[2].weight, DEFAULT_RPC_WEIGHT);
494 }
495
496 #[test]
497 fn test_deserialize_rpc_urls_none_when_field_missing() {
498 let json = r#"{}"#;
499 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
500
501 assert!(result.rpc_urls.is_none());
502 }
503
504 #[test]
505 fn test_deserialize_rpc_urls_none_when_null() {
506 let json = r#"{"rpc_urls": null}"#;
507 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
508
509 assert!(result.rpc_urls.is_none());
510 }
511
512 #[test]
513 fn test_deserialize_rpc_urls_empty_array() {
514 let json = r#"{"rpc_urls": []}"#;
515 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
516
517 let urls = result.rpc_urls.unwrap();
518 assert!(urls.is_empty());
519 }
520
521 #[test]
522 fn test_deserialize_rpc_urls_weight_zero() {
523 let json = r#"{"rpc_urls": [{"url": "https://disabled.com", "weight": 0}]}"#;
524 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
525
526 let urls = result.rpc_urls.unwrap();
527 assert_eq!(urls[0].weight, 0);
528 }
529
530 #[test]
531 fn test_deserialize_rpc_urls_weight_max() {
532 let json = r#"{"rpc_urls": [{"url": "https://max.com", "weight": 100}]}"#;
533 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
534
535 let urls = result.rpc_urls.unwrap();
536 assert_eq!(urls[0].weight, 100);
537 }
538
539 #[test]
540 fn test_deserialize_rpc_urls_invalid_not_array() {
541 let json = r#"{"rpc_urls": "https://not-an-array.com"}"#;
542 let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
543
544 assert!(result.is_err());
545 let err = result.unwrap_err().to_string();
546 assert!(
547 err.contains("rpc_urls must be an array"),
548 "Error should mention array requirement: {}",
549 err
550 );
551 }
552
553 #[test]
554 fn test_deserialize_rpc_urls_invalid_number_in_array() {
555 let json = r#"{"rpc_urls": [123, 456]}"#;
556 let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
557
558 assert!(result.is_err());
559 let err = result.unwrap_err().to_string();
560 assert!(
561 err.contains("rpc_urls must be an array of strings or RpcConfig objects"),
562 "Error should mention valid types: {}",
563 err
564 );
565 }
566
567 #[test]
568 fn test_deserialize_rpc_urls_invalid_boolean_in_array() {
569 let json = r#"{"rpc_urls": [true, false]}"#;
570 let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
571
572 assert!(result.is_err());
573 }
574
575 #[test]
576 fn test_deserialize_rpc_urls_invalid_nested_array() {
577 let json = r#"{"rpc_urls": [["nested", "array"]]}"#;
578 let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
579
580 assert!(result.is_err());
581 }
582
583 #[test]
584 fn test_deserialize_rpc_urls_invalid_object_in_array() {
585 let json = r#"{"rpc_urls": {"not": "an_array"}}"#;
586 let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
587
588 assert!(result.is_err());
589 }
590
591 #[test]
592 fn test_deserialize_rpc_urls_invalid_object_missing_url() {
593 let json = r#"{"rpc_urls": [{"weight": 50}]}"#;
595 let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
596
597 assert!(result.is_err());
598 let err = result.unwrap_err().to_string();
599 assert!(
600 err.contains("url") || err.contains("missing field"),
601 "Error should mention missing url field: {}",
602 err
603 );
604 }
605
606 #[test]
607 fn test_deserialize_rpc_urls_mixed_valid_and_invalid() {
608 let json = r#"{"rpc_urls": ["https://valid.com", 12345]}"#;
610 let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
611
612 assert!(result.is_err());
613 }
614
615 #[test]
616 fn test_deserialize_rpc_urls_preserves_url_with_special_chars() {
617 let json = r#"{"rpc_urls": ["https://rpc.example.com/v1?api_key=abc123&network=mainnet"]}"#;
618 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
619
620 let urls = result.rpc_urls.unwrap();
621 assert_eq!(
622 urls[0].url,
623 "https://rpc.example.com/v1?api_key=abc123&network=mainnet"
624 );
625 }
626
627 #[test]
628 fn test_deserialize_rpc_urls_preserves_url_with_port() {
629 let json = r#"{"rpc_urls": ["http://localhost:8545"]}"#;
630 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
631
632 let urls = result.rpc_urls.unwrap();
633 assert_eq!(urls[0].url, "http://localhost:8545");
634 }
635
636 #[test]
637 fn test_deserialize_rpc_urls_unicode_url() {
638 let json = r#"{"rpc_urls": ["https://测试.example.com"]}"#;
639 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
640
641 let urls = result.rpc_urls.unwrap();
642 assert_eq!(urls[0].url, "https://测试.example.com");
643 }
644
645 #[test]
646 fn test_deserialize_rpc_urls_empty_string_url() {
647 let json = r#"{"rpc_urls": [""]}"#;
650 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
651
652 let urls = result.rpc_urls.unwrap();
653 assert_eq!(urls[0].url, "");
654 }
655
656 #[test]
657 fn test_deserialize_rpc_urls_whitespace_url() {
658 let json = r#"{"rpc_urls": [" https://rpc.example.com "]}"#;
659 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
660
661 let urls = result.rpc_urls.unwrap();
662 assert_eq!(urls[0].url, " https://rpc.example.com ");
664 }
665
666 #[test]
671 fn test_masked_rpc_config_from_rpc_config() {
672 let config = RpcConfig::new("https://eth-mainnet.g.alchemy.com/v2/secret-key".to_string());
673 let masked: MaskedRpcConfig = config.into();
674
675 assert_eq!(masked.url, "https://eth-mainnet.g.alchemy.com/***");
676 assert_eq!(masked.weight, DEFAULT_RPC_WEIGHT);
677 }
678
679 #[test]
680 fn test_masked_rpc_config_preserves_weight() {
681 let config =
682 RpcConfig::with_weight("https://mainnet.infura.io/v3/project-id".to_string(), 75)
683 .unwrap();
684 let masked: MaskedRpcConfig = config.into();
685
686 assert_eq!(masked.url, "https://mainnet.infura.io/***");
687 assert_eq!(masked.weight, 75);
688 }
689
690 #[test]
691 fn test_masked_rpc_config_from_reference() {
692 let config = RpcConfig::new("https://rpc.ankr.com/eth/secret".to_string());
693 let masked = MaskedRpcConfig::from(&config);
694
695 assert_eq!(masked.url, "https://rpc.ankr.com/***");
696 assert_eq!(masked.weight, DEFAULT_RPC_WEIGHT);
697 }
698
699 #[test]
700 fn test_masked_rpc_config_serialization() {
701 let masked = MaskedRpcConfig {
702 url: "https://eth-mainnet.g.alchemy.com/***".to_string(),
703 weight: 100,
704 };
705
706 let serialized = serde_json::to_string(&masked).unwrap();
707 assert!(serialized.contains("https://eth-mainnet.g.alchemy.com/***"));
708 assert!(serialized.contains("100"));
709 }
710
711 #[test]
712 fn test_masked_rpc_config_deserialization() {
713 let json = r#"{"url": "https://rpc.example.com/***", "weight": 50}"#;
714 let masked: MaskedRpcConfig = serde_json::from_str(json).unwrap();
715
716 assert_eq!(masked.url, "https://rpc.example.com/***");
717 assert_eq!(masked.weight, 50);
718 }
719}