datadog_api/
config.rs

1#[cfg(feature = "keyring")]
2use keyring::Entry;
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::path::PathBuf;
6use zeroize::{Zeroize, ZeroizeOnDrop};
7
8/// Retry configuration for API requests.
9#[derive(Debug, Clone, Deserialize, Serialize)]
10pub struct RetryConfig {
11    /// Maximum number of retry attempts
12    pub max_retries: u32,
13    /// Initial backoff duration in milliseconds
14    pub initial_backoff_ms: u64,
15    /// Maximum backoff duration in milliseconds
16    pub max_backoff_ms: u64,
17    /// Backoff multiplier for exponential backoff
18    pub backoff_multiplier: f64,
19}
20
21impl Default for RetryConfig {
22    fn default() -> Self {
23        Self {
24            max_retries: 3,
25            initial_backoff_ms: 100,
26            max_backoff_ms: 10000,
27            backoff_multiplier: 2.0,
28        }
29    }
30}
31
32/// HTTP client configuration for connection pooling and timeouts.
33#[derive(Debug, Clone, Deserialize, Serialize)]
34pub struct HttpConfig {
35    /// Request timeout in seconds (default: 30)
36    pub timeout_secs: u64,
37    /// Maximum idle connections per host in the pool (default: 10)
38    pub pool_max_idle_per_host: usize,
39    /// Idle connection timeout in seconds (default: 90)
40    pub pool_idle_timeout_secs: u64,
41    /// Enable TCP keepalive with given interval in seconds (default: Some(60))
42    pub tcp_keepalive_secs: Option<u64>,
43}
44
45impl Default for HttpConfig {
46    fn default() -> Self {
47        Self {
48            timeout_secs: 30,
49            pool_max_idle_per_host: 10,
50            pool_idle_timeout_secs: 90,
51            tcp_keepalive_secs: Some(60),
52        }
53    }
54}
55
56/// Datadog API configuration containing credentials and regional settings.
57#[derive(Clone, Deserialize, Serialize)]
58pub struct DatadogConfig {
59    /// Datadog API key for authentication
60    pub api_key: SecretString,
61    /// Datadog application key for authentication
62    pub app_key: SecretString,
63    /// Datadog site/region (defaults to datadoghq.com)
64    #[serde(default = "default_site")]
65    pub site: String,
66    /// Retry configuration
67    #[serde(default)]
68    pub retry_config: RetryConfig,
69    /// HTTP client configuration (timeouts, connection pool)
70    #[serde(default)]
71    pub http_config: HttpConfig,
72    /// List of unstable operations that require the DD-OPERATION-UNSTABLE header
73    #[serde(default = "default_unstable_operations")]
74    pub unstable_operations: Vec<String>,
75    /// Override base URL (for testing with mock servers)
76    #[serde(skip)]
77    base_url_override: Option<String>,
78}
79
80impl fmt::Debug for DatadogConfig {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        f.debug_struct("DatadogConfig")
83            .field("api_key", &"[REDACTED]")
84            .field("app_key", &"[REDACTED]")
85            .field("site", &self.site)
86            .field("retry_config", &self.retry_config)
87            .field("http_config", &self.http_config)
88            .field("unstable_operations", &self.unstable_operations)
89            .field(
90                "base_url_override",
91                &self.base_url_override.as_ref().map(|_| "[SET]"),
92            )
93            .finish()
94    }
95}
96
97const fn default_site_const() -> &'static str {
98    "datadoghq.com"
99}
100
101fn default_site() -> String {
102    default_site_const().to_string()
103}
104
105fn default_unstable_operations() -> Vec<String> {
106    vec!["incidents".to_string()]
107}
108
109impl DatadogConfig {
110    /// Creates a new Datadog configuration with the specified credentials.
111    ///
112    /// Uses the default site (datadoghq.com / US1 region).
113    #[must_use]
114    pub fn new(api_key: String, application_key: String) -> Self {
115        Self {
116            api_key: SecretString::new(api_key),
117            app_key: SecretString::new(application_key),
118            site: default_site(),
119            retry_config: RetryConfig::default(),
120            http_config: HttpConfig::default(),
121            unstable_operations: default_unstable_operations(),
122            base_url_override: None,
123        }
124    }
125
126    /// Sets the Datadog site/region for this configuration.
127    ///
128    /// # Examples
129    ///
130    /// ```ignore
131    /// let config = DatadogConfig::new(api_key, app_key)
132    ///     .with_site("datadoghq.eu".to_string());
133    /// ```
134    #[must_use]
135    pub fn with_site(mut self, site: String) -> Self {
136        self.site = site;
137        self
138    }
139
140    /// Sets a custom base URL (for testing with mock servers).
141    #[must_use]
142    pub fn with_base_url(mut self, base_url: String) -> Self {
143        self.base_url_override = Some(base_url);
144        self
145    }
146
147    /// Returns the full API base URL for the configured Datadog site.
148    #[must_use]
149    pub fn base_url(&self) -> String {
150        self.base_url_override
151            .clone()
152            .unwrap_or_else(|| format!("https://api.{}", self.site))
153    }
154
155    /// Creates a configuration from environment variables.
156    ///
157    /// # Environment Variables
158    ///
159    /// - `DD_API_KEY` (required): Datadog API key
160    /// - `DD_APP_KEY` (required): Datadog application key
161    /// - `DD_SITE` (optional): Datadog site, defaults to datadoghq.com
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if required environment variables are not set.
166    pub fn from_env() -> crate::Result<Self> {
167        let api_key = std::env::var("DD_API_KEY")
168            .map_err(|_| crate::Error::ConfigError("DD_API_KEY not set".to_string()))?;
169
170        let application_key = std::env::var("DD_APP_KEY")
171            .map_err(|_| crate::Error::ConfigError("DD_APP_KEY not set".to_string()))?;
172
173        let site = std::env::var("DD_SITE").unwrap_or_else(|_| default_site());
174
175        Ok(Self {
176            api_key: SecretString::new(api_key),
177            app_key: SecretString::new(application_key),
178            site,
179            retry_config: RetryConfig::default(),
180            http_config: HttpConfig::default(),
181            unstable_operations: default_unstable_operations(),
182            base_url_override: None,
183        })
184    }
185
186    /// Attempt to load credentials from ~/.datadog-mcp/credentials.json, falling back to env vars.
187    pub fn from_env_or_file() -> crate::Result<Self> {
188        if let Ok(file_cfg) = Self::from_credentials_file() {
189            return Ok(file_cfg);
190        }
191        #[cfg(feature = "keyring")]
192        if let Ok(keyring_cfg) = Self::from_keyring() {
193            return Ok(keyring_cfg);
194        }
195        Self::from_env()
196    }
197
198    fn from_credentials_file() -> crate::Result<Self> {
199        let home = std::env::var("HOME").map_err(|_| {
200            crate::Error::ConfigError("HOME not set; cannot read credentials file".to_string())
201        })?;
202        let path = PathBuf::from(home)
203            .join(".datadog-mcp")
204            .join("credentials.json");
205        let content = std::fs::read_to_string(&path).map_err(|e| {
206            crate::Error::ConfigError(format!("Failed to read {}: {}", path.display(), e))
207        })?;
208        let file_cfg: FileCredentials = serde_json::from_str(&content).map_err(|e| {
209            crate::Error::ConfigError(format!(
210                "Invalid credentials file {}: {}",
211                path.display(),
212                e
213            ))
214        })?;
215        Ok(Self::new(file_cfg.api_key, file_cfg.app_key)
216            .with_site(file_cfg.site.unwrap_or_else(default_site)))
217    }
218
219    /// Load configuration from the system keyring entry, if present.
220    ///
221    /// Profile defaults to `DD_PROFILE` or `default`.
222    #[cfg(feature = "keyring")]
223    pub fn from_keyring() -> crate::Result<Self> {
224        let profile = std::env::var("DD_PROFILE").unwrap_or_else(|_| "default".to_string());
225        let entry = Entry::new(KEYRING_SERVICE, &profile)
226            .map_err(|e| crate::Error::ConfigError(format!("Failed to access keyring: {e}")))?;
227        let secret = entry
228            .get_password()
229            .map_err(|e| crate::Error::ConfigError(format!("Failed to read keyring entry: {e}")))?;
230        let creds: FileCredentials = serde_json::from_str(&secret).map_err(|e| {
231            crate::Error::ConfigError(format!("Invalid keyring credentials format: {e}"))
232        })?;
233        Ok(Self::new(creds.api_key, creds.app_key)
234            .with_site(creds.site.unwrap_or_else(default_site)))
235    }
236
237    /// Store the current configuration in the system keyring entry.
238    ///
239    /// Profile defaults to `DD_PROFILE` or `default`.
240    #[cfg(feature = "keyring")]
241    pub fn store_in_keyring(&self) -> crate::Result<()> {
242        let profile = std::env::var("DD_PROFILE").unwrap_or_else(|_| "default".to_string());
243        let entry = Entry::new(KEYRING_SERVICE, &profile)
244            .map_err(|e| crate::Error::ConfigError(format!("Failed to access keyring: {e}")))?;
245        let payload = serde_json::to_string(&FileCredentials {
246            api_key: self.api_key.expose().to_string(),
247            app_key: self.app_key.expose().to_string(),
248            site: Some(self.site.clone()),
249        })
250        .map_err(|e| crate::Error::ConfigError(format!("Failed to serialize credentials: {e}")))?;
251        entry.set_password(&payload).map_err(|e| {
252            crate::Error::ConfigError(format!("Failed to store keyring entry: {e}"))
253        })?;
254        Ok(())
255    }
256}
257
258/// Wrapper for secrets that zeroize on drop and redact debug output.
259#[derive(Clone, Deserialize, Serialize, Zeroize, ZeroizeOnDrop, PartialEq, Eq)]
260#[serde(transparent)]
261pub struct SecretString(String);
262
263impl SecretString {
264    pub fn new(value: impl Into<String>) -> Self {
265        Self(value.into())
266    }
267
268    pub fn expose(&self) -> &str {
269        &self.0
270    }
271}
272
273impl fmt::Debug for SecretString {
274    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275        f.write_str("[REDACTED]")
276    }
277}
278
279impl fmt::Display for SecretString {
280    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281        f.write_str("[REDACTED]")
282    }
283}
284
285impl PartialEq<str> for SecretString {
286    fn eq(&self, other: &str) -> bool {
287        self.0 == other
288    }
289}
290
291impl PartialEq<String> for SecretString {
292    fn eq(&self, other: &String) -> bool {
293        &self.0 == other
294    }
295}
296
297impl PartialEq<&str> for SecretString {
298    fn eq(&self, other: &&str) -> bool {
299        self.0 == *other
300    }
301}
302
303#[derive(Debug, Clone, Deserialize, Serialize)]
304struct FileCredentials {
305    api_key: String,
306    app_key: String,
307    #[serde(default)]
308    site: Option<String>,
309}
310
311#[cfg(feature = "keyring")]
312const KEYRING_SERVICE: &str = "datadog-mcp";
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::Error;
318    use serial_test::serial;
319    use std::env;
320
321    #[test]
322    fn test_config_new() {
323        let config = DatadogConfig::new("test_api_key".to_string(), "test_app_key".to_string());
324
325        assert_eq!(config.api_key, "test_api_key");
326        assert_eq!(config.app_key, "test_app_key");
327        assert_eq!(config.site, "datadoghq.com");
328    }
329
330    #[test]
331    fn test_config_with_site() {
332        let config = DatadogConfig::new("test_api_key".to_string(), "test_app_key".to_string())
333            .with_site("datadoghq.eu".to_string());
334
335        assert_eq!(config.site, "datadoghq.eu");
336    }
337
338    #[test]
339    fn test_base_url_us1() {
340        let config = DatadogConfig::new("test_api_key".to_string(), "test_app_key".to_string());
341
342        assert_eq!(config.base_url(), "https://api.datadoghq.com");
343    }
344
345    #[test]
346    fn test_base_url_eu() {
347        let config = DatadogConfig::new("test_api_key".to_string(), "test_app_key".to_string())
348            .with_site("datadoghq.eu".to_string());
349
350        assert_eq!(config.base_url(), "https://api.datadoghq.eu");
351    }
352
353    #[test]
354    #[serial]
355    fn test_from_env_success() {
356        env::set_var("DD_API_KEY", "env_api_key");
357        env::set_var("DD_APP_KEY", "env_app_key");
358        env::set_var("DD_SITE", "us3.datadoghq.com");
359
360        let config = DatadogConfig::from_env().expect("Failed to create config from env");
361
362        assert_eq!(config.api_key, "env_api_key");
363        assert_eq!(config.app_key, "env_app_key");
364        assert_eq!(config.site, "us3.datadoghq.com");
365
366        env::remove_var("DD_API_KEY");
367        env::remove_var("DD_APP_KEY");
368        env::remove_var("DD_SITE");
369    }
370
371    #[test]
372    #[serial]
373    fn test_from_env_default_site() {
374        env::set_var("DD_API_KEY", "env_api_key");
375        env::set_var("DD_APP_KEY", "env_app_key");
376        env::remove_var("DD_SITE");
377
378        let config = DatadogConfig::from_env().expect("Failed to create config from env");
379
380        assert_eq!(config.site, "datadoghq.com");
381
382        env::remove_var("DD_API_KEY");
383        env::remove_var("DD_APP_KEY");
384    }
385
386    #[test]
387    #[serial]
388    fn test_from_env_missing_api_key() {
389        env::remove_var("DD_API_KEY");
390        env::set_var("DD_APP_KEY", "env_app_key");
391
392        let result = DatadogConfig::from_env();
393
394        assert!(result.is_err());
395        if let Err(Error::ConfigError(msg)) = result {
396            assert!(msg.contains("DD_API_KEY"));
397        } else {
398            panic!("Expected ConfigError");
399        }
400
401        env::remove_var("DD_APP_KEY");
402    }
403
404    #[test]
405    #[serial]
406    fn test_from_env_missing_app_key() {
407        env::set_var("DD_API_KEY", "env_api_key");
408        env::remove_var("DD_APP_KEY");
409
410        let result = DatadogConfig::from_env();
411
412        assert!(result.is_err());
413        if let Err(Error::ConfigError(msg)) = result {
414            assert!(msg.contains("DD_APP_KEY"));
415        } else {
416            panic!("Expected ConfigError");
417        }
418
419        env::remove_var("DD_API_KEY");
420    }
421
422    #[test]
423    fn test_config_serialization() {
424        let config = DatadogConfig::new("api_key".to_string(), "app_key".to_string())
425            .with_site("datadoghq.eu".to_string());
426
427        let json = serde_json::to_string(&config).expect("Failed to serialize");
428        let deserialized: DatadogConfig =
429            serde_json::from_str(&json).expect("Failed to deserialize");
430
431        assert_eq!(config.api_key, deserialized.api_key);
432        assert_eq!(config.app_key, deserialized.app_key);
433        assert_eq!(config.site, deserialized.site);
434    }
435
436    #[test]
437    fn test_http_config_default() {
438        let config = HttpConfig::default();
439        assert_eq!(config.timeout_secs, 30);
440        assert_eq!(config.pool_max_idle_per_host, 10);
441        assert_eq!(config.pool_idle_timeout_secs, 90);
442        assert_eq!(config.tcp_keepalive_secs, Some(60));
443    }
444
445    #[test]
446    fn test_http_config_serialization() {
447        let config = HttpConfig {
448            timeout_secs: 60,
449            pool_max_idle_per_host: 20,
450            pool_idle_timeout_secs: 120,
451            tcp_keepalive_secs: None,
452        };
453
454        let json = serde_json::to_string(&config).expect("Failed to serialize");
455        let deserialized: HttpConfig =
456            serde_json::from_str(&json).expect("Failed to deserialize");
457
458        assert_eq!(config.timeout_secs, deserialized.timeout_secs);
459        assert_eq!(
460            config.pool_max_idle_per_host,
461            deserialized.pool_max_idle_per_host
462        );
463        assert_eq!(config.tcp_keepalive_secs, deserialized.tcp_keepalive_secs);
464    }
465}