Skip to main content

threexui_rs/
config.rs

1#[derive(Debug, Clone)]
2pub struct ClientConfig {
3    pub host: String,
4    pub port: u16,
5    pub base_path: String,
6    pub tls: bool,
7    pub accept_invalid_certs: bool,
8    pub timeout_secs: u64,
9    /// Optional outbound proxy URL. Supports `http://`, `https://`,
10    /// `socks5://` and `socks5h://` schemes. Auth may be embedded
11    /// (`http://user:pass@host:port`) or supplied separately via
12    /// [`ClientConfigBuilder::proxy_auth`].
13    pub proxy: Option<String>,
14    pub proxy_username: Option<String>,
15    pub proxy_password: Option<String>,
16}
17
18impl ClientConfig {
19    pub fn builder() -> ClientConfigBuilder {
20        ClientConfigBuilder::default()
21    }
22
23    pub fn base_url(&self) -> String {
24        let scheme = if self.tls { "https" } else { "http" };
25        format!("{}://{}:{}{}", scheme, self.host, self.port, self.base_path)
26    }
27}
28
29#[derive(Debug, Default)]
30pub struct ClientConfigBuilder {
31    host: Option<String>,
32    port: Option<u16>,
33    base_path: Option<String>,
34    tls: bool,
35    accept_invalid_certs: bool,
36    timeout_secs: Option<u64>,
37    proxy: Option<String>,
38    proxy_username: Option<String>,
39    proxy_password: Option<String>,
40}
41
42impl ClientConfigBuilder {
43    pub fn host(mut self, host: impl Into<String>) -> Self {
44        self.host = Some(host.into());
45        self
46    }
47
48    pub fn port(mut self, port: u16) -> Self {
49        self.port = Some(port);
50        self
51    }
52
53    pub fn base_path(mut self, path: impl Into<String>) -> Self {
54        self.base_path = Some(normalize_base_path(path.into()));
55        self
56    }
57
58    pub fn tls(mut self, tls: bool) -> Self {
59        self.tls = tls;
60        self
61    }
62
63    pub fn accept_invalid_certs(mut self, accept: bool) -> Self {
64        self.accept_invalid_certs = accept;
65        self
66    }
67
68    pub fn timeout_secs(mut self, secs: u64) -> Self {
69        self.timeout_secs = Some(secs);
70        self
71    }
72
73    /// Route every request through an outbound proxy.
74    ///
75    /// Accepts `http://`, `https://`, `socks5://` or `socks5h://` URLs.
76    /// Authentication may be embedded (`http://u:p@host:1080`) or set
77    /// separately via [`Self::proxy_auth`].
78    pub fn proxy(mut self, url: impl Into<String>) -> Self {
79        self.proxy = Some(url.into());
80        self
81    }
82
83    /// Supply basic-auth credentials for the proxy.
84    /// Has no effect unless [`Self::proxy`] is also set.
85    pub fn proxy_auth(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
86        self.proxy_username = Some(username.into());
87        self.proxy_password = Some(password.into());
88        self
89    }
90
91    /// Disable any previously configured proxy.
92    pub fn no_proxy(mut self) -> Self {
93        self.proxy = None;
94        self.proxy_username = None;
95        self.proxy_password = None;
96        self
97    }
98
99    pub fn build(self) -> crate::Result<ClientConfig> {
100        let host = self
101            .host
102            .ok_or_else(|| crate::Error::Config("host is required".into()))?;
103        let port = self
104            .port
105            .ok_or_else(|| crate::Error::Config("port is required".into()))?;
106        if port == 0 {
107            return Err(crate::Error::Config("port cannot be 0".into()));
108        }
109        if let Some(url) = &self.proxy {
110            // Surface bad URLs / unsupported schemes early as Error::Config.
111            reqwest::Proxy::all(url.as_str())
112                .map_err(|e| crate::Error::Config(format!("invalid proxy url: {}", e)))?;
113        }
114        Ok(ClientConfig {
115            host,
116            port,
117            base_path: self.base_path.unwrap_or_else(|| "/".to_string()),
118            tls: self.tls,
119            accept_invalid_certs: self.accept_invalid_certs,
120            timeout_secs: self.timeout_secs.unwrap_or(30),
121            proxy: self.proxy,
122            proxy_username: self.proxy_username,
123            proxy_password: self.proxy_password,
124        })
125    }
126}
127
128fn normalize_base_path(mut path: String) -> String {
129    if !path.starts_with('/') {
130        path.insert(0, '/');
131    }
132    if !path.ends_with('/') {
133        path.push('/');
134    }
135    path
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn build_minimal_config() {
144        let cfg = ClientConfig::builder()
145            .host("192.168.1.1")
146            .port(2053)
147            .build()
148            .unwrap();
149        assert_eq!(cfg.host, "192.168.1.1");
150        assert_eq!(cfg.port, 2053);
151        assert_eq!(cfg.base_path, "/");
152        assert!(!cfg.tls);
153        assert_eq!(cfg.timeout_secs, 30);
154    }
155
156    #[test]
157    fn build_full_config() {
158        let cfg = ClientConfig::builder()
159            .host("example.com")
160            .port(443)
161            .base_path("secret")
162            .tls(true)
163            .accept_invalid_certs(true)
164            .timeout_secs(60)
165            .build()
166            .unwrap();
167        assert_eq!(cfg.base_path, "/secret/");
168        assert!(cfg.tls);
169        assert!(cfg.accept_invalid_certs);
170        assert_eq!(cfg.timeout_secs, 60);
171    }
172
173    #[test]
174    fn base_url_http() {
175        let cfg = ClientConfig::builder()
176            .host("192.168.1.1")
177            .port(2053)
178            .build()
179            .unwrap();
180        assert_eq!(cfg.base_url(), "http://192.168.1.1:2053/");
181    }
182
183    #[test]
184    fn base_url_https_with_path() {
185        let cfg = ClientConfig::builder()
186            .host("example.com")
187            .port(443)
188            .base_path("/secret/")
189            .tls(true)
190            .build()
191            .unwrap();
192        assert_eq!(cfg.base_url(), "https://example.com:443/secret/");
193    }
194
195    #[test]
196    fn missing_host_errors() {
197        let err = ClientConfig::builder().port(2053).build().unwrap_err();
198        assert!(err.to_string().contains("host is required"));
199    }
200
201    #[test]
202    fn proxy_http_url_accepted() {
203        let cfg = ClientConfig::builder()
204            .host("localhost")
205            .port(2053)
206            .proxy("http://127.0.0.1:8080")
207            .build()
208            .unwrap();
209        assert_eq!(cfg.proxy.as_deref(), Some("http://127.0.0.1:8080"));
210    }
211
212    #[test]
213    fn proxy_socks5_url_accepted() {
214        let cfg = ClientConfig::builder()
215            .host("localhost")
216            .port(2053)
217            .proxy("socks5://127.0.0.1:1080")
218            .proxy_auth("user", "pass")
219            .build()
220            .unwrap();
221        assert_eq!(cfg.proxy_username.as_deref(), Some("user"));
222        assert_eq!(cfg.proxy_password.as_deref(), Some("pass"));
223    }
224
225    #[test]
226    fn proxy_invalid_url_errors() {
227        let err = ClientConfig::builder()
228            .host("localhost")
229            .port(2053)
230            .proxy("not a url at all")
231            .build()
232            .unwrap_err();
233        assert!(err.to_string().contains("invalid proxy url"), "{}", err);
234    }
235
236    #[test]
237    fn no_proxy_clears_settings() {
238        let cfg = ClientConfig::builder()
239            .host("localhost")
240            .port(2053)
241            .proxy("http://1.2.3.4:8080")
242            .proxy_auth("u", "p")
243            .no_proxy()
244            .build()
245            .unwrap();
246        assert!(cfg.proxy.is_none());
247        assert!(cfg.proxy_username.is_none());
248        assert!(cfg.proxy_password.is_none());
249    }
250
251    #[test]
252    fn port_zero_errors() {
253        let err = ClientConfig::builder()
254            .host("localhost")
255            .port(0)
256            .build()
257            .unwrap_err();
258        assert!(err.to_string().contains("port cannot be 0"));
259    }
260}