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: {err}"
549 );
550 }
551
552 #[test]
553 fn test_deserialize_rpc_urls_invalid_number_in_array() {
554 let json = r#"{"rpc_urls": [123, 456]}"#;
555 let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
556
557 assert!(result.is_err());
558 let err = result.unwrap_err().to_string();
559 assert!(
560 err.contains("rpc_urls must be an array of strings or RpcConfig objects"),
561 "Error should mention valid types: {err}"
562 );
563 }
564
565 #[test]
566 fn test_deserialize_rpc_urls_invalid_boolean_in_array() {
567 let json = r#"{"rpc_urls": [true, false]}"#;
568 let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
569
570 assert!(result.is_err());
571 }
572
573 #[test]
574 fn test_deserialize_rpc_urls_invalid_nested_array() {
575 let json = r#"{"rpc_urls": [["nested", "array"]]}"#;
576 let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
577
578 assert!(result.is_err());
579 }
580
581 #[test]
582 fn test_deserialize_rpc_urls_invalid_object_in_array() {
583 let json = r#"{"rpc_urls": {"not": "an_array"}}"#;
584 let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
585
586 assert!(result.is_err());
587 }
588
589 #[test]
590 fn test_deserialize_rpc_urls_invalid_object_missing_url() {
591 let json = r#"{"rpc_urls": [{"weight": 50}]}"#;
593 let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
594
595 assert!(result.is_err());
596 let err = result.unwrap_err().to_string();
597 assert!(
598 err.contains("url") || err.contains("missing field"),
599 "Error should mention missing url field: {err}"
600 );
601 }
602
603 #[test]
604 fn test_deserialize_rpc_urls_mixed_valid_and_invalid() {
605 let json = r#"{"rpc_urls": ["https://valid.com", 12345]}"#;
607 let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
608
609 assert!(result.is_err());
610 }
611
612 #[test]
613 fn test_deserialize_rpc_urls_preserves_url_with_special_chars() {
614 let json = r#"{"rpc_urls": ["https://rpc.example.com/v1?api_key=abc123&network=mainnet"]}"#;
615 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
616
617 let urls = result.rpc_urls.unwrap();
618 assert_eq!(
619 urls[0].url,
620 "https://rpc.example.com/v1?api_key=abc123&network=mainnet"
621 );
622 }
623
624 #[test]
625 fn test_deserialize_rpc_urls_preserves_url_with_port() {
626 let json = r#"{"rpc_urls": ["http://localhost:8545"]}"#;
627 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
628
629 let urls = result.rpc_urls.unwrap();
630 assert_eq!(urls[0].url, "http://localhost:8545");
631 }
632
633 #[test]
634 fn test_deserialize_rpc_urls_unicode_url() {
635 let json = r#"{"rpc_urls": ["https://测试.example.com"]}"#;
636 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
637
638 let urls = result.rpc_urls.unwrap();
639 assert_eq!(urls[0].url, "https://测试.example.com");
640 }
641
642 #[test]
643 fn test_deserialize_rpc_urls_empty_string_url() {
644 let json = r#"{"rpc_urls": [""]}"#;
647 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
648
649 let urls = result.rpc_urls.unwrap();
650 assert_eq!(urls[0].url, "");
651 }
652
653 #[test]
654 fn test_deserialize_rpc_urls_whitespace_url() {
655 let json = r#"{"rpc_urls": [" https://rpc.example.com "]}"#;
656 let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
657
658 let urls = result.rpc_urls.unwrap();
659 assert_eq!(urls[0].url, " https://rpc.example.com ");
661 }
662
663 #[test]
668 fn test_masked_rpc_config_from_rpc_config() {
669 let config = RpcConfig::new("https://eth-mainnet.g.alchemy.com/v2/secret-key".to_string());
670 let masked: MaskedRpcConfig = config.into();
671
672 assert_eq!(masked.url, "https://eth-mainnet.g.alchemy.com/***");
673 assert_eq!(masked.weight, DEFAULT_RPC_WEIGHT);
674 }
675
676 #[test]
677 fn test_masked_rpc_config_preserves_weight() {
678 let config =
679 RpcConfig::with_weight("https://mainnet.infura.io/v3/project-id".to_string(), 75)
680 .unwrap();
681 let masked: MaskedRpcConfig = config.into();
682
683 assert_eq!(masked.url, "https://mainnet.infura.io/***");
684 assert_eq!(masked.weight, 75);
685 }
686
687 #[test]
688 fn test_masked_rpc_config_from_reference() {
689 let config = RpcConfig::new("https://rpc.ankr.com/eth/secret".to_string());
690 let masked = MaskedRpcConfig::from(&config);
691
692 assert_eq!(masked.url, "https://rpc.ankr.com/***");
693 assert_eq!(masked.weight, DEFAULT_RPC_WEIGHT);
694 }
695
696 #[test]
697 fn test_masked_rpc_config_serialization() {
698 let masked = MaskedRpcConfig {
699 url: "https://eth-mainnet.g.alchemy.com/***".to_string(),
700 weight: 100,
701 };
702
703 let serialized = serde_json::to_string(&masked).unwrap();
704 assert!(serialized.contains("https://eth-mainnet.g.alchemy.com/***"));
705 assert!(serialized.contains("100"));
706 }
707
708 #[test]
709 fn test_masked_rpc_config_deserialization() {
710 let json = r#"{"url": "https://rpc.example.com/***", "weight": 50}"#;
711 let masked: MaskedRpcConfig = serde_json::from_str(json).unwrap();
712
713 assert_eq!(masked.url, "https://rpc.example.com/***");
714 assert_eq!(masked.weight, 50);
715 }
716}