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#[derive(Clone, Debug, PartialEq, Eq)]
10#[non_exhaustive]
11pub enum BuiltInConnectionString {
12 #[cfg(any(feature = "reqwest", feature = "hyper"))]
14 Http(url::Url),
15 #[cfg(feature = "ws-base")]
17 Ws(url::Url, Option<alloy_transport::Authorization>),
18 #[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 pub async fn connect(s: &str) -> Result<BoxTransport, TransportError> {
63 let connection = Self::from_str(s)?;
64 connection.connect_boxed().await
65 }
66
67 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 pub async fn connect_boxed(&self) -> Result<BoxTransport, TransportError> {
107 self.connect_boxed_with(ConnectionConfig::default()).await
108 }
109
110 pub async fn connect_boxed_with(
121 &self,
122 config: ConnectionConfig,
123 ) -> Result<BoxTransport, TransportError> {
124 let _ = &config; match self {
128 #[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 #[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 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; #[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 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 #[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 #[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 #[cfg(feature = "ipc")]
248 pub fn try_as_ipc(s: &str) -> Result<Self, TransportError> {
249 let original = s;
250 let s = s.strip_prefix("file://").or_else(|| s.strip_prefix("ipc://")).unwrap_or(s);
251
252 let path = std::path::Path::new(s);
254 let _meta = path.metadata().map_err(|e| {
255 let msg = if original == s {
256 format!("failed to read IPC path '{}': {e}", path.display())
257 } else {
258 format!("failed to read IPC path '{}' from '{original}': {e}", path.display())
259 };
260 TransportErrorKind::custom_str(&msg)
261 })?;
262
263 Ok(Self::Ipc(path.to_path_buf()))
264 }
265}
266
267impl FromStr for BuiltInConnectionString {
268 type Err = RpcError<TransportErrorKind>;
269
270 #[allow(clippy::let_and_return)]
271 fn from_str(s: &str) -> Result<Self, Self::Err> {
272 let res = Err(TransportErrorKind::custom_str(&format!(
273 "No transports enabled. Enable one of: reqwest, hyper, ws, ipc. Connection info: '{s}'"
274 )));
275 #[cfg(any(feature = "reqwest", feature = "hyper"))]
276 let res = res.or_else(|_| Self::try_as_http(s));
277 #[cfg(feature = "ws-base")]
278 let res = res.or_else(|_| Self::try_as_ws(s));
279 #[cfg(feature = "ipc")]
280 let res = res.or_else(|_| Self::try_as_ipc(s));
281 res
282 }
283}
284
285#[derive(Clone, Debug, Default)]
290#[non_exhaustive]
291pub struct ConnectionConfig {
292 pub auth: Option<alloy_transport::Authorization>,
294 pub max_retries: Option<u32>,
296 pub retry_interval: Option<Duration>,
300 #[cfg(all(feature = "ws-base", not(target_family = "wasm")))]
302 pub ws_config: Option<alloy_transport_ws::WebSocketConfig>,
303}
304
305impl ConnectionConfig {
306 pub const fn new() -> Self {
308 Self {
309 auth: None,
310 max_retries: None,
311 retry_interval: None,
312 #[cfg(all(feature = "ws-base", not(target_family = "wasm")))]
313 ws_config: None,
314 }
315 }
316
317 pub fn with_auth(mut self, auth: alloy_transport::Authorization) -> Self {
319 self.auth = Some(auth);
320 self
321 }
322
323 pub const fn with_max_retries(mut self, max_retries: u32) -> Self {
325 self.max_retries = Some(max_retries);
326 self
327 }
328
329 pub const fn with_retry_interval(mut self, retry_interval: Duration) -> Self {
333 self.retry_interval = Some(retry_interval);
334 self
335 }
336
337 #[cfg(all(feature = "ws-base", not(target_family = "wasm")))]
339 pub const fn with_ws_config(mut self, config: alloy_transport_ws::WebSocketConfig) -> Self {
340 self.ws_config = Some(config);
341 self
342 }
343}
344
345#[cfg(test)]
346mod test {
347 use super::*;
348 use similar_asserts::assert_eq;
349 use url::Url;
350
351 #[test]
352 fn test_parsing_urls() {
353 assert_eq!(
354 BuiltInConnectionString::from_str("http://localhost:8545").unwrap(),
355 BuiltInConnectionString::Http("http://localhost:8545".parse::<Url>().unwrap())
356 );
357 assert_eq!(
358 BuiltInConnectionString::from_str("localhost:8545").unwrap(),
359 BuiltInConnectionString::Http("http://localhost:8545".parse::<Url>().unwrap())
360 );
361 assert_eq!(
362 BuiltInConnectionString::from_str("https://localhost:8545").unwrap(),
363 BuiltInConnectionString::Http("https://localhost:8545".parse::<Url>().unwrap())
364 );
365 assert_eq!(
366 BuiltInConnectionString::from_str("http://127.0.0.1:8545").unwrap(),
367 BuiltInConnectionString::Http("http://127.0.0.1:8545".parse::<Url>().unwrap())
368 );
369
370 assert_eq!(
371 BuiltInConnectionString::from_str("http://localhost").unwrap(),
372 BuiltInConnectionString::Http("http://localhost".parse::<Url>().unwrap())
373 );
374 assert_eq!(
375 BuiltInConnectionString::from_str("127.0.0.1:8545").unwrap(),
376 BuiltInConnectionString::Http("http://127.0.0.1:8545".parse::<Url>().unwrap())
377 );
378 assert_eq!(
379 BuiltInConnectionString::from_str("http://user:pass@example.com").unwrap(),
380 BuiltInConnectionString::Http("http://user:pass@example.com".parse::<Url>().unwrap())
381 );
382 }
383
384 #[test]
385 #[cfg(feature = "ws-base")]
386 fn test_parsing_ws() {
387 use alloy_transport::Authorization;
388
389 assert_eq!(
390 BuiltInConnectionString::from_str("ws://localhost:8545").unwrap(),
391 BuiltInConnectionString::Ws("ws://localhost:8545".parse::<Url>().unwrap(), None)
392 );
393 assert_eq!(
394 BuiltInConnectionString::from_str("wss://localhost:8545").unwrap(),
395 BuiltInConnectionString::Ws("wss://localhost:8545".parse::<Url>().unwrap(), None)
396 );
397 assert_eq!(
398 BuiltInConnectionString::from_str("ws://127.0.0.1:8545").unwrap(),
399 BuiltInConnectionString::Ws("ws://127.0.0.1:8545".parse::<Url>().unwrap(), None)
400 );
401
402 assert_eq!(
403 BuiltInConnectionString::from_str("ws://alice:pass@127.0.0.1:8545").unwrap(),
404 BuiltInConnectionString::Ws(
405 "ws://alice:pass@127.0.0.1:8545".parse::<Url>().unwrap(),
406 Some(Authorization::basic("alice", "pass"))
407 )
408 );
409 }
410
411 #[test]
412 #[cfg(feature = "ipc")]
413 #[cfg_attr(windows, ignore = "TODO: windows IPC")]
414 fn empty_ipc_path_error_includes_path() {
415 let err = BuiltInConnectionString::try_as_ipc("ipc://").unwrap_err().to_string();
416
417 assert!(
418 err.contains("failed to read IPC path '' from 'ipc://'"),
419 "unexpected IPC path error: {err}"
420 );
421 }
422
423 #[test]
424 #[cfg(feature = "ipc")]
425 #[cfg_attr(windows, ignore = "TODO: windows IPC")]
426 fn test_parsing_ipc() {
427 use alloy_node_bindings::Anvil;
428
429 let temp_dir = tempfile::tempdir().unwrap();
431 let ipc_path = temp_dir.path().join("anvil.ipc");
432 let ipc_arg = format!("--ipc={}", ipc_path.display());
433 let _anvil = Anvil::new().arg(ipc_arg).spawn();
434 let path_str = ipc_path.to_str().unwrap();
435
436 assert_eq!(
437 BuiltInConnectionString::from_str(&format!("ipc://{path_str}")).unwrap(),
438 BuiltInConnectionString::Ipc(ipc_path.clone())
439 );
440
441 assert_eq!(
442 BuiltInConnectionString::from_str(&format!("file://{path_str}")).unwrap(),
443 BuiltInConnectionString::Ipc(ipc_path.clone())
444 );
445
446 assert_eq!(
447 BuiltInConnectionString::from_str(ipc_path.to_str().unwrap()).unwrap(),
448 BuiltInConnectionString::Ipc(ipc_path.clone())
449 );
450 }
451
452 #[test]
453 #[cfg(feature = "ws-base")]
454 fn test_ws_config_auth_priority() {
455 use alloy_transport::Authorization;
456
457 let config_auth = Authorization::bearer("config-token");
459 let url_auth = Some(Authorization::basic("user", "pass"));
460
461 let _ws_connection =
462 BuiltInConnectionString::Ws("ws://user:pass@localhost:8545".parse().unwrap(), url_auth);
463
464 let config = ConnectionConfig::new().with_auth(config_auth.clone());
465
466 assert_eq!(config.auth.as_ref().unwrap().to_string(), config_auth.to_string());
470 }
471
472 #[test]
473 fn test_backward_compatibility() {
474 let default_config = ConnectionConfig::default();
476 assert!(default_config.auth.is_none());
477 assert!(default_config.max_retries.is_none());
478
479 }
481}