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", &params).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, &params).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}