Skip to main content

porkbun_api/
transport.rs

1//! HTTP transport layer traits and implementations
2use http_body_util::Full;
3use hyper::body::Body;
4use hyper::{body::Bytes, Request, Response};
5
6use std::future::Future;
7
8/// A trait representing an HTTP request-response action. This trait needs to be implemented by a type in order to be used as a transport layer by the [Client](crate::Client).
9pub trait MakeRequest: Sized {
10    /// The HTTP body type of the returned response.
11    /// In order for a type to be useable as a transport layer for the [Client](crate::Client)
12    /// `Body::Error` has to implement `Into<Self::Error>`.
13    type Body: Body;
14    /// The error type this interface can return
15    type Error: std::error::Error + Send + Sync + 'static;
16    /// Perform an HTTP request, returning a response asynchronously.
17    fn request(
18        &self,
19        request: Request<Full<Bytes>>,
20    ) -> impl Future<Output = std::result::Result<Response<Self::Body>, Self::Error>>;
21}
22
23#[cfg(feature = "default-client")]
24mod default_impl {
25    use super::MakeRequest;
26    use cookie::time::OffsetDateTime;
27    use cookie::{Cookie, CookieJar};
28    use http_body_util::Full;
29    use hyper::client::conn::http2::Builder as Http2Builder;
30    use hyper::{
31        body::{Bytes, Incoming},
32        client::conn::http2::SendRequest,
33        Request, Response, StatusCode,
34    };
35    use hyper_util::rt::{TokioExecutor, TokioIo};
36    use rustls::ClientConfig;
37    use std::{
38        error::Error,
39        fmt::{Debug, Display},
40        sync::Arc,
41        time::Duration,
42    };
43    use tokio::{
44        net::TcpStream,
45        sync::{Mutex, RwLock},
46    };
47    use tokio_rustls::TlsConnector;
48
49    struct Http2Only {
50        force_ipv4: bool,
51        config: Arc<ClientConfig>,
52        send: tokio::sync::Mutex<Option<SendRequest<Full<Bytes>>>>,
53    }
54
55    impl Http2Only {
56        async fn make_connection(&self) -> Result<SendRequest<Full<Bytes>>, DefaultTransportError> {
57            let host = if self.force_ipv4 {
58                "api-ipv4.porkbun.com"
59            } else {
60                "api.porkbun.com"
61            };
62            #[cfg(feature = "tracing")]
63            tracing::debug!(target: "porkbun_api::transport", "connecting to {}", host);
64            let arc_config = self.config.clone();
65            let server_name = host.try_into().unwrap();
66            let tokio_tls_connecto = TlsConnector::from(arc_config);
67            let tcp = TcpStream::connect(if self.force_ipv4 {
68                "api-ipv4.porkbun.com:443"
69            } else {
70                "api.porkbun.com:443"
71            })
72            .await
73            .map_err(DefaultTransportErrorImpl::ConnectionError)?;
74            let connection = tokio_tls_connecto
75                .connect(server_name, tcp)
76                .await
77                .map_err(DefaultTransportErrorImpl::ConnectionError)?;
78            let hyper_io = TokioIo::new(connection);
79
80            let (send, conn) = Http2Builder::new(TokioExecutor::new())
81                .handshake(hyper_io)
82                .await?;
83            #[cfg(feature = "tracing")]
84            tracing::debug!(target: "porkbun_api::transport", "connection established");
85            tokio::spawn(conn);
86            Ok(send)
87        }
88        pub fn new(force_ipv4: bool) -> Self {
89            use rustls_platform_verifier::BuilderVerifierExt;
90
91            let mut config = rustls::ClientConfig::builder()
92                .with_platform_verifier()
93                .expect("Failed to create platform verifier")
94                .with_no_client_auth();
95            config.alpn_protocols = vec![b"h2".into()];
96            let config = Arc::new(config);
97
98            Self {
99                force_ipv4,
100                config,
101                send: Mutex::new(None),
102            }
103        }
104    }
105
106    impl Default for Http2Only {
107        fn default() -> Self {
108            Self::new(false)
109        }
110    }
111
112    impl MakeRequest for Http2Only {
113        type Body = Incoming;
114        type Error = DefaultTransportError;
115        async fn request(
116            &self,
117            request: Request<Full<Bytes>>,
118        ) -> Result<Response<Self::Body>, Self::Error> {
119            let mut lock = self.send.lock().await;
120            if lock.is_none() || lock.as_ref().is_some_and(|l| l.is_closed()) {
121                #[cfg(feature = "tracing")]
122                tracing::debug!(target: "porkbun_api::transport", "connection closed or not established, reconnecting");
123                let conn = self.make_connection().await?;
124                *lock = Some(conn)
125            }
126            let sender = lock.as_mut().unwrap();
127            sender.ready().await?;
128            sender
129                .send_request(request)
130                .await
131                .map_err(DefaultTransportError::from)
132        }
133    }
134
135    #[derive(Clone)]
136    struct Retry502<T: MakeRequest> {
137        inner: T,
138    }
139
140    impl<T: MakeRequest> Retry502<T> {
141        fn wrapping(inner: T) -> Self {
142            Self { inner }
143        }
144    }
145
146    impl<E, T: MakeRequest<Error = E>> MakeRequest for Retry502<T>
147    where
148        DefaultTransportError: From<E>,
149    {
150        type Body = T::Body;
151        type Error = DefaultTransportError;
152        async fn request(
153            &self,
154            request: Request<Full<Bytes>>,
155        ) -> Result<Response<Self::Body>, Self::Error> {
156            let sleep_time = Duration::from_millis(250);
157            //would be better if this was a timeout wrapper
158            let max_sleep = 10;
159            let mut slept = 0;
160
161            let resp = loop {
162                let resp = self.inner.request(request.clone()).await?;
163                if resp.status() != StatusCode::SERVICE_UNAVAILABLE {
164                    break resp;
165                } else if slept >= max_sleep {
166                    #[cfg(feature = "tracing")]
167                    tracing::warn!(target: "porkbun_api::transport", "retry limit reached after {} attempts", max_sleep);
168                    return Err(DefaultTransportError(DefaultTransportErrorImpl::RetryError));
169                } else {
170                    slept += 1;
171                    #[cfg(feature = "tracing")]
172                    tracing::info!(target: "porkbun_api::transport", "received 502, retrying (attempt {}/{}).", slept, max_sleep);
173                    tokio::time::sleep(sleep_time).await
174                }
175            };
176            Ok(resp)
177        }
178    }
179
180    /// A structure that tracks cookies for requests and responses.
181    ///
182    /// This structure manages cookies according to [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265).
183    /// it is not fully compliant (it doesn't check the secure flag, doesn't purge expired entries)
184    /// but should be good enough for just talking to porkbun.
185    pub struct TrackCookies<T> {
186        inner: T,
187        cookie_jar: RwLock<CookieJar>,
188    }
189
190    impl<T> TrackCookies<T> {
191        /// Creates a new `TrackCookies` instance.
192        pub fn wrapping(inner: T) -> Self {
193            Self {
194                inner,
195                cookie_jar: RwLock::new(CookieJar::new()),
196            }
197        }
198
199        /// Checks if a cookie is valid for the given request.
200        fn is_cookie_valid_for_request(cookie: &Cookie, request: &Request<Full<Bytes>>) -> bool {
201            // Check domain
202            if let Some(domain) = cookie.domain() {
203                if !request.uri().host().unwrap_or("").ends_with(domain) {
204                    return false;
205                }
206            }
207            // Check path
208            if let Some(path) = cookie.path() {
209                if !request.uri().path().starts_with(path) {
210                    return false;
211                }
212            }
213            // Check if the cookie is expired
214            if let Some(expires) = cookie.expires_datetime() {
215                if expires <= OffsetDateTime::now_utc() {
216                    return false;
217                }
218            }
219            true
220        }
221    }
222    impl<T: MakeRequest> MakeRequest for TrackCookies<T> {
223        type Body = T::Body;
224        type Error = T::Error;
225        /// Makes a request, adding cookies to the request and extracting cookies from the response.
226        async fn request(
227            &self,
228            mut request: Request<Full<Bytes>>,
229        ) -> Result<Response<T::Body>, T::Error> {
230            // Add cookies to the request
231            let cookie_header = {
232                let jar = self.cookie_jar.read().await;
233                jar.iter()
234                    .filter(|cookie| Self::is_cookie_valid_for_request(cookie, &request))
235                    .map(|c| {
236                        let (name, value) = c.name_value_trimmed();
237                        format!("{name}={value}")
238                    })
239                    .collect::<Vec<_>>()
240                    .join("; ")
241            };
242
243            if !cookie_header.is_empty() {
244                #[cfg(feature = "tracing")]
245                tracing::trace!(target: "porkbun_api::transport", "added {} cookies to request", cookie_header.split("; ").count());
246                request
247                    .headers_mut()
248                    .insert(hyper::header::COOKIE, cookie_header.parse().unwrap());
249            }
250
251            let response = self.inner.request(request).await?;
252
253            // parse_encoded, parse_split_encoded
254            let cookies = response
255                .headers()
256                .get_all(hyper::header::SET_COOKIE)
257                .iter()
258                .filter_map(|h| h.to_str().ok())
259                .filter_map(|s| Cookie::parse(s).ok())
260                .collect::<Vec<_>>();
261
262            // Extract cookies from the response
263            if !cookies.is_empty() {
264                #[cfg(feature = "tracing")]
265                tracing::trace!(target: "porkbun_api::transport", "stored {} cookies from response", cookies.len());
266                let mut jar = self.cookie_jar.write().await;
267                for cookie in cookies {
268                    jar.add(cookie.into_owned());
269                }
270            }
271
272            Ok(response)
273        }
274    }
275
276    /// A default implementation of the http stack. Requests need to be made from within a tokio runtime.
277    ///
278    /// This version currently respects the `BUNSESSION2` cookie send by the api server
279    /// and will retry requests if it receives a response with a 502 statuscode every 250ms, up to a maximum of 10 times.
280    ///
281    /// this implementation is subject to change in a minor release.
282    pub struct DefaultTransport(Retry502<TrackCookies<Http2Only>>);
283
284    impl Default for DefaultTransport {
285        fn default() -> Self {
286            Self(Retry502::wrapping(TrackCookies::wrapping(
287                Http2Only::default(),
288            )))
289        }
290    }
291
292    impl DefaultTransport {
293        /// creates a new instance of this transport.
294        /// if `force_ipv4` is set to true, it will connect to `api-ipv4.porbun.com` instead of `api.porkbun.com`, forcing the ping command to return an IPv4 address.
295        pub fn new(force_ipv4: bool) -> Self {
296            Self(Retry502::wrapping(TrackCookies::wrapping(Http2Only::new(
297                force_ipv4,
298            ))))
299        }
300    }
301
302    #[allow(clippy::enum_variant_names)]
303    #[derive(Debug)]
304    enum DefaultTransportErrorImpl {
305        ConnectionError(std::io::Error),
306        RetryError,
307        HttpError(hyper::Error),
308    }
309
310    impl From<hyper::Error> for DefaultTransportErrorImpl {
311        fn from(value: hyper::Error) -> Self {
312            Self::HttpError(value)
313        }
314    }
315
316    /// The error type returned by [DefaultTransport]
317    pub struct DefaultTransportError(DefaultTransportErrorImpl);
318
319    impl Debug for DefaultTransportError {
320        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321            Debug::fmt(&self.0, f)
322        }
323    }
324
325    impl<T> From<T> for DefaultTransportError
326    where
327        T: Into<DefaultTransportErrorImpl>,
328    {
329        fn from(value: T) -> Self {
330            Self(value.into())
331        }
332    }
333
334    impl Error for DefaultTransportError {
335        fn source(&self) -> Option<&(dyn Error + 'static)> {
336            match &self.0 {
337                DefaultTransportErrorImpl::ConnectionError(e) => Some(e),
338                DefaultTransportErrorImpl::HttpError(e) => Some(e),
339                DefaultTransportErrorImpl::RetryError => None,
340            }
341        }
342    }
343
344    impl Display for DefaultTransportError {
345        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346            f.write_str(match self.0 {
347                DefaultTransportErrorImpl::ConnectionError(_) => "Failed to connect to endpoint",
348                DefaultTransportErrorImpl::HttpError(_) => "HTTP protocol error",
349                DefaultTransportErrorImpl::RetryError => {
350                    "Server took to many tries to reply with a non-502 statuscode"
351                }
352            })
353        }
354    }
355
356    impl MakeRequest for DefaultTransport {
357        type Body = Incoming;
358        type Error = DefaultTransportError;
359        async fn request(
360            &self,
361            request: Request<Full<Bytes>>,
362        ) -> Result<Response<Self::Body>, Self::Error> {
363            self.0.request(request).await
364        }
365    }
366}
367
368#[cfg(feature = "default-client")]
369pub use default_impl::*;