Skip to main content

dns_update_lite/
http.rs

1use crate::Error;
2use bytes::Bytes;
3use http::{
4    Method,
5    header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue},
6};
7use http_body_util::{BodyExt, Full};
8use hyper_rustls::HttpsConnector;
9use hyper_util::{
10    client::legacy::{Client, connect::HttpConnector},
11    rt::TokioExecutor,
12};
13use serde::{Serialize, de::DeserializeOwned};
14use std::time::Duration;
15
16// Re-export for external callers (ovh etc.) so they can `use crate::http::{Method, HeaderMap, HeaderValue}`
17pub use http::Method as HttpMethod;
18pub use http::header::{HeaderMap as HttpHeaderMap, HeaderValue as HttpHeaderValue};
19
20pub type HyperClient = Client<HttpsConnector<HttpConnector>, Full<Bytes>>;
21
22#[derive(Debug, Clone)]
23pub struct HttpClientBuilder {
24    timeout: Duration,
25    headers: HeaderMap,
26}
27
28#[derive(Clone)]
29pub struct HttpClient {
30    headers: HeaderMap,
31    client: HyperClient,
32    timeout: Duration,
33}
34
35#[derive(Clone)]
36pub struct HttpRequest {
37    method: Method,
38    url: String,
39    headers: HeaderMap,
40    body: Option<String>,
41    client: HyperClient,
42    timeout: Duration,
43}
44
45impl std::fmt::Debug for HttpClient {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.debug_struct("HttpClient")
48            .field("headers", &self.headers)
49            .field("timeout", &self.timeout)
50            .finish_non_exhaustive()
51    }
52}
53
54impl std::fmt::Debug for HttpRequest {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.debug_struct("HttpRequest")
57            .field("method", &self.method)
58            .field("url", &self.url)
59            .field("headers", &self.headers)
60            .field("body", &self.body)
61            .finish_non_exhaustive()
62    }
63}
64
65impl Default for HttpClientBuilder {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl HttpClientBuilder {
72    pub fn new() -> Self {
73        let mut headers = HeaderMap::new();
74        headers.append(CONTENT_TYPE, HeaderValue::from_static("application/json"));
75
76        Self {
77            timeout: Duration::from_secs(30),
78            headers,
79        }
80    }
81
82    pub fn with_header(mut self, name: &'static str, value: impl AsRef<str>) -> Self {
83        if let (Ok(val), Ok(n)) = (
84            HeaderValue::from_str(value.as_ref()),
85            name.parse::<HeaderName>(),
86        ) {
87            self.headers.append(n, val);
88        }
89        self
90    }
91
92    pub fn set_header(mut self, name: &'static str, value: impl AsRef<str>) -> Self {
93        if let (Ok(val), Ok(n)) = (
94            HeaderValue::from_str(value.as_ref()),
95            name.parse::<HeaderName>(),
96        ) {
97            self.headers.insert(n, val);
98        }
99        self
100    }
101
102    pub fn without_header(mut self, name: &'static str) -> Self {
103        if let Ok(n) = name.parse::<HeaderName>() {
104            self.headers.remove(n);
105        } else {
106            self.headers.remove(name);
107        }
108        self
109    }
110
111    pub fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
112        if let Some(timeout) = timeout {
113            self.timeout = timeout;
114        }
115        self
116    }
117
118    pub fn build(self) -> HttpClient {
119        let connector = build_https_connector();
120        let client = Client::builder(TokioExecutor::new()).build(connector);
121        HttpClient {
122            headers: self.headers,
123            client,
124            timeout: self.timeout,
125        }
126    }
127}
128
129impl HttpClient {
130    pub fn request(&self, method: Method, url: impl Into<String>) -> HttpRequest {
131        HttpRequest {
132            method,
133            url: url.into(),
134            headers: self.headers.clone(),
135            body: None,
136            client: self.client.clone(),
137            timeout: self.timeout,
138        }
139    }
140
141    pub fn get(&self, url: impl Into<String>) -> HttpRequest {
142        self.request(Method::GET, url)
143    }
144
145    pub fn post(&self, url: impl Into<String>) -> HttpRequest {
146        self.request(Method::POST, url)
147    }
148
149    pub fn put(&self, url: impl Into<String>) -> HttpRequest {
150        self.request(Method::PUT, url)
151    }
152
153    pub fn delete(&self, url: impl Into<String>) -> HttpRequest {
154        self.request(Method::DELETE, url)
155    }
156
157    pub fn patch(&self, url: impl Into<String>) -> HttpRequest {
158        self.request(Method::PATCH, url)
159    }
160}
161
162impl HttpRequest {
163    pub fn with_header(mut self, name: &'static str, value: impl AsRef<str>) -> Self {
164        if let (Ok(val), Ok(n)) = (
165            HeaderValue::from_str(value.as_ref()),
166            name.parse::<HeaderName>(),
167        ) {
168            self.headers.append(n, val);
169        }
170        self
171    }
172
173    pub fn set_header(mut self, name: &'static str, value: impl AsRef<str>) -> Self {
174        if let (Ok(val), Ok(n)) = (
175            HeaderValue::from_str(value.as_ref()),
176            name.parse::<HeaderName>(),
177        ) {
178            self.headers.insert(n, val);
179        }
180        self
181    }
182
183    pub fn with_body<B: Serialize>(mut self, body: B) -> crate::Result<Self> {
184        match serde_json::to_string(&body) {
185            Ok(body) => {
186                self.body = Some(body);
187                Ok(self)
188            }
189            Err(err) => Err(Error::Serialize(format!(
190                "Failed to serialize request: {err}"
191            ))),
192        }
193    }
194
195    pub fn with_raw_body(mut self, body: String) -> Self {
196        self.body = Some(body);
197        self
198    }
199
200    pub async fn send<T>(self) -> crate::Result<T>
201    where
202        T: DeserializeOwned,
203    {
204        let response = self.send_raw().await?;
205        serde_json::from_slice::<T>(response.as_bytes()).map_err(|err| {
206            Error::Serialize(format!(
207                "Failed to deserialize response: {err} (body: {})",
208                body_snippet(&response)
209            ))
210        })
211    }
212
213    pub async fn send_raw(self) -> crate::Result<String> {
214        self.send_raw_with_headers().await.map(|(body, _)| body)
215    }
216
217    pub async fn send_raw_with_headers(self) -> crate::Result<(String, HeaderMap)> {
218        let url = self.url.clone();
219        let timeout = self.timeout;
220        let body_opt = self.body.clone();
221        let method = self.method.clone();
222        let headers = self.headers.clone();
223        let client = self.client.clone();
224
225        let body_bytes = body_opt.map(Bytes::from).unwrap_or_default();
226        let full = Full::new(body_bytes);
227
228        let mut builder = http::Request::builder().method(method).uri(url.as_str());
229        for (k, v) in headers.iter() {
230            builder = builder.header(k, v);
231        }
232        let req = builder
233            .body(full)
234            .map_err(|e| Error::Api(format!("Failed to build request to {url}: {e}")))?;
235
236        let resp = tokio::time::timeout(timeout, client.request(req))
237            .await
238            .map_err(|_| Error::Api(format!("Request to {url} timed out after {timeout:?}")))?
239            .map_err(|e| Error::Api(format!("Failed to send request to {url}: {e}")))?;
240
241        let status = resp.status();
242        let resp_headers = resp.headers().clone();
243        let collected = resp
244            .collect()
245            .await
246            .map_err(|e| Error::Api(format!("Failed to read response from {url}: {e}")))?;
247        let bytes = collected.to_bytes();
248        let body_str = String::from_utf8_lossy(&bytes).to_string();
249
250        let code = status.as_u16();
251        match code {
252            204 => Ok((String::new(), resp_headers)),
253            200..=299 => Ok((body_str, resp_headers)),
254            401 => Err(Error::Unauthorized),
255            404 => Err(Error::NotFound),
256            _ => Err(Error::Api(http_status_message(code, &body_str))),
257        }
258    }
259
260    pub async fn send_with_retry<T>(self, max_retries: u32) -> crate::Result<T>
261    where
262        T: DeserializeOwned,
263    {
264        let mut attempts: u32 = 0;
265        // Destructure to allow loop reuse after move
266        let Self {
267            method,
268            url,
269            headers,
270            body,
271            client,
272            timeout,
273        } = self;
274
275        loop {
276            let body_bytes = body.clone().map(Bytes::from).unwrap_or_default();
277            let full = Full::new(body_bytes);
278            let mut builder = http::Request::builder()
279                .method(method.clone())
280                .uri(url.as_str());
281            for (k, v) in headers.iter() {
282                builder = builder.header(k, v);
283            }
284            let req = builder
285                .body(full)
286                .map_err(|e| Error::Api(format!("Failed to build request to {url}: {e}")))?;
287
288            let resp = tokio::time::timeout(timeout, client.request(req))
289                .await
290                .map_err(|_| Error::Api(format!("Request to {url} timed out after {timeout:?}")))?
291                .map_err(|e| Error::Api(format!("Failed to send request to {url}: {e}")))?;
292
293            let status = resp.status();
294            let resp_headers = resp.headers().clone();
295            let collected = resp
296                .collect()
297                .await
298                .map_err(|e| Error::Api(format!("Failed to read response from {url}: {e}")))?;
299            let bytes = collected.to_bytes();
300            let text = String::from_utf8_lossy(&bytes).to_string();
301            let code = status.as_u16();
302
303            match code {
304                204 => {
305                    return serde_json::from_str("{}").map_err(|err| {
306                        Error::Serialize(format!("Failed to create empty response: {err}"))
307                    });
308                }
309                200..=299 => {
310                    let parse_target = if text.trim().is_empty() { "{}" } else { &text };
311                    return serde_json::from_str(parse_target).map_err(|err| {
312                        Error::Serialize(format!(
313                            "Failed to deserialize response from {}: {err} (body: {})",
314                            url,
315                            body_snippet(&text)
316                        ))
317                    });
318                }
319                429 | 503 if attempts < max_retries => {
320                    let delay = retry_after(&resp_headers)
321                        .unwrap_or_else(|| Duration::from_secs(1u64 << attempts.min(6)));
322                    tokio::time::sleep(delay.min(MAX_RETRY_DELAY)).await;
323                    attempts += 1;
324                    continue;
325                }
326                401 => return Err(Error::Unauthorized),
327                404 => return Err(Error::NotFound),
328                _ => {
329                    return Err(Error::Api(http_status_message(code, &text)));
330                }
331            }
332        }
333    }
334}
335
336/// Create a hyper-rustls HTTPS connector respecting the crate's feature flags.
337///
338/// Crypto provider selection is driven by `aws-lc-rs` vs `ring`:
339/// - `aws-lc-rs` (default) uses the aws-lc-rs provider
340/// - `ring` uses the ring provider
341///
342/// TLS verifier selection is driven by exactly one of:
343/// - `webpki-tokio` (default): Mozilla roots via `webpki-roots`
344/// - `native-tokio`: platform native roots via `rustls-native-certs`
345/// - `rustls-platform-verifier`: OS verifier via `rustls-platform-verifier`
346///
347/// If multiple verifier features are enabled, priority is:
348/// `rustls-platform-verifier` > `native-tokio` > `webpki-tokio`.
349pub(crate) fn build_https_connector() -> HttpsConnector<HttpConnector> {
350    install_crypto_provider();
351
352    // Priority: platform-verifier > native-tokio > webpki-tokio
353    #[cfg(feature = "rustls-platform-verifier")]
354    {
355        if let Ok(connector) = try_build_with_platform_verifier() {
356            return connector;
357        }
358        // fallthrough to next verifier on error
359    }
360
361    #[cfg(feature = "native-tokio")]
362    {
363        if let Ok(connector) = try_build_with_native_roots() {
364            return connector;
365        }
366        // fallthrough
367    }
368
369    #[cfg(feature = "webpki-tokio")]
370    {
371        return build_with_webpki_roots();
372    }
373
374    #[cfg(not(any(
375        feature = "webpki-tokio",
376        feature = "native-tokio",
377        feature = "rustls-platform-verifier"
378    )))]
379    {
380        compile_error!(
381            "At least one verifier feature must be enabled: webpki-tokio, native-tokio, or rustls-platform-verifier"
382        );
383    }
384
385    #[allow(unreachable_code)]
386    {
387        panic!("Failed to build HTTPS connector: no verifier succeeded")
388    }
389}
390
391pub(crate) fn build_hyper_client() -> HyperClient {
392    let connector = build_https_connector();
393    Client::builder(TokioExecutor::new()).build(connector)
394}
395
396#[allow(dead_code)]
397pub(crate) fn build_hyper_client_with_timeout(_timeout: Duration) -> HyperClient {
398    // timeout is handled per-request via tokio::time::timeout, not via hyper client
399    build_hyper_client()
400}
401
402fn install_crypto_provider() {
403    #[cfg(feature = "aws-lc-rs")]
404    {
405        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
406    }
407    #[cfg(all(feature = "ring", not(feature = "aws-lc-rs")))]
408    {
409        let _ = rustls::crypto::ring::default_provider().install_default();
410    }
411    #[cfg(not(any(feature = "aws-lc-rs", feature = "ring")))]
412    {
413        compile_error!("Either aws-lc-rs or ring feature must be enabled");
414    }
415}
416
417#[cfg(feature = "rustls-platform-verifier")]
418fn try_build_with_platform_verifier() -> Result<HttpsConnector<HttpConnector>, rustls::Error> {
419    let builder = hyper_rustls::HttpsConnectorBuilder::new().try_with_platform_verifier()?;
420    #[cfg(feature = "http2")]
421    {
422        Ok(builder
423            .https_or_http()
424            .enable_http1()
425            .enable_http2()
426            .build())
427    }
428    #[cfg(not(feature = "http2"))]
429    {
430        Ok(builder.https_or_http().enable_http1().build())
431    }
432}
433
434#[cfg(feature = "native-tokio")]
435fn try_build_with_native_roots() -> std::io::Result<HttpsConnector<HttpConnector>> {
436    let builder = hyper_rustls::HttpsConnectorBuilder::new().with_native_roots()?;
437    #[cfg(feature = "http2")]
438    {
439        Ok(builder
440            .https_or_http()
441            .enable_http1()
442            .enable_http2()
443            .build())
444    }
445    #[cfg(not(feature = "http2"))]
446    {
447        Ok(builder.https_or_http().enable_http1().build())
448    }
449}
450
451#[cfg(feature = "webpki-tokio")]
452fn build_with_webpki_roots() -> HttpsConnector<HttpConnector> {
453    let builder = hyper_rustls::HttpsConnectorBuilder::new().with_webpki_roots();
454    #[cfg(feature = "http2")]
455    {
456        builder
457            .https_or_http()
458            .enable_http1()
459            .enable_http2()
460            .build()
461    }
462    #[cfg(not(feature = "http2"))]
463    {
464        builder.https_or_http().enable_http1().build()
465    }
466}
467
468const MAX_RETRY_DELAY: Duration = Duration::from_secs(60);
469const MAX_BODY_SNIPPET: usize = 512;
470
471fn body_snippet(body: &str) -> &str {
472    let trimmed = body.trim();
473    if trimmed.len() <= MAX_BODY_SNIPPET {
474        trimmed
475    } else {
476        &trimmed[..trimmed.ceil_char_boundary(MAX_BODY_SNIPPET)]
477    }
478}
479
480fn retry_after(headers: &HeaderMap) -> Option<Duration> {
481    headers
482        .get("retry-after")?
483        .to_str()
484        .ok()?
485        .parse::<u64>()
486        .ok()
487        .map(Duration::from_secs)
488}
489
490fn http_status_message(code: u16, body: &str) -> String {
491    let trimmed = body.trim();
492    if code == 400 {
493        if trimmed.is_empty() {
494            "BadRequest".to_string()
495        } else {
496            format!("BadRequest {trimmed}")
497        }
498    } else if trimmed.is_empty() {
499        format!("HTTP {code}")
500    } else {
501        format!("HTTP {code}: {trimmed}")
502    }
503}