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}