Skip to main content

exa_async/
config.rs

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