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    /// Base URL for the PMC Open Access Cloud (AWS S3) service.
49    ///
50    /// Default: <https://pmc-oa-opendata.s3.amazonaws.com>
51    ///
52    /// NCBI is retiring the PMC FTP service (and the legacy `oa_package` tar.gz
53    /// bundles) in August 2026; article full-text XML and media are served from
54    /// this S3 bucket as individual per-article files instead. This should rarely
55    /// need to be changed unless using a proxy or test environment.
56    pub oa_cloud_base_url: Option<String>,
57
58    /// Maximum number of PMC OA Cloud (AWS S3) files to download concurrently
59    /// per article.
60    ///
61    /// Downloads from the `pmc-oa-opendata` S3 bucket are **not** subject to the
62    /// NCBI E-utilities rate limit (that quota only applies to eutils), so an
63    /// article's individual files (full-text XML, figures, supplementary media)
64    /// are fetched concurrently. This bounds that concurrency.
65    ///
66    /// Default: 8. A value of 0 is treated as 1 (sequential).
67    pub oa_download_concurrency: Option<usize>,
68
69    /// Email address for identification (recommended by NCBI)
70    ///
71    /// NCBI recommends including an email address in requests for contact
72    /// in case of problems. This is automatically added to requests.
73    pub email: Option<String>,
74
75    /// Tool name for identification (recommended by NCBI)
76    ///
77    /// NCBI recommends including a tool name in requests.
78    /// Default: "pubmed-client"
79    pub tool: Option<String>,
80
81    /// Retry configuration for handling transient failures
82    ///
83    /// Default: 3 retries with exponential backoff starting at 1 second
84    pub retry_config: RetryConfig,
85
86    /// Cache configuration for response caching
87    ///
88    /// Default: disabled (`None`). Use [`ClientConfig::with_cache`],
89    /// [`ClientConfig::with_redis_cache`], or [`ClientConfig::with_sqlite_cache`]
90    /// to enable caching.
91    pub cache_config: Option<CacheConfig>,
92}
93
94impl ClientConfig {
95    /// Create a new configuration with default settings
96    ///
97    /// # Example
98    ///
99    /// ```
100    /// use pubmed_client::config::ClientConfig;
101    ///
102    /// let config = ClientConfig::new();
103    /// ```
104    pub fn new() -> Self {
105        Self {
106            api_key: None,
107            rate_limit: None,
108            timeout: Duration::from_secs(30),
109            user_agent: None,
110            base_url: None,
111            oa_cloud_base_url: None,
112            oa_download_concurrency: None,
113            email: None,
114            tool: None,
115            retry_config: RetryConfig::default(),
116            cache_config: None,
117        }
118    }
119
120    /// Set the NCBI API key
121    ///
122    /// # Arguments
123    ///
124    /// * `api_key` - Your NCBI E-utilities API key
125    ///
126    /// # Example
127    ///
128    /// ```
129    /// use pubmed_client::config::ClientConfig;
130    ///
131    /// let config = ClientConfig::new()
132    ///     .with_api_key("your_api_key_here");
133    /// ```
134    pub fn with_api_key<S: Into<String>>(mut self, api_key: S) -> Self {
135        self.api_key = Some(api_key.into());
136        self
137    }
138
139    /// Set a custom rate limit
140    ///
141    /// # Arguments
142    ///
143    /// * `rate` - Requests per second (must be positive)
144    ///
145    /// # Example
146    ///
147    /// ```
148    /// use pubmed_client::config::ClientConfig;
149    ///
150    /// // Custom rate limit of 5 requests per second
151    /// let config = ClientConfig::new()
152    ///     .with_rate_limit(5.0);
153    /// ```
154    pub fn with_rate_limit(mut self, rate: f64) -> Self {
155        if rate > 0.0 {
156            self.rate_limit = Some(rate);
157        }
158        self
159    }
160
161    /// Set the HTTP request timeout
162    ///
163    /// # Arguments
164    ///
165    /// * `timeout` - Maximum time to wait for HTTP responses
166    ///
167    /// # Example
168    ///
169    /// ```
170    /// use pubmed_client::config::ClientConfig;
171    /// use pubmed_client::time::Duration;
172    ///
173    /// let config = ClientConfig::new()
174    ///     .with_timeout(Duration::from_secs(60));
175    /// ```
176    pub fn with_timeout(mut self, timeout: Duration) -> Self {
177        self.timeout = timeout;
178        self
179    }
180
181    /// Set the HTTP request timeout in seconds (convenience method)
182    ///
183    /// # Arguments
184    ///
185    /// * `timeout_seconds` - Maximum time to wait for HTTP responses in seconds
186    ///
187    /// # Example
188    ///
189    /// ```
190    /// use pubmed_client::config::ClientConfig;
191    ///
192    /// let config = ClientConfig::new()
193    ///     .with_timeout_seconds(60);
194    /// ```
195    pub fn with_timeout_seconds(mut self, timeout_seconds: u64) -> Self {
196        self.timeout = Duration::from_secs(timeout_seconds);
197        self
198    }
199
200    /// Set a custom User-Agent string
201    ///
202    /// # Arguments
203    ///
204    /// * `user_agent` - Custom User-Agent for HTTP requests
205    ///
206    /// # Example
207    ///
208    /// ```
209    /// use pubmed_client::config::ClientConfig;
210    ///
211    /// let config = ClientConfig::new()
212    ///     .with_user_agent("MyApp/1.0");
213    /// ```
214    pub fn with_user_agent<S: Into<String>>(mut self, user_agent: S) -> Self {
215        self.user_agent = Some(user_agent.into());
216        self
217    }
218
219    /// Set a custom base URL for NCBI E-utilities
220    ///
221    /// # Arguments
222    ///
223    /// * `base_url` - Base URL for E-utilities API
224    ///
225    /// # Example
226    ///
227    /// ```
228    /// use pubmed_client::config::ClientConfig;
229    ///
230    /// let config = ClientConfig::new()
231    ///     .with_base_url("https://proxy.example.com/eutils");
232    /// ```
233    pub fn with_base_url<S: Into<String>>(mut self, base_url: S) -> Self {
234        self.base_url = Some(base_url.into());
235        self
236    }
237
238    /// Set the base URL for the PMC Open Access Cloud (AWS S3) service.
239    ///
240    /// # Arguments
241    ///
242    /// * `base_url` - Base URL for the PMC OA Cloud service
243    ///
244    /// # Example
245    ///
246    /// ```
247    /// use pubmed_client::config::ClientConfig;
248    ///
249    /// let config = ClientConfig::new()
250    ///     .with_oa_cloud_base_url("https://proxy.example.com/pmc-oa");
251    /// ```
252    pub fn with_oa_cloud_base_url<S: Into<String>>(mut self, base_url: S) -> Self {
253        self.oa_cloud_base_url = Some(base_url.into());
254        self
255    }
256
257    /// Set the maximum number of PMC OA Cloud (AWS S3) files downloaded
258    /// concurrently per article.
259    ///
260    /// # Arguments
261    ///
262    /// * `concurrency` - Maximum simultaneous S3 file downloads (0 is treated as 1)
263    ///
264    /// # Example
265    ///
266    /// ```
267    /// use pubmed_client::config::ClientConfig;
268    ///
269    /// let config = ClientConfig::new()
270    ///     .with_oa_download_concurrency(16);
271    /// ```
272    pub fn with_oa_download_concurrency(mut self, concurrency: usize) -> Self {
273        self.oa_download_concurrency = Some(concurrency);
274        self
275    }
276
277    /// Set email address for NCBI identification
278    ///
279    /// # Arguments
280    ///
281    /// * `email` - Your email address for NCBI contact
282    ///
283    /// # Example
284    ///
285    /// ```
286    /// use pubmed_client::config::ClientConfig;
287    ///
288    /// let config = ClientConfig::new()
289    ///     .with_email("researcher@university.edu");
290    /// ```
291    pub fn with_email<S: Into<String>>(mut self, email: S) -> Self {
292        self.email = Some(email.into());
293        self
294    }
295
296    /// Set tool name for NCBI identification
297    ///
298    /// # Arguments
299    ///
300    /// * `tool` - Your application/tool name
301    ///
302    /// # Example
303    ///
304    /// ```
305    /// use pubmed_client::config::ClientConfig;
306    ///
307    /// let config = ClientConfig::new()
308    ///     .with_tool("BioinformaticsApp");
309    /// ```
310    pub fn with_tool<S: Into<String>>(mut self, tool: S) -> Self {
311        self.tool = Some(tool.into());
312        self
313    }
314
315    /// Set retry configuration for handling transient failures
316    ///
317    /// # Arguments
318    ///
319    /// * `retry_config` - Custom retry configuration
320    ///
321    /// # Example
322    ///
323    /// ```
324    /// use pubmed_client::config::ClientConfig;
325    /// use pubmed_client::retry::RetryConfig;
326    /// use pubmed_client::time::Duration;
327    ///
328    /// let retry_config = RetryConfig::new()
329    ///     .with_max_retries(5)
330    ///     .with_initial_delay(Duration::from_secs(2));
331    ///
332    /// let config = ClientConfig::new()
333    ///     .with_retry_config(retry_config);
334    /// ```
335    pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
336        self.retry_config = retry_config;
337        self
338    }
339
340    /// Enable caching with default configuration
341    ///
342    /// # Example
343    ///
344    /// ```
345    /// use pubmed_client::config::ClientConfig;
346    ///
347    /// let config = ClientConfig::new()
348    ///     .with_cache();
349    /// ```
350    pub fn with_cache(mut self) -> Self {
351        self.cache_config = Some(CacheConfig::default());
352        self
353    }
354
355    /// Set cache configuration
356    ///
357    /// # Arguments
358    ///
359    /// * `cache_config` - Custom cache configuration
360    ///
361    /// # Example
362    ///
363    /// ```
364    /// use pubmed_client::config::ClientConfig;
365    /// use pubmed_client::cache::CacheConfig;
366    ///
367    /// let cache_config = CacheConfig {
368    ///     max_capacity: 5000,
369    ///     ..Default::default()
370    /// };
371    ///
372    /// let config = ClientConfig::new()
373    ///     .with_cache_config(cache_config);
374    /// ```
375    pub fn with_cache_config(mut self, cache_config: CacheConfig) -> Self {
376        self.cache_config = Some(cache_config);
377        self
378    }
379
380    /// Disable all caching
381    ///
382    /// # Example
383    ///
384    /// ```
385    /// use pubmed_client::config::ClientConfig;
386    ///
387    /// let config = ClientConfig::new()
388    ///     .without_cache();
389    /// ```
390    pub fn without_cache(mut self) -> Self {
391        self.cache_config = None;
392        self
393    }
394
395    /// Enable Redis-backed caching.
396    ///
397    /// Requires the `cache-redis` feature.  The cache uses JSON serialisation
398    /// with per-entry TTL (default: 7 days from [`CacheConfig::default`]).
399    ///
400    /// # Arguments
401    ///
402    /// * `url` - Redis connection URL, e.g. `"redis://127.0.0.1/"`
403    ///
404    /// # Example
405    ///
406    /// ```ignore
407    /// use pubmed_client::config::ClientConfig;
408    ///
409    /// let config = ClientConfig::new()
410    ///     .with_redis_cache("redis://127.0.0.1/");
411    /// ```
412    #[cfg(feature = "cache-redis")]
413    pub fn with_redis_cache(mut self, url: impl Into<String>) -> Self {
414        use crate::cache::CacheBackendConfig;
415        let ttl = self
416            .cache_config
417            .as_ref()
418            .map(|c| c.time_to_live)
419            .unwrap_or(CacheConfig::default().time_to_live);
420        self.cache_config = Some(CacheConfig {
421            backend: CacheBackendConfig::Redis { url: url.into() },
422            time_to_live: ttl,
423            ..CacheConfig::default()
424        });
425        self
426    }
427
428    /// Enable SQLite-backed caching.
429    ///
430    /// Requires the `cache-sqlite` feature.  Not available on WASM targets.
431    /// The database file is created automatically if it does not exist.
432    ///
433    /// # Arguments
434    ///
435    /// * `path` - Path to the SQLite database file
436    ///
437    /// # Example
438    ///
439    /// ```ignore
440    /// use pubmed_client::config::ClientConfig;
441    ///
442    /// let config = ClientConfig::new()
443    ///     .with_sqlite_cache("/tmp/pubmed_cache.db");
444    /// ```
445    #[cfg(feature = "cache-sqlite")]
446    pub fn with_sqlite_cache(mut self, path: impl Into<std::path::PathBuf>) -> Self {
447        use crate::cache::CacheBackendConfig;
448        let ttl = self
449            .cache_config
450            .as_ref()
451            .map(|c| c.time_to_live)
452            .unwrap_or(CacheConfig::default().time_to_live);
453        self.cache_config = Some(CacheConfig {
454            backend: CacheBackendConfig::Sqlite { path: path.into() },
455            time_to_live: ttl,
456            ..CacheConfig::default()
457        });
458        self
459    }
460
461    /// Get the effective rate limit based on configuration
462    ///
463    /// Returns the configured rate limit, or the appropriate default
464    /// based on whether an API key is present.
465    ///
466    /// # Returns
467    ///
468    /// - Custom rate limit if set
469    /// - 10.0 requests/second if API key is present
470    /// - 3.0 requests/second if no API key
471    pub fn effective_rate_limit(&self) -> f64 {
472        self.rate_limit.unwrap_or_else(|| {
473            if self.api_key.is_some() {
474                10.0 // NCBI rate limit with API key
475            } else {
476                3.0 // NCBI rate limit without API key
477            }
478        })
479    }
480
481    /// Create a rate limiter based on this configuration
482    ///
483    /// # Returns
484    ///
485    /// A `RateLimiter` configured with the appropriate rate limit
486    ///
487    /// # Example
488    ///
489    /// ```
490    /// use pubmed_client::config::ClientConfig;
491    ///
492    /// let config = ClientConfig::new().with_api_key("your_key");
493    /// let rate_limiter = config.create_rate_limiter();
494    /// ```
495    pub fn create_rate_limiter(&self) -> RateLimiter {
496        RateLimiter::new(self.effective_rate_limit())
497    }
498
499    /// Get the base URL for E-utilities
500    ///
501    /// Returns the configured base URL or the default NCBI E-utilities URL.
502    pub fn effective_base_url(&self) -> &str {
503        self.base_url
504            .as_deref()
505            .unwrap_or("https://eutils.ncbi.nlm.nih.gov/entrez/eutils")
506    }
507
508    /// Get the effective PMC OA Cloud (AWS S3) base URL.
509    ///
510    /// Returns the configured cloud base URL or the default
511    /// `https://pmc-oa-opendata.s3.amazonaws.com`.
512    pub fn effective_oa_cloud_base_url(&self) -> &str {
513        self.oa_cloud_base_url
514            .as_deref()
515            .unwrap_or("https://pmc-oa-opendata.s3.amazonaws.com")
516    }
517
518    /// Get the effective per-article S3 download concurrency.
519    ///
520    /// Returns the configured value (with 0 normalized to 1) or the default of 8.
521    pub fn effective_oa_download_concurrency(&self) -> usize {
522        self.oa_download_concurrency.unwrap_or(8).max(1)
523    }
524
525    /// Get the User-Agent string
526    ///
527    /// Returns the configured User-Agent or a default based on the crate name and version.
528    pub fn effective_user_agent(&self) -> String {
529        self.user_agent.clone().unwrap_or_else(|| {
530            let version = env!("CARGO_PKG_VERSION");
531            format!("pubmed-client/{version}")
532        })
533    }
534
535    /// Get the tool name for NCBI identification
536    ///
537    /// Returns the configured tool name or the default.
538    pub fn effective_tool(&self) -> &str {
539        self.tool.as_deref().unwrap_or("pubmed-client")
540    }
541
542    /// Build query parameters for NCBI API requests
543    ///
544    /// This includes API key, email, and tool parameters when configured.
545    pub fn build_api_params(&self) -> Vec<(String, String)> {
546        let mut params = Vec::new();
547
548        if let Some(ref api_key) = self.api_key {
549            params.push(("api_key".to_string(), api_key.clone()));
550        }
551
552        if let Some(ref email) = self.email {
553            params.push(("email".to_string(), email.clone()));
554        }
555
556        params.push(("tool".to_string(), self.effective_tool().to_string()));
557
558        params
559    }
560}
561
562impl Default for ClientConfig {
563    fn default() -> Self {
564        Self::new()
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use std::mem;
571
572    use super::*;
573
574    #[test]
575    fn test_default_config() {
576        let config = ClientConfig::new();
577        assert!(config.api_key.is_none());
578        assert!(config.rate_limit.is_none());
579        assert_eq!(config.timeout, Duration::from_secs(30));
580        assert_eq!(config.effective_rate_limit(), 3.0);
581    }
582
583    #[test]
584    fn test_config_with_api_key() {
585        let config = ClientConfig::new().with_api_key("test_key");
586        assert_eq!(config.api_key.as_ref().unwrap(), "test_key");
587        assert_eq!(config.effective_rate_limit(), 10.0);
588    }
589
590    #[test]
591    fn test_custom_rate_limit() {
592        let config = ClientConfig::new().with_rate_limit(5.0);
593        assert_eq!(config.effective_rate_limit(), 5.0);
594
595        // Custom rate limit overrides API key default
596        let config_with_key = ClientConfig::new()
597            .with_api_key("test")
598            .with_rate_limit(7.0);
599        assert_eq!(config_with_key.effective_rate_limit(), 7.0);
600    }
601
602    #[test]
603    fn test_invalid_rate_limit() {
604        let config = ClientConfig::new().with_rate_limit(-1.0);
605        assert!(config.rate_limit.is_none());
606        assert_eq!(config.effective_rate_limit(), 3.0);
607    }
608
609    #[test]
610    fn test_fluent_interface() {
611        let config = ClientConfig::new()
612            .with_api_key("test_key")
613            .with_rate_limit(5.0)
614            .with_timeout(Duration::from_secs(60))
615            .with_email("test@example.com")
616            .with_tool("TestApp");
617
618        assert_eq!(config.api_key.as_ref().unwrap(), "test_key");
619        assert_eq!(config.effective_rate_limit(), 5.0);
620        assert_eq!(config.timeout, Duration::from_secs(60));
621        assert_eq!(config.email.as_ref().unwrap(), "test@example.com");
622        assert_eq!(config.effective_tool(), "TestApp");
623    }
624
625    #[test]
626    fn test_api_params() {
627        let config = ClientConfig::new()
628            .with_api_key("test_key")
629            .with_email("test@example.com")
630            .with_tool("TestApp");
631
632        let params = config.build_api_params();
633        assert_eq!(params.len(), 3);
634
635        assert!(params.contains(&("api_key".to_string(), "test_key".to_string())));
636        assert!(params.contains(&("email".to_string(), "test@example.com".to_string())));
637        assert!(params.contains(&("tool".to_string(), "TestApp".to_string())));
638    }
639
640    #[test]
641    fn test_effective_values() {
642        let config = ClientConfig::new();
643
644        assert_eq!(
645            config.effective_base_url(),
646            "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
647        );
648        assert!(config.effective_user_agent().starts_with("pubmed-client/"));
649        assert_eq!(config.effective_tool(), "pubmed-client");
650    }
651
652    #[test]
653    fn test_oa_download_concurrency() {
654        // Default when unset.
655        assert_eq!(ClientConfig::new().effective_oa_download_concurrency(), 8);
656        // Custom value is honored.
657        assert_eq!(
658            ClientConfig::new()
659                .with_oa_download_concurrency(16)
660                .effective_oa_download_concurrency(),
661            16
662        );
663        // Zero is normalized to sequential (1).
664        assert_eq!(
665            ClientConfig::new()
666                .with_oa_download_concurrency(0)
667                .effective_oa_download_concurrency(),
668            1
669        );
670    }
671
672    #[test]
673    fn test_rate_limiter_creation() {
674        let config = ClientConfig::new().with_rate_limit(5.0);
675        let rate_limiter = config.create_rate_limiter();
676        // The rate limiter creation should succeed
677        assert!(mem::size_of_val(&rate_limiter) > 0);
678    }
679}