Skip to main content

exa_async/
config.rs

1use reqwest::header::{HeaderMap, HeaderValue};
2use secrecy::{ExposeSecret, SecretString};
3
4/// Default Exa API base URL
5pub const EXA_DEFAULT_BASE: &str = "https://api.exa.ai";
6/// Header name for API key authentication
7pub const HDR_X_API_KEY: &str = "x-api-key";
8
9/// Configuration for the Exa client
10///
11/// Debug output automatically redacts `api_key` via [`SecretString`].
12#[derive(Clone, Debug)]
13pub struct ExaConfig {
14    api_base: String,
15    api_key: Option<SecretString>,
16}
17
18impl Default for ExaConfig {
19    fn default() -> Self {
20        let api_key = std::env::var("EXA_API_KEY")
21            .ok()
22            .map(|v| v.trim().to_string())
23            .filter(|v| !v.is_empty())
24            .map(SecretString::from);
25
26        let api_base = std::env::var("EXA_BASE_URL")
27            .ok()
28            .map(|v| v.trim().to_string())
29            .filter(|v| !v.is_empty())
30            .unwrap_or_else(|| EXA_DEFAULT_BASE.into());
31
32        Self { api_base, api_key }
33    }
34}
35
36impl ExaConfig {
37    /// Creates a new configuration with default settings
38    ///
39    /// Attempts to read from environment variables:
40    /// - `EXA_API_KEY` for API key authentication
41    /// - `EXA_BASE_URL` for custom API base URL (defaults to `https://api.exa.ai`)
42    #[must_use]
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Sets the API base URL
48    #[must_use]
49    pub fn with_api_base(mut self, base: impl Into<String>) -> Self {
50        self.api_base = base.into();
51        self
52    }
53
54    /// Sets the API key
55    #[must_use]
56    pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
57        self.api_key = Some(SecretString::from(key.into()));
58        self
59    }
60
61    /// Returns the configured API base URL
62    #[must_use]
63    pub fn api_base(&self) -> &str {
64        &self.api_base
65    }
66}
67
68/// Configuration trait for the Exa client
69///
70/// Implement this trait to provide custom authentication and API configuration.
71pub trait Config: Send + Sync {
72    /// Returns HTTP headers to include in requests
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if header values contain invalid characters.
77    fn headers(&self) -> Result<HeaderMap, crate::error::ExaError>;
78
79    /// Constructs the full URL for an API endpoint
80    fn url(&self, path: &str) -> String;
81
82    /// Returns query parameters to include in requests
83    fn query(&self) -> Vec<(&str, &str)>;
84
85    /// Validates that authentication credentials are present.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error if authentication is not properly configured.
90    fn validate_auth(&self) -> Result<(), crate::error::ExaError>;
91}
92
93impl Config for ExaConfig {
94    fn headers(&self) -> Result<HeaderMap, crate::error::ExaError> {
95        use crate::error::ExaError;
96
97        let mut h = HeaderMap::new();
98
99        if let Some(secret) = &self.api_key {
100            let key = secret.expose_secret().trim();
101            if !key.is_empty() {
102                h.insert(
103                    HDR_X_API_KEY,
104                    HeaderValue::from_str(key)
105                        .map_err(|_| ExaError::Config("Invalid x-api-key value".into()))?,
106                );
107            }
108        }
109
110        Ok(h)
111    }
112
113    fn url(&self, path: &str) -> String {
114        let base = self.api_base.trim_end_matches('/');
115        let path = path.trim_start_matches('/');
116        format!("{base}/{path}")
117    }
118
119    fn query(&self) -> Vec<(&str, &str)> {
120        vec![]
121    }
122
123    fn validate_auth(&self) -> Result<(), crate::error::ExaError> {
124        match &self.api_key {
125            Some(secret) if !secret.expose_secret().trim().is_empty() => Ok(()),
126            _ => Err(crate::error::ExaError::Config(
127                "Missing Exa credentials: set EXA_API_KEY environment variable".into(),
128            )),
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::test_support::EnvGuard;
137    use serial_test::serial;
138
139    #[test]
140    #[serial(env)]
141    fn config_reads_env_vars() {
142        let _key = EnvGuard::set("EXA_API_KEY", "test-key-123");
143        let _base = EnvGuard::set("EXA_BASE_URL", "https://custom.exa.ai");
144
145        let cfg = ExaConfig::new();
146        assert_eq!(cfg.api_base(), "https://custom.exa.ai");
147
148        let h = cfg.headers().unwrap();
149        assert_eq!(
150            h.get(HDR_X_API_KEY).unwrap().to_str().unwrap(),
151            "test-key-123"
152        );
153    }
154
155    #[test]
156    #[serial(env)]
157    fn config_defaults_base_url() {
158        let _key = EnvGuard::set("EXA_API_KEY", "k");
159        let _base = EnvGuard::remove("EXA_BASE_URL");
160
161        let cfg = ExaConfig::new();
162        assert_eq!(cfg.api_base(), EXA_DEFAULT_BASE);
163    }
164
165    #[test]
166    #[serial(env)]
167    fn validate_auth_missing_key() {
168        let _key = EnvGuard::remove("EXA_API_KEY");
169
170        let cfg = ExaConfig::new();
171        assert!(cfg.validate_auth().is_err());
172    }
173
174    #[test]
175    #[serial(env)]
176    fn validate_auth_with_key() {
177        let _key = EnvGuard::set("EXA_API_KEY", "test-key");
178
179        let cfg = ExaConfig::new();
180        assert!(cfg.validate_auth().is_ok());
181    }
182
183    #[test]
184    fn builder_methods() {
185        let cfg = ExaConfig::new()
186            .with_api_base("https://test.exa.ai")
187            .with_api_key("my-key");
188
189        assert_eq!(cfg.api_base(), "https://test.exa.ai");
190        assert!(cfg.validate_auth().is_ok());
191
192        let h = cfg.headers().unwrap();
193        assert_eq!(h.get(HDR_X_API_KEY).unwrap().to_str().unwrap(), "my-key");
194    }
195
196    #[test]
197    fn debug_output_redacts_api_key() {
198        let cfg = ExaConfig::new().with_api_key("super-secret-key-12345");
199        let debug_str = format!("{cfg:?}");
200
201        assert!(
202            !debug_str.contains("super-secret-key-12345"),
203            "Debug output should not contain the API key"
204        );
205        // SecretString uses [REDACTED] format
206        assert!(
207            debug_str.contains("[REDACTED]"),
208            "Debug output should contain '[REDACTED]', got: {debug_str}"
209        );
210    }
211
212    #[test]
213    fn validate_auth_rejects_empty_or_whitespace() {
214        // Empty string
215        let cfg = ExaConfig::new().with_api_key("");
216        assert!(cfg.validate_auth().is_err());
217
218        // Whitespace only
219        let cfg = ExaConfig::new().with_api_key("   ");
220        assert!(cfg.validate_auth().is_err());
221
222        // Newline-padded
223        let cfg = ExaConfig::new().with_api_key("\n");
224        assert!(cfg.validate_auth().is_err());
225
226        // Valid key with whitespace (should pass after trim)
227        let cfg = ExaConfig::new().with_api_key("  valid-key  ");
228        assert!(cfg.validate_auth().is_ok());
229    }
230
231    #[test]
232    #[serial(env)]
233    fn config_trims_whitespace_padded_env_key() {
234        let _key = EnvGuard::set("EXA_API_KEY", "  trimmed-key  \n");
235        let _base = EnvGuard::remove("EXA_BASE_URL");
236
237        let cfg = ExaConfig::new();
238        assert!(cfg.validate_auth().is_ok());
239
240        let h = cfg.headers().unwrap();
241        assert_eq!(
242            h.get(HDR_X_API_KEY).unwrap().to_str().unwrap(),
243            "trimmed-key",
244            "Headers should contain the trimmed key"
245        );
246    }
247
248    #[test]
249    #[serial(env)]
250    fn config_rejects_whitespace_only_env_key() {
251        let _key = EnvGuard::set("EXA_API_KEY", "   ");
252        let _base = EnvGuard::remove("EXA_BASE_URL");
253
254        let cfg = ExaConfig::new();
255        assert!(cfg.validate_auth().is_err());
256    }
257}