pubmed_client/pmc/client.rs
1use std::time::Duration;
2
3use crate::cache::{PmcCache, create_cache};
4use crate::common::PubMedId;
5use crate::config::ClientConfig;
6use crate::error::Result;
7use crate::pmc::extracted::ExtractedFigure;
8use crate::pmc::oa_api;
9use crate::pmc::oa_api::OaSubsetInfo;
10use crate::pmc::parser::parse_pmc_xml;
11use crate::rate_limit::RateLimiter;
12use crate::request::RequestExecutor;
13use crate::tls::install_default_crypto_provider;
14use pubmed_parser::pmc::PmcArticle;
15use reqwest::Client;
16use tracing::info;
17
18#[cfg(not(target_arch = "wasm32"))]
19use {crate::pmc::cloud::PmcCloudClient, std::path::Path};
20
21use super::common;
22
23/// Client for interacting with PMC (PubMed Central) API
24#[derive(Clone)]
25pub struct PmcClient {
26 client: Client,
27 base_url: String,
28 rate_limiter: RateLimiter,
29 config: ClientConfig,
30 #[cfg(not(target_arch = "wasm32"))]
31 cloud_client: PmcCloudClient,
32 cache: Option<PmcCache>,
33}
34
35impl PmcClient {
36 /// Create a new PMC client with default configuration
37 ///
38 /// Uses default NCBI rate limiting (3 requests/second) and no API key.
39 /// For production use, consider using `with_config()` to set an API key.
40 ///
41 /// # Example
42 ///
43 /// ```
44 /// use pubmed_client::PmcClient;
45 ///
46 /// let client = PmcClient::new();
47 /// ```
48 pub fn new() -> Self {
49 let config = ClientConfig::new();
50 Self::with_config(config)
51 }
52
53 pub fn get_pmc_config(&self) -> &ClientConfig {
54 &self.config
55 }
56
57 #[cfg(not(target_arch = "wasm32"))]
58 pub fn get_cloud_client_config(&self) -> &ClientConfig {
59 &self.cloud_client.config
60 }
61
62 /// Create a new PMC client with custom configuration
63 ///
64 /// # Arguments
65 ///
66 /// * `config` - Client configuration including rate limits, API key, etc.
67 ///
68 /// # Example
69 ///
70 /// ```
71 /// use pubmed_client::{PmcClient, ClientConfig};
72 ///
73 /// let config = ClientConfig::new()
74 /// .with_api_key("your_api_key_here")
75 /// .with_email("researcher@university.edu");
76 ///
77 /// let client = PmcClient::with_config(config);
78 /// ```
79 pub fn with_config(config: ClientConfig) -> Self {
80 let rate_limiter = config.create_rate_limiter();
81 let base_url = config.effective_base_url().to_string();
82
83 // rustls has no built-in provider under `rustls-tls`; install one first.
84 install_default_crypto_provider();
85
86 // reqwest's client builder only fails if the TLS backend cannot be
87 // initialized — an unrecoverable process-level environment error — so
88 // this infallible public constructor is allowed to `expect` here.
89 #[allow(clippy::expect_used)]
90 let client = {
91 #[cfg(not(target_arch = "wasm32"))]
92 {
93 Client::builder()
94 .user_agent(config.effective_user_agent())
95 .timeout(Duration::from_secs(config.timeout.as_secs()))
96 .build()
97 .expect("Failed to create HTTP client")
98 }
99
100 #[cfg(target_arch = "wasm32")]
101 {
102 Client::builder()
103 .user_agent(config.effective_user_agent())
104 .build()
105 .expect("Failed to create HTTP client")
106 }
107 };
108
109 let cache = config.cache_config.as_ref().map(create_cache);
110
111 Self {
112 #[cfg(not(target_arch = "wasm32"))]
113 cloud_client: PmcCloudClient::with_shared(
114 client.clone(),
115 rate_limiter.clone(),
116 config.clone(),
117 ),
118 client,
119 base_url,
120 rate_limiter,
121 cache,
122 config,
123 }
124 }
125
126 /// Create a new PMC client with custom HTTP client and default configuration
127 ///
128 /// # Arguments
129 ///
130 /// * `client` - Custom reqwest client with specific configuration
131 ///
132 /// # Example
133 ///
134 /// ```
135 /// use pubmed_client::PmcClient;
136 /// use reqwest::Client;
137 /// use std::time::Duration;
138 ///
139 /// let http_client = Client::builder()
140 /// .timeout(Duration::from_secs(30))
141 /// .build()
142 /// .unwrap();
143 ///
144 /// let client = PmcClient::with_client(http_client);
145 /// ```
146 pub fn with_client(client: Client) -> Self {
147 let config = ClientConfig::new();
148 let rate_limiter = config.create_rate_limiter();
149 let base_url = config.effective_base_url().to_string();
150
151 Self {
152 #[cfg(not(target_arch = "wasm32"))]
153 cloud_client: PmcCloudClient::with_shared(
154 client.clone(),
155 rate_limiter.clone(),
156 config.clone(),
157 ),
158 client,
159 base_url,
160 rate_limiter,
161 cache: None,
162 config,
163 }
164 }
165
166 /// Set a custom base URL for the PMC API
167 ///
168 /// # Arguments
169 ///
170 /// * `base_url` - The base URL for the PMC API
171 pub fn with_base_url(mut self, base_url: String) -> Self {
172 self.base_url = base_url;
173 self
174 }
175
176 /// Fetch full text from PMC using PMCID
177 ///
178 /// # Arguments
179 ///
180 /// * `pmcid` - PMC ID (with or without "PMC" prefix)
181 ///
182 /// # Returns
183 ///
184 /// Returns a `Result<PmcArticle>` containing the structured full text
185 ///
186 /// # Errors
187 ///
188 /// * `ParseError::PmcNotAvailable` - If PMC full text is not available
189 /// * `PubMedError::RequestError` - If the HTTP request fails
190 /// * `ParseError::XmlError` - If XML parsing fails
191 ///
192 /// # Example
193 ///
194 /// ```no_run
195 /// use pubmed_client::PmcClient;
196 ///
197 /// #[tokio::main]
198 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
199 /// let client = PmcClient::new();
200 /// let full_text = client.fetch_full_text("PMC7906746").await?;
201 /// println!("Title: {}", full_text.title().unwrap_or("Untitled"));
202 /// println!("Sections: {}", full_text.sections().len());
203 /// Ok(())
204 /// }
205 /// ```
206 pub async fn fetch_full_text(&self, pmcid: &str) -> Result<PmcArticle> {
207 let normalized_pmcid = common::normalize_pmcid(pmcid);
208 let cache_key = format!("pmc:{}", normalized_pmcid);
209
210 // Check cache first if available
211 if let Some(cache) = &self.cache
212 && let Some(cached) = cache.get(&cache_key).await
213 {
214 info!(pmcid = %normalized_pmcid, "Cache hit for PMC full text");
215 return Ok(cached);
216 }
217
218 // Fetch from API if not cached
219 let xml_content = self.fetch_xml(pmcid).await?;
220 let full_text = parse_pmc_xml(&xml_content, &normalized_pmcid)?;
221
222 // Store in cache if available
223 if let Some(cache) = &self.cache {
224 cache.insert(cache_key, full_text.clone()).await;
225 }
226
227 Ok(full_text)
228 }
229
230 /// Fetch raw XML content from PMC
231 ///
232 /// # Arguments
233 ///
234 /// * `pmcid` - PMC ID (with or without "PMC" prefix)
235 ///
236 /// # Returns
237 ///
238 /// Returns a `Result<String>` containing the raw XML content
239 pub async fn fetch_xml(&self, pmcid: &str) -> Result<String> {
240 common::fetch_pmc_xml(&self.executor(), &self.base_url, pmcid).await
241 }
242
243 /// Check if PMC full text is available for a given PMID
244 ///
245 /// # Arguments
246 ///
247 /// * `pmid` - PubMed ID
248 ///
249 /// # Returns
250 ///
251 /// Returns `Result<Option<String>>` containing the PMCID if available
252 ///
253 /// # Example
254 ///
255 /// ```no_run
256 /// use pubmed_client::PmcClient;
257 ///
258 /// #[tokio::main]
259 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
260 /// let client = PmcClient::new();
261 /// if let Some(pmcid) = client.check_pmc_availability("33515491").await? {
262 /// println!("PMC available: {}", pmcid);
263 /// let full_text = client.fetch_full_text(&pmcid).await?;
264 /// println!("Title: {}", full_text.title().unwrap_or("Untitled"));
265 /// } else {
266 /// println!("PMC not available");
267 /// }
268 /// Ok(())
269 /// }
270 /// ```
271 pub async fn check_pmc_availability(&self, pmid: &str) -> Result<Option<String>> {
272 // Validate and parse PMID
273 let pmid_obj = PubMedId::parse(pmid)?;
274 let pmid_value = pmid_obj.as_u32().to_string();
275
276 let response = self
277 .executor()
278 .get_endpoint(
279 &self.base_url,
280 "elink.fcgi",
281 &[
282 ("dbfrom", "pubmed"),
283 ("db", "pmc"),
284 ("id", pmid_value.as_str()),
285 ("retmode", "json"),
286 ],
287 )
288 .await?;
289
290 let link_result: serde_json::Value = response.json().await?;
291
292 // Extract PMCID from response
293 if let Some(linksets) = link_result["linksets"].as_array() {
294 for linkset in linksets {
295 if let Some(linksetdbs) = linkset["linksetdbs"].as_array() {
296 for linksetdb in linksetdbs {
297 if linksetdb["dbto"] == "pmc"
298 && let Some(links) = linksetdb["links"].as_array()
299 && let Some(pmcid) = links.first().and_then(json_uid)
300 {
301 return Ok(Some(format!("PMC{pmcid}")));
302 }
303 }
304 }
305 }
306 }
307 Ok(None)
308 }
309
310 /// Check if a PMC article is in the OA (Open Access) subset
311 ///
312 /// The OA subset contains articles with programmatic access to full-text XML.
313 /// Some publishers restrict programmatic access even though the article may be
314 /// viewable on the PMC website.
315 ///
316 /// # Arguments
317 ///
318 /// * `pmcid` - PMC ID (with or without "PMC" prefix)
319 ///
320 /// # Returns
321 ///
322 /// Returns `Result<OaSubsetInfo>` containing detailed information about OA availability
323 ///
324 /// # Example
325 ///
326 /// ```no_run
327 /// use pubmed_client::PmcClient;
328 ///
329 /// #[tokio::main]
330 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
331 /// let client = PmcClient::new();
332 /// let oa_info = client.is_oa_subset("PMC7906746").await?;
333 ///
334 /// if oa_info.is_oa_subset {
335 /// println!("Article is in OA subset");
336 /// if let Some(link) = oa_info.download_link {
337 /// println!("Download: {}", link);
338 /// }
339 /// } else {
340 /// println!("Article is NOT in OA subset");
341 /// if let Some(code) = oa_info.error_code {
342 /// println!("Reason: {}", code);
343 /// }
344 /// }
345 /// Ok(())
346 /// }
347 /// ```
348 pub async fn is_oa_subset(&self, pmcid: &str) -> Result<OaSubsetInfo> {
349 let url = oa_api::build_oa_api_url(pmcid)?;
350
351 let response = self.executor().get(&url).await?;
352
353 let xml_content = response.text().await?;
354
355 // Parse the OA API XML response
356 Ok(oa_api::parse_oa_response(&xml_content, pmcid)?)
357 }
358
359 /// Download a PMC article's files from the PMC OA Cloud (AWS S3) service
360 ///
361 /// # Arguments
362 ///
363 /// * `pmcid` - PMC ID (with or without "PMC" prefix)
364 /// * `output_dir` - Directory to download the article's files into
365 ///
366 /// # Returns
367 ///
368 /// Returns a `Result<Vec<String>>` containing the list of downloaded file paths
369 ///
370 /// # Errors
371 ///
372 /// * `ParseError::InvalidPmid` - If the PMCID format is invalid
373 /// * `PubMedError::RequestError` - If the HTTP request fails
374 /// * `ParseError::IoError` - If file operations fail
375 /// * `ParseError::PmcNotAvailable` - If the article is not available in OA
376 ///
377 /// # Example
378 ///
379 /// ```no_run
380 /// use pubmed_client::PmcClient;
381 /// use std::path::Path;
382 ///
383 /// #[tokio::main]
384 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
385 /// let client = PmcClient::new();
386 /// let output_dir = Path::new("./extracted_articles");
387 /// let files = client.download_files("PMC7906746", output_dir).await?;
388 ///
389 /// for file in files {
390 /// println!("Downloaded: {}", file);
391 /// }
392 /// Ok(())
393 /// }
394 /// ```
395 #[cfg(not(target_arch = "wasm32"))]
396 pub async fn download_files<P: AsRef<Path>>(
397 &self,
398 pmcid: &str,
399 output_dir: P,
400 ) -> Result<Vec<String>> {
401 self.cloud_client.download_files(pmcid, output_dir).await
402 }
403
404 /// Download the article's files and match figures with their captions from XML
405 ///
406 /// # Arguments
407 ///
408 /// * `pmcid` - PMC ID (with or without "PMC" prefix)
409 /// * `output_dir` - Directory to download the article's files into
410 ///
411 /// # Returns
412 ///
413 /// Returns a `Result<Vec<ExtractedFigure>>` containing figures with both XML metadata and file paths
414 ///
415 /// # Errors
416 ///
417 /// * `ParseError::InvalidPmid` - If the PMCID format is invalid
418 /// * `PubMedError::RequestError` - If the HTTP request fails
419 /// * `ParseError::IoError` - If file operations fail
420 /// * `ParseError::PmcNotAvailable` - If the article is not available in OA
421 ///
422 /// # Example
423 ///
424 /// ```no_run
425 /// use pubmed_client::PmcClient;
426 /// use std::path::Path;
427 ///
428 /// #[tokio::main]
429 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
430 /// let client = PmcClient::new();
431 /// let output_dir = Path::new("./extracted_articles");
432 /// let figures = client.extract_figures_with_captions("PMC7906746", output_dir).await?;
433 ///
434 /// for figure in figures {
435 /// println!("Figure {}: {:?}", figure.figure.id, figure.figure.caption);
436 /// println!("File: {}", figure.extracted_file_path);
437 /// }
438 /// Ok(())
439 /// }
440 /// ```
441 #[cfg(not(target_arch = "wasm32"))]
442 pub async fn extract_figures_with_captions<P: AsRef<Path>>(
443 &self,
444 pmcid: &str,
445 output_dir: P,
446 ) -> Result<Vec<ExtractedFigure>> {
447 self.cloud_client
448 .extract_figures_with_captions(pmcid, output_dir)
449 .await
450 }
451
452 /// Clear all cached PMC data
453 ///
454 /// # Example
455 ///
456 /// ```no_run
457 /// use pubmed_client::PmcClient;
458 ///
459 /// #[tokio::main]
460 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
461 /// let client = PmcClient::new();
462 /// client.clear_cache().await;
463 /// Ok(())
464 /// }
465 /// ```
466 pub async fn clear_cache(&self) {
467 if let Some(cache) = &self.cache {
468 cache.clear().await;
469 info!("Cleared PMC cache");
470 }
471 }
472
473 /// Get cache statistics
474 ///
475 /// Returns the number of items in cache, or 0 if caching is disabled
476 ///
477 /// # Example
478 ///
479 /// ```
480 /// use pubmed_client::PmcClient;
481 ///
482 /// let client = PmcClient::new();
483 /// let count = client.cache_entry_count();
484 /// println!("Cache entries: {}", count);
485 /// ```
486 pub fn cache_entry_count(&self) -> u64 {
487 self.cache.as_ref().map_or(0, |cache| cache.entry_count())
488 }
489
490 /// Synchronize cache operations to ensure all pending operations are flushed
491 ///
492 /// This is useful for testing to ensure cache statistics are accurate
493 pub async fn sync_cache(&self) {
494 if let Some(cache) = &self.cache {
495 cache.sync().await;
496 }
497 }
498
499 /// Build a request executor borrowing this client's HTTP client, rate limiter, and config.
500 fn executor(&self) -> RequestExecutor<'_> {
501 RequestExecutor::new(&self.client, &self.rate_limiter, &self.config)
502 }
503}
504
505impl Default for PmcClient {
506 fn default() -> Self {
507 Self::new()
508 }
509}
510
511/// Read an Entrez UID out of an ELink `links` entry.
512///
513/// NCBI writes these as JSON strings, but has used bare numbers too, so both
514/// are accepted. Formatting the `Value` directly would embed its JSON quotes in
515/// the id.
516fn json_uid(value: &serde_json::Value) -> Option<String> {
517 match value {
518 serde_json::Value::String(uid) => Some(uid.clone()),
519 serde_json::Value::Number(uid) => Some(uid.to_string()),
520 _ => None,
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 #[test]
529 fn test_json_uid_accepts_strings_and_numbers() {
530 assert_eq!(
531 json_uid(&serde_json::json!("7092803")).as_deref(),
532 Some("7092803")
533 );
534 assert_eq!(
535 json_uid(&serde_json::json!(7092803)).as_deref(),
536 Some("7092803")
537 );
538 assert_eq!(json_uid(&serde_json::json!(null)), None);
539 }
540
541 #[test]
542 fn test_client_creation() {
543 let client = PmcClient::new();
544 assert!(client.base_url.contains("eutils.ncbi.nlm.nih.gov"));
545 }
546
547 #[test]
548 fn test_custom_base_url() {
549 let client = PmcClient::new().with_base_url("https://custom.api.example.com".to_string());
550 assert_eq!(client.base_url, "https://custom.api.example.com");
551 }
552}