1use http_body_util::Full;
3use hyper::body::Body;
4use hyper::{body::Bytes, Request, Response};
5
6use std::future::Future;
7
8pub trait MakeRequest: Sized {
10 type Body: Body;
14 type Error: std::error::Error + Send + Sync + 'static;
16 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 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 pub struct TrackCookies<T> {
186 inner: T,
187 cookie_jar: RwLock<CookieJar>,
188 }
189
190 impl<T> TrackCookies<T> {
191 pub fn wrapping(inner: T) -> Self {
193 Self {
194 inner,
195 cookie_jar: RwLock::new(CookieJar::new()),
196 }
197 }
198
199 fn is_cookie_valid_for_request(cookie: &Cookie, request: &Request<Full<Bytes>>) -> bool {
201 if let Some(domain) = cookie.domain() {
203 if !request.uri().host().unwrap_or("").ends_with(domain) {
204 return false;
205 }
206 }
207 if let Some(path) = cookie.path() {
209 if !request.uri().path().starts_with(path) {
210 return false;
211 }
212 }
213 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 async fn request(
227 &self,
228 mut request: Request<Full<Bytes>>,
229 ) -> Result<Response<T::Body>, T::Error> {
230 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 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 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 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 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 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::*;