Skip to main content

lastid_sdk/config/
network.rs

1//! Network configuration for the `LastID` SDK.
2//!
3//! Provides fine-grained control over HTTP client behavior including:
4//! - Proxy configuration for corporate networks
5//! - Granular timeout settings
6//! - Connection pool tuning
7//! - Correlation ID headers for request tracing
8
9#[cfg(feature = "json-schema")]
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13use crate::constants::{
14    DEFAULT_CONNECT_TIMEOUT_SECONDS, DEFAULT_CORRELATION_ID_HEADER,
15    DEFAULT_POOL_IDLE_TIMEOUT_SECONDS, DEFAULT_POOL_MAX_IDLE_PER_HOST,
16    DEFAULT_READ_TIMEOUT_SECONDS, DEFAULT_TIMEOUT_SECONDS,
17};
18
19/// Network configuration for HTTP client behavior.
20///
21/// Controls proxy settings, timeouts, and connection pooling.
22/// All fields have sensible defaults suitable for most deployments.
23///
24/// # Example
25///
26/// ```toml
27/// [network]
28/// proxy_url = "http://proxy.corp.example.com:8080"
29/// connect_timeout_seconds = 10
30/// read_timeout_seconds = 30
31/// correlation_id_header = "X-Correlation-ID"
32/// ```
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[cfg_attr(feature = "json-schema", derive(JsonSchema))]
35#[serde(default)]
36pub struct NetworkConfig {
37    /// HTTP proxy URL for corporate networks.
38    ///
39    /// When set, all HTTP requests will be routed through this proxy.
40    /// Supports HTTP and HTTPS proxy URLs.
41    ///
42    /// # Example
43    ///
44    /// ```text
45    /// http://proxy.corp.example.com:8080
46    /// ```
47    #[serde(default)]
48    pub proxy_url: Option<String>,
49
50    /// HTTPS proxy URL (if different from HTTP proxy).
51    ///
52    /// When set, HTTPS requests use this proxy while HTTP requests
53    /// use `proxy_url`. If not set, `proxy_url` is used for all requests.
54    #[serde(default)]
55    pub https_proxy_url: Option<String>,
56
57    /// Hostnames to bypass proxy (comma-separated).
58    ///
59    /// Requests to these hosts will not use the proxy.
60    ///
61    /// # Example
62    ///
63    /// ```text
64    /// localhost,127.0.0.1,.internal.corp
65    /// ```
66    #[serde(default)]
67    pub no_proxy: Option<String>,
68
69    /// TCP connection timeout in seconds.
70    ///
71    /// Maximum time to wait for establishing a TCP connection.
72    /// Default: 10 seconds
73    #[serde(default = "default_connect_timeout")]
74    pub connect_timeout_seconds: u64,
75
76    /// HTTP response read timeout in seconds.
77    ///
78    /// Maximum time to wait for reading the response body after
79    /// headers are received. Default: 30 seconds
80    #[serde(default = "default_read_timeout")]
81    pub read_timeout_seconds: u64,
82
83    /// Overall request timeout in seconds.
84    ///
85    /// Maximum total time for a complete request/response cycle.
86    /// Default: 30 seconds
87    #[serde(default = "default_timeout")]
88    pub request_timeout_seconds: u64,
89
90    /// Connection pool idle timeout in seconds.
91    ///
92    /// How long idle connections are kept in the pool before closing.
93    /// Default: 30 seconds
94    #[serde(default = "default_pool_idle_timeout")]
95    pub pool_idle_timeout_seconds: u64,
96
97    /// Maximum idle connections per host.
98    ///
99    /// Maximum number of idle connections kept open to each host.
100    /// Default: 5
101    #[serde(default = "default_pool_max_idle")]
102    pub pool_max_idle_per_host: usize,
103
104    /// Custom header name for correlation IDs.
105    ///
106    /// When set, the SDK will add this header to all requests
107    /// with a unique correlation ID for request tracing.
108    ///
109    /// Default: "X-Request-ID"
110    #[serde(default = "default_correlation_header")]
111    pub correlation_id_header: String,
112
113    /// Enable correlation ID generation.
114    ///
115    /// When true, the SDK generates and attaches correlation IDs
116    /// to all HTTP requests for tracing purposes.
117    /// Default: true
118    #[serde(default = "default_true")]
119    pub enable_correlation_ids: bool,
120}
121
122const fn default_connect_timeout() -> u64 {
123    DEFAULT_CONNECT_TIMEOUT_SECONDS
124}
125
126const fn default_read_timeout() -> u64 {
127    DEFAULT_READ_TIMEOUT_SECONDS
128}
129
130const fn default_timeout() -> u64 {
131    DEFAULT_TIMEOUT_SECONDS
132}
133
134const fn default_pool_idle_timeout() -> u64 {
135    DEFAULT_POOL_IDLE_TIMEOUT_SECONDS
136}
137
138const fn default_pool_max_idle() -> usize {
139    DEFAULT_POOL_MAX_IDLE_PER_HOST
140}
141
142fn default_correlation_header() -> String {
143    DEFAULT_CORRELATION_ID_HEADER.to_string()
144}
145
146const fn default_true() -> bool {
147    true
148}
149
150impl Default for NetworkConfig {
151    fn default() -> Self {
152        Self {
153            proxy_url: None,
154            https_proxy_url: None,
155            no_proxy: None,
156            connect_timeout_seconds: default_connect_timeout(),
157            read_timeout_seconds: default_read_timeout(),
158            request_timeout_seconds: default_timeout(),
159            pool_idle_timeout_seconds: default_pool_idle_timeout(),
160            pool_max_idle_per_host: default_pool_max_idle(),
161            correlation_id_header: default_correlation_header(),
162            // Disabled by default in WASM builds - browsers block custom headers
163            // unless the server explicitly allows them via CORS (Access-Control-Allow-Headers).
164            // The X-Request-ID header is not in the CORS safe list, so enabling this
165            // in browser contexts causes preflight failures.
166            enable_correlation_ids: !cfg!(target_arch = "wasm32"),
167        }
168    }
169}
170
171impl NetworkConfig {
172    /// Merge another configuration into this one.
173    ///
174    /// Non-default values from `other` override values in `self`.
175    pub fn merge(&mut self, other: &Self) {
176        if other.proxy_url.is_some() {
177            self.proxy_url.clone_from(&other.proxy_url);
178        }
179        if other.https_proxy_url.is_some() {
180            self.https_proxy_url.clone_from(&other.https_proxy_url);
181        }
182        if other.no_proxy.is_some() {
183            self.no_proxy.clone_from(&other.no_proxy);
184        }
185        if other.connect_timeout_seconds != default_connect_timeout() {
186            self.connect_timeout_seconds = other.connect_timeout_seconds;
187        }
188        if other.read_timeout_seconds != default_read_timeout() {
189            self.read_timeout_seconds = other.read_timeout_seconds;
190        }
191        if other.request_timeout_seconds != default_timeout() {
192            self.request_timeout_seconds = other.request_timeout_seconds;
193        }
194        if other.pool_idle_timeout_seconds != default_pool_idle_timeout() {
195            self.pool_idle_timeout_seconds = other.pool_idle_timeout_seconds;
196        }
197        if other.pool_max_idle_per_host != default_pool_max_idle() {
198            self.pool_max_idle_per_host = other.pool_max_idle_per_host;
199        }
200        if other.correlation_id_header != default_correlation_header() {
201            self.correlation_id_header
202                .clone_from(&other.correlation_id_header);
203        }
204        if !other.enable_correlation_ids {
205            self.enable_correlation_ids = false;
206        }
207    }
208
209    /// Validate the network configuration.
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if any timeout is zero.
214    pub fn validate(&self) -> Result<(), crate::error::LastIDError> {
215        if self.connect_timeout_seconds == 0 {
216            return Err(crate::error::LastIDError::config(
217                "connect_timeout_seconds must be > 0",
218            ));
219        }
220        if self.read_timeout_seconds == 0 {
221            return Err(crate::error::LastIDError::config(
222                "read_timeout_seconds must be > 0",
223            ));
224        }
225        if self.request_timeout_seconds == 0 {
226            return Err(crate::error::LastIDError::config(
227                "request_timeout_seconds must be > 0",
228            ));
229        }
230        if self.pool_max_idle_per_host == 0 {
231            return Err(crate::error::LastIDError::config(
232                "pool_max_idle_per_host must be > 0",
233            ));
234        }
235        if self.correlation_id_header.is_empty() && self.enable_correlation_ids {
236            return Err(crate::error::LastIDError::config(
237                "correlation_id_header cannot be empty when correlation IDs are enabled",
238            ));
239        }
240        Ok(())
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_default_network_config() {
250        let config = NetworkConfig::default();
251
252        assert!(config.proxy_url.is_none());
253        assert_eq!(
254            config.connect_timeout_seconds,
255            DEFAULT_CONNECT_TIMEOUT_SECONDS
256        );
257        assert_eq!(config.read_timeout_seconds, DEFAULT_READ_TIMEOUT_SECONDS);
258        assert_eq!(config.request_timeout_seconds, DEFAULT_TIMEOUT_SECONDS);
259        assert_eq!(
260            config.pool_idle_timeout_seconds,
261            DEFAULT_POOL_IDLE_TIMEOUT_SECONDS
262        );
263        assert_eq!(
264            config.pool_max_idle_per_host,
265            DEFAULT_POOL_MAX_IDLE_PER_HOST
266        );
267        assert_eq!(config.correlation_id_header, DEFAULT_CORRELATION_ID_HEADER);
268        assert!(config.enable_correlation_ids);
269    }
270
271    #[test]
272    fn test_validate_valid_config() {
273        let config = NetworkConfig::default();
274        assert!(config.validate().is_ok());
275    }
276
277    #[test]
278    fn test_validate_zero_connect_timeout() {
279        let mut config = NetworkConfig::default();
280        config.connect_timeout_seconds = 0;
281        assert!(config.validate().is_err());
282    }
283
284    #[test]
285    fn test_validate_zero_read_timeout() {
286        let mut config = NetworkConfig::default();
287        config.read_timeout_seconds = 0;
288        assert!(config.validate().is_err());
289    }
290
291    #[test]
292    fn test_validate_empty_correlation_header() {
293        let mut config = NetworkConfig::default();
294        config.correlation_id_header = String::new();
295        config.enable_correlation_ids = true;
296        assert!(config.validate().is_err());
297    }
298
299    #[test]
300    fn test_validate_empty_header_when_disabled() {
301        let mut config = NetworkConfig::default();
302        config.correlation_id_header = String::new();
303        config.enable_correlation_ids = false;
304        assert!(config.validate().is_ok());
305    }
306
307    #[test]
308    fn test_merge() {
309        let mut base = NetworkConfig::default();
310
311        let other = NetworkConfig {
312            proxy_url: Some("http://proxy.example.com:8080".into()),
313            connect_timeout_seconds: 20,
314            ..Default::default()
315        };
316
317        base.merge(&other);
318
319        assert_eq!(base.proxy_url, Some("http://proxy.example.com:8080".into()));
320        assert_eq!(base.connect_timeout_seconds, 20);
321        // Should keep defaults for non-overridden fields
322        assert_eq!(base.read_timeout_seconds, DEFAULT_READ_TIMEOUT_SECONDS);
323    }
324
325    #[test]
326    fn test_from_toml() {
327        let toml = r#"
328            proxy_url = "http://proxy.corp.example.com:8080"
329            connect_timeout_seconds = 15
330            read_timeout_seconds = 45
331            pool_max_idle_per_host = 10
332            correlation_id_header = "X-Trace-ID"
333        "#;
334
335        let config: NetworkConfig = toml::from_str(toml).expect("Valid TOML");
336
337        assert_eq!(
338            config.proxy_url,
339            Some("http://proxy.corp.example.com:8080".into())
340        );
341        assert_eq!(config.connect_timeout_seconds, 15);
342        assert_eq!(config.read_timeout_seconds, 45);
343        assert_eq!(config.pool_max_idle_per_host, 10);
344        assert_eq!(config.correlation_id_header, "X-Trace-ID");
345    }
346}