alloy_rpc_client/
builtin.rs

1use alloy_json_rpc::RpcError;
2use alloy_transport::{BoxTransport, TransportConnect, TransportError, TransportErrorKind};
3use std::str::FromStr;
4
5#[cfg(any(feature = "ws", 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")]
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")]
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",
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    /// Connect with the given connection string.
49    ///
50    /// # Notes
51    ///
52    /// - If `hyper` feature is enabled
53    /// - WS will extract auth, however, auth is disabled for wasm.
54    pub async fn connect_boxed(&self) -> Result<BoxTransport, TransportError> {
55        // NB:
56        // HTTP match will always produce hyper if the feature is enabled.
57        // WS match arms are fall-through. Auth arm is disabled for wasm.
58        match self {
59            // reqwest is enabled, hyper is not
60            #[cfg(all(not(feature = "hyper"), feature = "reqwest"))]
61            Self::Http(url) => {
62                Ok(alloy_transport::Transport::boxed(
63                    alloy_transport_http::Http::<reqwest::Client>::new(url.clone()),
64                ))
65            }
66
67            // hyper is enabled, reqwest is not
68            #[cfg(feature = "hyper")]
69            Self::Http(url) => Ok(alloy_transport::Transport::boxed(
70                alloy_transport_http::HyperTransport::new_hyper(url.clone()),
71            )),
72
73            #[cfg(all(not(target_family = "wasm"), feature = "ws"))]
74            Self::Ws(url, Some(auth)) => alloy_transport_ws::WsConnect::new(url.clone())
75                .with_auth(auth.clone())
76                .into_service()
77                .await
78                .map(alloy_transport::Transport::boxed),
79
80            #[cfg(feature = "ws")]
81            Self::Ws(url, _) => alloy_transport_ws::WsConnect::new(url.clone())
82                .into_service()
83                .await
84                .map(alloy_transport::Transport::boxed),
85
86            #[cfg(feature = "ipc")]
87            Self::Ipc(path) => alloy_transport_ipc::IpcConnect::new(path.to_owned())
88                .into_service()
89                .await
90                .map(alloy_transport::Transport::boxed),
91
92            #[cfg(not(any(
93                feature = "reqwest",
94                feature = "hyper",
95                feature = "ws",
96                feature = "ipc"
97            )))]
98            _ => Err(TransportErrorKind::custom_str(
99                "No transports enabled. Enable one of: reqwest, hyper, ws, ipc",
100            )),
101        }
102    }
103
104    /// Tries to parse the given string as an HTTP URL.
105    #[cfg(any(feature = "reqwest", feature = "hyper"))]
106    pub fn try_as_http(s: &str) -> Result<Self, TransportError> {
107        let url = if s.starts_with("localhost:") || s.parse::<std::net::SocketAddr>().is_ok() {
108            let s = format!("http://{s}");
109            url::Url::parse(&s)
110        } else {
111            url::Url::parse(s)
112        }
113        .map_err(TransportErrorKind::custom)?;
114
115        let scheme = url.scheme();
116        if scheme != "http" && scheme != "https" {
117            let msg = format!("invalid URL scheme: {scheme}; expected `http` or `https`");
118            return Err(TransportErrorKind::custom_str(&msg));
119        }
120
121        Ok(Self::Http(url))
122    }
123
124    /// Tries to parse the given string as a WebSocket URL.
125    #[cfg(feature = "ws")]
126    pub fn try_as_ws(s: &str) -> Result<Self, TransportError> {
127        let url = if s.starts_with("localhost:") || s.parse::<std::net::SocketAddr>().is_ok() {
128            let s = format!("ws://{s}");
129            url::Url::parse(&s)
130        } else {
131            url::Url::parse(s)
132        }
133        .map_err(TransportErrorKind::custom)?;
134
135        let scheme = url.scheme();
136        if scheme != "ws" && scheme != "wss" {
137            let msg = format!("invalid URL scheme: {scheme}; expected `ws` or `wss`");
138            return Err(TransportErrorKind::custom_str(&msg));
139        }
140
141        let auth = alloy_transport::Authorization::extract_from_url(&url);
142
143        Ok(Self::Ws(url, auth))
144    }
145
146    /// Tries to parse the given string as an IPC path, returning an error if
147    /// the path does not exist.
148    #[cfg(feature = "ipc")]
149    pub fn try_as_ipc(s: &str) -> Result<Self, TransportError> {
150        let s = s.strip_prefix("file://").or_else(|| s.strip_prefix("ipc://")).unwrap_or(s);
151
152        // Check if it exists.
153        let path = std::path::Path::new(s);
154        let _meta = path.metadata().map_err(|e| {
155            let msg = format!("failed to read IPC path {}: {e}", path.display());
156            TransportErrorKind::custom_str(&msg)
157        })?;
158
159        Ok(Self::Ipc(path.to_path_buf()))
160    }
161}
162
163impl FromStr for BuiltInConnectionString {
164    type Err = RpcError<TransportErrorKind>;
165
166    #[allow(clippy::let_and_return)]
167    fn from_str(s: &str) -> Result<Self, Self::Err> {
168        let res = Err(TransportErrorKind::custom_str(&format!(
169            "No transports enabled. Enable one of: reqwest, hyper, ws, ipc. Connection info: '{s}'"
170        )));
171        #[cfg(any(feature = "reqwest", feature = "hyper"))]
172        let res = res.or_else(|_| Self::try_as_http(s));
173        #[cfg(feature = "ws")]
174        let res = res.or_else(|_| Self::try_as_ws(s));
175        #[cfg(feature = "ipc")]
176        let res = res.or_else(|_| Self::try_as_ipc(s));
177        res
178    }
179}
180
181#[cfg(test)]
182mod test {
183    use super::*;
184    use similar_asserts::assert_eq;
185    use url::Url;
186
187    #[test]
188    fn test_parsing_urls() {
189        assert_eq!(
190            BuiltInConnectionString::from_str("http://localhost:8545").unwrap(),
191            BuiltInConnectionString::Http("http://localhost:8545".parse::<Url>().unwrap())
192        );
193        assert_eq!(
194            BuiltInConnectionString::from_str("localhost:8545").unwrap(),
195            BuiltInConnectionString::Http("http://localhost:8545".parse::<Url>().unwrap())
196        );
197        assert_eq!(
198            BuiltInConnectionString::from_str("https://localhost:8545").unwrap(),
199            BuiltInConnectionString::Http("https://localhost:8545".parse::<Url>().unwrap())
200        );
201        assert_eq!(
202            BuiltInConnectionString::from_str("localhost:8545").unwrap(),
203            BuiltInConnectionString::Http("http://localhost:8545".parse::<Url>().unwrap())
204        );
205        assert_eq!(
206            BuiltInConnectionString::from_str("http://127.0.0.1:8545").unwrap(),
207            BuiltInConnectionString::Http("http://127.0.0.1:8545".parse::<Url>().unwrap())
208        );
209
210        assert_eq!(
211            BuiltInConnectionString::from_str("http://localhost").unwrap(),
212            BuiltInConnectionString::Http("http://localhost".parse::<Url>().unwrap())
213        );
214        assert_eq!(
215            BuiltInConnectionString::from_str("127.0.0.1:8545").unwrap(),
216            BuiltInConnectionString::Http("http://127.0.0.1:8545".parse::<Url>().unwrap())
217        );
218        assert_eq!(
219            BuiltInConnectionString::from_str("http://user:pass@example.com").unwrap(),
220            BuiltInConnectionString::Http("http://user:pass@example.com".parse::<Url>().unwrap())
221        );
222    }
223
224    #[test]
225    #[cfg(feature = "ws")]
226    fn test_parsing_ws() {
227        use alloy_transport::Authorization;
228
229        assert_eq!(
230            BuiltInConnectionString::from_str("ws://localhost:8545").unwrap(),
231            BuiltInConnectionString::Ws("ws://localhost:8545".parse::<Url>().unwrap(), None)
232        );
233        assert_eq!(
234            BuiltInConnectionString::from_str("wss://localhost:8545").unwrap(),
235            BuiltInConnectionString::Ws("wss://localhost:8545".parse::<Url>().unwrap(), None)
236        );
237        assert_eq!(
238            BuiltInConnectionString::from_str("ws://127.0.0.1:8545").unwrap(),
239            BuiltInConnectionString::Ws("ws://127.0.0.1:8545".parse::<Url>().unwrap(), None)
240        );
241
242        assert_eq!(
243            BuiltInConnectionString::from_str("ws://alice:pass@127.0.0.1:8545").unwrap(),
244            BuiltInConnectionString::Ws(
245                "ws://alice:pass@127.0.0.1:8545".parse::<Url>().unwrap(),
246                Some(Authorization::basic("alice", "pass"))
247            )
248        );
249    }
250
251    #[test]
252    #[cfg(feature = "ipc")]
253    #[cfg_attr(windows, ignore = "TODO: windows IPC")]
254    fn test_parsing_ipc() {
255        use alloy_node_bindings::Anvil;
256
257        // Spawn an Anvil instance to create an IPC socket, as it's different from a normal file.
258        let temp_dir = tempfile::tempdir().unwrap();
259        let ipc_path = temp_dir.path().join("anvil.ipc");
260        let ipc_arg = format!("--ipc={}", ipc_path.display());
261        let _anvil = Anvil::new().arg(ipc_arg).spawn();
262        let path_str = ipc_path.to_str().unwrap();
263
264        assert_eq!(
265            BuiltInConnectionString::from_str(&format!("ipc://{path_str}")).unwrap(),
266            BuiltInConnectionString::Ipc(ipc_path.clone())
267        );
268
269        assert_eq!(
270            BuiltInConnectionString::from_str(&format!("file://{path_str}")).unwrap(),
271            BuiltInConnectionString::Ipc(ipc_path.clone())
272        );
273
274        assert_eq!(
275            BuiltInConnectionString::from_str(ipc_path.to_str().unwrap()).unwrap(),
276            BuiltInConnectionString::Ipc(ipc_path.clone())
277        );
278    }
279}