Skip to content

Commit 4ba2d8c

Browse files
authored
Use IntoUrl for more ergonomic function signatures (payjoin#520)
Re: payjoin#513 Inspired by [`reqwest::IntoUrl`](https://docs.rs/reqwest/latest/reqwest/trait.IntoUrl.html) After some consideration, the goal is no longer to remove `url` types from the public API. That crate is ancient and has a stable release cadence and conservative MSRV targets (1.63 even with the latest) that we can track. However, I still think the `IntoUrl` interface makes our typestate machines easier to work downstream with for the tradeoff of added complexity in our crate. Note, in payjoin-cli We're still validating URLs in configuration, and using the `url::Url` abstraction in function signatures makes more sense than becoming more loose for testing. The greatest advantage of this change may accrue to downstream FFI users, who I imagine use their native language's Url type and conversion to a stringly type that payjoin-ffi handles using IntoUrl rather than forcing them to use the `payjoin::Url` re-export and error handling.
2 parents 0451383 + b3f0966 commit 4ba2d8c

File tree

11 files changed

+207
-67
lines changed

11 files changed

+207
-67
lines changed

payjoin-cli/src/app/v1.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ impl App {
147147
.map_err(|e| anyhow!("Failed to parse pj_endpoint: {}", e))?;
148148

149149
let mut pj_uri =
150-
payjoin::receive::v1::build_v1_pj_uri(&pj_receiver_address, &pj_part, false);
150+
payjoin::receive::v1::build_v1_pj_uri(&pj_receiver_address, &pj_part, false)?;
151151
pj_uri.amount = Some(amount);
152152

153153
Ok(pj_uri.to_string())

payjoin-cli/src/app/v2.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ impl AppTrait for App {
8080
let address = self.bitcoind()?.get_new_address(None, None)?.assume_checked();
8181
let ohttp_keys = unwrap_ohttp_keys_or_else_fetch(&self.config).await?;
8282
let session =
83-
Receiver::new(address, self.config.pj_directory.clone(), ohttp_keys.clone(), None);
83+
Receiver::new(address, self.config.pj_directory.clone(), ohttp_keys.clone(), None)?;
8484
self.db.insert_recv_session(session.clone())?;
8585
self.spawn_payjoin_receiver(session, Some(amount)).await
8686
}

payjoin/src/into_url.rs

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
use url::{ParseError, Url};
2+
3+
#[derive(Debug)]
4+
pub enum Error {
5+
BadScheme,
6+
ParseError(ParseError),
7+
}
8+
9+
impl std::fmt::Display for Error {
10+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11+
use Error::*;
12+
13+
match self {
14+
BadScheme => write!(f, "URL scheme is not allowed"),
15+
ParseError(e) => write!(f, "{}", e),
16+
}
17+
}
18+
}
19+
20+
impl std::error::Error for Error {}
21+
22+
impl From<ParseError> for Error {
23+
fn from(err: ParseError) -> Error { Error::ParseError(err) }
24+
}
25+
26+
type Result<T> = core::result::Result<T, Error>;
27+
28+
/// Try to convert some type into a [`Url`].
29+
///
30+
/// This trait is "sealed", such that only types within payjoin can
31+
/// implement it.
32+
///
33+
/// This design is inspired by the `reqwest` crate's design:
34+
/// see <https://docs.rs/reqwest/latest/reqwest/trait.IntoUrl.html>
35+
pub trait IntoUrl: IntoUrlSealed {}
36+
37+
impl IntoUrl for &Url {}
38+
impl IntoUrl for Url {}
39+
impl IntoUrl for &str {}
40+
impl IntoUrl for &String {}
41+
impl IntoUrl for String {}
42+
43+
pub trait IntoUrlSealed {
44+
/// Besides parsing as a valid `Url`, the `Url` must be a valid
45+
/// `http::Uri`, in that it makes sense to use in a network request.
46+
fn into_url(self) -> Result<Url>;
47+
48+
fn as_str(&self) -> &str;
49+
}
50+
51+
impl IntoUrlSealed for &Url {
52+
fn into_url(self) -> Result<Url> { self.clone().into_url() }
53+
54+
fn as_str(&self) -> &str { self.as_ref() }
55+
}
56+
57+
impl IntoUrlSealed for Url {
58+
fn into_url(self) -> Result<Url> {
59+
if self.has_host() {
60+
Ok(self)
61+
} else {
62+
Err(Error::BadScheme)
63+
}
64+
}
65+
66+
fn as_str(&self) -> &str { self.as_ref() }
67+
}
68+
69+
impl IntoUrlSealed for &str {
70+
fn into_url(self) -> Result<Url> { Url::parse(self)?.into_url() }
71+
72+
fn as_str(&self) -> &str { self }
73+
}
74+
75+
impl IntoUrlSealed for &String {
76+
fn into_url(self) -> Result<Url> { (&**self).into_url() }
77+
78+
fn as_str(&self) -> &str { self.as_ref() }
79+
}
80+
81+
impl IntoUrlSealed for String {
82+
fn into_url(self) -> Result<Url> { (&*self).into_url() }
83+
84+
fn as_str(&self) -> &str { self.as_ref() }
85+
}
86+
87+
#[cfg(test)]
88+
mod tests {
89+
use super::*;
90+
91+
#[test]
92+
fn http_uri_scheme_is_allowed() {
93+
let url = "http://localhost".into_url().unwrap();
94+
assert_eq!(url.scheme(), "http");
95+
}
96+
97+
#[test]
98+
fn https_uri_scheme_is_allowed() {
99+
let url = "https://localhost".into_url().unwrap();
100+
assert_eq!(url.scheme(), "https");
101+
}
102+
103+
#[test]
104+
fn into_url_file_scheme() {
105+
let err = "file:///etc/hosts".into_url().unwrap_err();
106+
assert_eq!(err.to_string(), "URL scheme is not allowed");
107+
}
108+
109+
#[test]
110+
fn into_url_blob_scheme() {
111+
let err = "blob:https://example.com".into_url().unwrap_err();
112+
assert_eq!(err.to_string(), "URL scheme is not allowed");
113+
}
114+
}

payjoin/src/io.rs

+16-11
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
33
use reqwest::{Client, Proxy};
44

5-
use crate::{OhttpKeys, Url};
5+
use crate::into_url::IntoUrl;
6+
use crate::OhttpKeys;
67

78
/// Fetch the ohttp keys from the specified payjoin directory via proxy.
89
///
@@ -13,11 +14,11 @@ use crate::{OhttpKeys, Url};
1314
/// * `payjoin_directory`: The payjoin directory from which to fetch the ohttp keys. This
1415
/// directory stores and forwards payjoin client payloads.
1516
pub async fn fetch_ohttp_keys(
16-
ohttp_relay: Url,
17-
payjoin_directory: Url,
17+
ohttp_relay: impl IntoUrl,
18+
payjoin_directory: impl IntoUrl,
1819
) -> Result<OhttpKeys, Error> {
19-
let ohttp_keys_url = payjoin_directory.join("/ohttp-keys")?;
20-
let proxy = Proxy::all(ohttp_relay.as_str())?;
20+
let ohttp_keys_url = payjoin_directory.into_url()?.join("/ohttp-keys")?;
21+
let proxy = Proxy::all(ohttp_relay.into_url()?.as_str())?;
2122
let client = Client::builder().proxy(proxy).build()?;
2223
let res = client.get(ohttp_keys_url).send().await?;
2324
let body = res.bytes().await?.to_vec();
@@ -36,12 +37,12 @@ pub async fn fetch_ohttp_keys(
3637
/// * `cert_der`: The DER-encoded certificate to use for local HTTPS connections.
3738
#[cfg(feature = "_danger-local-https")]
3839
pub async fn fetch_ohttp_keys_with_cert(
39-
ohttp_relay: Url,
40-
payjoin_directory: Url,
40+
ohttp_relay: impl IntoUrl,
41+
payjoin_directory: impl IntoUrl,
4142
cert_der: Vec<u8>,
4243
) -> Result<OhttpKeys, Error> {
43-
let ohttp_keys_url = payjoin_directory.join("/ohttp-keys")?;
44-
let proxy = Proxy::all(ohttp_relay.as_str())?;
44+
let ohttp_keys_url = payjoin_directory.into_url()?.join("/ohttp-keys")?;
45+
let proxy = Proxy::all(ohttp_relay.into_url()?.as_str())?;
4546
let client = Client::builder()
4647
.danger_accept_invalid_certs(true)
4748
.use_rustls_tls()
@@ -58,14 +59,18 @@ pub struct Error(InternalError);
5859

5960
#[derive(Debug)]
6061
enum InternalError {
61-
ParseUrl(crate::ParseError),
62+
ParseUrl(crate::into_url::Error),
6263
Reqwest(reqwest::Error),
6364
Io(std::io::Error),
6465
#[cfg(feature = "_danger-local-https")]
6566
Rustls(rustls::Error),
6667
InvalidOhttpKeys(String),
6768
}
6869

70+
impl From<url::ParseError> for Error {
71+
fn from(value: url::ParseError) -> Self { Self(InternalError::ParseUrl(value.into())) }
72+
}
73+
6974
macro_rules! impl_from_error {
7075
($from:ty, $to:ident) => {
7176
impl From<$from> for Error {
@@ -74,8 +79,8 @@ macro_rules! impl_from_error {
7479
};
7580
}
7681

82+
impl_from_error!(crate::into_url::Error, ParseUrl);
7783
impl_from_error!(reqwest::Error, Reqwest);
78-
impl_from_error!(crate::ParseError, ParseUrl);
7984
impl_from_error!(std::io::Error, Io);
8085
#[cfg(feature = "_danger-local-https")]
8186
impl_from_error!(rustls::Error, Rustls);

payjoin/src/lib.rs

+4-1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ pub(crate) mod bech32;
3939
#[cfg_attr(docsrs, doc(cfg(feature = "directory")))]
4040
pub mod directory;
4141

42+
#[cfg(feature = "_core")]
43+
pub(crate) mod into_url;
4244
#[cfg(feature = "io")]
4345
#[cfg_attr(docsrs, doc(cfg(feature = "io")))]
4446
pub mod io;
@@ -51,10 +53,11 @@ pub use request::*;
5153
#[cfg(feature = "_core")]
5254
mod uri;
5355

56+
#[cfg(feature = "_core")]
57+
pub use into_url::{Error as IntoUrlError, IntoUrl};
5458
#[cfg(feature = "_core")]
5559
pub use uri::{PjParseError, PjUri, Uri, UriExt};
5660
#[cfg(feature = "_core")]
5761
pub use url::{ParseError, Url};
58-
5962
#[cfg(feature = "_core")]
6063
pub(crate) mod error_codes;

payjoin/src/receive/v1/exclusive/mod.rs

+5-4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub(crate) use error::InternalRequestError;
33
pub use error::RequestError;
44

55
use super::*;
6+
use crate::into_url::IntoUrl;
67

78
/// 4_000_000 * 4 / 3 fits in u32
89
const MAX_CONTENT_LENGTH: usize = 4_000_000 * 4 / 3;
@@ -14,12 +15,12 @@ pub trait Headers {
1415

1516
pub fn build_v1_pj_uri<'a>(
1617
address: &bitcoin::Address,
17-
endpoint: &url::Url,
18+
endpoint: impl IntoUrl,
1819
disable_output_substitution: bool,
19-
) -> crate::uri::PjUri<'a> {
20+
) -> Result<crate::uri::PjUri<'a>, crate::into_url::Error> {
2021
let extras =
21-
crate::uri::PayjoinExtras { endpoint: endpoint.clone(), disable_output_substitution };
22-
bitcoin_uri::Uri::with_extras(address.clone(), extras)
22+
crate::uri::PayjoinExtras { endpoint: endpoint.into_url()?, disable_output_substitution };
23+
Ok(bitcoin_uri::Uri::with_extras(address.clone(), extras))
2324
}
2425

2526
impl UncheckedProposal {

payjoin/src/receive/v2/error.rs

+22-12
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ impl From<InternalSessionError> for Error {
2323

2424
#[derive(Debug)]
2525
pub(crate) enum InternalSessionError {
26+
/// Url parsing failed
27+
ParseUrl(crate::into_url::Error),
2628
/// The session has expired
2729
Expired(std::time::SystemTime),
2830
/// OHTTP Encapsulation failed
@@ -35,6 +37,10 @@ pub(crate) enum InternalSessionError {
3537
UnexpectedStatusCode(http::StatusCode),
3638
}
3739

40+
impl From<crate::into_url::Error> for SessionError {
41+
fn from(e: crate::into_url::Error) -> Self { InternalSessionError::ParseUrl(e).into() }
42+
}
43+
3844
impl From<std::time::SystemTime> for Error {
3945
fn from(e: std::time::SystemTime) -> Self { InternalSessionError::Expired(e).into() }
4046
}
@@ -51,31 +57,35 @@ impl From<HpkeError> for Error {
5157

5258
impl fmt::Display for SessionError {
5359
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
60+
use InternalSessionError::*;
61+
5462
match &self.0 {
55-
InternalSessionError::Expired(expiry) => write!(f, "Session expired at {:?}", expiry),
56-
InternalSessionError::OhttpEncapsulation(e) =>
57-
write!(f, "OHTTP Encapsulation Error: {}", e),
58-
InternalSessionError::Hpke(e) => write!(f, "Hpke decryption failed: {}", e),
59-
InternalSessionError::UnexpectedResponseSize(size) => write!(
63+
ParseUrl(e) => write!(f, "URL parsing failed: {}", e),
64+
Expired(expiry) => write!(f, "Session expired at {:?}", expiry),
65+
OhttpEncapsulation(e) => write!(f, "OHTTP Encapsulation Error: {}", e),
66+
Hpke(e) => write!(f, "Hpke decryption failed: {}", e),
67+
UnexpectedResponseSize(size) => write!(
6068
f,
6169
"Unexpected response size {}, expected {} bytes",
6270
size,
6371
crate::directory::ENCAPSULATED_MESSAGE_BYTES
6472
),
65-
InternalSessionError::UnexpectedStatusCode(status) =>
66-
write!(f, "Unexpected status code: {}", status),
73+
UnexpectedStatusCode(status) => write!(f, "Unexpected status code: {}", status),
6774
}
6875
}
6976
}
7077

7178
impl error::Error for SessionError {
7279
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
80+
use InternalSessionError::*;
81+
7382
match &self.0 {
74-
InternalSessionError::Expired(_) => None,
75-
InternalSessionError::OhttpEncapsulation(e) => Some(e),
76-
InternalSessionError::Hpke(e) => Some(e),
77-
InternalSessionError::UnexpectedResponseSize(_) => None,
78-
InternalSessionError::UnexpectedStatusCode(_) => None,
83+
ParseUrl(e) => Some(e),
84+
Expired(_) => None,
85+
OhttpEncapsulation(e) => Some(e),
86+
Hpke(e) => Some(e),
87+
UnexpectedResponseSize(_) => None,
88+
UnexpectedStatusCode(_) => None,
7989
}
8090
}
8191
}

0 commit comments

Comments
 (0)