1use crate::error::{AptosError, AptosResult};
7use crate::retry::RetryConfig;
8use crate::types::ChainId;
9use std::time::Duration;
10use url::Url;
11
12pub fn validate_url_scheme(url: &Url) -> AptosResult<()> {
24 match url.scheme() {
25 "https" => Ok(()),
26 "http" => {
27 Ok(())
29 }
30 scheme => Err(AptosError::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) -> AptosResult<Vec<u8>> {
56 if let Some(content_length) = response.content_length()
58 && content_length > max_size as u64
59 {
60 return Err(AptosError::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(AptosError::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_mins(1)),
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_mins(5),
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 AptosConfig {
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 {
351 match self {
352 Network::Mainnet => ChainId::mainnet(),
353 Network::Testnet => ChainId::testnet(),
354 Network::Devnet => ChainId::new(0),
355 Network::Local => ChainId::new(4),
356 Network::Custom => ChainId::new(0),
357 }
358 }
359
360 pub fn as_str(&self) -> &'static str {
362 match self {
363 Network::Mainnet => "mainnet",
364 Network::Testnet => "testnet",
365 Network::Devnet => "devnet",
366 Network::Local => "local",
367 Network::Custom => "custom",
368 }
369 }
370}
371
372impl Default for AptosConfig {
373 fn default() -> Self {
374 Self::devnet()
375 }
376}
377
378impl AptosConfig {
379 #[allow(clippy::missing_panics_doc)]
389 #[must_use]
390 pub fn mainnet() -> Self {
391 Self {
392 network: Network::Mainnet,
393 fullnode_url: Url::parse("https://fullnode.mainnet.aptoslabs.com/v1")
394 .expect("valid mainnet URL"),
395 indexer_url: Some(
396 Url::parse("https://indexer.mainnet.aptoslabs.com/v1/graphql")
397 .expect("valid indexer URL"),
398 ),
399 faucet_url: None, timeout: Duration::from_secs(30),
401 retry_config: RetryConfig::conservative(), pool_config: PoolConfig::default(),
403 api_key: None,
404 }
405 }
406
407 #[allow(clippy::missing_panics_doc)]
417 #[must_use]
418 pub fn testnet() -> Self {
419 Self {
420 network: Network::Testnet,
421 fullnode_url: Url::parse("https://fullnode.testnet.aptoslabs.com/v1")
422 .expect("valid testnet URL"),
423 indexer_url: Some(
424 Url::parse("https://indexer.testnet.aptoslabs.com/v1/graphql")
425 .expect("valid indexer URL"),
426 ),
427 faucet_url: Some(
428 Url::parse("https://faucet.testnet.aptoslabs.com").expect("valid faucet URL"),
429 ),
430 timeout: Duration::from_secs(30),
431 retry_config: RetryConfig::default(),
432 pool_config: PoolConfig::default(),
433 api_key: None,
434 }
435 }
436
437 #[allow(clippy::missing_panics_doc)]
447 #[must_use]
448 pub fn devnet() -> Self {
449 Self {
450 network: Network::Devnet,
451 fullnode_url: Url::parse("https://fullnode.devnet.aptoslabs.com/v1")
452 .expect("valid devnet URL"),
453 indexer_url: Some(
454 Url::parse("https://indexer.devnet.aptoslabs.com/v1/graphql")
455 .expect("valid indexer URL"),
456 ),
457 faucet_url: Some(
458 Url::parse("https://faucet.devnet.aptoslabs.com").expect("valid faucet URL"),
459 ),
460 timeout: Duration::from_secs(30),
461 retry_config: RetryConfig::default(),
462 pool_config: PoolConfig::default(),
463 api_key: None,
464 }
465 }
466
467 #[allow(clippy::missing_panics_doc)]
480 #[must_use]
481 pub fn local() -> Self {
482 Self {
483 network: Network::Local,
484 fullnode_url: Url::parse("http://127.0.0.1:8080/v1").expect("valid local URL"),
485 indexer_url: None,
486 faucet_url: Some(Url::parse("http://127.0.0.1:8081").expect("valid local faucet URL")),
487 timeout: Duration::from_secs(10),
488 retry_config: RetryConfig::aggressive(), pool_config: PoolConfig::low_latency(), api_key: None,
491 }
492 }
493
494 pub fn custom(fullnode_url: &str) -> AptosResult<Self> {
515 let url = Url::parse(fullnode_url)?;
516 validate_url_scheme(&url)?;
517 Ok(Self {
518 network: Network::Custom,
519 fullnode_url: url,
520 indexer_url: None,
521 faucet_url: None,
522 timeout: Duration::from_secs(30),
523 retry_config: RetryConfig::default(),
524 pool_config: PoolConfig::default(),
525 api_key: None,
526 })
527 }
528
529 #[must_use]
531 pub fn with_timeout(mut self, timeout: Duration) -> Self {
532 self.timeout = timeout;
533 self
534 }
535
536 #[must_use]
548 pub fn with_retry(mut self, retry_config: RetryConfig) -> Self {
549 self.retry_config = retry_config;
550 self
551 }
552
553 #[must_use]
557 pub fn without_retry(mut self) -> Self {
558 self.retry_config = RetryConfig::no_retry();
559 self
560 }
561
562 #[must_use]
569 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
570 self.retry_config.max_retries = max_retries;
571 self
572 }
573
574 #[must_use]
586 pub fn with_pool(mut self, pool_config: PoolConfig) -> Self {
587 self.pool_config = pool_config;
588 self
589 }
590
591 #[must_use]
596 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
597 self.api_key = Some(api_key.into());
598 self
599 }
600
601 pub fn with_indexer_url(mut self, url: &str) -> AptosResult<Self> {
612 let parsed = Url::parse(url)?;
613 validate_url_scheme(&parsed)?;
614 self.indexer_url = Some(parsed);
615 Ok(self)
616 }
617
618 pub fn with_faucet_url(mut self, url: &str) -> AptosResult<Self> {
629 let parsed = Url::parse(url)?;
630 validate_url_scheme(&parsed)?;
631 self.faucet_url = Some(parsed);
632 Ok(self)
633 }
634
635 pub fn network(&self) -> Network {
637 self.network
638 }
639
640 pub fn fullnode_url(&self) -> &Url {
642 &self.fullnode_url
643 }
644
645 pub fn indexer_url(&self) -> Option<&Url> {
647 self.indexer_url.as_ref()
648 }
649
650 pub fn faucet_url(&self) -> Option<&Url> {
652 self.faucet_url.as_ref()
653 }
654
655 pub fn chain_id(&self) -> ChainId {
657 self.network.chain_id()
658 }
659
660 pub fn retry_config(&self) -> &RetryConfig {
662 &self.retry_config
663 }
664
665 pub fn timeout(&self) -> Duration {
667 self.timeout
668 }
669
670 pub fn pool_config(&self) -> &PoolConfig {
672 &self.pool_config
673 }
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679
680 #[test]
681 fn test_mainnet_config() {
682 let config = AptosConfig::mainnet();
683 assert_eq!(config.network(), Network::Mainnet);
684 assert!(config.fullnode_url().as_str().contains("mainnet"));
685 assert!(config.faucet_url().is_none());
686 }
687
688 #[test]
689 fn test_testnet_config() {
690 let config = AptosConfig::testnet();
691 assert_eq!(config.network(), Network::Testnet);
692 assert!(config.fullnode_url().as_str().contains("testnet"));
693 assert!(config.faucet_url().is_some());
694 }
695
696 #[test]
697 fn test_devnet_config() {
698 let config = AptosConfig::devnet();
699 assert_eq!(config.network(), Network::Devnet);
700 assert!(config.fullnode_url().as_str().contains("devnet"));
701 assert!(config.faucet_url().is_some());
702 assert!(config.indexer_url().is_some());
703 }
704
705 #[test]
706 fn test_local_config() {
707 let config = AptosConfig::local();
708 assert_eq!(config.network(), Network::Local);
709 assert!(config.fullnode_url().as_str().contains("127.0.0.1"));
710 assert!(config.faucet_url().is_some());
711 assert!(config.indexer_url().is_none());
712 }
713
714 #[test]
715 fn test_custom_config() {
716 let config = AptosConfig::custom("https://custom.example.com/v1").unwrap();
717 assert_eq!(config.network(), Network::Custom);
718 assert_eq!(
719 config.fullnode_url().as_str(),
720 "https://custom.example.com/v1"
721 );
722 }
723
724 #[test]
725 fn test_custom_config_invalid_url() {
726 let result = AptosConfig::custom("not a valid url");
727 assert!(result.is_err());
728 }
729
730 #[test]
731 fn test_builder_methods() {
732 let config = AptosConfig::testnet()
733 .with_timeout(Duration::from_mins(1))
734 .with_max_retries(5)
735 .with_api_key("test-key");
736
737 assert_eq!(config.timeout, Duration::from_mins(1));
738 assert_eq!(config.retry_config.max_retries, 5);
739 assert_eq!(config.api_key, Some("test-key".to_string()));
740 }
741
742 #[test]
743 fn test_with_max_retries_preserves_other_fields() {
744 let custom_retry = RetryConfig::builder()
746 .max_retries(2)
747 .initial_delay_ms(321)
748 .max_delay_ms(54_321)
749 .exponential_base(1.75)
750 .jitter(false)
751 .jitter_factor(0.125)
752 .retryable_status_codes([418, 599])
753 .build();
754
755 let config = AptosConfig::testnet()
756 .with_retry(custom_retry)
757 .with_max_retries(9);
758
759 let retry = config.retry_config();
760 assert_eq!(retry.max_retries, 9);
762 assert_eq!(retry.initial_delay_ms, 321);
764 assert_eq!(retry.max_delay_ms, 54_321);
765 assert!((retry.exponential_base - 1.75).abs() < f64::EPSILON);
766 assert!(!retry.jitter);
767 assert!((retry.jitter_factor - 0.125).abs() < f64::EPSILON);
768 assert!(retry.is_retryable_status(418));
769 assert!(retry.is_retryable_status(599));
770 assert!(!retry.is_retryable_status(500));
772 }
773
774 #[test]
775 fn test_retry_config() {
776 let config = AptosConfig::testnet().with_retry(RetryConfig::aggressive());
777
778 assert_eq!(config.retry_config.max_retries, 5);
779 assert_eq!(config.retry_config.initial_delay_ms, 50);
780
781 let config = AptosConfig::testnet().without_retry();
782 assert_eq!(config.retry_config.max_retries, 0);
783 }
784
785 #[test]
786 fn test_network_retry_defaults() {
787 let mainnet = AptosConfig::mainnet();
789 assert_eq!(mainnet.retry_config.max_retries, 3);
790
791 let local = AptosConfig::local();
793 assert_eq!(local.retry_config.max_retries, 5);
794 }
795
796 #[test]
797 fn test_pool_config_default() {
798 let config = PoolConfig::default();
799 assert_eq!(config.max_idle_total, 100);
800 assert_eq!(config.idle_timeout, Duration::from_secs(90));
801 assert!(config.tcp_nodelay);
802 }
803
804 #[test]
805 fn test_pool_config_presets() {
806 let high = PoolConfig::high_throughput();
807 assert_eq!(high.max_idle_per_host, Some(32));
808 assert_eq!(high.max_idle_total, 256);
809
810 let low = PoolConfig::low_latency();
811 assert_eq!(low.max_idle_per_host, Some(8));
812 assert_eq!(low.idle_timeout, Duration::from_secs(30));
813
814 let minimal = PoolConfig::minimal();
815 assert_eq!(minimal.max_idle_per_host, Some(2));
816 assert_eq!(minimal.max_idle_total, 8);
817 }
818
819 #[test]
820 fn test_pool_config_builder() {
821 let config = PoolConfig::builder()
822 .max_idle_per_host(16)
823 .max_idle_total(64)
824 .idle_timeout(Duration::from_mins(1))
825 .tcp_nodelay(false)
826 .build();
827
828 assert_eq!(config.max_idle_per_host, Some(16));
829 assert_eq!(config.max_idle_total, 64);
830 assert_eq!(config.idle_timeout, Duration::from_mins(1));
831 assert!(!config.tcp_nodelay);
832 }
833
834 #[test]
835 fn test_pool_config_builder_tcp_keepalive() {
836 let config = PoolConfig::builder()
837 .tcp_keepalive(Duration::from_secs(30))
838 .build();
839 assert_eq!(config.tcp_keepalive, Some(Duration::from_secs(30)));
840
841 let config = PoolConfig::builder().no_tcp_keepalive().build();
842 assert_eq!(config.tcp_keepalive, None);
843 }
844
845 #[test]
846 fn test_pool_config_builder_unlimited_idle() {
847 let config = PoolConfig::builder().unlimited_idle_per_host().build();
848 assert_eq!(config.max_idle_per_host, None);
849 }
850
851 #[test]
852 fn test_aptos_config_with_pool() {
853 let config = AptosConfig::testnet().with_pool(PoolConfig::high_throughput());
854
855 assert_eq!(config.pool_config.max_idle_total, 256);
856 }
857
858 #[test]
859 fn test_aptos_config_with_indexer_url() {
860 let config = AptosConfig::testnet()
861 .with_indexer_url("https://custom-indexer.example.com/graphql")
862 .unwrap();
863 assert_eq!(
864 config.indexer_url().unwrap().as_str(),
865 "https://custom-indexer.example.com/graphql"
866 );
867 }
868
869 #[test]
870 fn test_aptos_config_with_faucet_url() {
871 let config = AptosConfig::mainnet()
872 .with_faucet_url("https://custom-faucet.example.com")
873 .unwrap();
874 assert_eq!(
875 config.faucet_url().unwrap().as_str(),
876 "https://custom-faucet.example.com/"
877 );
878 }
879
880 #[test]
881 fn test_aptos_config_default() {
882 let config = AptosConfig::default();
883 assert_eq!(config.network(), Network::Devnet);
884 }
885
886 #[test]
887 fn test_network_chain_id() {
888 assert_eq!(Network::Mainnet.chain_id().id(), 1);
889 assert_eq!(Network::Testnet.chain_id().id(), 2);
890 assert_eq!(Network::Devnet.chain_id().id(), 0);
893 assert_eq!(Network::Local.chain_id().id(), 4);
894 assert_eq!(Network::Custom.chain_id().id(), 0);
895 }
896
897 #[test]
898 fn test_network_as_str() {
899 assert_eq!(Network::Mainnet.as_str(), "mainnet");
900 assert_eq!(Network::Testnet.as_str(), "testnet");
901 assert_eq!(Network::Devnet.as_str(), "devnet");
902 assert_eq!(Network::Local.as_str(), "local");
903 assert_eq!(Network::Custom.as_str(), "custom");
904 }
905
906 #[test]
907 fn test_aptos_config_getters() {
908 let config = AptosConfig::testnet();
909
910 assert_eq!(config.timeout(), Duration::from_secs(30));
911 assert!(config.retry_config().max_retries > 0);
912 assert!(config.pool_config().max_idle_total > 0);
913 assert_eq!(config.chain_id().id(), 2);
914 }
915
916 #[tokio::test]
917 async fn test_read_response_bounded_normal() {
918 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
919 let server = MockServer::start().await;
920 Mock::given(method("GET"))
921 .respond_with(ResponseTemplate::new(200).set_body_string("hello world"))
922 .mount(&server)
923 .await;
924
925 let response = reqwest::get(server.uri()).await.unwrap();
926 let body = read_response_bounded(response, 1024).await.unwrap();
927 assert_eq!(body, b"hello world");
928 }
929
930 #[tokio::test]
931 async fn test_read_response_bounded_rejects_oversized_content_length() {
932 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
933 let server = MockServer::start().await;
934 let body = "x".repeat(200);
938 Mock::given(method("GET"))
939 .respond_with(ResponseTemplate::new(200).set_body_string(body))
940 .mount(&server)
941 .await;
942
943 let response = reqwest::get(server.uri()).await.unwrap();
944 let result = read_response_bounded(response, 100).await;
946 assert!(result.is_err());
947 let err = result.unwrap_err().to_string();
948 assert!(err.contains("response too large"));
949 }
950
951 #[tokio::test]
952 async fn test_read_response_bounded_rejects_oversized_body() {
953 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
954 let server = MockServer::start().await;
955 let large_body = "x".repeat(500);
956 Mock::given(method("GET"))
957 .respond_with(ResponseTemplate::new(200).set_body_string(large_body))
958 .mount(&server)
959 .await;
960
961 let response = reqwest::get(server.uri()).await.unwrap();
962 let result = read_response_bounded(response, 100).await;
963 assert!(result.is_err());
964 }
965
966 #[tokio::test]
967 async fn test_read_response_bounded_exact_limit() {
968 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
969 let server = MockServer::start().await;
970 let body = "x".repeat(100);
971 Mock::given(method("GET"))
972 .respond_with(ResponseTemplate::new(200).set_body_string(body.clone()))
973 .mount(&server)
974 .await;
975
976 let response = reqwest::get(server.uri()).await.unwrap();
977 let result = read_response_bounded(response, 100).await.unwrap();
978 assert_eq!(result.len(), 100);
979 }
980
981 #[tokio::test]
982 async fn test_read_response_bounded_empty() {
983 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
984 let server = MockServer::start().await;
985 Mock::given(method("GET"))
986 .respond_with(ResponseTemplate::new(200))
987 .mount(&server)
988 .await;
989
990 let response = reqwest::get(server.uri()).await.unwrap();
991 let result = read_response_bounded(response, 1024).await.unwrap();
992 assert!(result.is_empty());
993 }
994}