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_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 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 {
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 AptosConfig {
368 fn default() -> Self {
369 Self::devnet()
370 }
371}
372
373impl AptosConfig {
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://fullnode.mainnet.aptoslabs.com/v1")
389 .expect("valid mainnet URL"),
390 indexer_url: Some(
391 Url::parse("https://indexer.mainnet.aptoslabs.com/v1/graphql")
392 .expect("valid indexer URL"),
393 ),
394 faucet_url: None, timeout: Duration::from_secs(30),
396 retry_config: RetryConfig::conservative(), pool_config: PoolConfig::default(),
398 api_key: None,
399 }
400 }
401
402 #[allow(clippy::missing_panics_doc)]
412 #[must_use]
413 pub fn testnet() -> Self {
414 Self {
415 network: Network::Testnet,
416 fullnode_url: Url::parse("https://fullnode.testnet.aptoslabs.com/v1")
417 .expect("valid testnet URL"),
418 indexer_url: Some(
419 Url::parse("https://indexer.testnet.aptoslabs.com/v1/graphql")
420 .expect("valid indexer URL"),
421 ),
422 faucet_url: Some(
423 Url::parse("https://faucet.testnet.aptoslabs.com").expect("valid faucet URL"),
424 ),
425 timeout: Duration::from_secs(30),
426 retry_config: RetryConfig::default(),
427 pool_config: PoolConfig::default(),
428 api_key: None,
429 }
430 }
431
432 #[allow(clippy::missing_panics_doc)]
442 #[must_use]
443 pub fn devnet() -> Self {
444 Self {
445 network: Network::Devnet,
446 fullnode_url: Url::parse("https://fullnode.devnet.aptoslabs.com/v1")
447 .expect("valid devnet URL"),
448 indexer_url: Some(
449 Url::parse("https://indexer.devnet.aptoslabs.com/v1/graphql")
450 .expect("valid indexer URL"),
451 ),
452 faucet_url: Some(
453 Url::parse("https://faucet.devnet.aptoslabs.com").expect("valid faucet URL"),
454 ),
455 timeout: Duration::from_secs(30),
456 retry_config: RetryConfig::default(),
457 pool_config: PoolConfig::default(),
458 api_key: None,
459 }
460 }
461
462 #[allow(clippy::missing_panics_doc)]
475 #[must_use]
476 pub fn local() -> Self {
477 Self {
478 network: Network::Local,
479 fullnode_url: Url::parse("http://127.0.0.1:8080/v1").expect("valid local URL"),
480 indexer_url: None,
481 faucet_url: Some(Url::parse("http://127.0.0.1:8081").expect("valid local faucet URL")),
482 timeout: Duration::from_secs(10),
483 retry_config: RetryConfig::aggressive(), pool_config: PoolConfig::low_latency(), api_key: None,
486 }
487 }
488
489 pub fn custom(fullnode_url: &str) -> AptosResult<Self> {
510 let url = Url::parse(fullnode_url)?;
511 validate_url_scheme(&url)?;
512 Ok(Self {
513 network: Network::Custom,
514 fullnode_url: url,
515 indexer_url: None,
516 faucet_url: None,
517 timeout: Duration::from_secs(30),
518 retry_config: RetryConfig::default(),
519 pool_config: PoolConfig::default(),
520 api_key: None,
521 })
522 }
523
524 #[must_use]
526 pub fn with_timeout(mut self, timeout: Duration) -> Self {
527 self.timeout = timeout;
528 self
529 }
530
531 #[must_use]
543 pub fn with_retry(mut self, retry_config: RetryConfig) -> Self {
544 self.retry_config = retry_config;
545 self
546 }
547
548 #[must_use]
552 pub fn without_retry(mut self) -> Self {
553 self.retry_config = RetryConfig::no_retry();
554 self
555 }
556
557 #[must_use]
561 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
562 self.retry_config = RetryConfig::builder()
563 .max_retries(max_retries)
564 .initial_delay_ms(self.retry_config.initial_delay_ms)
565 .max_delay_ms(self.retry_config.max_delay_ms)
566 .exponential_base(self.retry_config.exponential_base)
567 .jitter(self.retry_config.jitter)
568 .build();
569 self
570 }
571
572 #[must_use]
584 pub fn with_pool(mut self, pool_config: PoolConfig) -> Self {
585 self.pool_config = pool_config;
586 self
587 }
588
589 #[must_use]
594 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
595 self.api_key = Some(api_key.into());
596 self
597 }
598
599 pub fn with_indexer_url(mut self, url: &str) -> AptosResult<Self> {
610 let parsed = Url::parse(url)?;
611 validate_url_scheme(&parsed)?;
612 self.indexer_url = Some(parsed);
613 Ok(self)
614 }
615
616 pub fn with_faucet_url(mut self, url: &str) -> AptosResult<Self> {
627 let parsed = Url::parse(url)?;
628 validate_url_scheme(&parsed)?;
629 self.faucet_url = Some(parsed);
630 Ok(self)
631 }
632
633 pub fn network(&self) -> Network {
635 self.network
636 }
637
638 pub fn fullnode_url(&self) -> &Url {
640 &self.fullnode_url
641 }
642
643 pub fn indexer_url(&self) -> Option<&Url> {
645 self.indexer_url.as_ref()
646 }
647
648 pub fn faucet_url(&self) -> Option<&Url> {
650 self.faucet_url.as_ref()
651 }
652
653 pub fn chain_id(&self) -> ChainId {
655 self.network.chain_id()
656 }
657
658 pub fn retry_config(&self) -> &RetryConfig {
660 &self.retry_config
661 }
662
663 pub fn timeout(&self) -> Duration {
665 self.timeout
666 }
667
668 pub fn pool_config(&self) -> &PoolConfig {
670 &self.pool_config
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 #[test]
679 fn test_mainnet_config() {
680 let config = AptosConfig::mainnet();
681 assert_eq!(config.network(), Network::Mainnet);
682 assert!(config.fullnode_url().as_str().contains("mainnet"));
683 assert!(config.faucet_url().is_none());
684 }
685
686 #[test]
687 fn test_testnet_config() {
688 let config = AptosConfig::testnet();
689 assert_eq!(config.network(), Network::Testnet);
690 assert!(config.fullnode_url().as_str().contains("testnet"));
691 assert!(config.faucet_url().is_some());
692 }
693
694 #[test]
695 fn test_devnet_config() {
696 let config = AptosConfig::devnet();
697 assert_eq!(config.network(), Network::Devnet);
698 assert!(config.fullnode_url().as_str().contains("devnet"));
699 assert!(config.faucet_url().is_some());
700 assert!(config.indexer_url().is_some());
701 }
702
703 #[test]
704 fn test_local_config() {
705 let config = AptosConfig::local();
706 assert_eq!(config.network(), Network::Local);
707 assert!(config.fullnode_url().as_str().contains("127.0.0.1"));
708 assert!(config.faucet_url().is_some());
709 assert!(config.indexer_url().is_none());
710 }
711
712 #[test]
713 fn test_custom_config() {
714 let config = AptosConfig::custom("https://custom.example.com/v1").unwrap();
715 assert_eq!(config.network(), Network::Custom);
716 assert_eq!(
717 config.fullnode_url().as_str(),
718 "https://custom.example.com/v1"
719 );
720 }
721
722 #[test]
723 fn test_custom_config_invalid_url() {
724 let result = AptosConfig::custom("not a valid url");
725 assert!(result.is_err());
726 }
727
728 #[test]
729 fn test_builder_methods() {
730 let config = AptosConfig::testnet()
731 .with_timeout(Duration::from_secs(60))
732 .with_max_retries(5)
733 .with_api_key("test-key");
734
735 assert_eq!(config.timeout, Duration::from_secs(60));
736 assert_eq!(config.retry_config.max_retries, 5);
737 assert_eq!(config.api_key, Some("test-key".to_string()));
738 }
739
740 #[test]
741 fn test_retry_config() {
742 let config = AptosConfig::testnet().with_retry(RetryConfig::aggressive());
743
744 assert_eq!(config.retry_config.max_retries, 5);
745 assert_eq!(config.retry_config.initial_delay_ms, 50);
746
747 let config = AptosConfig::testnet().without_retry();
748 assert_eq!(config.retry_config.max_retries, 0);
749 }
750
751 #[test]
752 fn test_network_retry_defaults() {
753 let mainnet = AptosConfig::mainnet();
755 assert_eq!(mainnet.retry_config.max_retries, 3);
756
757 let local = AptosConfig::local();
759 assert_eq!(local.retry_config.max_retries, 5);
760 }
761
762 #[test]
763 fn test_pool_config_default() {
764 let config = PoolConfig::default();
765 assert_eq!(config.max_idle_total, 100);
766 assert_eq!(config.idle_timeout, Duration::from_secs(90));
767 assert!(config.tcp_nodelay);
768 }
769
770 #[test]
771 fn test_pool_config_presets() {
772 let high = PoolConfig::high_throughput();
773 assert_eq!(high.max_idle_per_host, Some(32));
774 assert_eq!(high.max_idle_total, 256);
775
776 let low = PoolConfig::low_latency();
777 assert_eq!(low.max_idle_per_host, Some(8));
778 assert_eq!(low.idle_timeout, Duration::from_secs(30));
779
780 let minimal = PoolConfig::minimal();
781 assert_eq!(minimal.max_idle_per_host, Some(2));
782 assert_eq!(minimal.max_idle_total, 8);
783 }
784
785 #[test]
786 fn test_pool_config_builder() {
787 let config = PoolConfig::builder()
788 .max_idle_per_host(16)
789 .max_idle_total(64)
790 .idle_timeout(Duration::from_secs(60))
791 .tcp_nodelay(false)
792 .build();
793
794 assert_eq!(config.max_idle_per_host, Some(16));
795 assert_eq!(config.max_idle_total, 64);
796 assert_eq!(config.idle_timeout, Duration::from_secs(60));
797 assert!(!config.tcp_nodelay);
798 }
799
800 #[test]
801 fn test_pool_config_builder_tcp_keepalive() {
802 let config = PoolConfig::builder()
803 .tcp_keepalive(Duration::from_secs(30))
804 .build();
805 assert_eq!(config.tcp_keepalive, Some(Duration::from_secs(30)));
806
807 let config = PoolConfig::builder().no_tcp_keepalive().build();
808 assert_eq!(config.tcp_keepalive, None);
809 }
810
811 #[test]
812 fn test_pool_config_builder_unlimited_idle() {
813 let config = PoolConfig::builder().unlimited_idle_per_host().build();
814 assert_eq!(config.max_idle_per_host, None);
815 }
816
817 #[test]
818 fn test_aptos_config_with_pool() {
819 let config = AptosConfig::testnet().with_pool(PoolConfig::high_throughput());
820
821 assert_eq!(config.pool_config.max_idle_total, 256);
822 }
823
824 #[test]
825 fn test_aptos_config_with_indexer_url() {
826 let config = AptosConfig::testnet()
827 .with_indexer_url("https://custom-indexer.example.com/graphql")
828 .unwrap();
829 assert_eq!(
830 config.indexer_url().unwrap().as_str(),
831 "https://custom-indexer.example.com/graphql"
832 );
833 }
834
835 #[test]
836 fn test_aptos_config_with_faucet_url() {
837 let config = AptosConfig::mainnet()
838 .with_faucet_url("https://custom-faucet.example.com")
839 .unwrap();
840 assert_eq!(
841 config.faucet_url().unwrap().as_str(),
842 "https://custom-faucet.example.com/"
843 );
844 }
845
846 #[test]
847 fn test_aptos_config_default() {
848 let config = AptosConfig::default();
849 assert_eq!(config.network(), Network::Devnet);
850 }
851
852 #[test]
853 fn test_network_chain_id() {
854 assert_eq!(Network::Mainnet.chain_id().id(), 1);
855 assert_eq!(Network::Testnet.chain_id().id(), 2);
856 assert_eq!(Network::Devnet.chain_id().id(), 165);
857 assert_eq!(Network::Local.chain_id().id(), 4);
858 assert_eq!(Network::Custom.chain_id().id(), 0);
859 }
860
861 #[test]
862 fn test_network_as_str() {
863 assert_eq!(Network::Mainnet.as_str(), "mainnet");
864 assert_eq!(Network::Testnet.as_str(), "testnet");
865 assert_eq!(Network::Devnet.as_str(), "devnet");
866 assert_eq!(Network::Local.as_str(), "local");
867 assert_eq!(Network::Custom.as_str(), "custom");
868 }
869
870 #[test]
871 fn test_aptos_config_getters() {
872 let config = AptosConfig::testnet();
873
874 assert_eq!(config.timeout(), Duration::from_secs(30));
875 assert!(config.retry_config().max_retries > 0);
876 assert!(config.pool_config().max_idle_total > 0);
877 assert_eq!(config.chain_id().id(), 2);
878 }
879
880 #[tokio::test]
881 async fn test_read_response_bounded_normal() {
882 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
883 let server = MockServer::start().await;
884 Mock::given(method("GET"))
885 .respond_with(ResponseTemplate::new(200).set_body_string("hello world"))
886 .mount(&server)
887 .await;
888
889 let response = reqwest::get(server.uri()).await.unwrap();
890 let body = read_response_bounded(response, 1024).await.unwrap();
891 assert_eq!(body, b"hello world");
892 }
893
894 #[tokio::test]
895 async fn test_read_response_bounded_rejects_oversized_content_length() {
896 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
897 let server = MockServer::start().await;
898 let body = "x".repeat(200);
902 Mock::given(method("GET"))
903 .respond_with(ResponseTemplate::new(200).set_body_string(body))
904 .mount(&server)
905 .await;
906
907 let response = reqwest::get(server.uri()).await.unwrap();
908 let result = read_response_bounded(response, 100).await;
910 assert!(result.is_err());
911 let err = result.unwrap_err().to_string();
912 assert!(err.contains("response too large"));
913 }
914
915 #[tokio::test]
916 async fn test_read_response_bounded_rejects_oversized_body() {
917 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
918 let server = MockServer::start().await;
919 let large_body = "x".repeat(500);
920 Mock::given(method("GET"))
921 .respond_with(ResponseTemplate::new(200).set_body_string(large_body))
922 .mount(&server)
923 .await;
924
925 let response = reqwest::get(server.uri()).await.unwrap();
926 let result = read_response_bounded(response, 100).await;
927 assert!(result.is_err());
928 }
929
930 #[tokio::test]
931 async fn test_read_response_bounded_exact_limit() {
932 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
933 let server = MockServer::start().await;
934 let body = "x".repeat(100);
935 Mock::given(method("GET"))
936 .respond_with(ResponseTemplate::new(200).set_body_string(body.clone()))
937 .mount(&server)
938 .await;
939
940 let response = reqwest::get(server.uri()).await.unwrap();
941 let result = read_response_bounded(response, 100).await.unwrap();
942 assert_eq!(result.len(), 100);
943 }
944
945 #[tokio::test]
946 async fn test_read_response_bounded_empty() {
947 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
948 let server = MockServer::start().await;
949 Mock::given(method("GET"))
950 .respond_with(ResponseTemplate::new(200))
951 .mount(&server)
952 .await;
953
954 let response = reqwest::get(server.uri()).await.unwrap();
955 let result = read_response_bounded(response, 1024).await.unwrap();
956 assert!(result.is_empty());
957 }
958}