pubmed_client/
config.rs

1use crate::cache::CacheConfig;
2use crate::rate_limit::RateLimiter;
3use crate::retry::RetryConfig;
4use crate::time::Duration;
5
6/// Configuration options for PubMed and PMC clients
7///
8/// This configuration allows customization of rate limiting, API keys,
9/// timeouts, and other client behavior to comply with NCBI guidelines
10/// and optimize performance.
11#[derive(Clone)]
12pub struct ClientConfig {
13    /// NCBI E-utilities API key for increased rate limits
14    ///
15    /// With an API key:
16    /// - Rate limit increases from 3 to 10 requests per second
17    /// - Better stability and reduced chance of blocking
18    /// - Required for high-volume applications
19    ///
20    /// Get your API key at: <https://ncbiinsights.ncbi.nlm.nih.gov/2017/11/02/new-api-keys-for-the-e-utilities/>
21    pub api_key: Option<String>,
22
23    /// Rate limit in requests per second
24    ///
25    /// Default values:
26    /// - 3.0 without API key (NCBI guideline)
27    /// - 10.0 with API key (NCBI guideline)
28    ///
29    /// Setting this value overrides the automatic selection based on API key presence.
30    pub rate_limit: Option<f64>,
31
32    /// HTTP request timeout
33    ///
34    /// Default: 30 seconds
35    pub timeout: Duration,
36
37    /// Custom User-Agent string for HTTP requests
38    ///
39    /// Default: "pubmed-client/{version}"
40    pub user_agent: Option<String>,
41
42    /// Base URL for NCBI E-utilities
43    ///
44    /// Default: <https://eutils.ncbi.nlm.nih.gov/entrez/eutils>
45    /// This should rarely need to be changed unless using a proxy or test environment.
46    pub base_url: Option<String>,
47
48    /// Email address for identification (recommended by NCBI)
49    ///
50    /// NCBI recommends including an email address in requests for contact
51    /// in case of problems. This is automatically added to requests.
52    pub email: Option<String>,
53
54    /// Tool name for identification (recommended by NCBI)
55    ///
56    /// NCBI recommends including a tool name in requests.
57    /// Default: "pubmed-client"
58    pub tool: Option<String>,
59
60    /// Retry configuration for handling transient failures
61    ///
62    /// Default: 3 retries with exponential backoff starting at 1 second
63    pub retry_config: RetryConfig,
64
65    /// Cache configuration for response caching
66    ///
67    /// Default: disabled (`None`). Use [`ClientConfig::with_cache`],
68    /// [`ClientConfig::with_redis_cache`], or [`ClientConfig::with_sqlite_cache`]
69    /// to enable caching.
70    pub cache_config: Option<CacheConfig>,
71}
72
73impl ClientConfig {
74    /// Create a new configuration with default settings
75    ///
76    /// # Example
77    ///
78    /// ```
79    /// use pubmed_client::config::ClientConfig;
80    ///
81    /// let config = ClientConfig::new();
82    /// ```
83    pub fn new() -> Self {
84        Self {
85            api_key: None,
86            rate_limit: None,
87            timeout: Duration::from_secs(30),
88            user_agent: None,
89            base_url: None,
90            email: None,
91            tool: None,
92            retry_config: RetryConfig::default(),
93            cache_config: None,
94        }
95    }
96
97    /// Set the NCBI API key
98    ///
99    /// # Arguments
100    ///
101    /// * `api_key` - Your NCBI E-utilities API key
102    ///
103    /// # Example
104    ///
105    /// ```
106    /// use pubmed_client::config::ClientConfig;
107    ///
108    /// let config = ClientConfig::new()
109    ///     .with_api_key("your_api_key_here");
110    /// ```
111    pub fn with_api_key<S: Into<String>>(mut self, api_key: S) -> Self {
112        self.api_key = Some(api_key.into());
113        self
114    }
115
116    /// Set a custom rate limit
117    ///
118    /// # Arguments
119    ///
120    /// * `rate` - Requests per second (must be positive)
121    ///
122    /// # Example
123    ///
124    /// ```
125    /// use pubmed_client::config::ClientConfig;
126    ///
127    /// // Custom rate limit of 5 requests per second
128    /// let config = ClientConfig::new()
129    ///     .with_rate_limit(5.0);
130    /// ```
131    pub fn with_rate_limit(mut self, rate: f64) -> Self {
132        if rate > 0.0 {
133            self.rate_limit = Some(rate);
134        }
135        self
136    }
137
138    /// Set the HTTP request timeout
139    ///
140    /// # Arguments
141    ///
142    /// * `timeout` - Maximum time to wait for HTTP responses
143    ///
144    /// # Example
145    ///
146    /// ```
147    /// use pubmed_client::config::ClientConfig;
148    /// use pubmed_client::time::Duration;
149    ///
150    /// let config = ClientConfig::new()
151    ///     .with_timeout(Duration::from_secs(60));
152    /// ```
153    pub fn with_timeout(mut self, timeout: Duration) -> Self {
154        self.timeout = timeout;
155        self
156    }
157
158    /// Set the HTTP request timeout in seconds (convenience method)
159    ///
160    /// # Arguments
161    ///
162    /// * `timeout_seconds` - Maximum time to wait for HTTP responses in seconds
163    ///
164    /// # Example
165    ///
166    /// ```
167    /// use pubmed_client::config::ClientConfig;
168    ///
169    /// let config = ClientConfig::new()
170    ///     .with_timeout_seconds(60);
171    /// ```
172    pub fn with_timeout_seconds(mut self, timeout_seconds: u64) -> Self {
173        self.timeout = Duration::from_secs(timeout_seconds);
174        self
175    }
176
177    /// Set a custom User-Agent string
178    ///
179    /// # Arguments
180    ///
181    /// * `user_agent` - Custom User-Agent for HTTP requests
182    ///
183    /// # Example
184    ///
185    /// ```
186    /// use pubmed_client::config::ClientConfig;
187    ///
188    /// let config = ClientConfig::new()
189    ///     .with_user_agent("MyApp/1.0");
190    /// ```
191    pub fn with_user_agent<S: Into<String>>(mut self, user_agent: S) -> Self {
192        self.user_agent = Some(user_agent.into());
193        self
194    }
195
196    /// Set a custom base URL for NCBI E-utilities
197    ///
198    /// # Arguments
199    ///
200    /// * `base_url` - Base URL for E-utilities API
201    ///
202    /// # Example
203    ///
204    /// ```
205    /// use pubmed_client::config::ClientConfig;
206    ///
207    /// let config = ClientConfig::new()
208    ///     .with_base_url("https://proxy.example.com/eutils");
209    /// ```
210    pub fn with_base_url<S: Into<String>>(mut self, base_url: S) -> Self {
211        self.base_url = Some(base_url.into());
212        self
213    }
214
215    /// Set email address for NCBI identification
216    ///
217    /// # Arguments
218    ///
219    /// * `email` - Your email address for NCBI contact
220    ///
221    /// # Example
222    ///
223    /// ```
224    /// use pubmed_client::config::ClientConfig;
225    ///
226    /// let config = ClientConfig::new()
227    ///     .with_email("researcher@university.edu");
228    /// ```
229    pub fn with_email<S: Into<String>>(mut self, email: S) -> Self {
230        self.email = Some(email.into());
231        self
232    }
233
234    /// Set tool name for NCBI identification
235    ///
236    /// # Arguments
237    ///
238    /// * `tool` - Your application/tool name
239    ///
240    /// # Example
241    ///
242    /// ```
243    /// use pubmed_client::config::ClientConfig;
244    ///
245    /// let config = ClientConfig::new()
246    ///     .with_tool("BioinformaticsApp");
247    /// ```
248    pub fn with_tool<S: Into<String>>(mut self, tool: S) -> Self {
249        self.tool = Some(tool.into());
250        self
251    }
252
253    /// Set retry configuration for handling transient failures
254    ///
255    /// # Arguments
256    ///
257    /// * `retry_config` - Custom retry configuration
258    ///
259    /// # Example
260    ///
261    /// ```
262    /// use pubmed_client::config::ClientConfig;
263    /// use pubmed_client::retry::RetryConfig;
264    /// use pubmed_client::time::Duration;
265    ///
266    /// let retry_config = RetryConfig::new()
267    ///     .with_max_retries(5)
268    ///     .with_initial_delay(Duration::from_secs(2));
269    ///
270    /// let config = ClientConfig::new()
271    ///     .with_retry_config(retry_config);
272    /// ```
273    pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
274        self.retry_config = retry_config;
275        self
276    }
277
278    /// Enable caching with default configuration
279    ///
280    /// # Example
281    ///
282    /// ```
283    /// use pubmed_client::config::ClientConfig;
284    ///
285    /// let config = ClientConfig::new()
286    ///     .with_cache();
287    /// ```
288    pub fn with_cache(mut self) -> Self {
289        self.cache_config = Some(CacheConfig::default());
290        self
291    }
292
293    /// Set cache configuration
294    ///
295    /// # Arguments
296    ///
297    /// * `cache_config` - Custom cache configuration
298    ///
299    /// # Example
300    ///
301    /// ```
302    /// use pubmed_client::config::ClientConfig;
303    /// use pubmed_client::cache::CacheConfig;
304    ///
305    /// let cache_config = CacheConfig {
306    ///     max_capacity: 5000,
307    ///     ..Default::default()
308    /// };
309    ///
310    /// let config = ClientConfig::new()
311    ///     .with_cache_config(cache_config);
312    /// ```
313    pub fn with_cache_config(mut self, cache_config: CacheConfig) -> Self {
314        self.cache_config = Some(cache_config);
315        self
316    }
317
318    /// Disable all caching
319    ///
320    /// # Example
321    ///
322    /// ```
323    /// use pubmed_client::config::ClientConfig;
324    ///
325    /// let config = ClientConfig::new()
326    ///     .without_cache();
327    /// ```
328    pub fn without_cache(mut self) -> Self {
329        self.cache_config = None;
330        self
331    }
332
333    /// Enable Redis-backed caching.
334    ///
335    /// Requires the `cache-redis` feature.  The cache uses JSON serialisation
336    /// with per-entry TTL (default: 7 days from [`CacheConfig::default`]).
337    ///
338    /// # Arguments
339    ///
340    /// * `url` - Redis connection URL, e.g. `"redis://127.0.0.1/"`
341    ///
342    /// # Example
343    ///
344    /// ```ignore
345    /// use pubmed_client::config::ClientConfig;
346    ///
347    /// let config = ClientConfig::new()
348    ///     .with_redis_cache("redis://127.0.0.1/");
349    /// ```
350    #[cfg(feature = "cache-redis")]
351    pub fn with_redis_cache(mut self, url: impl Into<String>) -> Self {
352        use crate::cache::CacheBackendConfig;
353        let ttl = self
354            .cache_config
355            .as_ref()
356            .map(|c| c.time_to_live)
357            .unwrap_or(CacheConfig::default().time_to_live);
358        self.cache_config = Some(CacheConfig {
359            backend: CacheBackendConfig::Redis { url: url.into() },
360            time_to_live: ttl,
361            ..CacheConfig::default()
362        });
363        self
364    }
365
366    /// Enable SQLite-backed caching.
367    ///
368    /// Requires the `cache-sqlite` feature.  Not available on WASM targets.
369    /// The database file is created automatically if it does not exist.
370    ///
371    /// # Arguments
372    ///
373    /// * `path` - Path to the SQLite database file
374    ///
375    /// # Example
376    ///
377    /// ```ignore
378    /// use pubmed_client::config::ClientConfig;
379    ///
380    /// let config = ClientConfig::new()
381    ///     .with_sqlite_cache("/tmp/pubmed_cache.db");
382    /// ```
383    #[cfg(feature = "cache-sqlite")]
384    pub fn with_sqlite_cache(mut self, path: impl Into<std::path::PathBuf>) -> Self {
385        use crate::cache::CacheBackendConfig;
386        let ttl = self
387            .cache_config
388            .as_ref()
389            .map(|c| c.time_to_live)
390            .unwrap_or(CacheConfig::default().time_to_live);
391        self.cache_config = Some(CacheConfig {
392            backend: CacheBackendConfig::Sqlite { path: path.into() },
393            time_to_live: ttl,
394            ..CacheConfig::default()
395        });
396        self
397    }
398
399    /// Get the effective rate limit based on configuration
400    ///
401    /// Returns the configured rate limit, or the appropriate default
402    /// based on whether an API key is present.
403    ///
404    /// # Returns
405    ///
406    /// - Custom rate limit if set
407    /// - 10.0 requests/second if API key is present
408    /// - 3.0 requests/second if no API key
409    pub fn effective_rate_limit(&self) -> f64 {
410        self.rate_limit.unwrap_or_else(|| {
411            if self.api_key.is_some() {
412                10.0 // NCBI rate limit with API key
413            } else {
414                3.0 // NCBI rate limit without API key
415            }
416        })
417    }
418
419    /// Create a rate limiter based on this configuration
420    ///
421    /// # Returns
422    ///
423    /// A `RateLimiter` configured with the appropriate rate limit
424    ///
425    /// # Example
426    ///
427    /// ```
428    /// use pubmed_client::config::ClientConfig;
429    ///
430    /// let config = ClientConfig::new().with_api_key("your_key");
431    /// let rate_limiter = config.create_rate_limiter();
432    /// ```
433    pub fn create_rate_limiter(&self) -> RateLimiter {
434        RateLimiter::new(self.effective_rate_limit())
435    }
436
437    /// Get the base URL for E-utilities
438    ///
439    /// Returns the configured base URL or the default NCBI E-utilities URL.
440    pub fn effective_base_url(&self) -> &str {
441        self.base_url
442            .as_deref()
443            .unwrap_or("https://eutils.ncbi.nlm.nih.gov/entrez/eutils")
444    }
445
446    /// Get the User-Agent string
447    ///
448    /// Returns the configured User-Agent or a default based on the crate name and version.
449    pub fn effective_user_agent(&self) -> String {
450        self.user_agent.clone().unwrap_or_else(|| {
451            let version = env!("CARGO_PKG_VERSION");
452            format!("pubmed-client/{version}")
453        })
454    }
455
456    /// Get the tool name for NCBI identification
457    ///
458    /// Returns the configured tool name or the default.
459    pub fn effective_tool(&self) -> &str {
460        self.tool.as_deref().unwrap_or("pubmed-client")
461    }
462
463    /// Build query parameters for NCBI API requests
464    ///
465    /// This includes API key, email, and tool parameters when configured.
466    pub fn build_api_params(&self) -> Vec<(String, String)> {
467        let mut params = Vec::new();
468
469        if let Some(ref api_key) = self.api_key {
470            params.push(("api_key".to_string(), api_key.clone()));
471        }
472
473        if let Some(ref email) = self.email {
474            params.push(("email".to_string(), email.clone()));
475        }
476
477        params.push(("tool".to_string(), self.effective_tool().to_string()));
478
479        params
480    }
481}
482
483impl Default for ClientConfig {
484    fn default() -> Self {
485        Self::new()
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use std::mem;
492
493    use super::*;
494
495    #[test]
496    fn test_default_config() {
497        let config = ClientConfig::new();
498        assert!(config.api_key.is_none());
499        assert!(config.rate_limit.is_none());
500        assert_eq!(config.timeout, Duration::from_secs(30));
501        assert_eq!(config.effective_rate_limit(), 3.0);
502    }
503
504    #[test]
505    fn test_config_with_api_key() {
506        let config = ClientConfig::new().with_api_key("test_key");
507        assert_eq!(config.api_key.as_ref().unwrap(), "test_key");
508        assert_eq!(config.effective_rate_limit(), 10.0);
509    }
510
511    #[test]
512    fn test_custom_rate_limit() {
513        let config = ClientConfig::new().with_rate_limit(5.0);
514        assert_eq!(config.effective_rate_limit(), 5.0);
515
516        // Custom rate limit overrides API key default
517        let config_with_key = ClientConfig::new()
518            .with_api_key("test")
519            .with_rate_limit(7.0);
520        assert_eq!(config_with_key.effective_rate_limit(), 7.0);
521    }
522
523    #[test]
524    fn test_invalid_rate_limit() {
525        let config = ClientConfig::new().with_rate_limit(-1.0);
526        assert!(config.rate_limit.is_none());
527        assert_eq!(config.effective_rate_limit(), 3.0);
528    }
529
530    #[test]
531    fn test_fluent_interface() {
532        let config = ClientConfig::new()
533            .with_api_key("test_key")
534            .with_rate_limit(5.0)
535            .with_timeout(Duration::from_secs(60))
536            .with_email("test@example.com")
537            .with_tool("TestApp");
538
539        assert_eq!(config.api_key.as_ref().unwrap(), "test_key");
540        assert_eq!(config.effective_rate_limit(), 5.0);
541        assert_eq!(config.timeout, Duration::from_secs(60));
542        assert_eq!(config.email.as_ref().unwrap(), "test@example.com");
543        assert_eq!(config.effective_tool(), "TestApp");
544    }
545
546    #[test]
547    fn test_api_params() {
548        let config = ClientConfig::new()
549            .with_api_key("test_key")
550            .with_email("test@example.com")
551            .with_tool("TestApp");
552
553        let params = config.build_api_params();
554        assert_eq!(params.len(), 3);
555
556        assert!(params.contains(&("api_key".to_string(), "test_key".to_string())));
557        assert!(params.contains(&("email".to_string(), "test@example.com".to_string())));
558        assert!(params.contains(&("tool".to_string(), "TestApp".to_string())));
559    }
560
561    #[test]
562    fn test_effective_values() {
563        let config = ClientConfig::new();
564
565        assert_eq!(
566            config.effective_base_url(),
567            "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
568        );
569        assert!(config.effective_user_agent().starts_with("pubmed-client/"));
570        assert_eq!(config.effective_tool(), "pubmed-client");
571    }
572
573    #[test]
574    fn test_rate_limiter_creation() {
575        let config = ClientConfig::new().with_rate_limit(5.0);
576        let rate_limiter = config.create_rate_limiter();
577        // The rate limiter creation should succeed
578        assert!(mem::size_of_val(&rate_limiter) > 0);
579    }
580}