pubmed_client/pubmed/client/mod.rs
1mod citmatch;
2mod egquery;
3mod einfo;
4mod elink;
5mod espell;
6mod history;
7mod summary;
8mod xml_text;
9
10use std::time::Duration;
11
12use crate::common::PubMedId;
13use crate::config::ClientConfig;
14use crate::error::{ParseError, PubMedError, Result};
15use crate::pubmed::models::PubMedArticle;
16use crate::pubmed::parser::parse_articles_from_xml;
17use crate::pubmed::query::SortOrder;
18use crate::pubmed::responses::ESearchResult;
19use crate::rate_limit::RateLimiter;
20use crate::request::RequestExecutor;
21use crate::tls::install_default_crypto_provider;
22use reqwest::{Client, Response};
23use tracing::{debug, info, instrument, warn};
24
25/// Client for interacting with PubMed API
26#[derive(Clone)]
27pub struct PubMedClient {
28 client: Client,
29 pub(crate) base_url: String,
30 rate_limiter: RateLimiter,
31 config: ClientConfig,
32}
33
34impl PubMedClient {
35 /// Create a search query builder for this client
36 ///
37 /// # Example
38 ///
39 /// ```no_run
40 /// use pubmed_client::PubMedClient;
41 ///
42 /// #[tokio::main]
43 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
44 /// let client = PubMedClient::new();
45 /// let articles = client
46 /// .search()
47 /// .query("covid-19 treatment")
48 /// .free_full_text_only()
49 /// .published_after(2020)
50 /// .limit(10)
51 /// .search_and_fetch(&client)
52 /// .await?;
53 ///
54 /// println!("Found {} articles", articles.len());
55 /// Ok(())
56 /// }
57 /// ```
58 pub fn search(&self) -> super::query::SearchQuery {
59 super::query::SearchQuery::new()
60 }
61
62 /// Create a new PubMed client with default configuration
63 ///
64 /// Uses default NCBI rate limiting (3 requests/second) and no API key.
65 /// For production use, consider using `with_config()` to set an API key.
66 ///
67 /// # Example
68 ///
69 /// ```
70 /// use pubmed_client::PubMedClient;
71 ///
72 /// let client = PubMedClient::new();
73 /// ```
74 pub fn new() -> Self {
75 let config = ClientConfig::new();
76 Self::with_config(config)
77 }
78
79 /// Create a new PubMed client with custom configuration
80 ///
81 /// # Arguments
82 ///
83 /// * `config` - Client configuration including rate limits, API key, etc.
84 ///
85 /// # Example
86 ///
87 /// ```
88 /// use pubmed_client::{PubMedClient, ClientConfig};
89 ///
90 /// let config = ClientConfig::new()
91 /// .with_api_key("your_api_key_here")
92 /// .with_email("researcher@university.edu");
93 ///
94 /// let client = PubMedClient::with_config(config);
95 /// ```
96 pub fn with_config(config: ClientConfig) -> Self {
97 let rate_limiter = config.create_rate_limiter();
98 let base_url = config.effective_base_url().to_string();
99
100 // rustls has no built-in provider under `rustls-tls`; install one first.
101 install_default_crypto_provider();
102
103 // reqwest's client builder only fails if the TLS backend cannot be
104 // initialized — an unrecoverable process-level environment error — so
105 // this infallible public constructor is allowed to `expect` here.
106 #[allow(clippy::expect_used)]
107 let client = {
108 #[cfg(not(target_arch = "wasm32"))]
109 {
110 Client::builder()
111 .user_agent(config.effective_user_agent())
112 .timeout(Duration::from_secs(config.timeout.as_secs()))
113 .build()
114 .expect("Failed to create HTTP client")
115 }
116
117 #[cfg(target_arch = "wasm32")]
118 {
119 Client::builder()
120 .user_agent(config.effective_user_agent())
121 .build()
122 .expect("Failed to create HTTP client")
123 }
124 };
125
126 Self {
127 client,
128 base_url,
129 rate_limiter,
130 config,
131 }
132 }
133
134 /// Create a new PubMed client with custom HTTP client and default configuration
135 ///
136 /// # Arguments
137 ///
138 /// * `client` - Custom reqwest client with specific configuration
139 ///
140 /// # Example
141 ///
142 /// ```
143 /// use pubmed_client::PubMedClient;
144 /// use reqwest::Client;
145 /// use std::time::Duration;
146 ///
147 /// let http_client = Client::builder()
148 /// .timeout(Duration::from_secs(30))
149 /// .build()
150 /// .unwrap();
151 ///
152 /// let client = PubMedClient::with_client(http_client);
153 /// ```
154 pub fn with_client(client: Client) -> Self {
155 let config = ClientConfig::new();
156 let rate_limiter = config.create_rate_limiter();
157 let base_url = config.effective_base_url().to_string();
158
159 Self {
160 client,
161 base_url,
162 rate_limiter,
163 config,
164 }
165 }
166
167 /// Get a reference to the client configuration
168 pub(crate) fn config(&self) -> &ClientConfig {
169 &self.config
170 }
171
172 /// Build a request executor borrowing this client's HTTP client, rate limiter, and config.
173 pub(crate) fn executor(&self) -> RequestExecutor<'_> {
174 RequestExecutor::new(&self.client, &self.rate_limiter, &self.config)
175 }
176
177 /// GET an E-utilities endpoint relative to the configured base URL.
178 ///
179 /// Builds the URL from `params` plus the API parameters (api_key / email /
180 /// tool), then performs a rate-limited, retrying request and returns a
181 /// success-checked response.
182 pub(crate) async fn get_eutils(
183 &self,
184 endpoint: &str,
185 params: &[(&str, &str)],
186 ) -> Result<Response> {
187 self.executor()
188 .get_endpoint(&self.base_url, endpoint, params)
189 .await
190 }
191
192 /// Fetch article metadata by PMID with full details including abstract
193 ///
194 /// # Arguments
195 ///
196 /// * `pmid` - PubMed ID as a string
197 ///
198 /// # Returns
199 ///
200 /// Returns a `Result<PubMedArticle>` containing the article metadata with abstract
201 ///
202 /// # Errors
203 ///
204 /// * `ParseError::ArticleNotFound` - If the article is not found
205 /// * `PubMedError::RequestError` - If the HTTP request fails
206 /// * `ParseError::JsonError` - If JSON parsing fails
207 ///
208 /// # Example
209 ///
210 /// ```no_run
211 /// use pubmed_client::PubMedClient;
212 ///
213 /// #[tokio::main]
214 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
215 /// let client = PubMedClient::new();
216 /// let article = client.fetch_article("31978945").await?;
217 /// println!("Title: {}", article.title);
218 /// if let Some(abstract_text) = &article.abstract_text {
219 /// println!("Abstract: {}", abstract_text);
220 /// }
221 /// Ok(())
222 /// }
223 /// ```
224 #[instrument(skip(self), fields(pmid = %pmid))]
225 pub async fn fetch_article(&self, pmid: &str) -> Result<PubMedArticle> {
226 let mut articles = self.fetch_articles(&[pmid]).await?;
227
228 if articles.len() == 1 {
229 Ok(articles.remove(0))
230 } else {
231 // Try to find by PMID in case batch returned extra/different articles
232 let idx = articles.iter().position(|a| a.pmid == pmid);
233 match idx {
234 Some(i) => Ok(articles.remove(i)),
235 None => Err(ParseError::ArticleNotFound {
236 pmid: pmid.to_string(),
237 }
238 .into()),
239 }
240 }
241 }
242
243 /// Search for articles using a query string
244 ///
245 /// # Arguments
246 ///
247 /// * `query` - Search query string
248 /// * `limit` - Maximum number of results to return
249 /// * `sort` - Optional sort order for results
250 ///
251 /// # Returns
252 ///
253 /// Returns a `Result<Vec<String>>` containing PMIDs of matching articles
254 ///
255 /// # Errors
256 ///
257 /// * `PubMedError::RequestError` - If the HTTP request fails
258 /// * `ParseError::JsonError` - If JSON parsing fails
259 ///
260 /// # Example
261 ///
262 /// ```no_run
263 /// use pubmed_client::PubMedClient;
264 ///
265 /// #[tokio::main]
266 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
267 /// let client = PubMedClient::new();
268 /// let pmids = client.search_articles("covid-19 treatment", 10, None).await?;
269 /// println!("Found {} articles", pmids.len());
270 /// Ok(())
271 /// }
272 /// ```
273 #[instrument(skip(self, sort), fields(query = %query, limit = limit))]
274 pub async fn search_articles(
275 &self,
276 query: &str,
277 limit: usize,
278 sort: Option<&SortOrder>,
279 ) -> Result<Vec<String>> {
280 // PubMed limits: retstart cannot exceed 9998, and retmax is capped at 9999
281 // This means we can only retrieve the first 9,999 results (indices 0-9998)
282 const MAX_RETRIEVABLE: usize = 9999;
283
284 if limit > MAX_RETRIEVABLE {
285 return Err(PubMedError::SearchLimitExceeded {
286 requested: limit,
287 maximum: MAX_RETRIEVABLE,
288 });
289 }
290
291 if query.trim().is_empty() {
292 debug!("Empty query provided, returning empty results");
293 return Ok(Vec::new());
294 }
295
296 let limit_str = limit.to_string();
297 let mut params = vec![
298 ("db", "pubmed"),
299 ("term", query),
300 ("retmax", limit_str.as_str()),
301 ("retstart", "0"),
302 ("retmode", "json"),
303 ];
304 if let Some(sort_order) = sort {
305 params.push(("sort", sort_order.as_api_param()));
306 }
307
308 debug!("Making initial ESearch API request");
309 let response = self.get_eutils("esearch.fcgi", ¶ms).await?;
310
311 let search_result: ESearchResult = response.json().await?;
312
313 // Check for API error response (NCBI sometimes returns 200 OK with ERROR field)
314 if let Some(error_msg) = &search_result.esearchresult.error {
315 return Err(PubMedError::ApiError {
316 status: 200,
317 message: format!("NCBI ESearch API error: {}", error_msg),
318 });
319 }
320
321 let total_count: usize = search_result
322 .esearchresult
323 .count
324 .as_ref()
325 .and_then(|c| c.parse().ok())
326 .unwrap_or(0);
327
328 if total_count >= limit {
329 warn!(
330 "Total results ({}) exceed or equal requested limit ({}). Only the first {} results can be retrieved.",
331 total_count, limit, MAX_RETRIEVABLE
332 );
333 }
334
335 Ok(search_result.esearchresult.idlist)
336 }
337
338 /// Fetch multiple articles by PMIDs in a single batch request
339 ///
340 /// This method sends a single EFetch request with multiple PMIDs (comma-separated),
341 /// which is significantly more efficient than fetching articles one by one.
342 /// For large numbers of PMIDs, the request is automatically split into batches.
343 ///
344 /// # Arguments
345 ///
346 /// * `pmids` - Slice of PubMed IDs as strings
347 ///
348 /// # Returns
349 ///
350 /// Returns a `Result<Vec<PubMedArticle>>` containing articles with metadata.
351 /// Articles that fail to parse are skipped (logged via tracing).
352 ///
353 /// # Example
354 ///
355 /// ```no_run
356 /// use pubmed_client::PubMedClient;
357 ///
358 /// #[tokio::main]
359 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
360 /// let client = PubMedClient::new();
361 /// let articles = client.fetch_articles(&["31978945", "33515491", "25760099"]).await?;
362 /// for article in &articles {
363 /// println!("{}: {}", article.pmid, article.title);
364 /// }
365 /// Ok(())
366 /// }
367 /// ```
368 #[instrument(skip(self), fields(pmids_count = pmids.len()))]
369 pub async fn fetch_articles(&self, pmids: &[&str]) -> Result<Vec<PubMedArticle>> {
370 self.batch_fetch_pmids(
371 pmids,
372 "efetch.fcgi",
373 &[("retmode", "xml"), ("rettype", "abstract")],
374 |xml| Ok(parse_articles_from_xml(xml)?),
375 )
376 .await
377 }
378
379 /// Fetch records for many PMIDs by chunking them into NCBI-sized batches.
380 ///
381 /// Validates every PMID upfront, then splits the request into batches of up to
382 /// 200 IDs (per NCBI guidance), issues one E-utilities call per batch against
383 /// `endpoint` (with `extra_params` appended to the shared `db`/`id` pair), and
384 /// parses each response body with `parse_fn`. Empty response bodies are skipped.
385 pub(crate) async fn batch_fetch_pmids<T, F>(
386 &self,
387 pmids: &[&str],
388 endpoint: &str,
389 extra_params: &[(&str, &str)],
390 parse_fn: F,
391 ) -> Result<Vec<T>>
392 where
393 F: Fn(&str) -> Result<Vec<T>>,
394 {
395 if pmids.is_empty() {
396 return Ok(Vec::new());
397 }
398
399 // Validate all PMIDs upfront
400 let validated: Vec<u32> = pmids
401 .iter()
402 .map(|pmid| {
403 PubMedId::parse(pmid)
404 .map(|p| p.as_u32())
405 .map_err(PubMedError::from)
406 })
407 .collect::<Result<Vec<_>>>()?;
408
409 // NCBI recommends batches of up to 200 IDs per request
410 const BATCH_SIZE: usize = 200;
411
412 let mut all_items = Vec::with_capacity(pmids.len());
413
414 for chunk in validated.chunks(BATCH_SIZE) {
415 let id_list: String = chunk
416 .iter()
417 .map(|id| id.to_string())
418 .collect::<Vec<_>>()
419 .join(",");
420
421 let mut params: Vec<(&str, &str)> = vec![("db", "pubmed"), ("id", id_list.as_str())];
422 params.extend_from_slice(extra_params);
423
424 debug!(
425 batch_size = chunk.len(),
426 endpoint, "Making batch E-utilities API request"
427 );
428 let response = self.get_eutils(endpoint, ¶ms).await?;
429 let body = response.text().await?;
430
431 if body.trim().is_empty() {
432 continue;
433 }
434
435 let items = parse_fn(&body)?;
436 info!(
437 requested = chunk.len(),
438 parsed = items.len(),
439 endpoint,
440 "Batch fetch completed"
441 );
442 all_items.extend(items);
443 }
444
445 Ok(all_items)
446 }
447
448 /// Search and fetch multiple articles with metadata
449 ///
450 /// Uses batch fetching internally for efficient retrieval.
451 ///
452 /// # Arguments
453 ///
454 /// * `query` - Search query string
455 /// * `limit` - Maximum number of articles to fetch
456 ///
457 /// # Returns
458 ///
459 /// Returns a `Result<Vec<PubMedArticle>>` containing articles with metadata
460 ///
461 /// # Example
462 ///
463 /// ```no_run
464 /// use pubmed_client::PubMedClient;
465 ///
466 /// #[tokio::main]
467 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
468 /// let client = PubMedClient::new();
469 /// let articles = client.search_and_fetch("covid-19", 5, None).await?;
470 /// for article in articles {
471 /// println!("{}: {}", article.pmid, article.title);
472 /// }
473 /// Ok(())
474 /// }
475 /// ```
476 pub async fn search_and_fetch(
477 &self,
478 query: &str,
479 limit: usize,
480 sort: Option<&SortOrder>,
481 ) -> Result<Vec<PubMedArticle>> {
482 let pmids = self.search_articles(query, limit, sort).await?;
483
484 let pmid_refs: Vec<&str> = pmids.iter().map(|s| s.as_str()).collect();
485 self.fetch_articles(&pmid_refs).await
486 }
487}
488
489impl Default for PubMedClient {
490 fn default() -> Self {
491 Self::new()
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use std::{
498 mem,
499 time::{Duration, Instant},
500 };
501
502 use super::*;
503
504 #[test]
505 fn test_client_config_rate_limiting() {
506 // Test default configuration (no API key)
507 let config = ClientConfig::new();
508 assert_eq!(config.effective_rate_limit(), 3.0);
509
510 // Test with API key
511 let config_with_key = ClientConfig::new().with_api_key("test_key");
512 assert_eq!(config_with_key.effective_rate_limit(), 10.0);
513
514 // Test custom rate limit
515 let config_custom = ClientConfig::new().with_rate_limit(5.0);
516 assert_eq!(config_custom.effective_rate_limit(), 5.0);
517
518 // Test custom rate limit overrides API key default
519 let config_override = ClientConfig::new()
520 .with_api_key("test_key")
521 .with_rate_limit(7.0);
522 assert_eq!(config_override.effective_rate_limit(), 7.0);
523 }
524
525 #[test]
526 fn test_client_api_params() {
527 let config = ClientConfig::new()
528 .with_api_key("test_key_123")
529 .with_email("test@example.com")
530 .with_tool("TestTool");
531
532 let params = config.build_api_params();
533
534 // Should have 3 parameters
535 assert_eq!(params.len(), 3);
536
537 // Check each parameter
538 assert!(params.contains(&("api_key".to_string(), "test_key_123".to_string())));
539 assert!(params.contains(&("email".to_string(), "test@example.com".to_string())));
540 assert!(params.contains(&("tool".to_string(), "TestTool".to_string())));
541 }
542
543 #[test]
544 fn test_config_effective_values() {
545 let config = ClientConfig::new()
546 .with_email("test@example.com")
547 .with_tool("TestApp");
548
549 assert_eq!(
550 config.effective_base_url(),
551 "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
552 );
553 assert!(config.effective_user_agent().starts_with("pubmed-client/"));
554 assert_eq!(config.effective_tool(), "TestApp");
555 }
556
557 #[test]
558 fn test_rate_limiter_creation_from_config() {
559 let config = ClientConfig::new()
560 .with_api_key("test_key")
561 .with_rate_limit(8.0);
562
563 let rate_limiter = config.create_rate_limiter();
564
565 // Rate limiter should be created successfully
566 // We can't easily test the exact rate without async context,
567 // but we can verify it was created
568 assert!(mem::size_of_val(&rate_limiter) > 0);
569 }
570
571 #[tokio::test]
572 async fn test_invalid_pmid_rate_limiting() {
573 let config = ClientConfig::new().with_rate_limit(5.0);
574 let client = PubMedClient::with_config(config);
575
576 // Invalid PMID should fail before rate limiting (validation happens first)
577 let start = Instant::now();
578 let result = client.fetch_article("invalid_pmid").await;
579 assert!(result.is_err());
580
581 let elapsed = start.elapsed();
582 // Should fail quickly without consuming rate limit token
583 assert!(elapsed < Duration::from_millis(100));
584 }
585
586 #[tokio::test]
587 async fn test_fetch_articles_empty_input() {
588 let client = PubMedClient::new();
589
590 let result = client.fetch_articles(&[]).await;
591 assert!(result.is_ok());
592 assert!(result.unwrap().is_empty());
593 }
594
595 #[tokio::test]
596 async fn test_fetch_articles_invalid_pmid() {
597 let client = PubMedClient::new();
598
599 let result = client.fetch_articles(&["not_a_number"]).await;
600 assert!(result.is_err());
601 }
602
603 #[tokio::test]
604 async fn test_fetch_articles_validates_all_pmids_before_request() {
605 let client = PubMedClient::new();
606
607 // Mix of valid and invalid - should fail on validation before any network request
608 let start = Instant::now();
609 let result = client
610 .fetch_articles(&["31978945", "invalid", "33515491"])
611 .await;
612 assert!(result.is_err());
613
614 // Should fail quickly (validation only, no network)
615 let elapsed = start.elapsed();
616 assert!(elapsed < Duration::from_millis(100));
617 }
618}