Skip to main content

icann_rdap_client/http/
reqwest.rs

1//! Creates a Reqwest client.
2
3// TODO see if this can be removed with a buildstructor upgrade
4// see https://github.com/BrynCooke/buildstructor/issues/200
5#![allow(mismatched_lifetime_syntaxes)]
6
7use std::collections::HashSet;
8
9pub use reqwest::{
10    header::{self, HeaderValue},
11    Client as ReqwestClient, Error as ReqwestError,
12};
13
14use icann_rdap_common::{
15    media_types::{JSON_MEDIA_TYPE, RDAP_MEDIA_TYPE},
16    prelude::ExtensionId,
17};
18
19#[cfg(not(target_arch = "wasm32"))]
20use {icann_rdap_common::VERSION, std::net::SocketAddr, std::time::Duration};
21
22/// Configures the HTTP client.
23#[derive(Clone)]
24pub struct ReqwestClientConfig {
25    /// This string is appended to the user agent.
26    ///
27    /// It is provided so
28    /// library users may identify their programs.
29    /// This is ignored on wasm32.
30    pub user_agent_suffix: String,
31
32    /// If set to true, connections will be required to use HTTPS.
33    ///
34    /// This is ignored on wasm32.
35    pub https_only: bool,
36
37    /// If set to true, invalid host names will be accepted.
38    ///
39    /// This is ignored on wasm32.
40    pub accept_invalid_host_names: bool,
41
42    /// If set to true, invalid certificates will be accepted.
43    ///
44    /// This is ignored on wasm32.
45    pub accept_invalid_certificates: bool,
46
47    /// If true, HTTP redirects will be followed.
48    ///
49    /// This is ignored on wasm32.
50    pub follow_redirects: bool,
51
52    /// Specify Host
53    pub host: Option<HeaderValue>,
54
55    /// Specify the value of the origin header.
56    ///
57    /// Most browsers ignore this by default.
58    pub origin: Option<HeaderValue>,
59
60    /// Query timeout in seconds.
61    ///
62    /// This corresponds to the total timeout of the request (connection plus reading all the data).
63    ///
64    /// This is ignored on wasm32.
65    pub timeout_secs: u64,
66
67    /// Extension IDs.
68    ///
69    /// The set of extension identifiers to be used in the exts_list in the media type.
70    pub exts_list: HashSet<ExtensionId>,
71}
72
73impl Default for ReqwestClientConfig {
74    fn default() -> Self {
75        Self {
76            user_agent_suffix: "library".to_string(),
77            https_only: true,
78            accept_invalid_host_names: false,
79            accept_invalid_certificates: false,
80            follow_redirects: true,
81            host: None,
82            origin: None,
83            timeout_secs: 60,
84            exts_list: HashSet::default(),
85        }
86    }
87}
88
89#[buildstructor::buildstructor]
90impl ReqwestClientConfig {
91    #[builder]
92    pub fn new(
93        user_agent_suffix: Option<String>,
94        https_only: Option<bool>,
95        accept_invalid_host_names: Option<bool>,
96        accept_invalid_certificates: Option<bool>,
97        follow_redirects: Option<bool>,
98        host: Option<HeaderValue>,
99        origin: Option<HeaderValue>,
100        timeout_secs: Option<u64>,
101        exts_list: Option<HashSet<ExtensionId>>,
102    ) -> Self {
103        let default = Self::default();
104        Self {
105            user_agent_suffix: user_agent_suffix.unwrap_or(default.user_agent_suffix),
106            https_only: https_only.unwrap_or(default.https_only),
107            accept_invalid_host_names: accept_invalid_host_names
108                .unwrap_or(default.accept_invalid_host_names),
109            accept_invalid_certificates: accept_invalid_certificates
110                .unwrap_or(default.accept_invalid_certificates),
111            follow_redirects: follow_redirects.unwrap_or(default.follow_redirects),
112            host,
113            origin,
114            timeout_secs: timeout_secs.unwrap_or(default.timeout_secs),
115            exts_list: exts_list.unwrap_or_default(),
116        }
117    }
118
119    #[builder(entry = "from_config", exit = "build")]
120    pub fn new_from_config(
121        &self,
122        user_agent_suffix: Option<String>,
123        https_only: Option<bool>,
124        accept_invalid_host_names: Option<bool>,
125        accept_invalid_certificates: Option<bool>,
126        follow_redirects: Option<bool>,
127        host: Option<HeaderValue>,
128        origin: Option<HeaderValue>,
129        timeout_secs: Option<u64>,
130        exts_list: Option<HashSet<ExtensionId>>,
131    ) -> Self {
132        Self {
133            user_agent_suffix: user_agent_suffix.unwrap_or(self.user_agent_suffix.clone()),
134            https_only: https_only.unwrap_or(self.https_only),
135            accept_invalid_host_names: accept_invalid_host_names
136                .unwrap_or(self.accept_invalid_host_names),
137            accept_invalid_certificates: accept_invalid_certificates
138                .unwrap_or(self.accept_invalid_certificates),
139            follow_redirects: follow_redirects.unwrap_or(self.follow_redirects),
140            host: host.map_or(self.host.clone(), Some),
141            origin: origin.map_or(self.origin.clone(), Some),
142            timeout_secs: timeout_secs.unwrap_or(self.timeout_secs),
143            exts_list: exts_list.unwrap_or(self.exts_list.clone()),
144        }
145    }
146}
147
148/// Creates an HTTP client using Reqwest. The Reqwest
149/// client holds its own connection pools, so in many
150/// uses cases creating only one client per process is
151/// necessary.
152#[cfg(not(target_arch = "wasm32"))]
153pub fn create_reqwest_client(config: &ReqwestClientConfig) -> Result<ReqwestClient, ReqwestError> {
154    let default_headers = default_headers(config);
155
156    let mut client = reqwest::Client::builder();
157
158    let redirects = if config.follow_redirects {
159        reqwest::redirect::Policy::default()
160    } else {
161        reqwest::redirect::Policy::none()
162    };
163    client = client
164        .timeout(Duration::from_secs(config.timeout_secs))
165        .user_agent(format!(
166            "icann_rdap client {VERSION} {}",
167            config.user_agent_suffix
168        ))
169        .redirect(redirects)
170        .https_only(config.https_only)
171        .danger_accept_invalid_hostnames(config.accept_invalid_host_names)
172        .danger_accept_invalid_certs(config.accept_invalid_certificates);
173
174    let client = client.default_headers(default_headers).build()?;
175    Ok(client)
176}
177
178/// Creates an HTTP client using Reqwest. The Reqwest
179/// client holds its own connection pools, so in many
180/// uses cases creating only one client per process is
181/// necessary.
182#[cfg(not(target_arch = "wasm32"))]
183pub fn create_reqwest_client_with_addr(
184    config: &ReqwestClientConfig,
185    domain: &str,
186    addr: SocketAddr,
187) -> Result<ReqwestClient, ReqwestError> {
188    let default_headers = default_headers(config);
189
190    let mut client = reqwest::Client::builder();
191
192    let redirects = if config.follow_redirects {
193        reqwest::redirect::Policy::default()
194    } else {
195        reqwest::redirect::Policy::none()
196    };
197    client = client
198        .timeout(Duration::from_secs(config.timeout_secs))
199        .user_agent(format!(
200            "icann_rdap client {VERSION} {}",
201            config.user_agent_suffix
202        ))
203        .redirect(redirects)
204        .https_only(config.https_only)
205        .danger_accept_invalid_hostnames(config.accept_invalid_host_names)
206        .danger_accept_invalid_certs(config.accept_invalid_certificates)
207        .resolve(domain, addr);
208
209    let client = client.default_headers(default_headers).build()?;
210    Ok(client)
211}
212
213/// Creates an HTTP client using Reqwest. The Reqwest
214/// client holds its own connection pools, so in many
215/// uses cases creating only one client per process is
216/// necessary.
217/// Note that the WASM version does not set redirect policy,
218/// https_only, or TLS settings.
219#[cfg(target_arch = "wasm32")]
220pub fn create_reqwest_client(config: &ReqwestClientConfig) -> Result<ReqwestClient, ReqwestError> {
221    let default_headers = default_headers(config);
222
223    let client = reqwest::Client::builder();
224
225    let client = client.default_headers(default_headers).build()?;
226    Ok(client)
227}
228
229fn default_headers(config: &ReqwestClientConfig) -> header::HeaderMap {
230    let mut default_headers = header::HeaderMap::new();
231    let accept_media_types = if config.exts_list.is_empty() {
232        format!("{RDAP_MEDIA_TYPE}, {JSON_MEDIA_TYPE}")
233    } else {
234        let mut exts_list: Vec<String> = config.exts_list.iter().map(|e| e.to_string()).collect();
235        exts_list.sort();
236        let exts_list_param = exts_list.join(" ");
237        format!("{RDAP_MEDIA_TYPE};exts_list=\"{exts_list_param}\", {JSON_MEDIA_TYPE}")
238    };
239    // We are unwrapping this value because this should never happen as the construction of
240    // the header value is under our control. Unwrapping will cause a fail fast whereas propagating
241    // the result up the stack may get it swallowed.
242    let accept_value = HeaderValue::from_str(&accept_media_types).unwrap();
243    default_headers.insert(header::ACCEPT, accept_value);
244    if let Some(host) = &config.host {
245        default_headers.insert(header::HOST, host.into());
246    };
247    if let Some(origin) = &config.origin {
248        default_headers.insert(header::ORIGIN, origin.into());
249    }
250    default_headers
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use icann_rdap_common::prelude::ExtensionId;
257    use std::collections::HashSet;
258
259    #[test]
260    fn test_default_headers_empty_exts_list() {
261        // GIVEN a config with an empty extensions list
262        let config = ReqwestClientConfig {
263            exts_list: HashSet::new(),
264            ..Default::default()
265        };
266
267        // WHEN the default headers are generated
268        let headers = default_headers(&config);
269        let accept_header = headers.get(header::ACCEPT).unwrap();
270
271        // THEN the accept header should only include RDAP and JSON media types without exts_list parameter
272        let expected = format!("{RDAP_MEDIA_TYPE}, {JSON_MEDIA_TYPE}");
273        assert_eq!(accept_header.to_str().unwrap(), expected);
274    }
275
276    #[test]
277    fn test_default_headers_with_exts_list() {
278        // GIVEN a config with multiple extensions in the list
279        let mut exts_list = HashSet::new();
280        exts_list.insert(ExtensionId::Cidr0);
281        exts_list.insert(ExtensionId::JsContact);
282        let config = ReqwestClientConfig {
283            exts_list,
284            ..Default::default()
285        };
286
287        // WHEN the default headers are generated
288        let headers = default_headers(&config);
289        let accept_header = headers.get(header::ACCEPT).unwrap();
290
291        // THEN the accept header should include exts_list parameter with sorted space-separated extension IDs
292        let expected =
293            format!("{RDAP_MEDIA_TYPE};exts_list=\"cidr0 jscontact\", {JSON_MEDIA_TYPE}");
294        assert_eq!(accept_header.to_str().unwrap(), expected);
295    }
296
297    #[test]
298    fn test_default_headers_single_extension() {
299        // GIVEN a config with a single extension in the list
300        let mut exts_list = HashSet::new();
301        exts_list.insert(ExtensionId::Redacted);
302        let config = ReqwestClientConfig {
303            exts_list,
304            ..Default::default()
305        };
306
307        // WHEN the default headers are generated
308        let headers = default_headers(&config);
309        let accept_header = headers.get(header::ACCEPT).unwrap();
310
311        // THEN the accept header should include exts_list parameter with the single extension name
312        let expected = format!("{RDAP_MEDIA_TYPE};exts_list=\"redacted\", {JSON_MEDIA_TYPE}");
313        assert_eq!(accept_header.to_str().unwrap(), expected);
314    }
315
316    #[test]
317    fn test_default_headers_with_host_and_origin() {
318        // GIVEN a config with extensions, host, and origin headers
319        let mut exts_list = HashSet::new();
320        exts_list.insert(ExtensionId::Sorting);
321        let config = ReqwestClientConfig {
322            host: Some(HeaderValue::from_static("example.com")),
323            origin: Some(HeaderValue::from_static("https://example.com")),
324            exts_list,
325            ..Default::default()
326        };
327
328        // WHEN the default headers are generated
329        let headers = default_headers(&config);
330
331        // THEN all headers should be properly set
332        // Check Accept header with exts_list parameter
333        let accept_header = headers.get(header::ACCEPT).unwrap();
334        let expected = format!("{RDAP_MEDIA_TYPE};exts_list=\"sorting\", {JSON_MEDIA_TYPE}");
335        assert_eq!(accept_header.to_str().unwrap(), expected);
336
337        // Check Host header
338        let host_header = headers.get(header::HOST).unwrap();
339        assert_eq!(host_header, "example.com");
340
341        // Check Origin header
342        let origin_header = headers.get(header::ORIGIN).unwrap();
343        assert_eq!(origin_header, "https://example.com");
344    }
345}