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]
566 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
567 self.retry_config = RetryConfig::builder()
568 .max_retries(max_retries)
569 .initial_delay_ms(self.retry_config.initial_delay_ms)
570 .max_delay_ms(self.retry_config.max_delay_ms)
571 .exponential_base(self.retry_config.exponential_base)
572 .jitter(self.retry_config.jitter)
573 .build();
574 self
575 }
576
577 #[must_use]
589 pub fn with_pool(mut self, pool_config: PoolConfig) -> Self {
590 self.pool_config = pool_config;
591 self
592 }
593
594 #[must_use]
599 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
600 self.api_key = Some(api_key.into());
601 self
602 }
603
604 pub fn with_indexer_url(mut self, url: &str) -> AptosResult<Self> {
615 let parsed = Url::parse(url)?;
616 validate_url_scheme(&parsed)?;
617 self.indexer_url = Some(parsed);
618 Ok(self)
619 }
620
621 pub fn with_faucet_url(mut self, url: &str) -> AptosResult<Self> {
632 let parsed = Url::parse(url)?;
633 validate_url_scheme(&parsed)?;
634 self.faucet_url = Some(parsed);
635 Ok(self)
636 }
637
638 pub fn network(&self) -> Network {
640 self.network
641 }
642
643 pub fn fullnode_url(&self) -> &Url {
645 &self.fullnode_url
646 }
647
648 pub fn indexer_url(&self) -> Option<&Url> {
650 self.indexer_url.as_ref()
651 }
652
653 pub fn faucet_url(&self) -> Option<&Url> {
655 self.faucet_url.as_ref()
656 }
657
658 pub fn chain_id(&self) -> ChainId {
660 self.network.chain_id()
661 }
662
663 pub fn retry_config(&self) -> &RetryConfig {
665 &self.retry_config
666 }
667
668 pub fn timeout(&self) -> Duration {
670 self.timeout
671 }
672
673 pub fn pool_config(&self) -> &PoolConfig {
675 &self.pool_config
676 }
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682
683 #[test]
684 fn test_mainnet_config() {
685 let config = AptosConfig::mainnet();
686 assert_eq!(config.network(), Network::Mainnet);
687 assert!(config.fullnode_url().as_str().contains("mainnet"));
688 assert!(config.faucet_url().is_none());
689 }
690
691 #[test]
692 fn test_testnet_config() {
693 let config = AptosConfig::testnet();
694 assert_eq!(config.network(), Network::Testnet);
695 assert!(config.fullnode_url().as_str().contains("testnet"));
696 assert!(config.faucet_url().is_some());
697 }
698
699 #[test]
700 fn test_devnet_config() {
701 let config = AptosConfig::devnet();
702 assert_eq!(config.network(), Network::Devnet);
703 assert!(config.fullnode_url().as_str().contains("devnet"));
704 assert!(config.faucet_url().is_some());
705 assert!(config.indexer_url().is_some());
706 }
707
708 #[test]
709 fn test_local_config() {
710 let config = AptosConfig::local();
711 assert_eq!(config.network(), Network::Local);
712 assert!(config.fullnode_url().as_str().contains("127.0.0.1"));
713 assert!(config.faucet_url().is_some());
714 assert!(config.indexer_url().is_none());
715 }
716
717 #[test]
718 fn test_custom_config() {
719 let config = AptosConfig::custom("https://custom.example.com/v1").unwrap();
720 assert_eq!(config.network(), Network::Custom);
721 assert_eq!(
722 config.fullnode_url().as_str(),
723 "https://custom.example.com/v1"
724 );
725 }
726
727 #[test]
728 fn test_custom_config_invalid_url() {
729 let result = AptosConfig::custom("not a valid url");
730 assert!(result.is_err());
731 }
732
733 #[test]
734 fn test_builder_methods() {
735 let config = AptosConfig::testnet()
736 .with_timeout(Duration::from_mins(1))
737 .with_max_retries(5)
738 .with_api_key("test-key");
739
740 assert_eq!(config.timeout, Duration::from_mins(1));
741 assert_eq!(config.retry_config.max_retries, 5);
742 assert_eq!(config.api_key, Some("test-key".to_string()));
743 }
744
745 #[test]
746 fn test_retry_config() {
747 let config = AptosConfig::testnet().with_retry(RetryConfig::aggressive());
748
749 assert_eq!(config.retry_config.max_retries, 5);
750 assert_eq!(config.retry_config.initial_delay_ms, 50);
751
752 let config = AptosConfig::testnet().without_retry();
753 assert_eq!(config.retry_config.max_retries, 0);
754 }
755
756 #[test]
757 fn test_network_retry_defaults() {
758 let mainnet = AptosConfig::mainnet();
760 assert_eq!(mainnet.retry_config.max_retries, 3);
761
762 let local = AptosConfig::local();
764 assert_eq!(local.retry_config.max_retries, 5);
765 }
766
767 #[test]
768 fn test_pool_config_default() {
769 let config = PoolConfig::default();
770 assert_eq!(config.max_idle_total, 100);
771 assert_eq!(config.idle_timeout, Duration::from_secs(90));
772 assert!(config.tcp_nodelay);
773 }
774
775 #[test]
776 fn test_pool_config_presets() {
777 let high = PoolConfig::high_throughput();
778 assert_eq!(high.max_idle_per_host, Some(32));
779 assert_eq!(high.max_idle_total, 256);
780
781 let low = PoolConfig::low_latency();
782 assert_eq!(low.max_idle_per_host, Some(8));
783 assert_eq!(low.idle_timeout, Duration::from_secs(30));
784
785 let minimal = PoolConfig::minimal();
786 assert_eq!(minimal.max_idle_per_host, Some(2));
787 assert_eq!(minimal.max_idle_total, 8);
788 }
789
790 #[test]
791 fn test_pool_config_builder() {
792 let config = PoolConfig::builder()
793 .max_idle_per_host(16)
794 .max_idle_total(64)
795 .idle_timeout(Duration::from_mins(1))
796 .tcp_nodelay(false)
797 .build();
798
799 assert_eq!(config.max_idle_per_host, Some(16));
800 assert_eq!(config.max_idle_total, 64);
801 assert_eq!(config.idle_timeout, Duration::from_mins(1));
802 assert!(!config.tcp_nodelay);
803 }
804
805 #[test]
806 fn test_pool_config_builder_tcp_keepalive() {
807 let config = PoolConfig::builder()
808 .tcp_keepalive(Duration::from_secs(30))
809 .build();
810 assert_eq!(config.tcp_keepalive, Some(Duration::from_secs(30)));
811
812 let config = PoolConfig::builder().no_tcp_keepalive().build();
813 assert_eq!(config.tcp_keepalive, None);
814 }
815
816 #[test]
817 fn test_pool_config_builder_unlimited_idle() {
818 let config = PoolConfig::builder().unlimited_idle_per_host().build();
819 assert_eq!(config.max_idle_per_host, None);
820 }
821
822 #[test]
823 fn test_aptos_config_with_pool() {
824 let config = AptosConfig::testnet().with_pool(PoolConfig::high_throughput());
825
826 assert_eq!(config.pool_config.max_idle_total, 256);
827 }
828
829 #[test]
830 fn test_aptos_config_with_indexer_url() {
831 let config = AptosConfig::testnet()
832 .with_indexer_url("https://custom-indexer.example.com/graphql")
833 .unwrap();
834 assert_eq!(
835 config.indexer_url().unwrap().as_str(),
836 "https://custom-indexer.example.com/graphql"
837 );
838 }
839
840 #[test]
841 fn test_aptos_config_with_faucet_url() {
842 let config = AptosConfig::mainnet()
843 .with_faucet_url("https://custom-faucet.example.com")
844 .unwrap();
845 assert_eq!(
846 config.faucet_url().unwrap().as_str(),
847 "https://custom-faucet.example.com/"
848 );
849 }
850
851 #[test]
852 fn test_aptos_config_default() {
853 let config = AptosConfig::default();
854 assert_eq!(config.network(), Network::Devnet);
855 }
856
857 #[test]
858 fn test_network_chain_id() {
859 assert_eq!(Network::Mainnet.chain_id().id(), 1);
860 assert_eq!(Network::Testnet.chain_id().id(), 2);
861 assert_eq!(Network::Devnet.chain_id().id(), 0);
864 assert_eq!(Network::Local.chain_id().id(), 4);
865 assert_eq!(Network::Custom.chain_id().id(), 0);
866 }
867
868 #[test]
869 fn test_network_as_str() {
870 assert_eq!(Network::Mainnet.as_str(), "mainnet");
871 assert_eq!(Network::Testnet.as_str(), "testnet");
872 assert_eq!(Network::Devnet.as_str(), "devnet");
873 assert_eq!(Network::Local.as_str(), "local");
874 assert_eq!(Network::Custom.as_str(), "custom");
875 }
876
877 #[test]
878 fn test_aptos_config_getters() {
879 let config = AptosConfig::testnet();
880
881 assert_eq!(config.timeout(), Duration::from_secs(30));
882 assert!(config.retry_config().max_retries > 0);
883 assert!(config.pool_config().max_idle_total > 0);
884 assert_eq!(config.chain_id().id(), 2);
885 }
886
887 #[tokio::test]
888 async fn test_read_response_bounded_normal() {
889 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
890 let server = MockServer::start().await;
891 Mock::given(method("GET"))
892 .respond_with(ResponseTemplate::new(200).set_body_string("hello world"))
893 .mount(&server)
894 .await;
895
896 let response = reqwest::get(server.uri()).await.unwrap();
897 let body = read_response_bounded(response, 1024).await.unwrap();
898 assert_eq!(body, b"hello world");
899 }
900
901 #[tokio::test]
902 async fn test_read_response_bounded_rejects_oversized_content_length() {
903 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
904 let server = MockServer::start().await;
905 let body = "x".repeat(200);
909 Mock::given(method("GET"))
910 .respond_with(ResponseTemplate::new(200).set_body_string(body))
911 .mount(&server)
912 .await;
913
914 let response = reqwest::get(server.uri()).await.unwrap();
915 let result = read_response_bounded(response, 100).await;
917 assert!(result.is_err());
918 let err = result.unwrap_err().to_string();
919 assert!(err.contains("response too large"));
920 }
921
922 #[tokio::test]
923 async fn test_read_response_bounded_rejects_oversized_body() {
924 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
925 let server = MockServer::start().await;
926 let large_body = "x".repeat(500);
927 Mock::given(method("GET"))
928 .respond_with(ResponseTemplate::new(200).set_body_string(large_body))
929 .mount(&server)
930 .await;
931
932 let response = reqwest::get(server.uri()).await.unwrap();
933 let result = read_response_bounded(response, 100).await;
934 assert!(result.is_err());
935 }
936
937 #[tokio::test]
938 async fn test_read_response_bounded_exact_limit() {
939 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
940 let server = MockServer::start().await;
941 let body = "x".repeat(100);
942 Mock::given(method("GET"))
943 .respond_with(ResponseTemplate::new(200).set_body_string(body.clone()))
944 .mount(&server)
945 .await;
946
947 let response = reqwest::get(server.uri()).await.unwrap();
948 let result = read_response_bounded(response, 100).await.unwrap();
949 assert_eq!(result.len(), 100);
950 }
951
952 #[tokio::test]
953 async fn test_read_response_bounded_empty() {
954 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
955 let server = MockServer::start().await;
956 Mock::given(method("GET"))
957 .respond_with(ResponseTemplate::new(200))
958 .mount(&server)
959 .await;
960
961 let response = reqwest::get(server.uri()).await.unwrap();
962 let result = read_response_bounded(response, 1024).await.unwrap();
963 assert!(result.is_empty());
964 }
965}