Skip to main content

sockudo_http/
config.rs

1use crate::{Result, SockudoError, Token};
2use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
3use std::time::Duration;
4use zeroize::{Zeroize, ZeroizeOnDrop};
5
6/// Configuration for the Sockudo client
7#[derive(Clone, Debug)]
8pub struct Config {
9    scheme: String,
10    host: String,
11    port: Option<u16>,
12    app_id: String,
13    token: Token,
14    timeout: Duration,
15    encryption_master_key: Option<EncryptionKey>,
16    pool_max_idle_per_host: usize,
17    enable_retry: bool,
18    max_retries: u32,
19    auto_idempotency_key: bool,
20}
21
22/// Wrapper for encryption key that ensures it's zeroed on drop
23#[derive(Clone, Zeroize, ZeroizeOnDrop)]
24struct EncryptionKey(Vec<u8>);
25
26impl std::fmt::Debug for EncryptionKey {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(f, "EncryptionKey([REDACTED])")
29    }
30}
31
32impl Config {
33    /// Creates a new configuration builder
34    pub fn builder() -> ConfigBuilder {
35        ConfigBuilder::default()
36    }
37
38    /// Creates a new configuration (for backward compatibility)
39    pub fn new(
40        app_id: impl Into<String>,
41        key: impl Into<String>,
42        secret: impl Into<String>,
43    ) -> Self {
44        ConfigBuilder::default()
45            .app_id(app_id)
46            .key(key)
47            .secret(secret)
48            .build()
49            .expect("Basic config should always be valid")
50    }
51
52    /// Validates the configuration
53    pub fn validate(&self) -> Result<()> {
54        if self.app_id.is_empty() {
55            return Err(SockudoError::Config {
56                message: "App ID cannot be empty".to_string(),
57            });
58        }
59
60        if self.token.key.is_empty() {
61            return Err(SockudoError::Config {
62                message: "App key cannot be empty".to_string(),
63            });
64        }
65
66        if let Some(ref key) = self.encryption_master_key
67            && key.0.len() != 32
68        {
69            return Err(SockudoError::Config {
70                message: format!("Encryption key must be 32 bytes, got {}", key.0.len()),
71            });
72        }
73
74        Ok(())
75    }
76
77    // Getters
78    pub fn scheme(&self) -> &str {
79        &self.scheme
80    }
81
82    pub fn host(&self) -> &str {
83        &self.host
84    }
85
86    pub fn port(&self) -> Option<u16> {
87        self.port
88    }
89
90    pub fn app_id(&self) -> &str {
91        &self.app_id
92    }
93
94    pub fn token(&self) -> &Token {
95        &self.token
96    }
97
98    pub fn timeout(&self) -> Duration {
99        self.timeout
100    }
101
102    pub fn encryption_master_key(&self) -> Option<&[u8]> {
103        self.encryption_master_key.as_ref().map(|k| k.0.as_slice())
104    }
105
106    pub fn pool_max_idle_per_host(&self) -> usize {
107        self.pool_max_idle_per_host
108    }
109
110    pub fn enable_retry(&self) -> bool {
111        self.enable_retry
112    }
113
114    pub fn max_retries(&self) -> u32 {
115        self.max_retries
116    }
117
118    pub fn auto_idempotency_key(&self) -> bool {
119        self.auto_idempotency_key
120    }
121
122    /// Gets the base URL
123    pub fn base_url(&self) -> String {
124        let port = match self.port {
125            Some(port) => format!(":{}", port),
126            None => String::new(),
127        };
128        format!("{}://{}{}", self.scheme, self.host, port)
129    }
130
131    /// Gets the prefix path for API requests
132    pub fn prefix_path(&self, sub_path: &str) -> String {
133        format!("/apps/{}{}", self.app_id, sub_path)
134    }
135}
136
137/// Builder for Sockudo configuration
138#[derive(Default)]
139pub struct ConfigBuilder {
140    scheme: Option<String>,
141    host: Option<String>,
142    port: Option<u16>,
143    app_id: Option<String>,
144    key: Option<String>,
145    secret: Option<String>,
146    timeout: Option<Duration>,
147    encryption_master_key: Option<EncryptionKey>,
148    pool_max_idle_per_host: Option<usize>,
149    enable_retry: Option<bool>,
150    max_retries: Option<u32>,
151    auto_idempotency_key: Option<bool>,
152}
153
154impl ConfigBuilder {
155    /// Sets the app ID
156    pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
157        self.app_id = Some(app_id.into());
158        self
159    }
160
161    /// Sets the app key
162    pub fn key(mut self, key: impl Into<String>) -> Self {
163        self.key = Some(key.into());
164        self
165    }
166
167    /// Sets the app secret
168    pub fn secret(mut self, secret: impl Into<String>) -> Self {
169        self.secret = Some(secret.into());
170        self
171    }
172
173    /// Sets the cluster
174    pub fn cluster(mut self, cluster: impl AsRef<str>) -> Self {
175        self.host = Some(format!("api-{}.sockudo.io", cluster.as_ref()));
176        self
177    }
178
179    /// Sets a custom host
180    pub fn host(mut self, host: impl Into<String>) -> Self {
181        self.host = Some(host.into());
182        self
183    }
184
185    /// Sets whether to use TLS
186    pub fn use_tls(mut self, use_tls: bool) -> Self {
187        self.scheme = Some(if use_tls { "https" } else { "http" }.to_string());
188        self
189    }
190
191    /// Sets the port
192    pub fn port(mut self, port: u16) -> Self {
193        self.port = Some(port);
194        self
195    }
196
197    /// Sets the timeout
198    pub fn timeout(mut self, timeout: Duration) -> Self {
199        self.timeout = Some(timeout);
200        self
201    }
202
203    /// Sets the encryption master key from raw bytes
204    pub fn encryption_master_key(mut self, key: Vec<u8>) -> Result<Self> {
205        if key.len() != 32 {
206            return Err(SockudoError::Config {
207                message: format!("Encryption key must be 32 bytes, got {}", key.len()),
208            });
209        }
210        self.encryption_master_key = Some(EncryptionKey(key));
211        Ok(self)
212    }
213
214    /// Sets the encryption master key from base64
215    pub fn encryption_master_key_base64(self, key_base64: impl AsRef<str>) -> Result<Self> {
216        let decoded = BASE64
217            .decode(key_base64.as_ref())
218            .map_err(|e| SockudoError::Config {
219                message: format!("Invalid base64 encryption key: {}", e),
220            })?;
221
222        self.encryption_master_key(decoded)
223    }
224
225    /// Sets the maximum idle connections per host
226    pub fn pool_max_idle_per_host(mut self, max: usize) -> Self {
227        self.pool_max_idle_per_host = Some(max);
228        self
229    }
230
231    /// Enables or disables retry logic
232    pub fn enable_retry(mut self, enable: bool) -> Self {
233        self.enable_retry = Some(enable);
234        self
235    }
236
237    /// Sets the maximum number of retries
238    pub fn max_retries(mut self, max: u32) -> Self {
239        self.max_retries = Some(max);
240        self
241    }
242
243    /// Enables or disables automatic deterministic idempotency key generation.
244    /// When enabled (default), each `trigger()` / `trigger_batch()` call that lacks
245    /// an explicit idempotency key will receive one derived from the client's session ID
246    /// and a monotonically increasing publish serial.
247    pub fn auto_idempotency_key(mut self, enable: bool) -> Self {
248        self.auto_idempotency_key = Some(enable);
249        self
250    }
251
252    /// Builds the configuration
253    pub fn build(self) -> Result<Config> {
254        let app_id = self.app_id.ok_or_else(|| SockudoError::Config {
255            message: "App ID is required".to_string(),
256        })?;
257
258        let key = self.key.ok_or_else(|| SockudoError::Config {
259            message: "App key is required".to_string(),
260        })?;
261
262        let secret = self.secret.ok_or_else(|| SockudoError::Config {
263            message: "App secret is required".to_string(),
264        })?;
265
266        let config = Config {
267            scheme: self.scheme.unwrap_or_else(|| "https".to_string()),
268            host: self.host.unwrap_or_else(|| "api.sockudo.io".to_string()),
269            port: self.port,
270            app_id,
271            token: Token::new(key, secret),
272            timeout: self.timeout.unwrap_or(Duration::from_secs(30)),
273            encryption_master_key: self.encryption_master_key,
274            pool_max_idle_per_host: self.pool_max_idle_per_host.unwrap_or(10),
275            enable_retry: self.enable_retry.unwrap_or(true),
276            max_retries: self.max_retries.unwrap_or(3),
277            auto_idempotency_key: self.auto_idempotency_key.unwrap_or(true),
278        };
279
280        config.validate()?;
281        Ok(config)
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn test_config_builder() {
291        let config = Config::builder()
292            .app_id("123")
293            .key("key")
294            .secret("secret")
295            .cluster("eu")
296            .timeout(Duration::from_secs(10))
297            .enable_retry(false)
298            .build()
299            .unwrap();
300
301        assert_eq!(config.app_id(), "123");
302        assert_eq!(config.host(), "api-eu.sockudo.io");
303        assert_eq!(config.timeout(), Duration::from_secs(10));
304        assert!(!config.enable_retry());
305    }
306
307    #[test]
308    fn test_config_validation() {
309        assert!(Config::builder().build().is_err());
310        assert!(Config::builder().app_id("123").build().is_err());
311        assert!(
312            Config::builder()
313                .app_id("123")
314                .key("key")
315                .secret("secret")
316                .build()
317                .is_ok()
318        );
319    }
320
321    #[test]
322    fn test_encryption_key_validation() {
323        let config = Config::builder()
324            .app_id("123")
325            .key("key")
326            .secret("secret")
327            .encryption_master_key(vec![0u8; 32])
328            .unwrap()
329            .build()
330            .unwrap();
331
332        assert!(config.encryption_master_key().is_some());
333
334        // Wrong size should fail
335        assert!(
336            Config::builder()
337                .app_id("123")
338                .key("key")
339                .secret("secret")
340                .encryption_master_key(vec![0u8; 16])
341                .is_err()
342        );
343    }
344}