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