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}