Skip to main content

alloy_rpc_client/
builtin.rs

1use alloy_json_rpc::RpcError;
2use alloy_transport::{BoxTransport, TransportConnect, TransportError, TransportErrorKind};
3use std::{str::FromStr, time::Duration};
4
5#[cfg(any(feature = "ws-base", feature = "ipc"))]
6use alloy_pubsub::PubSubConnect;
7
8/// Connection string for built-in transports.
9#[derive(Clone, Debug, PartialEq, Eq)]
10#[non_exhaustive]
11pub enum BuiltInConnectionString {
12    /// HTTP transport.
13    #[cfg(any(feature = "reqwest", feature = "hyper"))]
14    Http(url::Url),
15    /// WebSocket transport.
16    #[cfg(feature = "ws-base")]
17    Ws(url::Url, Option<alloy_transport::Authorization>),
18    /// IPC transport.
19    #[cfg(feature = "ipc")]
20    Ipc(std::path::PathBuf),
21}
22
23impl TransportConnect for BuiltInConnectionString {
24    fn is_local(&self) -> bool {
25        match self {
26            #[cfg(any(feature = "reqwest", feature = "hyper"))]
27            Self::Http(url) => alloy_transport::utils::guess_local_url(url),
28            #[cfg(feature = "ws-base")]
29            Self::Ws(url, _) => alloy_transport::utils::guess_local_url(url),
30            #[cfg(feature = "ipc")]
31            Self::Ipc(_) => true,
32            #[cfg(not(any(
33                feature = "reqwest",
34                feature = "hyper",
35                feature = "ws-base",
36                feature = "ipc"
37            )))]
38            _ => false,
39        }
40    }
41
42    async fn get_transport(&self) -> Result<BoxTransport, TransportError> {
43        self.connect_boxed().await
44    }
45}
46
47impl BuiltInConnectionString {
48    /// Parse a connection string and connect to it in one go.
49    ///
50    /// This is a convenience method that combines `from_str` and `connect_boxed`.
51    ///
52    /// # Example
53    ///
54    /// ```
55    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
56    /// use alloy_rpc_client::BuiltInConnectionString;
57    ///
58    /// let transport = BuiltInConnectionString::connect("http://localhost:8545").await?;
59    /// # Ok(())
60    /// # }
61    /// ```
62    pub async fn connect(s: &str) -> Result<BoxTransport, TransportError> {
63        let connection = Self::from_str(s)?;
64        connection.connect_boxed().await
65    }
66
67    /// Parse a connection string and connect with custom configuration.
68    ///
69    /// This method allows for fine-grained control over connection settings
70    /// such as authentication, retry behavior, and transport-specific options.
71    ///
72    /// # Examples
73    ///
74    /// Basic usage with authentication:
75    /// ```
76    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
77    /// use alloy_rpc_client::{BuiltInConnectionString, ConnectionConfig};
78    /// use alloy_transport::Authorization;
79    /// use std::time::Duration;
80    ///
81    /// // Configure connection with custom settings
82    /// let config = ConnectionConfig::new()
83    ///     .with_auth(Authorization::bearer("my-token"))
84    ///     .with_max_retries(3)
85    ///     .with_retry_interval(Duration::from_secs(2));
86    ///
87    /// // Connect to WebSocket endpoint with configuration
88    /// let transport = BuiltInConnectionString::connect_with("ws://localhost:8545", config).await?;
89    /// # Ok(())
90    /// # }
91    /// ```
92    pub async fn connect_with(
93        s: &str,
94        config: ConnectionConfig,
95    ) -> Result<BoxTransport, TransportError> {
96        let connection = Self::from_str(s)?;
97        connection.connect_boxed_with(config).await
98    }
99
100    /// Connect with the given connection string.
101    ///
102    /// # Notes
103    ///
104    /// - If `hyper` feature is enabled
105    /// - WS will extract auth, however, auth is disabled for wasm.
106    pub async fn connect_boxed(&self) -> Result<BoxTransport, TransportError> {
107        self.connect_boxed_with(ConnectionConfig::default()).await
108    }
109
110    /// Connect with the given connection string and custom configuration.
111    ///
112    /// This method provides fine-grained control over connection settings.
113    /// Configuration options are applied where supported by the transport.
114    ///
115    /// # Notes
116    ///
117    /// - If `hyper` feature is enabled
118    /// - WS will extract auth, however, auth is disabled for wasm.
119    /// - Some configuration options may not apply to all transport types.
120    pub async fn connect_boxed_with(
121        &self,
122        config: ConnectionConfig,
123    ) -> Result<BoxTransport, TransportError> {
124        // Note: Configuration is currently only applied to WebSocket transports.
125        // HTTP and IPC transports will use their default settings.
126        let _ = &config; // Suppress unused warning for non-WS transports
127        match self {
128            // reqwest is enabled, hyper is not
129            #[cfg(all(
130                not(feature = "hyper"),
131                feature = "reqwest",
132                not(all(target_os = "wasi", target_env = "p1"))
133            ))]
134            Self::Http(url) => {
135                Ok(alloy_transport::Transport::boxed(
136                    alloy_transport_http::Http::<reqwest::Client>::new(url.clone()),
137                ))
138            }
139
140            #[cfg(all(
141                not(feature = "hyper"),
142                feature = "reqwest",
143                all(target_os = "wasi", target_env = "p1")
144            ))]
145            Self::Http(_) => Err(TransportErrorKind::custom_str(
146                "reqwest HTTP transport is not supported on wasm32-wasip1",
147            )),
148
149            // hyper is enabled, reqwest is not
150            #[cfg(feature = "hyper")]
151            Self::Http(url) => Ok(alloy_transport::Transport::boxed(
152                alloy_transport_http::HyperTransport::new_hyper(url.clone()),
153            )),
154
155            #[cfg(feature = "ws-base")]
156            Self::Ws(url, existing_auth) => {
157                let mut ws_connect = alloy_transport_ws::WsConnect::new(url.clone());
158
159                // Apply authentication: prioritize config over existing URL auth
160                let auth = config.auth.as_ref().or(existing_auth.as_ref());
161                #[cfg(not(target_family = "wasm"))]
162                if let Some(auth) = auth {
163                    ws_connect = ws_connect.with_auth(auth.clone());
164                }
165                #[cfg(target_family = "wasm")]
166                let _ = auth; // Suppress unused warning on WASM
167
168                // Apply WebSocket-specific config
169                #[cfg(not(target_family = "wasm"))]
170                if let Some(ws_config) = config.ws_config {
171                    ws_connect = ws_connect.with_config(ws_config);
172                }
173
174                // Apply retry configuration
175                if let Some(max_retries) = config.max_retries {
176                    ws_connect = ws_connect.with_max_retries(max_retries);
177                }
178                if let Some(retry_interval) = config.retry_interval {
179                    ws_connect = ws_connect.with_retry_interval(retry_interval);
180                }
181
182                ws_connect.into_service().await.map(alloy_transport::Transport::boxed)
183            }
184
185            #[cfg(feature = "ipc")]
186            Self::Ipc(path) => alloy_transport_ipc::IpcConnect::new(path.to_owned())
187                .into_service()
188                .await
189                .map(alloy_transport::Transport::boxed),
190
191            #[cfg(not(any(
192                feature = "reqwest",
193                feature = "hyper",
194                feature = "ws-base",
195                feature = "ipc"
196            )))]
197            _ => Err(TransportErrorKind::custom_str(
198                "No transports enabled. Enable one of: reqwest, hyper, ws, ipc",
199            )),
200        }
201    }
202
203    /// Tries to parse the given string as an HTTP URL.
204    #[cfg(any(feature = "reqwest", feature = "hyper"))]
205    pub fn try_as_http(s: &str) -> Result<Self, TransportError> {
206        let url = if s.starts_with("localhost:") || s.parse::<std::net::SocketAddr>().is_ok() {
207            let s = format!("http://{s}");
208            url::Url::parse(&s)
209        } else {
210            url::Url::parse(s)
211        }
212        .map_err(TransportErrorKind::custom)?;
213
214        let scheme = url.scheme();
215        if scheme != "http" && scheme != "https" {
216            let msg = format!("invalid URL scheme: {scheme}; expected `http` or `https`");
217            return Err(TransportErrorKind::custom_str(&msg));
218        }
219
220        Ok(Self::Http(url))
221    }
222
223    /// Tries to parse the given string as a WebSocket URL.
224    #[cfg(feature = "ws-base")]
225    pub fn try_as_ws(s: &str) -> Result<Self, TransportError> {
226        let url = if s.starts_with("localhost:") || s.parse::<std::net::SocketAddr>().is_ok() {
227            let s = format!("ws://{s}");
228            url::Url::parse(&s)
229        } else {
230            url::Url::parse(s)
231        }
232        .map_err(TransportErrorKind::custom)?;
233
234        let scheme = url.scheme();
235        if scheme != "ws" && scheme != "wss" {
236            let msg = format!("invalid URL scheme: {scheme}; expected `ws` or `wss`");
237            return Err(TransportErrorKind::custom_str(&msg));
238        }
239
240        let auth = alloy_transport::Authorization::extract_from_url(&url);
241
242        Ok(Self::Ws(url, auth))
243    }
244
245    /// Tries to parse the given string as an IPC path, returning an error if
246    /// the path does not exist.
247    #[cfg(feature = "ipc")]
248    pub fn try_as_ipc(s: &str) -> Result<Self, TransportError> {
249        let s = s.strip_prefix("file://").or_else(|| s.strip_prefix("ipc://")).unwrap_or(s);
250
251        // Check if it exists.
252        let path = std::path::Path::new(s);
253        let _meta = path.metadata().map_err(|e| {
254            let msg = format!("failed to read IPC path {}: {e}", path.display());
255            TransportErrorKind::custom_str(&msg)
256        })?;
257
258        Ok(Self::Ipc(path.to_path_buf()))
259    }
260}
261
262impl FromStr for BuiltInConnectionString {
263    type Err = RpcError<TransportErrorKind>;
264
265    #[allow(clippy::let_and_return)]
266    fn from_str(s: &str) -> Result<Self, Self::Err> {
267        let res = Err(TransportErrorKind::custom_str(&format!(
268            "No transports enabled. Enable one of: reqwest, hyper, ws, ipc. Connection info: '{s}'"
269        )));
270        #[cfg(any(feature = "reqwest", feature = "hyper"))]
271        let res = res.or_else(|_| Self::try_as_http(s));
272        #[cfg(feature = "ws-base")]
273        let res = res.or_else(|_| Self::try_as_ws(s));
274        #[cfg(feature = "ipc")]
275        let res = res.or_else(|_| Self::try_as_ipc(s));
276        res
277    }
278}
279
280/// Configuration for connecting to built-in transports.
281///
282/// Provides a flexible way to configure various aspects of the connection,
283/// including authentication, retry behavior, and transport-specific settings.
284#[derive(Clone, Debug, Default)]
285#[non_exhaustive]
286pub struct ConnectionConfig {
287    /// Authorization header for authenticated connections.
288    pub auth: Option<alloy_transport::Authorization>,
289    /// Maximum number of connection retries.
290    pub max_retries: Option<u32>,
291    /// Base interval between connection retries.
292    ///
293    /// WebSocket reconnect retries use capped exponential backoff from this base interval.
294    pub retry_interval: Option<Duration>,
295    /// WebSocket-specific configuration.
296    #[cfg(all(feature = "ws-base", not(target_family = "wasm")))]
297    pub ws_config: Option<alloy_transport_ws::WebSocketConfig>,
298}
299
300impl ConnectionConfig {
301    /// Create a new empty configuration.
302    pub const fn new() -> Self {
303        Self {
304            auth: None,
305            max_retries: None,
306            retry_interval: None,
307            #[cfg(all(feature = "ws-base", not(target_family = "wasm")))]
308            ws_config: None,
309        }
310    }
311
312    /// Set the authorization header.
313    pub fn with_auth(mut self, auth: alloy_transport::Authorization) -> Self {
314        self.auth = Some(auth);
315        self
316    }
317
318    /// Set the maximum number of retries.
319    pub const fn with_max_retries(mut self, max_retries: u32) -> Self {
320        self.max_retries = Some(max_retries);
321        self
322    }
323
324    /// Set the base retry interval.
325    ///
326    /// WebSocket reconnect retries use capped exponential backoff from this base interval.
327    pub const fn with_retry_interval(mut self, retry_interval: Duration) -> Self {
328        self.retry_interval = Some(retry_interval);
329        self
330    }
331
332    /// Set the WebSocket configuration.
333    #[cfg(all(feature = "ws-base", not(target_family = "wasm")))]
334    pub const fn with_ws_config(mut self, config: alloy_transport_ws::WebSocketConfig) -> Self {
335        self.ws_config = Some(config);
336        self
337    }
338}
339
340#[cfg(test)]
341mod test {
342    use super::*;
343    use similar_asserts::assert_eq;
344    use url::Url;
345
346    #[test]
347    fn test_parsing_urls() {
348        assert_eq!(
349            BuiltInConnectionString::from_str("http://localhost:8545").unwrap(),
350            BuiltInConnectionString::Http("http://localhost:8545".parse::<Url>().unwrap())
351        );
352        assert_eq!(
353            BuiltInConnectionString::from_str("localhost:8545").unwrap(),
354            BuiltInConnectionString::Http("http://localhost:8545".parse::<Url>().unwrap())
355        );
356        assert_eq!(
357            BuiltInConnectionString::from_str("https://localhost:8545").unwrap(),
358            BuiltInConnectionString::Http("https://localhost:8545".parse::<Url>().unwrap())
359        );
360        assert_eq!(
361            BuiltInConnectionString::from_str("http://127.0.0.1:8545").unwrap(),
362            BuiltInConnectionString::Http("http://127.0.0.1:8545".parse::<Url>().unwrap())
363        );
364
365        assert_eq!(
366            BuiltInConnectionString::from_str("http://localhost").unwrap(),
367            BuiltInConnectionString::Http("http://localhost".parse::<Url>().unwrap())
368        );
369        assert_eq!(
370            BuiltInConnectionString::from_str("127.0.0.1:8545").unwrap(),
371            BuiltInConnectionString::Http("http://127.0.0.1:8545".parse::<Url>().unwrap())
372        );
373        assert_eq!(
374            BuiltInConnectionString::from_str("http://user:pass@example.com").unwrap(),
375            BuiltInConnectionString::Http("http://user:pass@example.com".parse::<Url>().unwrap())
376        );
377    }
378
379    #[test]
380    #[cfg(feature = "ws-base")]
381    fn test_parsing_ws() {
382        use alloy_transport::Authorization;
383
384        assert_eq!(
385            BuiltInConnectionString::from_str("ws://localhost:8545").unwrap(),
386            BuiltInConnectionString::Ws("ws://localhost:8545".parse::<Url>().unwrap(), None)
387        );
388        assert_eq!(
389            BuiltInConnectionString::from_str("wss://localhost:8545").unwrap(),
390            BuiltInConnectionString::Ws("wss://localhost:8545".parse::<Url>().unwrap(), None)
391        );
392        assert_eq!(
393            BuiltInConnectionString::from_str("ws://127.0.0.1:8545").unwrap(),
394            BuiltInConnectionString::Ws("ws://127.0.0.1:8545".parse::<Url>().unwrap(), None)
395        );
396
397        assert_eq!(
398            BuiltInConnectionString::from_str("ws://alice:pass@127.0.0.1:8545").unwrap(),
399            BuiltInConnectionString::Ws(
400                "ws://alice:pass@127.0.0.1:8545".parse::<Url>().unwrap(),
401                Some(Authorization::basic("alice", "pass"))
402            )
403        );
404    }
405
406    #[test]
407    #[cfg(feature = "ipc")]
408    #[cfg_attr(windows, ignore = "TODO: windows IPC")]
409    fn test_parsing_ipc() {
410        use alloy_node_bindings::Anvil;
411
412        // Spawn an Anvil instance to create an IPC socket, as it's different from a normal file.
413        let temp_dir = tempfile::tempdir().unwrap();
414        let ipc_path = temp_dir.path().join("anvil.ipc");
415        let ipc_arg = format!("--ipc={}", ipc_path.display());
416        let _anvil = Anvil::new().arg(ipc_arg).spawn();
417        let path_str = ipc_path.to_str().unwrap();
418
419        assert_eq!(
420            BuiltInConnectionString::from_str(&format!("ipc://{path_str}")).unwrap(),
421            BuiltInConnectionString::Ipc(ipc_path.clone())
422        );
423
424        assert_eq!(
425            BuiltInConnectionString::from_str(&format!("file://{path_str}")).unwrap(),
426            BuiltInConnectionString::Ipc(ipc_path.clone())
427        );
428
429        assert_eq!(
430            BuiltInConnectionString::from_str(ipc_path.to_str().unwrap()).unwrap(),
431            BuiltInConnectionString::Ipc(ipc_path.clone())
432        );
433    }
434
435    #[test]
436    #[cfg(feature = "ws-base")]
437    fn test_ws_config_auth_priority() {
438        use alloy_transport::Authorization;
439
440        // Test that config auth takes precedence over URL auth
441        let config_auth = Authorization::bearer("config-token");
442        let url_auth = Some(Authorization::basic("user", "pass"));
443
444        let _ws_connection =
445            BuiltInConnectionString::Ws("ws://user:pass@localhost:8545".parse().unwrap(), url_auth);
446
447        let config = ConnectionConfig::new().with_auth(config_auth.clone());
448
449        // In the actual connect_boxed_with implementation:
450        // config.auth.as_ref().or(existing_auth.as_ref())
451        // This means config auth takes priority
452        assert_eq!(config.auth.as_ref().unwrap().to_string(), config_auth.to_string());
453    }
454
455    #[test]
456    fn test_backward_compatibility() {
457        // Verify connect() uses default config (maintaining backward compatibility)
458        let default_config = ConnectionConfig::default();
459        assert!(default_config.auth.is_none());
460        assert!(default_config.max_retries.is_none());
461
462        // connect() -> connect_boxed() -> connect_boxed_with(default) ensures compatibility
463    }
464}