truthlinked_sdk/
builder.rs

1use crate::error::{Result, TruthlinkedError};
2use crate::logging::LoggingConfig;
3use crate::retry::RetryConfig;
4use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
5use std::time::Duration;
6
7/// Builder for configuring Truthlinked API client
8/// 
9/// Provides a fluent interface for configuring all client options including
10/// timeouts, retries, logging, custom headers, and security settings.
11/// 
12/// # Example
13/// ```rust,no_run
14/// use truthlinked_sdk::ClientBuilder;
15/// use std::time::Duration;
16/// 
17/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
18/// let client = ClientBuilder::new("https://api.truthlinked.org", "tl_free_...")
19///     .timeout(Duration::from_secs(60))
20///     .retries(5)
21///     .user_agent("MyApp/1.0")
22///     .header("X-Request-ID", "12345")?
23///     .enable_logging()
24///     .build()?;
25/// # Ok(())
26/// # }
27/// ```
28#[derive(Debug)]
29pub struct ClientBuilder {
30    base_url: String,
31    license_key: String,
32    timeout: Duration,
33    connect_timeout: Duration,
34    retry_config: RetryConfig,
35    logging_config: LoggingConfig,
36    custom_headers: HeaderMap,
37    user_agent: Option<String>,
38    proxy_url: Option<String>,
39    pool_max_idle_per_host: usize,
40    pool_idle_timeout: Duration,
41    enable_gzip: bool,
42    enable_brotli: bool,
43    certificate_pins: Vec<String>,
44    allow_http: bool,  // For testing only
45}
46
47impl ClientBuilder {
48    /// Create a new client builder
49    /// 
50    /// # Arguments
51    /// * `base_url` - API base URL (must be HTTPS)
52    /// * `license_key` - Your Truthlinked license key
53    pub fn new(base_url: impl Into<String>, license_key: impl Into<String>) -> Self {
54        Self {
55            base_url: base_url.into(),
56            license_key: license_key.into(),
57            timeout: Duration::from_secs(30),
58            connect_timeout: Duration::from_secs(10),
59            retry_config: RetryConfig::production(),
60            logging_config: LoggingConfig::production(),
61            custom_headers: HeaderMap::new(),
62            user_agent: None,
63            proxy_url: None,
64            pool_max_idle_per_host: 10,
65            pool_idle_timeout: Duration::from_secs(90),
66            enable_gzip: true,
67            enable_brotli: true,
68            certificate_pins: Vec::new(),
69            allow_http: false,
70        }
71    }
72    
73    /// Set request timeout
74    pub fn timeout(mut self, timeout: Duration) -> Self {
75        self.timeout = timeout;
76        self
77    }
78    
79    /// Set connection timeout
80    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
81        self.connect_timeout = timeout;
82        self
83    }
84    
85    /// Set retry configuration
86    pub fn retry_config(mut self, config: RetryConfig) -> Self {
87        self.retry_config = config;
88        self
89    }
90    
91    /// Set number of retry attempts (convenience method)
92    pub fn retries(mut self, max_attempts: u32) -> Self {
93        self.retry_config.max_attempts = max_attempts;
94        self
95    }
96    
97    /// Set logging configuration
98    pub fn logging_config(mut self, config: LoggingConfig) -> Self {
99        self.logging_config = config;
100        self
101    }
102    
103    /// Enable development logging (convenience method)
104    pub fn enable_logging(mut self) -> Self {
105        self.logging_config = LoggingConfig::development();
106        self
107    }
108    
109    /// Disable all logging (convenience method)
110    pub fn disable_logging(mut self) -> Self {
111        self.logging_config = LoggingConfig::none();
112        self
113    }
114    
115    /// Add custom header
116    pub fn header(mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> Result<Self> {
117        let header_name = HeaderName::from_bytes(name.as_ref().as_bytes())
118            .map_err(|_| TruthlinkedError::InvalidRequest("Invalid header name".to_string()))?;
119        let header_value = HeaderValue::from_str(value.as_ref())
120            .map_err(|_| TruthlinkedError::InvalidRequest("Invalid header value".to_string()))?;
121        
122        self.custom_headers.insert(header_name, header_value);
123        Ok(self)
124    }
125    
126    /// Set User-Agent header
127    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
128        self.user_agent = Some(user_agent.into());
129        self
130    }
131    
132    /// Set HTTP proxy URL
133    pub fn proxy(mut self, proxy_url: impl Into<String>) -> Self {
134        self.proxy_url = Some(proxy_url.into());
135        self
136    }
137    
138    /// Set connection pool configuration
139    pub fn pool_config(mut self, max_idle_per_host: usize, idle_timeout: Duration) -> Self {
140        self.pool_max_idle_per_host = max_idle_per_host;
141        self.pool_idle_timeout = idle_timeout;
142        self
143    }
144    
145    /// Enable/disable gzip compression
146    pub fn gzip(mut self, enable: bool) -> Self {
147        self.enable_gzip = enable;
148        self
149    }
150    
151    /// Enable/disable brotli compression
152    pub fn brotli(mut self, enable: bool) -> Self {
153        self.enable_brotli = enable;
154        self
155    }
156    
157    /// Add certificate pin for enhanced security
158    /// 
159    /// # Arguments
160    /// * `pin` - SHA256 hash of the certificate's public key (base64 encoded)
161    pub fn certificate_pin(mut self, pin: impl Into<String>) -> Self {
162        self.certificate_pins.push(pin.into());
163        self
164    }
165    
166    /// Build the configured client
167    pub fn build(self) -> Result<crate::client::Client> {
168        // Validate base URL (allow HTTP only in testing mode)
169        if !self.allow_http && !self.base_url.starts_with("https://") {
170            return Err(TruthlinkedError::InvalidRequest(
171                "Base URL must use HTTPS".to_string()
172            ));
173        }
174        
175        // Build HTTP client
176        let mut client_builder = reqwest::Client::builder()
177            .timeout(self.timeout)
178            .connect_timeout(self.connect_timeout)
179            .pool_idle_timeout(self.pool_idle_timeout)
180            .pool_max_idle_per_host(self.pool_max_idle_per_host)
181            .https_only(!self.allow_http)  // Allow HTTP only in testing mode
182            .default_headers(self.custom_headers);
183        
184        // Set user agent
185        if let Some(user_agent) = self.user_agent {
186            client_builder = client_builder.user_agent(user_agent);
187        } else {
188            client_builder = client_builder.user_agent(format!(
189                "truthlinked-sdk/{}", 
190                env!("CARGO_PKG_VERSION")
191            ));
192        }
193        
194        // Set proxy if configured
195        if let Some(proxy_url) = self.proxy_url {
196            let proxy = reqwest::Proxy::all(&proxy_url)
197                .map_err(|_| TruthlinkedError::InvalidRequest("Invalid proxy URL".to_string()))?;
198            client_builder = client_builder.proxy(proxy);
199        }
200        
201        // TODO: Implement certificate pinning when reqwest supports it
202        if !self.certificate_pins.is_empty() {
203            tracing::warn!("Certificate pinning not yet implemented in reqwest");
204        }
205        
206        let http_client = client_builder.build()
207            .map_err(|_| TruthlinkedError::InvalidRequest("Failed to build HTTP client".to_string()))?;
208        
209        crate::client::Client::with_config(
210            http_client,
211            self.base_url,
212            self.license_key,
213            self.retry_config,
214            self.logging_config,
215        )
216    }
217}
218
219/// Convenience methods for common configurations
220impl ClientBuilder {
221    /// Production configuration with minimal logging and conservative timeouts
222    pub fn production(base_url: impl Into<String>, license_key: impl Into<String>) -> Self {
223        Self::new(base_url, license_key)
224            .timeout(Duration::from_secs(30))
225            .connect_timeout(Duration::from_secs(10))
226            .retry_config(RetryConfig::production())
227            .logging_config(LoggingConfig::production())
228    }
229    
230    /// Development configuration with verbose logging and shorter timeouts
231    pub fn development(base_url: impl Into<String>, license_key: impl Into<String>) -> Self {
232        Self::new(base_url, license_key)
233            .timeout(Duration::from_secs(60))
234            .connect_timeout(Duration::from_secs(5))
235            .retry_config(RetryConfig::aggressive())
236            .logging_config(LoggingConfig::development())
237    }
238    
239    /// Testing configuration with no retries and no logging
240    pub fn testing(base_url: impl Into<String>, license_key: impl Into<String>) -> Self {
241        let mut builder = Self::new(base_url, license_key)
242            .timeout(Duration::from_secs(5))
243            .connect_timeout(Duration::from_secs(2))
244            .retry_config(RetryConfig::none())
245            .logging_config(LoggingConfig::none());
246        builder.allow_http = true;  // Allow HTTP for testing
247        builder
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    
255    #[test]
256    fn test_builder_basic() {
257        let builder = ClientBuilder::new("https://api.example.com", "test_key");
258        assert_eq!(builder.base_url, "https://api.example.com");
259        assert_eq!(builder.license_key, "test_key");
260    }
261    
262    #[test]
263    fn test_builder_fluent_interface() {
264        let builder = ClientBuilder::new("https://api.example.com", "test_key")
265            .timeout(Duration::from_secs(60))
266            .retries(5)
267            .user_agent("TestApp/1.0")
268            .enable_logging();
269        
270        assert_eq!(builder.timeout, Duration::from_secs(60));
271        assert_eq!(builder.retry_config.max_attempts, 5);
272        assert_eq!(builder.user_agent, Some("TestApp/1.0".to_string()));
273    }
274    
275    #[test]
276    fn test_builder_presets() {
277        let prod_builder = ClientBuilder::production("https://api.example.com", "key");
278        assert_eq!(prod_builder.timeout, Duration::from_secs(30));
279        
280        let dev_builder = ClientBuilder::development("https://api.example.com", "key");
281        assert_eq!(dev_builder.timeout, Duration::from_secs(60));
282        
283        let test_builder = ClientBuilder::testing("https://api.example.com", "key");
284        assert_eq!(test_builder.timeout, Duration::from_secs(5));
285    }
286}