1use crate::error::{MovementError, MovementResult};
7use crate::retry::RetryConfig;
8use crate::types::ChainId;
9use std::time::Duration;
10use url::Url;
11
12pub fn validate_url_scheme(url: &Url) -> MovementResult<()> {
24 match url.scheme() {
25 "https" => Ok(()),
26 "http" => {
27 Ok(())
29 }
30 scheme => Err(MovementError::Config(format!(
31 "unsupported URL scheme '{scheme}': only 'http' and 'https' are allowed"
32 ))),
33 }
34}
35
36pub async fn read_response_bounded(
53 mut response: reqwest::Response,
54 max_size: usize,
55) -> MovementResult<Vec<u8>> {
56 if let Some(content_length) = response.content_length()
58 && content_length > max_size as u64
59 {
60 return Err(MovementError::Api {
61 status_code: response.status().as_u16(),
62 message: format!(
63 "response too large: Content-Length {content_length} bytes exceeds limit of {max_size} bytes"
64 ),
65 error_code: Some("RESPONSE_TOO_LARGE".into()),
66 vm_error_code: None,
67 });
68 }
69
70 let mut body = Vec::with_capacity(std::cmp::min(max_size, 1024 * 1024));
73 while let Some(chunk) = response.chunk().await? {
74 if body.len().saturating_add(chunk.len()) > max_size {
75 return Err(MovementError::Api {
76 status_code: response.status().as_u16(),
77 message: format!(
78 "response too large: exceeded limit of {max_size} bytes during streaming"
79 ),
80 error_code: Some("RESPONSE_TOO_LARGE".into()),
81 vm_error_code: None,
82 });
83 }
84 body.extend_from_slice(&chunk);
85 }
86
87 Ok(body)
88}
89
90#[derive(Debug, Clone)]
94pub struct PoolConfig {
95 pub max_idle_per_host: Option<usize>,
98 pub max_idle_total: usize,
101 pub idle_timeout: Duration,
104 pub tcp_keepalive: Option<Duration>,
107 pub tcp_nodelay: bool,
110 pub max_response_size: usize,
118}
119
120const DEFAULT_MAX_RESPONSE_SIZE: usize = 10 * 1024 * 1024;
130
131impl Default for PoolConfig {
132 fn default() -> Self {
133 Self {
134 max_idle_per_host: None, max_idle_total: 100,
136 idle_timeout: Duration::from_secs(90),
137 tcp_keepalive: Some(Duration::from_secs(60)),
138 tcp_nodelay: true,
139 max_response_size: DEFAULT_MAX_RESPONSE_SIZE,
140 }
141 }
142}
143
144impl PoolConfig {
145 pub fn builder() -> PoolConfigBuilder {
147 PoolConfigBuilder::default()
148 }
149
150 pub fn high_throughput() -> Self {
156 Self {
157 max_idle_per_host: Some(32),
158 max_idle_total: 256,
159 idle_timeout: Duration::from_secs(300),
160 tcp_keepalive: Some(Duration::from_secs(30)),
161 tcp_nodelay: true,
162 max_response_size: DEFAULT_MAX_RESPONSE_SIZE,
163 }
164 }
165
166 pub fn low_latency() -> Self {
172 Self {
173 max_idle_per_host: Some(8),
174 max_idle_total: 32,
175 idle_timeout: Duration::from_secs(30),
176 tcp_keepalive: Some(Duration::from_secs(15)),
177 tcp_nodelay: true,
178 max_response_size: DEFAULT_MAX_RESPONSE_SIZE,
179 }
180 }
181
182 pub fn minimal() -> Self {
187 Self {
188 max_idle_per_host: Some(2),
189 max_idle_total: 8,
190 idle_timeout: Duration::from_secs(10),
191 tcp_keepalive: None,
192 tcp_nodelay: true,
193 max_response_size: DEFAULT_MAX_RESPONSE_SIZE,
194 }
195 }
196}
197
198#[derive(Debug, Clone, Default)]
200#[allow(clippy::option_option)] pub struct PoolConfigBuilder {
202 max_idle_per_host: Option<usize>,
203 max_idle_total: Option<usize>,
204 idle_timeout: Option<Duration>,
205 tcp_keepalive: Option<Option<Duration>>,
207 tcp_nodelay: Option<bool>,
208 max_response_size: Option<usize>,
209}
210
211impl PoolConfigBuilder {
212 #[must_use]
214 pub fn max_idle_per_host(mut self, max: usize) -> Self {
215 self.max_idle_per_host = Some(max);
216 self
217 }
218
219 #[must_use]
221 pub fn unlimited_idle_per_host(mut self) -> Self {
222 self.max_idle_per_host = None;
223 self
224 }
225
226 #[must_use]
228 pub fn max_idle_total(mut self, max: usize) -> Self {
229 self.max_idle_total = Some(max);
230 self
231 }
232
233 #[must_use]
235 pub fn idle_timeout(mut self, timeout: Duration) -> Self {
236 self.idle_timeout = Some(timeout);
237 self
238 }
239
240 #[must_use]
242 pub fn tcp_keepalive(mut self, interval: Duration) -> Self {
243 self.tcp_keepalive = Some(Some(interval));
244 self
245 }
246
247 #[must_use]
249 pub fn no_tcp_keepalive(mut self) -> Self {
250 self.tcp_keepalive = Some(None);
251 self
252 }
253
254 #[must_use]
256 pub fn tcp_nodelay(mut self, enabled: bool) -> Self {
257 self.tcp_nodelay = Some(enabled);
258 self
259 }
260
261 #[must_use]
267 pub fn max_response_size(mut self, size: usize) -> Self {
268 self.max_response_size = Some(size);
269 self
270 }
271
272 pub fn build(self) -> PoolConfig {
274 let default = PoolConfig::default();
275 PoolConfig {
276 max_idle_per_host: self.max_idle_per_host.or(default.max_idle_per_host),
277 max_idle_total: self.max_idle_total.unwrap_or(default.max_idle_total),
278 idle_timeout: self.idle_timeout.unwrap_or(default.idle_timeout),
279 tcp_keepalive: self.tcp_keepalive.unwrap_or(default.tcp_keepalive),
280 tcp_nodelay: self.tcp_nodelay.unwrap_or(default.tcp_nodelay),
281 max_response_size: self.max_response_size.unwrap_or(default.max_response_size),
282 }
283 }
284}
285
286#[derive(Debug, Clone)]
309pub struct MovementConfig {
310 pub(crate) network: Network,
312 pub(crate) fullnode_url: Url,
314 pub(crate) indexer_url: Option<Url>,
316 pub(crate) faucet_url: Option<Url>,
318 pub(crate) timeout: Duration,
320 pub(crate) retry_config: RetryConfig,
322 pub(crate) pool_config: PoolConfig,
324 pub(crate) api_key: Option<String>,
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
330pub enum Network {
331 Mainnet,
333 Testnet,
335 Devnet,
337 Local,
339 Custom,
341}
342
343impl Network {
344 pub fn chain_id(&self) -> ChainId {
346 match self {
347 Network::Mainnet => ChainId::mainnet(),
348 Network::Testnet => ChainId::testnet(),
349 Network::Devnet => ChainId::new(165), Network::Local => ChainId::new(4), Network::Custom => ChainId::new(0), }
353 }
354
355 pub fn as_str(&self) -> &'static str {
357 match self {
358 Network::Mainnet => "mainnet",
359 Network::Testnet => "testnet",
360 Network::Devnet => "devnet",
361 Network::Local => "local",
362 Network::Custom => "custom",
363 }
364 }
365}
366
367impl Default for MovementConfig {
368 fn default() -> Self {
369 Self::devnet()
370 }
371}
372
373impl MovementConfig {
374 #[allow(clippy::missing_panics_doc)]
384 #[must_use]
385 pub fn mainnet() -> Self {
386 Self {
387 network: Network::Mainnet,
388 fullnode_url: Url::parse("https://mainnet.movementnetwork.xyz/v1")
389 .expect("valid mainnet URL"),
390 indexer_url: None,
391 faucet_url: None,
392 timeout: Duration::from_secs(30),
393 retry_config: RetryConfig::conservative(),
394 pool_config: PoolConfig::default(),
395 api_key: None,
396 }
397 }
398
399 #[allow(clippy::missing_panics_doc)]
409 #[must_use]
410 pub fn testnet() -> Self {
411 Self {
412 network: Network::Testnet,
413 fullnode_url: Url::parse("https://testnet.movementnetwork.xyz/v1")
414 .expect("valid testnet URL"),
415 indexer_url: None,
416 faucet_url: None,
417 timeout: Duration::from_secs(30),
418 retry_config: RetryConfig::default(),
419 pool_config: PoolConfig::default(),
420 api_key: None,
421 }
422 }
423
424 #[allow(clippy::missing_panics_doc)]
434 #[must_use]
435 pub fn devnet() -> Self {
436 Self::testnet()
438 }
439
440 #[allow(clippy::missing_panics_doc)]
453 #[must_use]
454 pub fn local() -> Self {
455 Self {
456 network: Network::Local,
457 fullnode_url: Url::parse("http://127.0.0.1:8080/v1").expect("valid local URL"),
458 indexer_url: None,
459 faucet_url: Some(Url::parse("http://127.0.0.1:8081").expect("valid local faucet URL")),
460 timeout: Duration::from_secs(10),
461 retry_config: RetryConfig::aggressive(), pool_config: PoolConfig::low_latency(), api_key: None,
464 }
465 }
466
467 pub fn custom(fullnode_url: &str) -> MovementResult<Self> {
488 let url = Url::parse(fullnode_url)?;
489 validate_url_scheme(&url)?;
490 Ok(Self {
491 network: Network::Custom,
492 fullnode_url: url,
493 indexer_url: None,
494 faucet_url: None,
495 timeout: Duration::from_secs(30),
496 retry_config: RetryConfig::default(),
497 pool_config: PoolConfig::default(),
498 api_key: None,
499 })
500 }
501
502 #[must_use]
504 pub fn with_timeout(mut self, timeout: Duration) -> Self {
505 self.timeout = timeout;
506 self
507 }
508
509 #[must_use]
521 pub fn with_retry(mut self, retry_config: RetryConfig) -> Self {
522 self.retry_config = retry_config;
523 self
524 }
525
526 #[must_use]
530 pub fn without_retry(mut self) -> Self {
531 self.retry_config = RetryConfig::no_retry();
532 self
533 }
534
535 #[must_use]
539 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
540 self.retry_config = RetryConfig::builder()
541 .max_retries(max_retries)
542 .initial_delay_ms(self.retry_config.initial_delay_ms)
543 .max_delay_ms(self.retry_config.max_delay_ms)
544 .exponential_base(self.retry_config.exponential_base)
545 .jitter(self.retry_config.jitter)
546 .build();
547 self
548 }
549
550 #[must_use]
562 pub fn with_pool(mut self, pool_config: PoolConfig) -> Self {
563 self.pool_config = pool_config;
564 self
565 }
566
567 #[must_use]
572 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
573 self.api_key = Some(api_key.into());
574 self
575 }
576
577 pub fn with_indexer_url(mut self, url: &str) -> MovementResult<Self> {
588 let parsed = Url::parse(url)?;
589 validate_url_scheme(&parsed)?;
590 self.indexer_url = Some(parsed);
591 Ok(self)
592 }
593
594 pub fn with_faucet_url(mut self, url: &str) -> MovementResult<Self> {
605 let parsed = Url::parse(url)?;
606 validate_url_scheme(&parsed)?;
607 self.faucet_url = Some(parsed);
608 Ok(self)
609 }
610
611 pub fn network(&self) -> Network {
613 self.network
614 }
615
616 pub fn fullnode_url(&self) -> &Url {
618 &self.fullnode_url
619 }
620
621 pub fn indexer_url(&self) -> Option<&Url> {
623 self.indexer_url.as_ref()
624 }
625
626 pub fn faucet_url(&self) -> Option<&Url> {
628 self.faucet_url.as_ref()
629 }
630
631 pub fn chain_id(&self) -> ChainId {
633 self.network.chain_id()
634 }
635
636 pub fn retry_config(&self) -> &RetryConfig {
638 &self.retry_config
639 }
640
641 pub fn timeout(&self) -> Duration {
643 self.timeout
644 }
645
646 pub fn pool_config(&self) -> &PoolConfig {
648 &self.pool_config
649 }
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655
656 #[test]
657 fn test_mainnet_config() {
658 let config = MovementConfig::mainnet();
659 assert_eq!(config.network(), Network::Mainnet);
660 assert!(config.fullnode_url().as_str().contains("mainnet"));
661 assert!(config.faucet_url().is_none());
662 }
663
664 #[test]
665 fn test_testnet_config() {
666 let config = MovementConfig::testnet();
667 assert_eq!(config.network(), Network::Testnet);
668 assert!(config.fullnode_url().as_str().contains("testnet"));
669 assert!(config.faucet_url().is_none());
670 }
671
672 #[test]
673 fn test_devnet_config() {
674 let config = MovementConfig::devnet();
676 assert_eq!(config.network(), Network::Testnet);
677 assert!(config.fullnode_url().as_str().contains("testnet"));
678 }
679
680 #[test]
681 fn test_local_config() {
682 let config = MovementConfig::local();
683 assert_eq!(config.network(), Network::Local);
684 assert!(config.fullnode_url().as_str().contains("127.0.0.1"));
685 assert!(config.faucet_url().is_some());
686 assert!(config.indexer_url().is_none());
687 }
688
689 #[test]
690 fn test_custom_config() {
691 let config = MovementConfig::custom("https://custom.example.com/v1").unwrap();
692 assert_eq!(config.network(), Network::Custom);
693 assert_eq!(
694 config.fullnode_url().as_str(),
695 "https://custom.example.com/v1"
696 );
697 }
698
699 #[test]
700 fn test_custom_config_invalid_url() {
701 let result = MovementConfig::custom("not a valid url");
702 assert!(result.is_err());
703 }
704
705 #[test]
706 fn test_builder_methods() {
707 let config = MovementConfig::testnet()
708 .with_timeout(Duration::from_secs(60))
709 .with_max_retries(5)
710 .with_api_key("test-key");
711
712 assert_eq!(config.timeout, Duration::from_secs(60));
713 assert_eq!(config.retry_config.max_retries, 5);
714 assert_eq!(config.api_key, Some("test-key".to_string()));
715 }
716
717 #[test]
718 fn test_retry_config() {
719 let config = MovementConfig::testnet().with_retry(RetryConfig::aggressive());
720
721 assert_eq!(config.retry_config.max_retries, 5);
722 assert_eq!(config.retry_config.initial_delay_ms, 50);
723
724 let config = MovementConfig::testnet().without_retry();
725 assert_eq!(config.retry_config.max_retries, 0);
726 }
727
728 #[test]
729 fn test_network_retry_defaults() {
730 let mainnet = MovementConfig::mainnet();
732 assert_eq!(mainnet.retry_config.max_retries, 3);
733
734 let local = MovementConfig::local();
736 assert_eq!(local.retry_config.max_retries, 5);
737 }
738
739 #[test]
740 fn test_pool_config_default() {
741 let config = PoolConfig::default();
742 assert_eq!(config.max_idle_total, 100);
743 assert_eq!(config.idle_timeout, Duration::from_secs(90));
744 assert!(config.tcp_nodelay);
745 }
746
747 #[test]
748 fn test_pool_config_presets() {
749 let high = PoolConfig::high_throughput();
750 assert_eq!(high.max_idle_per_host, Some(32));
751 assert_eq!(high.max_idle_total, 256);
752
753 let low = PoolConfig::low_latency();
754 assert_eq!(low.max_idle_per_host, Some(8));
755 assert_eq!(low.idle_timeout, Duration::from_secs(30));
756
757 let minimal = PoolConfig::minimal();
758 assert_eq!(minimal.max_idle_per_host, Some(2));
759 assert_eq!(minimal.max_idle_total, 8);
760 }
761
762 #[test]
763 fn test_pool_config_builder() {
764 let config = PoolConfig::builder()
765 .max_idle_per_host(16)
766 .max_idle_total(64)
767 .idle_timeout(Duration::from_secs(60))
768 .tcp_nodelay(false)
769 .build();
770
771 assert_eq!(config.max_idle_per_host, Some(16));
772 assert_eq!(config.max_idle_total, 64);
773 assert_eq!(config.idle_timeout, Duration::from_secs(60));
774 assert!(!config.tcp_nodelay);
775 }
776
777 #[test]
778 fn test_pool_config_builder_tcp_keepalive() {
779 let config = PoolConfig::builder()
780 .tcp_keepalive(Duration::from_secs(30))
781 .build();
782 assert_eq!(config.tcp_keepalive, Some(Duration::from_secs(30)));
783
784 let config = PoolConfig::builder().no_tcp_keepalive().build();
785 assert_eq!(config.tcp_keepalive, None);
786 }
787
788 #[test]
789 fn test_pool_config_builder_unlimited_idle() {
790 let config = PoolConfig::builder().unlimited_idle_per_host().build();
791 assert_eq!(config.max_idle_per_host, None);
792 }
793
794 #[test]
795 fn test_movement_config_with_pool() {
796 let config = MovementConfig::testnet().with_pool(PoolConfig::high_throughput());
797
798 assert_eq!(config.pool_config.max_idle_total, 256);
799 }
800
801 #[test]
802 fn test_movement_config_with_indexer_url() {
803 let config = MovementConfig::testnet()
804 .with_indexer_url("https://custom-indexer.example.com/graphql")
805 .unwrap();
806 assert_eq!(
807 config.indexer_url().unwrap().as_str(),
808 "https://custom-indexer.example.com/graphql"
809 );
810 }
811
812 #[test]
813 fn test_movement_config_with_faucet_url() {
814 let config = MovementConfig::mainnet()
815 .with_faucet_url("https://custom-faucet.example.com")
816 .unwrap();
817 assert_eq!(
818 config.faucet_url().unwrap().as_str(),
819 "https://custom-faucet.example.com/"
820 );
821 }
822
823 #[test]
824 fn test_movement_config_default() {
825 let config = MovementConfig::default();
826 assert_eq!(config.network(), Network::Testnet);
828 }
829
830 #[test]
831 fn test_network_chain_id() {
832 assert_eq!(Network::Mainnet.chain_id().id(), 1);
833 assert_eq!(Network::Testnet.chain_id().id(), 2);
834 assert_eq!(Network::Devnet.chain_id().id(), 165);
835 assert_eq!(Network::Local.chain_id().id(), 4);
836 assert_eq!(Network::Custom.chain_id().id(), 0);
837 }
838
839 #[test]
840 fn test_network_as_str() {
841 assert_eq!(Network::Mainnet.as_str(), "mainnet");
842 assert_eq!(Network::Testnet.as_str(), "testnet");
843 assert_eq!(Network::Devnet.as_str(), "devnet");
844 assert_eq!(Network::Local.as_str(), "local");
845 assert_eq!(Network::Custom.as_str(), "custom");
846 }
847
848 #[test]
849 fn test_movement_config_getters() {
850 let config = MovementConfig::testnet();
851
852 assert_eq!(config.timeout(), Duration::from_secs(30));
853 assert!(config.retry_config().max_retries > 0);
854 assert!(config.pool_config().max_idle_total > 0);
855 assert_eq!(config.chain_id().id(), 2);
856 }
857
858 #[tokio::test]
859 async fn test_read_response_bounded_normal() {
860 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
861 let server = MockServer::start().await;
862 Mock::given(method("GET"))
863 .respond_with(ResponseTemplate::new(200).set_body_string("hello world"))
864 .mount(&server)
865 .await;
866
867 let response = reqwest::get(server.uri()).await.unwrap();
868 let body = read_response_bounded(response, 1024).await.unwrap();
869 assert_eq!(body, b"hello world");
870 }
871
872 #[tokio::test]
873 async fn test_read_response_bounded_rejects_oversized_content_length() {
874 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
875 let server = MockServer::start().await;
876 let body = "x".repeat(200);
880 Mock::given(method("GET"))
881 .respond_with(ResponseTemplate::new(200).set_body_string(body))
882 .mount(&server)
883 .await;
884
885 let response = reqwest::get(server.uri()).await.unwrap();
886 let result = read_response_bounded(response, 100).await;
888 assert!(result.is_err());
889 let err = result.unwrap_err().to_string();
890 assert!(err.contains("response too large"));
891 }
892
893 #[tokio::test]
894 async fn test_read_response_bounded_rejects_oversized_body() {
895 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
896 let server = MockServer::start().await;
897 let large_body = "x".repeat(500);
898 Mock::given(method("GET"))
899 .respond_with(ResponseTemplate::new(200).set_body_string(large_body))
900 .mount(&server)
901 .await;
902
903 let response = reqwest::get(server.uri()).await.unwrap();
904 let result = read_response_bounded(response, 100).await;
905 assert!(result.is_err());
906 }
907
908 #[tokio::test]
909 async fn test_read_response_bounded_exact_limit() {
910 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
911 let server = MockServer::start().await;
912 let body = "x".repeat(100);
913 Mock::given(method("GET"))
914 .respond_with(ResponseTemplate::new(200).set_body_string(body.clone()))
915 .mount(&server)
916 .await;
917
918 let response = reqwest::get(server.uri()).await.unwrap();
919 let result = read_response_bounded(response, 100).await.unwrap();
920 assert_eq!(result.len(), 100);
921 }
922
923 #[tokio::test]
924 async fn test_read_response_bounded_empty() {
925 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
926 let server = MockServer::start().await;
927 Mock::given(method("GET"))
928 .respond_with(ResponseTemplate::new(200))
929 .mount(&server)
930 .await;
931
932 let response = reqwest::get(server.uri()).await.unwrap();
933 let result = read_response_bounded(response, 1024).await.unwrap();
934 assert!(result.is_empty());
935 }
936}