Skip to main content

papers_openalex/
client.rs

1use crate::cache::DiskCache;
2use crate::error::{OpenAlexError, Result};
3use crate::params::{FindWorksParams, GetParams, ListParams};
4use crate::response::{AutocompleteResponse, FindWorksResponse, ListResponse};
5use crate::types::*;
6use serde::de::DeserializeOwned;
7
8const DEFAULT_BASE_URL: &str = "https://api.openalex.org";
9
10/// Async client for the [OpenAlex REST API](https://docs.openalex.org).
11///
12/// Provides 30 methods covering all OpenAlex endpoints: 10 list, 10 get,
13/// 8 autocomplete, and 2 semantic search.
14///
15/// # Creating a client
16///
17/// ```no_run
18/// use papers_openalex::OpenAlexClient;
19///
20/// // Reads API key from OPENALEX_KEY env var (optional but recommended)
21/// let client = OpenAlexClient::new();
22///
23/// // Or pass an explicit API key
24/// let client = OpenAlexClient::with_api_key("your-key-here");
25/// ```
26///
27/// # Example: search and paginate
28///
29/// ```no_run
30/// # async fn example() -> papers_openalex::Result<()> {
31/// use papers_openalex::{OpenAlexClient, ListParams};
32///
33/// let client = OpenAlexClient::new();
34/// let params = ListParams::builder()
35///     .search("machine learning")
36///     .filter("publication_year:2024,is_oa:true")
37///     .sort("cited_by_count:desc")
38///     .per_page(5)
39///     .build();
40///
41/// let response = client.list_works(&params).await?;
42/// for work in &response.results {
43///     println!("{}: {} cites",
44///         work.display_name.as_deref().unwrap_or("?"),
45///         work.cited_by_count.unwrap_or(0));
46/// }
47/// # Ok(())
48/// # }
49/// ```
50///
51/// # Example: cursor pagination
52///
53/// ```no_run
54/// # async fn example() -> papers_openalex::Result<()> {
55/// use papers_openalex::{OpenAlexClient, ListParams};
56///
57/// let client = OpenAlexClient::new();
58/// let mut cursor = Some("*".to_string());
59///
60/// while let Some(c) = cursor {
61///     let params = ListParams {
62///         cursor: Some(c),
63///         per_page: Some(200),
64///         filter: Some("publication_year:2024".into()),
65///         ..Default::default()
66///     };
67///     let response = client.list_works(&params).await?;
68///     for work in &response.results {
69///         // process each work
70///     }
71///     cursor = response.meta.next_cursor;
72/// }
73/// # Ok(())
74/// # }
75/// ```
76#[derive(Clone)]
77pub struct OpenAlexClient {
78    http: reqwest::Client,
79    base_url: String,
80    api_key: Option<String>,
81    cache: Option<DiskCache>,
82}
83
84impl Default for OpenAlexClient {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90impl OpenAlexClient {
91    /// Create a new client, reading the API key from the `OPENALEX_KEY`
92    /// environment variable. The key is optional for most endpoints but
93    /// recommended for higher rate limits.
94    ///
95    /// ```no_run
96    /// use papers_openalex::OpenAlexClient;
97    /// let client = OpenAlexClient::new();
98    /// ```
99    pub fn new() -> Self {
100        Self {
101            http: reqwest::Client::new(),
102            base_url: DEFAULT_BASE_URL.to_string(),
103            api_key: std::env::var("OPENALEX_KEY").ok(),
104            cache: None,
105        }
106    }
107
108    /// Create a new client with an explicit API key.
109    ///
110    /// ```no_run
111    /// use papers_openalex::OpenAlexClient;
112    /// let client = OpenAlexClient::with_api_key("your-key-here");
113    /// ```
114    pub fn with_api_key(api_key: impl Into<String>) -> Self {
115        Self {
116            http: reqwest::Client::new(),
117            base_url: DEFAULT_BASE_URL.to_string(),
118            api_key: Some(api_key.into()),
119            cache: None,
120        }
121    }
122
123    /// Override the base URL. Useful for testing with a mock server.
124    ///
125    /// ```no_run
126    /// use papers_openalex::OpenAlexClient;
127    /// let client = OpenAlexClient::new()
128    ///     .with_base_url("http://localhost:8080");
129    /// ```
130    pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
131        self.base_url = url.into();
132        self
133    }
134
135    /// Enable disk caching of successful responses.
136    ///
137    /// ```no_run
138    /// use papers_openalex::{OpenAlexClient, DiskCache};
139    /// use std::time::Duration;
140    ///
141    /// let cache = DiskCache::default_location(Duration::from_secs(600)).unwrap();
142    /// let client = OpenAlexClient::new().with_cache(cache);
143    /// ```
144    pub fn with_cache(mut self, cache: DiskCache) -> Self {
145        self.cache = Some(cache);
146        self
147    }
148
149    // ── Private helpers ────────────────────────────────────────────────
150
151    fn append_api_key(&self, pairs: &mut Vec<(&str, String)>) {
152        if let Some(key) = &self.api_key {
153            pairs.push(("api_key", key.clone()));
154        }
155    }
156
157    async fn get_json<T: DeserializeOwned>(
158        &self,
159        path: &str,
160        mut query: Vec<(&str, String)>,
161    ) -> Result<T> {
162        self.append_api_key(&mut query);
163        let url = format!("{}{}", self.base_url, path);
164        if let Some(cache) = &self.cache {
165            if let Some(text) = cache.get(&url, &query, None) {
166                return serde_json::from_str(&text).map_err(OpenAlexError::Json);
167            }
168        }
169        let resp = self.http.get(&url).query(&query).send().await?;
170        let status = resp.status();
171        if !status.is_success() {
172            let message = resp.text().await.unwrap_or_default();
173            return Err(OpenAlexError::Api {
174                status: status.as_u16(),
175                message,
176            });
177        }
178        let text = resp.text().await?;
179        if let Some(cache) = &self.cache {
180            cache.set(&url, &query, None, &text);
181        }
182        serde_json::from_str(&text).map_err(OpenAlexError::Json)
183    }
184
185    async fn post_json<T: DeserializeOwned>(
186        &self,
187        path: &str,
188        mut query: Vec<(&str, String)>,
189        body: serde_json::Value,
190    ) -> Result<T> {
191        self.append_api_key(&mut query);
192        let url = format!("{}{}", self.base_url, path);
193        let body_str = body.to_string();
194        if let Some(cache) = &self.cache {
195            if let Some(text) = cache.get(&url, &query, Some(&body_str)) {
196                return serde_json::from_str(&text).map_err(OpenAlexError::Json);
197            }
198        }
199        let resp = self.http.post(&url).query(&query).json(&body).send().await?;
200        let status = resp.status();
201        if !status.is_success() {
202            let message = resp.text().await.unwrap_or_default();
203            return Err(OpenAlexError::Api {
204                status: status.as_u16(),
205                message,
206            });
207        }
208        let text = resp.text().await?;
209        if let Some(cache) = &self.cache {
210            cache.set(&url, &query, Some(&body_str), &text);
211        }
212        serde_json::from_str(&text).map_err(OpenAlexError::Json)
213    }
214
215    async fn list_entities<T: DeserializeOwned>(
216        &self,
217        path: &str,
218        params: &ListParams,
219    ) -> Result<ListResponse<T>> {
220        self.get_json(path, params.to_query_pairs()).await
221    }
222
223    async fn get_entity<T: DeserializeOwned>(
224        &self,
225        entity_path: &str,
226        id: &str,
227        params: &GetParams,
228    ) -> Result<T> {
229        let path = format!("{}/{}", entity_path, id);
230        self.get_json(&path, params.to_query_pairs()).await
231    }
232
233    async fn autocomplete_entity(
234        &self,
235        entity: &str,
236        q: &str,
237    ) -> Result<AutocompleteResponse> {
238        let path = format!("/autocomplete/{}", entity);
239        self.get_json(&path, vec![("q", q.to_string())]).await
240    }
241
242    // ── List endpoints ─────────────────────────────────────────────────
243
244    /// List scholarly works (articles, books, datasets, preprints). 240M+
245    /// records. Supports full-text search across titles, abstracts, and full
246    /// text. Filter by publication year, OA status, type, citations, author,
247    /// institution, topic, funder, and 130+ other fields.
248    ///
249    /// `GET /works`
250    ///
251    /// # Example
252    ///
253    /// ```no_run
254    /// # async fn example() -> papers_openalex::Result<()> {
255    /// use papers_openalex::{OpenAlexClient, ListParams};
256    ///
257    /// let client = OpenAlexClient::new();
258    /// let params = ListParams::builder()
259    ///     .filter("publication_year:2024,is_oa:true")
260    ///     .sort("cited_by_count:desc")
261    ///     .per_page(5)
262    ///     .build();
263    /// let response = client.list_works(&params).await?;
264    /// // response.meta.count => total matching works
265    /// // response.results    => Vec<Work>
266    /// # Ok(())
267    /// # }
268    /// ```
269    pub async fn list_works(&self, params: &ListParams) -> Result<ListResponse<Work>> {
270        self.list_entities("/works", params).await
271    }
272
273    /// List disambiguated author profiles. 110M+ records. Each author has a
274    /// unified identity across name variants, with linked ORCID, institutional
275    /// affiliations, publication history, and citation metrics.
276    ///
277    /// `GET /authors`
278    ///
279    /// # Example
280    ///
281    /// ```no_run
282    /// # async fn example() -> papers_openalex::Result<()> {
283    /// use papers_openalex::{OpenAlexClient, ListParams};
284    ///
285    /// let client = OpenAlexClient::new();
286    /// let params = ListParams::builder()
287    ///     .search("einstein")
288    ///     .per_page(3)
289    ///     .build();
290    /// let response = client.list_authors(&params).await?;
291    /// for author in &response.results {
292    ///     println!("{}: {} works",
293    ///         author.display_name.as_deref().unwrap_or("?"),
294    ///         author.works_count.unwrap_or(0));
295    /// }
296    /// # Ok(())
297    /// # }
298    /// ```
299    pub async fn list_authors(&self, params: &ListParams) -> Result<ListResponse<Author>> {
300        self.list_entities("/authors", params).await
301    }
302
303    /// List publishing venues: journals, repositories, conferences, ebook
304    /// platforms, and book series. Includes ISSN identifiers, OA status, APC
305    /// pricing, host organization, and impact metrics.
306    ///
307    /// `GET /sources`
308    ///
309    /// # Example
310    ///
311    /// ```no_run
312    /// # async fn example() -> papers_openalex::Result<()> {
313    /// use papers_openalex::{OpenAlexClient, ListParams};
314    ///
315    /// let client = OpenAlexClient::new();
316    /// let params = ListParams::builder()
317    ///     .filter("is_oa:true,type:journal")
318    ///     .sort("cited_by_count:desc")
319    ///     .per_page(5)
320    ///     .build();
321    /// let response = client.list_sources(&params).await?;
322    /// # Ok(())
323    /// # }
324    /// ```
325    pub async fn list_sources(&self, params: &ListParams) -> Result<ListResponse<Source>> {
326        self.list_entities("/sources", params).await
327    }
328
329    /// List research organizations: universities, hospitals, companies,
330    /// government agencies. Linked to ROR identifiers. Includes geographic
331    /// location, parent/child relationships, and affiliated repositories.
332    ///
333    /// `GET /institutions`
334    ///
335    /// # Example
336    ///
337    /// ```no_run
338    /// # async fn example() -> papers_openalex::Result<()> {
339    /// use papers_openalex::{OpenAlexClient, ListParams};
340    ///
341    /// let client = OpenAlexClient::new();
342    /// let params = ListParams::builder()
343    ///     .filter("country_code:US,type:education")
344    ///     .sort("cited_by_count:desc")
345    ///     .per_page(10)
346    ///     .build();
347    /// let response = client.list_institutions(&params).await?;
348    /// # Ok(())
349    /// # }
350    /// ```
351    pub async fn list_institutions(
352        &self,
353        params: &ListParams,
354    ) -> Result<ListResponse<Institution>> {
355        self.list_entities("/institutions", params).await
356    }
357
358    /// List research topics organized in a 3-level hierarchy: domain > field >
359    /// subfield > topic. AI-generated descriptions and keywords. Each work is
360    /// assigned up to 3 topics with relevance scores.
361    ///
362    /// `GET /topics`
363    ///
364    /// # Example
365    ///
366    /// ```no_run
367    /// # async fn example() -> papers_openalex::Result<()> {
368    /// use papers_openalex::{OpenAlexClient, ListParams};
369    ///
370    /// let client = OpenAlexClient::new();
371    /// let params = ListParams::builder()
372    ///     .search("machine learning")
373    ///     .per_page(5)
374    ///     .build();
375    /// let response = client.list_topics(&params).await?;
376    /// # Ok(())
377    /// # }
378    /// ```
379    pub async fn list_topics(&self, params: &ListParams) -> Result<ListResponse<Topic>> {
380        self.list_entities("/topics", params).await
381    }
382
383    /// List publishing organizations (e.g. Elsevier, Springer Nature). Includes
384    /// parent/child hierarchy, country of origin, and linked sources. Some
385    /// publishers also act as funders or institutions (see `roles`).
386    ///
387    /// `GET /publishers`
388    ///
389    /// # Example
390    ///
391    /// ```no_run
392    /// # async fn example() -> papers_openalex::Result<()> {
393    /// use papers_openalex::{OpenAlexClient, ListParams};
394    ///
395    /// let client = OpenAlexClient::new();
396    /// let params = ListParams::builder()
397    ///     .sort("works_count:desc")
398    ///     .per_page(10)
399    ///     .build();
400    /// let response = client.list_publishers(&params).await?;
401    /// # Ok(())
402    /// # }
403    /// ```
404    pub async fn list_publishers(&self, params: &ListParams) -> Result<ListResponse<Publisher>> {
405        self.list_entities("/publishers", params).await
406    }
407
408    /// List research domains (broadest level of topic hierarchy). 4 domains
409    /// total: Life Sciences, Social Sciences, Physical Sciences, Health
410    /// Sciences. Each domain contains multiple academic fields.
411    ///
412    /// `GET /domains`
413    ///
414    /// # Example
415    ///
416    /// ```no_run
417    /// # async fn example() -> papers_openalex::Result<()> {
418    /// use papers_openalex::{OpenAlexClient, ListParams};
419    ///
420    /// let client = OpenAlexClient::new();
421    /// let response = client.list_domains(&ListParams::default()).await?;
422    /// for domain in &response.results {
423    ///     println!("{}: {} works",
424    ///         domain.display_name.as_deref().unwrap_or("?"),
425    ///         domain.works_count.unwrap_or(0));
426    /// }
427    /// # Ok(())
428    /// # }
429    /// ```
430    pub async fn list_domains(&self, params: &ListParams) -> Result<ListResponse<Domain>> {
431        self.list_entities("/domains", params).await
432    }
433
434    /// List academic fields (second level of topic hierarchy). 26 fields total
435    /// (e.g. Computer Science, Medicine, Mathematics). Each field has a parent
436    /// domain and contains multiple subfields.
437    ///
438    /// `GET /fields`
439    ///
440    /// # Example
441    ///
442    /// ```no_run
443    /// # async fn example() -> papers_openalex::Result<()> {
444    /// use papers_openalex::{OpenAlexClient, ListParams};
445    ///
446    /// let client = OpenAlexClient::new();
447    /// let response = client.list_fields(&ListParams::default()).await?;
448    /// for field in &response.results {
449    ///     println!("{}: {} works",
450    ///         field.display_name.as_deref().unwrap_or("?"),
451    ///         field.works_count.unwrap_or(0));
452    /// }
453    /// # Ok(())
454    /// # }
455    /// ```
456    pub async fn list_fields(&self, params: &ListParams) -> Result<ListResponse<Field>> {
457        self.list_entities("/fields", params).await
458    }
459
460    /// List research subfields (third level of topic hierarchy). ~252 subfields
461    /// total (e.g. Artificial Intelligence, Organic Chemistry). Each subfield
462    /// has a parent field and domain, and contains multiple topics.
463    ///
464    /// `GET /subfields`
465    ///
466    /// # Example
467    ///
468    /// ```no_run
469    /// # async fn example() -> papers_openalex::Result<()> {
470    /// use papers_openalex::{OpenAlexClient, ListParams};
471    ///
472    /// let client = OpenAlexClient::new();
473    /// let response = client.list_subfields(&ListParams::default()).await?;
474    /// for subfield in &response.results {
475    ///     println!("{}: {} works",
476    ///         subfield.display_name.as_deref().unwrap_or("?"),
477    ///         subfield.works_count.unwrap_or(0));
478    /// }
479    /// # Ok(())
480    /// # }
481    /// ```
482    pub async fn list_subfields(&self, params: &ListParams) -> Result<ListResponse<Subfield>> {
483        self.list_entities("/subfields", params).await
484    }
485
486    /// List research funding organizations (e.g. NIH, NSF, ERC). Linked to
487    /// Crossref funder registry. Includes grant counts, funded works, and impact
488    /// metrics.
489    ///
490    /// `GET /funders`
491    ///
492    /// # Example
493    ///
494    /// ```no_run
495    /// # async fn example() -> papers_openalex::Result<()> {
496    /// use papers_openalex::{OpenAlexClient, ListParams};
497    ///
498    /// let client = OpenAlexClient::new();
499    /// let params = ListParams::builder()
500    ///     .filter("country_code:US")
501    ///     .sort("works_count:desc")
502    ///     .per_page(5)
503    ///     .build();
504    /// let response = client.list_funders(&params).await?;
505    /// # Ok(())
506    /// # }
507    /// ```
508    pub async fn list_funders(&self, params: &ListParams) -> Result<ListResponse<Funder>> {
509        self.list_entities("/funders", params).await
510    }
511
512    // ── Single entity endpoints ────────────────────────────────────────
513
514    /// Get a single scholarly work by ID. Returns full metadata including title,
515    /// authors, abstract (as inverted index), citations, topics, OA status,
516    /// locations, funding, and bibliographic data.
517    ///
518    /// `GET /works/{id}`
519    ///
520    /// Accepts: OpenAlex ID (`W...`), DOI, PMID (`pmid:...`), PMCID
521    /// (`pmcid:...`), MAG (`mag:...`).
522    ///
523    /// # Example
524    ///
525    /// ```no_run
526    /// # async fn example() -> papers_openalex::Result<()> {
527    /// use papers_openalex::{OpenAlexClient, GetParams};
528    ///
529    /// let client = OpenAlexClient::new();
530    ///
531    /// // By OpenAlex ID
532    /// let work = client.get_work("W2741809807", &GetParams::default()).await?;
533    ///
534    /// // By DOI
535    /// let work = client.get_work("https://doi.org/10.7717/peerj.4375", &GetParams::default()).await?;
536    ///
537    /// // With field selection
538    /// let params = GetParams::builder().select("id,display_name,cited_by_count").build();
539    /// let work = client.get_work("W2741809807", &params).await?;
540    /// # Ok(())
541    /// # }
542    /// ```
543    pub async fn get_work(&self, id: &str, params: &GetParams) -> Result<Work> {
544        self.get_entity("/works", id, params).await
545    }
546
547    /// Get a single author profile. Returns disambiguated identity with name
548    /// variants, institutional affiliations over time, publication/citation
549    /// counts, h-index, and topic expertise.
550    ///
551    /// `GET /authors/{id}`
552    ///
553    /// Accepts: OpenAlex ID (`A...`), ORCID.
554    ///
555    /// # Example
556    ///
557    /// ```no_run
558    /// # async fn example() -> papers_openalex::Result<()> {
559    /// use papers_openalex::{OpenAlexClient, GetParams};
560    ///
561    /// let client = OpenAlexClient::new();
562    /// let author = client.get_author("A5023888391", &GetParams::default()).await?;
563    /// println!("{}: h-index {}",
564    ///     author.display_name.as_deref().unwrap_or("?"),
565    ///     author.summary_stats.as_ref().and_then(|s| s.h_index).unwrap_or(0));
566    /// # Ok(())
567    /// # }
568    /// ```
569    pub async fn get_author(&self, id: &str, params: &GetParams) -> Result<Author> {
570        self.get_entity("/authors", id, params).await
571    }
572
573    /// Get a single publishing venue. Returns ISSNs, OA status, DOAJ membership,
574    /// APC pricing, host publisher hierarchy, impact metrics, and publication
575    /// year range.
576    ///
577    /// `GET /sources/{id}`
578    ///
579    /// Accepts: OpenAlex ID (`S...`), ISSN.
580    ///
581    /// # Example
582    ///
583    /// ```no_run
584    /// # async fn example() -> papers_openalex::Result<()> {
585    /// use papers_openalex::{OpenAlexClient, GetParams};
586    ///
587    /// let client = OpenAlexClient::new();
588    /// let source = client.get_source("S137773608", &GetParams::default()).await?;
589    /// println!("{} ({})", source.display_name.as_deref().unwrap_or("?"),
590    ///     source.r#type.as_deref().unwrap_or("unknown"));
591    /// # Ok(())
592    /// # }
593    /// ```
594    pub async fn get_source(&self, id: &str, params: &GetParams) -> Result<Source> {
595        self.get_entity("/sources", id, params).await
596    }
597
598    /// Get a single research institution. Returns ROR ID, geographic
599    /// coordinates, parent/child institution relationships, hosted repositories,
600    /// and research output metrics.
601    ///
602    /// `GET /institutions/{id}`
603    ///
604    /// Accepts: OpenAlex ID (`I...`), ROR.
605    ///
606    /// # Example
607    ///
608    /// ```no_run
609    /// # async fn example() -> papers_openalex::Result<()> {
610    /// use papers_openalex::{OpenAlexClient, GetParams};
611    ///
612    /// let client = OpenAlexClient::new();
613    /// let inst = client.get_institution("I136199984", &GetParams::default()).await?;
614    /// // Also accepts ROR:
615    /// let inst = client.get_institution("https://ror.org/03vek6s52", &GetParams::default()).await?;
616    /// # Ok(())
617    /// # }
618    /// ```
619    pub async fn get_institution(&self, id: &str, params: &GetParams) -> Result<Institution> {
620        self.get_entity("/institutions", id, params).await
621    }
622
623    /// Get a single research topic. Returns AI-generated description, keywords,
624    /// position in domain > field > subfield hierarchy, sibling topics, and work
625    /// counts.
626    ///
627    /// `GET /topics/{id}`
628    ///
629    /// Accepts: OpenAlex ID (`T...`).
630    ///
631    /// # Example
632    ///
633    /// ```no_run
634    /// # async fn example() -> papers_openalex::Result<()> {
635    /// use papers_openalex::{OpenAlexClient, GetParams};
636    ///
637    /// let client = OpenAlexClient::new();
638    /// let topic = client.get_topic("T10001", &GetParams::default()).await?;
639    /// println!("{}: {} works",
640    ///     topic.display_name.as_deref().unwrap_or("?"),
641    ///     topic.works_count.unwrap_or(0));
642    /// # Ok(())
643    /// # }
644    /// ```
645    pub async fn get_topic(&self, id: &str, params: &GetParams) -> Result<Topic> {
646        self.get_entity("/topics", id, params).await
647    }
648
649    /// Get a single publisher. Returns hierarchy level, parent publisher,
650    /// country codes, linked sources, and citation metrics.
651    ///
652    /// `GET /publishers/{id}`
653    ///
654    /// Accepts: OpenAlex ID (`P...`).
655    ///
656    /// # Example
657    ///
658    /// ```no_run
659    /// # async fn example() -> papers_openalex::Result<()> {
660    /// use papers_openalex::{OpenAlexClient, GetParams};
661    ///
662    /// let client = OpenAlexClient::new();
663    /// let publisher = client.get_publisher("P4310319965", &GetParams::default()).await?;
664    /// println!("{}: {} works",
665    ///     publisher.display_name.as_deref().unwrap_or("?"),
666    ///     publisher.works_count.unwrap_or(0));
667    /// # Ok(())
668    /// # }
669    /// ```
670    pub async fn get_publisher(&self, id: &str, params: &GetParams) -> Result<Publisher> {
671        self.get_entity("/publishers", id, params).await
672    }
673
674    /// Get a single research domain. Returns description, child fields,
675    /// sibling domains, and work/citation counts.
676    ///
677    /// `GET /domains/{id}`
678    ///
679    /// Accepts: numeric ID (e.g. `"3"` for Physical Sciences).
680    ///
681    /// # Example
682    ///
683    /// ```no_run
684    /// # async fn example() -> papers_openalex::Result<()> {
685    /// use papers_openalex::{OpenAlexClient, GetParams};
686    ///
687    /// let client = OpenAlexClient::new();
688    /// let domain = client.get_domain("3", &GetParams::default()).await?;
689    /// println!("{}: {} fields",
690    ///     domain.display_name.as_deref().unwrap_or("?"),
691    ///     domain.fields.as_ref().map(|f| f.len()).unwrap_or(0));
692    /// # Ok(())
693    /// # }
694    /// ```
695    pub async fn get_domain(&self, id: &str, params: &GetParams) -> Result<Domain> {
696        self.get_entity("/domains", id, params).await
697    }
698
699    /// Get a single academic field. Returns parent domain, child subfields,
700    /// sibling fields, and work/citation counts.
701    ///
702    /// `GET /fields/{id}`
703    ///
704    /// Accepts: numeric ID (e.g. `"17"` for Computer Science).
705    ///
706    /// # Example
707    ///
708    /// ```no_run
709    /// # async fn example() -> papers_openalex::Result<()> {
710    /// use papers_openalex::{OpenAlexClient, GetParams};
711    ///
712    /// let client = OpenAlexClient::new();
713    /// let field = client.get_field("17", &GetParams::default()).await?;
714    /// println!("{}: {} subfields",
715    ///     field.display_name.as_deref().unwrap_or("?"),
716    ///     field.subfields.as_ref().map(|s| s.len()).unwrap_or(0));
717    /// # Ok(())
718    /// # }
719    /// ```
720    pub async fn get_field(&self, id: &str, params: &GetParams) -> Result<Field> {
721        self.get_entity("/fields", id, params).await
722    }
723
724    /// Get a single research subfield. Returns parent field and domain, child
725    /// topics, sibling subfields, and work/citation counts.
726    ///
727    /// `GET /subfields/{id}`
728    ///
729    /// Accepts: numeric ID (e.g. `"1702"` for Artificial Intelligence).
730    ///
731    /// # Example
732    ///
733    /// ```no_run
734    /// # async fn example() -> papers_openalex::Result<()> {
735    /// use papers_openalex::{OpenAlexClient, GetParams};
736    ///
737    /// let client = OpenAlexClient::new();
738    /// let subfield = client.get_subfield("1702", &GetParams::default()).await?;
739    /// println!("{}: {} topics",
740    ///     subfield.display_name.as_deref().unwrap_or("?"),
741    ///     subfield.topics.as_ref().map(|t| t.len()).unwrap_or(0));
742    /// # Ok(())
743    /// # }
744    /// ```
745    pub async fn get_subfield(&self, id: &str, params: &GetParams) -> Result<Subfield> {
746        self.get_entity("/subfields", id, params).await
747    }
748
749    /// Get a single funder. Returns Wikidata description, Crossref funder ID,
750    /// grant/award counts, country, and research impact metrics.
751    ///
752    /// `GET /funders/{id}`
753    ///
754    /// Accepts: OpenAlex ID (`F...`).
755    ///
756    /// # Example
757    ///
758    /// ```no_run
759    /// # async fn example() -> papers_openalex::Result<()> {
760    /// use papers_openalex::{OpenAlexClient, GetParams};
761    ///
762    /// let client = OpenAlexClient::new();
763    /// let funder = client.get_funder("F4320332161", &GetParams::default()).await?;
764    /// println!("{} ({}): {} awards",
765    ///     funder.display_name.as_deref().unwrap_or("?"),
766    ///     funder.country_code.as_deref().unwrap_or("?"),
767    ///     funder.awards_count.unwrap_or(0));
768    /// # Ok(())
769    /// # }
770    /// ```
771    pub async fn get_funder(&self, id: &str, params: &GetParams) -> Result<Funder> {
772        self.get_entity("/funders", id, params).await
773    }
774
775    // ── Autocomplete endpoints ─────────────────────────────────────────
776
777    /// Autocomplete for works. Searches titles. Returns up to 10 results sorted
778    /// by citation count. Hint shows first author name.
779    ///
780    /// `GET /autocomplete/works?q=...`
781    ///
782    /// # Example
783    ///
784    /// ```no_run
785    /// # async fn example() -> papers_openalex::Result<()> {
786    /// use papers_openalex::OpenAlexClient;
787    ///
788    /// let client = OpenAlexClient::new();
789    /// let response = client.autocomplete_works("machine learning").await?;
790    /// for result in &response.results {
791    ///     println!("{} (by {})", result.display_name,
792    ///         result.hint.as_deref().unwrap_or("unknown author"));
793    /// }
794    /// # Ok(())
795    /// # }
796    /// ```
797    pub async fn autocomplete_works(&self, q: &str) -> Result<AutocompleteResponse> {
798        self.autocomplete_entity("works", q).await
799    }
800
801    /// Autocomplete for authors. Searches display names. Returns up to 10
802    /// results sorted by citation count. Hint shows last known institution and
803    /// country.
804    ///
805    /// `GET /autocomplete/authors?q=...`
806    ///
807    /// # Example
808    ///
809    /// ```no_run
810    /// # async fn example() -> papers_openalex::Result<()> {
811    /// use papers_openalex::OpenAlexClient;
812    ///
813    /// let client = OpenAlexClient::new();
814    /// let response = client.autocomplete_authors("einstein").await?;
815    /// for result in &response.results {
816    ///     println!("{} — {}", result.display_name,
817    ///         result.hint.as_deref().unwrap_or(""));
818    /// }
819    /// # Ok(())
820    /// # }
821    /// ```
822    pub async fn autocomplete_authors(&self, q: &str) -> Result<AutocompleteResponse> {
823        self.autocomplete_entity("authors", q).await
824    }
825
826    /// Autocomplete for sources (journals, repositories). Searches display
827    /// names. Returns up to 10 results sorted by citation count. Hint shows
828    /// host organization. External ID is ISSN.
829    ///
830    /// `GET /autocomplete/sources?q=...`
831    ///
832    /// # Example
833    ///
834    /// ```no_run
835    /// # async fn example() -> papers_openalex::Result<()> {
836    /// use papers_openalex::OpenAlexClient;
837    ///
838    /// let client = OpenAlexClient::new();
839    /// let response = client.autocomplete_sources("nature").await?;
840    /// for result in &response.results {
841    ///     println!("{} ({})", result.display_name,
842    ///         result.hint.as_deref().unwrap_or(""));
843    /// }
844    /// # Ok(())
845    /// # }
846    /// ```
847    pub async fn autocomplete_sources(&self, q: &str) -> Result<AutocompleteResponse> {
848        self.autocomplete_entity("sources", q).await
849    }
850
851    /// Autocomplete for institutions. Searches display names. Returns up to 10
852    /// results sorted by citation count. Hint shows city and country. External
853    /// ID is ROR.
854    ///
855    /// `GET /autocomplete/institutions?q=...`
856    ///
857    /// # Example
858    ///
859    /// ```no_run
860    /// # async fn example() -> papers_openalex::Result<()> {
861    /// use papers_openalex::OpenAlexClient;
862    ///
863    /// let client = OpenAlexClient::new();
864    /// let response = client.autocomplete_institutions("harvard").await?;
865    /// for result in &response.results {
866    ///     println!("{} — {}", result.display_name,
867    ///         result.hint.as_deref().unwrap_or(""));
868    /// }
869    /// # Ok(())
870    /// # }
871    /// ```
872    pub async fn autocomplete_institutions(&self, q: &str) -> Result<AutocompleteResponse> {
873        self.autocomplete_entity("institutions", q).await
874    }
875
876    /// Autocomplete for publishers. Searches display names. Returns up to 10
877    /// results sorted by citation count. Hint shows country.
878    ///
879    /// `GET /autocomplete/publishers?q=...`
880    ///
881    /// Note: this endpoint has been observed returning HTTP 500 errors
882    /// intermittently (server-side issue).
883    ///
884    /// # Example
885    ///
886    /// ```no_run
887    /// # async fn example() -> papers_openalex::Result<()> {
888    /// use papers_openalex::OpenAlexClient;
889    ///
890    /// let client = OpenAlexClient::new();
891    /// let response = client.autocomplete_publishers("elsevier").await?;
892    /// for result in &response.results {
893    ///     println!("{} ({})", result.display_name,
894    ///         result.hint.as_deref().unwrap_or(""));
895    /// }
896    /// # Ok(())
897    /// # }
898    /// ```
899    pub async fn autocomplete_publishers(&self, q: &str) -> Result<AutocompleteResponse> {
900        self.autocomplete_entity("publishers", q).await
901    }
902
903    /// Autocomplete for subfields. Searches display names. Returns up to 10
904    /// results sorted by citation count. Hint shows description.
905    ///
906    /// `GET /autocomplete/subfields?q=...`
907    ///
908    /// Note: autocomplete is only available for subfields, not domains or
909    /// fields (`/autocomplete/domains` and `/autocomplete/fields` return 404).
910    ///
911    /// # Quirks
912    ///
913    /// The subfield autocomplete endpoint returns `entity_type: null` and
914    /// `short_id: "Nones/..."` — these are known API quirks. The
915    /// [`AutocompleteResult`](crate::AutocompleteResult) fields are
916    /// `Option<String>` to handle this.
917    ///
918    /// # Example
919    ///
920    /// ```no_run
921    /// # async fn example() -> papers_openalex::Result<()> {
922    /// use papers_openalex::OpenAlexClient;
923    ///
924    /// let client = OpenAlexClient::new();
925    /// let response = client.autocomplete_subfields("artificial").await?;
926    /// for result in &response.results {
927    ///     println!("{} — {}", result.display_name,
928    ///         result.hint.as_deref().unwrap_or(""));
929    /// }
930    /// # Ok(())
931    /// # }
932    /// ```
933    pub async fn autocomplete_subfields(&self, q: &str) -> Result<AutocompleteResponse> {
934        self.autocomplete_entity("subfields", q).await
935    }
936
937    /// Autocomplete for funders. Searches display names. Returns up to 10
938    /// results sorted by citation count. Hint shows country and description.
939    ///
940    /// `GET /autocomplete/funders?q=...`
941    ///
942    /// # Example
943    ///
944    /// ```no_run
945    /// # async fn example() -> papers_openalex::Result<()> {
946    /// use papers_openalex::OpenAlexClient;
947    ///
948    /// let client = OpenAlexClient::new();
949    /// let response = client.autocomplete_funders("national science").await?;
950    /// for result in &response.results {
951    ///     println!("{} ({})", result.display_name,
952    ///         result.hint.as_deref().unwrap_or(""));
953    /// }
954    /// # Ok(())
955    /// # }
956    /// ```
957    pub async fn autocomplete_funders(&self, q: &str) -> Result<AutocompleteResponse> {
958        self.autocomplete_entity("funders", q).await
959    }
960
961    // ── Semantic search endpoints ──────────────────────────────────────
962
963    /// Semantic search for works via GET. Sends query as a query parameter.
964    /// Returns works ranked by AI similarity score (0-1). Maximum 10,000
965    /// character query. **Requires API key. Costs 1,000 credits per request.**
966    ///
967    /// `GET /find/works?query=...`
968    ///
969    /// # Example
970    ///
971    /// ```no_run
972    /// # async fn example() -> papers_openalex::Result<()> {
973    /// use papers_openalex::{OpenAlexClient, FindWorksParams};
974    ///
975    /// let client = OpenAlexClient::with_api_key("your-key");
976    /// let params = FindWorksParams::builder()
977    ///     .query("machine learning for drug discovery")
978    ///     .count(5)
979    ///     .build();
980    /// let response = client.find_works(&params).await?;
981    /// for result in &response.results {
982    ///     println!("Score {:.2}: {}", result.score,
983    ///         result.work.get("display_name")
984    ///             .and_then(|v| v.as_str())
985    ///             .unwrap_or("?"));
986    /// }
987    /// # Ok(())
988    /// # }
989    /// ```
990    pub async fn find_works(&self, params: &FindWorksParams) -> Result<FindWorksResponse> {
991        self.get_json("/find/works", params.to_query_pairs()).await
992    }
993
994    /// Semantic search for works via POST. Sends query in JSON body as
995    /// `{"query": "..."}`, useful for long queries exceeding URL length limits.
996    /// Same response format as [`find_works`](Self::find_works). **Requires API
997    /// key. Costs 1,000 credits per request.**
998    ///
999    /// `POST /find/works`
1000    ///
1001    /// # Example
1002    ///
1003    /// ```no_run
1004    /// # async fn example() -> papers_openalex::Result<()> {
1005    /// use papers_openalex::{OpenAlexClient, FindWorksParams};
1006    ///
1007    /// let client = OpenAlexClient::with_api_key("your-key");
1008    /// let long_query = "A very long research question or abstract text...";
1009    /// let params = FindWorksParams::builder()
1010    ///     .query(long_query)
1011    ///     .count(10)
1012    ///     .filter("publication_year:>2020")
1013    ///     .build();
1014    /// let response = client.find_works_post(&params).await?;
1015    /// # Ok(())
1016    /// # }
1017    /// ```
1018    pub async fn find_works_post(&self, params: &FindWorksParams) -> Result<FindWorksResponse> {
1019        let body = serde_json::json!({ "query": params.query });
1020        self.post_json("/find/works", params.to_post_query_pairs(), body)
1021            .await
1022    }
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027    use super::*;
1028    use crate::cache::DiskCache;
1029    use std::time::Duration;
1030    use wiremock::matchers::{method, path, query_param};
1031    use wiremock::{Mock, MockServer, ResponseTemplate};
1032
1033    fn minimal_list_json() -> String {
1034        r#"{
1035            "meta": {"count": 1, "db_response_time_ms": 10, "page": 1, "per_page": 1, "next_cursor": null, "groups_count": null},
1036            "results": [],
1037            "group_by": []
1038        }"#
1039        .to_string()
1040    }
1041
1042    fn minimal_autocomplete_json() -> String {
1043        r#"{
1044            "meta": {"count": 1, "db_response_time_ms": 10, "page": 1, "per_page": 10},
1045            "results": [{
1046                "id": "https://openalex.org/test",
1047                "short_id": "test/T1",
1048                "display_name": "Test",
1049                "hint": "hint",
1050                "cited_by_count": 0,
1051                "works_count": 0,
1052                "entity_type": "work",
1053                "external_id": null,
1054                "filter_key": "openalex"
1055            }]
1056        }"#
1057        .to_string()
1058    }
1059
1060    fn minimal_find_json() -> String {
1061        r#"{
1062            "meta": null,
1063            "results": []
1064        }"#
1065        .to_string()
1066    }
1067
1068    async fn setup_client(server: &MockServer) -> OpenAlexClient {
1069        OpenAlexClient::new().with_base_url(server.uri())
1070    }
1071
1072    // ── List endpoint tests ────────────────────────────────────────────
1073
1074    #[tokio::test]
1075    async fn test_list_works() {
1076        let server = MockServer::start().await;
1077        Mock::given(method("GET"))
1078            .and(path("/works"))
1079            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1080            .mount(&server)
1081            .await;
1082        let client = setup_client(&server).await;
1083        let resp = client.list_works(&ListParams::default()).await.unwrap();
1084        assert_eq!(resp.meta.count, 1);
1085    }
1086
1087    #[tokio::test]
1088    async fn test_list_authors() {
1089        let server = MockServer::start().await;
1090        Mock::given(method("GET"))
1091            .and(path("/authors"))
1092            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1093            .mount(&server)
1094            .await;
1095        let client = setup_client(&server).await;
1096        let resp = client.list_authors(&ListParams::default()).await.unwrap();
1097        assert_eq!(resp.meta.count, 1);
1098    }
1099
1100    #[tokio::test]
1101    async fn test_list_sources() {
1102        let server = MockServer::start().await;
1103        Mock::given(method("GET"))
1104            .and(path("/sources"))
1105            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1106            .mount(&server)
1107            .await;
1108        let client = setup_client(&server).await;
1109        let resp = client.list_sources(&ListParams::default()).await.unwrap();
1110        assert_eq!(resp.meta.count, 1);
1111    }
1112
1113    #[tokio::test]
1114    async fn test_list_institutions() {
1115        let server = MockServer::start().await;
1116        Mock::given(method("GET"))
1117            .and(path("/institutions"))
1118            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1119            .mount(&server)
1120            .await;
1121        let client = setup_client(&server).await;
1122        let resp = client
1123            .list_institutions(&ListParams::default())
1124            .await
1125            .unwrap();
1126        assert_eq!(resp.meta.count, 1);
1127    }
1128
1129    #[tokio::test]
1130    async fn test_list_topics() {
1131        let server = MockServer::start().await;
1132        Mock::given(method("GET"))
1133            .and(path("/topics"))
1134            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1135            .mount(&server)
1136            .await;
1137        let client = setup_client(&server).await;
1138        let resp = client.list_topics(&ListParams::default()).await.unwrap();
1139        assert_eq!(resp.meta.count, 1);
1140    }
1141
1142    #[tokio::test]
1143    async fn test_list_publishers() {
1144        let server = MockServer::start().await;
1145        Mock::given(method("GET"))
1146            .and(path("/publishers"))
1147            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1148            .mount(&server)
1149            .await;
1150        let client = setup_client(&server).await;
1151        let resp = client
1152            .list_publishers(&ListParams::default())
1153            .await
1154            .unwrap();
1155        assert_eq!(resp.meta.count, 1);
1156    }
1157
1158    #[tokio::test]
1159    async fn test_list_funders() {
1160        let server = MockServer::start().await;
1161        Mock::given(method("GET"))
1162            .and(path("/funders"))
1163            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1164            .mount(&server)
1165            .await;
1166        let client = setup_client(&server).await;
1167        let resp = client.list_funders(&ListParams::default()).await.unwrap();
1168        assert_eq!(resp.meta.count, 1);
1169    }
1170
1171    #[tokio::test]
1172    async fn test_list_domains() {
1173        let server = MockServer::start().await;
1174        Mock::given(method("GET"))
1175            .and(path("/domains"))
1176            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1177            .mount(&server)
1178            .await;
1179        let client = setup_client(&server).await;
1180        let resp = client.list_domains(&ListParams::default()).await.unwrap();
1181        assert_eq!(resp.meta.count, 1);
1182    }
1183
1184    #[tokio::test]
1185    async fn test_list_fields() {
1186        let server = MockServer::start().await;
1187        Mock::given(method("GET"))
1188            .and(path("/fields"))
1189            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1190            .mount(&server)
1191            .await;
1192        let client = setup_client(&server).await;
1193        let resp = client.list_fields(&ListParams::default()).await.unwrap();
1194        assert_eq!(resp.meta.count, 1);
1195    }
1196
1197    #[tokio::test]
1198    async fn test_list_subfields() {
1199        let server = MockServer::start().await;
1200        Mock::given(method("GET"))
1201            .and(path("/subfields"))
1202            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1203            .mount(&server)
1204            .await;
1205        let client = setup_client(&server).await;
1206        let resp = client
1207            .list_subfields(&ListParams::default())
1208            .await
1209            .unwrap();
1210        assert_eq!(resp.meta.count, 1);
1211    }
1212
1213    #[tokio::test]
1214    async fn test_list_with_all_params() {
1215        let server = MockServer::start().await;
1216        Mock::given(method("GET"))
1217            .and(path("/works"))
1218            .and(query_param("filter", "publication_year:2024"))
1219            .and(query_param("search", "machine learning"))
1220            .and(query_param("sort", "cited_by_count:desc"))
1221            .and(query_param("per-page", "10"))
1222            .and(query_param("page", "2"))
1223            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1224            .mount(&server)
1225            .await;
1226        let client = setup_client(&server).await;
1227        let params = ListParams::builder()
1228            .filter("publication_year:2024")
1229            .search("machine learning")
1230            .sort("cited_by_count:desc")
1231            .per_page(10)
1232            .page(2)
1233            .build();
1234        let resp = client.list_works(&params).await.unwrap();
1235        assert_eq!(resp.meta.count, 1);
1236    }
1237
1238    #[tokio::test]
1239    async fn test_list_with_cursor() {
1240        let server = MockServer::start().await;
1241        let cursor_json = r#"{
1242            "meta": {"count": 1000, "db_response_time_ms": 10, "page": null, "per_page": 1, "next_cursor": "abc123", "groups_count": null},
1243            "results": [],
1244            "group_by": []
1245        }"#;
1246        Mock::given(method("GET"))
1247            .and(path("/works"))
1248            .and(query_param("cursor", "*"))
1249            .respond_with(ResponseTemplate::new(200).set_body_string(cursor_json))
1250            .mount(&server)
1251            .await;
1252        let client = setup_client(&server).await;
1253        let params = ListParams {
1254            cursor: Some("*".into()),
1255            ..Default::default()
1256        };
1257        let resp = client.list_works(&params).await.unwrap();
1258        assert!(resp.meta.page.is_none());
1259        assert_eq!(resp.meta.next_cursor.as_deref(), Some("abc123"));
1260    }
1261
1262    #[tokio::test]
1263    async fn test_list_with_group_by() {
1264        let server = MockServer::start().await;
1265        let group_json = r#"{
1266            "meta": {"count": 1000, "db_response_time_ms": 10, "page": 1, "per_page": 1, "next_cursor": null, "groups_count": 2},
1267            "results": [],
1268            "group_by": [
1269                {"key": "article", "key_display_name": "article", "count": 500},
1270                {"key": "preprint", "key_display_name": "preprint", "count": 300}
1271            ]
1272        }"#;
1273        Mock::given(method("GET"))
1274            .and(path("/works"))
1275            .and(query_param("group_by", "type"))
1276            .respond_with(ResponseTemplate::new(200).set_body_string(group_json))
1277            .mount(&server)
1278            .await;
1279        let client = setup_client(&server).await;
1280        let params = ListParams {
1281            group_by: Some("type".into()),
1282            ..Default::default()
1283        };
1284        let resp = client.list_works(&params).await.unwrap();
1285        assert_eq!(resp.meta.groups_count, Some(2));
1286        assert_eq!(resp.group_by.len(), 2);
1287        assert_eq!(resp.group_by[0].key, "article");
1288    }
1289
1290    #[tokio::test]
1291    async fn test_list_with_sample_seed() {
1292        let server = MockServer::start().await;
1293        Mock::given(method("GET"))
1294            .and(path("/works"))
1295            .and(query_param("sample", "50"))
1296            .and(query_param("seed", "42"))
1297            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1298            .mount(&server)
1299            .await;
1300        let client = setup_client(&server).await;
1301        let params = ListParams {
1302            sample: Some(50),
1303            seed: Some(42),
1304            ..Default::default()
1305        };
1306        client.list_works(&params).await.unwrap();
1307    }
1308
1309    // ── Get endpoint tests ─────────────────────────────────────────────
1310
1311    #[tokio::test]
1312    async fn test_get_work() {
1313        let server = MockServer::start().await;
1314        Mock::given(method("GET"))
1315            .and(path("/works/W123"))
1316            .respond_with(
1317                ResponseTemplate::new(200)
1318                    .set_body_string(r#"{"id":"https://openalex.org/W123"}"#),
1319            )
1320            .mount(&server)
1321            .await;
1322        let client = setup_client(&server).await;
1323        let work = client.get_work("W123", &GetParams::default()).await.unwrap();
1324        assert_eq!(work.id, "https://openalex.org/W123");
1325    }
1326
1327    #[tokio::test]
1328    async fn test_get_author() {
1329        let server = MockServer::start().await;
1330        Mock::given(method("GET"))
1331            .and(path("/authors/A123"))
1332            .respond_with(
1333                ResponseTemplate::new(200)
1334                    .set_body_string(r#"{"id":"https://openalex.org/A123"}"#),
1335            )
1336            .mount(&server)
1337            .await;
1338        let client = setup_client(&server).await;
1339        let author = client
1340            .get_author("A123", &GetParams::default())
1341            .await
1342            .unwrap();
1343        assert_eq!(author.id, "https://openalex.org/A123");
1344    }
1345
1346    #[tokio::test]
1347    async fn test_get_source() {
1348        let server = MockServer::start().await;
1349        Mock::given(method("GET"))
1350            .and(path("/sources/S123"))
1351            .respond_with(
1352                ResponseTemplate::new(200)
1353                    .set_body_string(r#"{"id":"https://openalex.org/S123"}"#),
1354            )
1355            .mount(&server)
1356            .await;
1357        let client = setup_client(&server).await;
1358        let source = client
1359            .get_source("S123", &GetParams::default())
1360            .await
1361            .unwrap();
1362        assert_eq!(source.id, "https://openalex.org/S123");
1363    }
1364
1365    #[tokio::test]
1366    async fn test_get_institution() {
1367        let server = MockServer::start().await;
1368        Mock::given(method("GET"))
1369            .and(path("/institutions/I123"))
1370            .respond_with(
1371                ResponseTemplate::new(200)
1372                    .set_body_string(r#"{"id":"https://openalex.org/I123"}"#),
1373            )
1374            .mount(&server)
1375            .await;
1376        let client = setup_client(&server).await;
1377        let inst = client
1378            .get_institution("I123", &GetParams::default())
1379            .await
1380            .unwrap();
1381        assert_eq!(inst.id, "https://openalex.org/I123");
1382    }
1383
1384    #[tokio::test]
1385    async fn test_get_topic() {
1386        let server = MockServer::start().await;
1387        Mock::given(method("GET"))
1388            .and(path("/topics/T123"))
1389            .respond_with(
1390                ResponseTemplate::new(200)
1391                    .set_body_string(r#"{"id":"https://openalex.org/T123"}"#),
1392            )
1393            .mount(&server)
1394            .await;
1395        let client = setup_client(&server).await;
1396        let topic = client
1397            .get_topic("T123", &GetParams::default())
1398            .await
1399            .unwrap();
1400        assert_eq!(topic.id, "https://openalex.org/T123");
1401    }
1402
1403    #[tokio::test]
1404    async fn test_get_publisher() {
1405        let server = MockServer::start().await;
1406        Mock::given(method("GET"))
1407            .and(path("/publishers/P123"))
1408            .respond_with(
1409                ResponseTemplate::new(200)
1410                    .set_body_string(r#"{"id":"https://openalex.org/P123"}"#),
1411            )
1412            .mount(&server)
1413            .await;
1414        let client = setup_client(&server).await;
1415        let publisher = client
1416            .get_publisher("P123", &GetParams::default())
1417            .await
1418            .unwrap();
1419        assert_eq!(publisher.id, "https://openalex.org/P123");
1420    }
1421
1422    #[tokio::test]
1423    async fn test_get_funder() {
1424        let server = MockServer::start().await;
1425        Mock::given(method("GET"))
1426            .and(path("/funders/F123"))
1427            .respond_with(
1428                ResponseTemplate::new(200)
1429                    .set_body_string(r#"{"id":"https://openalex.org/F123"}"#),
1430            )
1431            .mount(&server)
1432            .await;
1433        let client = setup_client(&server).await;
1434        let funder = client
1435            .get_funder("F123", &GetParams::default())
1436            .await
1437            .unwrap();
1438        assert_eq!(funder.id, "https://openalex.org/F123");
1439    }
1440
1441    #[tokio::test]
1442    async fn test_get_domain() {
1443        let server = MockServer::start().await;
1444        Mock::given(method("GET"))
1445            .and(path("/domains/3"))
1446            .respond_with(
1447                ResponseTemplate::new(200)
1448                    .set_body_string(r#"{"id":"https://openalex.org/domains/3"}"#),
1449            )
1450            .mount(&server)
1451            .await;
1452        let client = setup_client(&server).await;
1453        let domain = client
1454            .get_domain("3", &GetParams::default())
1455            .await
1456            .unwrap();
1457        assert_eq!(domain.id, "https://openalex.org/domains/3");
1458    }
1459
1460    #[tokio::test]
1461    async fn test_get_field() {
1462        let server = MockServer::start().await;
1463        Mock::given(method("GET"))
1464            .and(path("/fields/17"))
1465            .respond_with(
1466                ResponseTemplate::new(200)
1467                    .set_body_string(r#"{"id":"https://openalex.org/fields/17"}"#),
1468            )
1469            .mount(&server)
1470            .await;
1471        let client = setup_client(&server).await;
1472        let field = client
1473            .get_field("17", &GetParams::default())
1474            .await
1475            .unwrap();
1476        assert_eq!(field.id, "https://openalex.org/fields/17");
1477    }
1478
1479    #[tokio::test]
1480    async fn test_get_subfield() {
1481        let server = MockServer::start().await;
1482        Mock::given(method("GET"))
1483            .and(path("/subfields/1702"))
1484            .respond_with(
1485                ResponseTemplate::new(200)
1486                    .set_body_string(r#"{"id":"https://openalex.org/subfields/1702"}"#),
1487            )
1488            .mount(&server)
1489            .await;
1490        let client = setup_client(&server).await;
1491        let subfield = client
1492            .get_subfield("1702", &GetParams::default())
1493            .await
1494            .unwrap();
1495        assert_eq!(subfield.id, "https://openalex.org/subfields/1702");
1496    }
1497
1498    #[tokio::test]
1499    async fn test_get_with_select() {
1500        let server = MockServer::start().await;
1501        Mock::given(method("GET"))
1502            .and(path("/works/W123"))
1503            .and(query_param("select", "id,display_name"))
1504            .respond_with(
1505                ResponseTemplate::new(200)
1506                    .set_body_string(r#"{"id":"https://openalex.org/W123","display_name":"Test"}"#),
1507            )
1508            .mount(&server)
1509            .await;
1510        let client = setup_client(&server).await;
1511        let params = GetParams::builder().select("id,display_name").build();
1512        let work = client.get_work("W123", &params).await.unwrap();
1513        assert_eq!(work.id, "https://openalex.org/W123");
1514    }
1515
1516    // ── Autocomplete endpoint tests ────────────────────────────────────
1517
1518    #[tokio::test]
1519    async fn test_autocomplete_works() {
1520        let server = MockServer::start().await;
1521        Mock::given(method("GET"))
1522            .and(path("/autocomplete/works"))
1523            .and(query_param("q", "machine"))
1524            .respond_with(
1525                ResponseTemplate::new(200).set_body_string(minimal_autocomplete_json()),
1526            )
1527            .mount(&server)
1528            .await;
1529        let client = setup_client(&server).await;
1530        let resp = client.autocomplete_works("machine").await.unwrap();
1531        assert_eq!(resp.results.len(), 1);
1532    }
1533
1534    #[tokio::test]
1535    async fn test_autocomplete_authors() {
1536        let server = MockServer::start().await;
1537        Mock::given(method("GET"))
1538            .and(path("/autocomplete/authors"))
1539            .and(query_param("q", "einstein"))
1540            .respond_with(
1541                ResponseTemplate::new(200).set_body_string(minimal_autocomplete_json()),
1542            )
1543            .mount(&server)
1544            .await;
1545        let client = setup_client(&server).await;
1546        let resp = client.autocomplete_authors("einstein").await.unwrap();
1547        assert_eq!(resp.results.len(), 1);
1548    }
1549
1550    #[tokio::test]
1551    async fn test_autocomplete_sources() {
1552        let server = MockServer::start().await;
1553        Mock::given(method("GET"))
1554            .and(path("/autocomplete/sources"))
1555            .and(query_param("q", "nature"))
1556            .respond_with(
1557                ResponseTemplate::new(200).set_body_string(minimal_autocomplete_json()),
1558            )
1559            .mount(&server)
1560            .await;
1561        let client = setup_client(&server).await;
1562        let resp = client.autocomplete_sources("nature").await.unwrap();
1563        assert_eq!(resp.results.len(), 1);
1564    }
1565
1566    #[tokio::test]
1567    async fn test_autocomplete_institutions() {
1568        let server = MockServer::start().await;
1569        Mock::given(method("GET"))
1570            .and(path("/autocomplete/institutions"))
1571            .and(query_param("q", "harvard"))
1572            .respond_with(
1573                ResponseTemplate::new(200).set_body_string(minimal_autocomplete_json()),
1574            )
1575            .mount(&server)
1576            .await;
1577        let client = setup_client(&server).await;
1578        let resp = client.autocomplete_institutions("harvard").await.unwrap();
1579        assert_eq!(resp.results.len(), 1);
1580    }
1581
1582    #[tokio::test]
1583    async fn test_autocomplete_publishers() {
1584        let server = MockServer::start().await;
1585        Mock::given(method("GET"))
1586            .and(path("/autocomplete/publishers"))
1587            .and(query_param("q", "elsevier"))
1588            .respond_with(
1589                ResponseTemplate::new(200).set_body_string(minimal_autocomplete_json()),
1590            )
1591            .mount(&server)
1592            .await;
1593        let client = setup_client(&server).await;
1594        let resp = client.autocomplete_publishers("elsevier").await.unwrap();
1595        assert_eq!(resp.results.len(), 1);
1596    }
1597
1598    #[tokio::test]
1599    async fn test_autocomplete_funders() {
1600        let server = MockServer::start().await;
1601        Mock::given(method("GET"))
1602            .and(path("/autocomplete/funders"))
1603            .and(query_param("q", "nsf"))
1604            .respond_with(
1605                ResponseTemplate::new(200).set_body_string(minimal_autocomplete_json()),
1606            )
1607            .mount(&server)
1608            .await;
1609        let client = setup_client(&server).await;
1610        let resp = client.autocomplete_funders("nsf").await.unwrap();
1611        assert_eq!(resp.results.len(), 1);
1612    }
1613
1614    #[tokio::test]
1615    async fn test_autocomplete_subfields() {
1616        let server = MockServer::start().await;
1617        Mock::given(method("GET"))
1618            .and(path("/autocomplete/subfields"))
1619            .and(query_param("q", "artificial"))
1620            .respond_with(
1621                ResponseTemplate::new(200).set_body_string(minimal_autocomplete_json()),
1622            )
1623            .mount(&server)
1624            .await;
1625        let client = setup_client(&server).await;
1626        let resp = client.autocomplete_subfields("artificial").await.unwrap();
1627        assert_eq!(resp.results.len(), 1);
1628    }
1629
1630    // ── Find works tests ───────────────────────────────────────────────
1631
1632    #[tokio::test]
1633    async fn test_find_works_get() {
1634        let server = MockServer::start().await;
1635        Mock::given(method("GET"))
1636            .and(path("/find/works"))
1637            .and(query_param("query", "drug discovery"))
1638            .and(query_param("count", "5"))
1639            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_find_json()))
1640            .mount(&server)
1641            .await;
1642        let client = setup_client(&server).await;
1643        let params = FindWorksParams::builder()
1644            .query("drug discovery")
1645            .count(5)
1646            .build();
1647        let resp = client.find_works(&params).await.unwrap();
1648        assert!(resp.results.is_empty());
1649    }
1650
1651    #[tokio::test]
1652    async fn test_find_works_post() {
1653        let server = MockServer::start().await;
1654        Mock::given(method("POST"))
1655            .and(path("/find/works"))
1656            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_find_json()))
1657            .mount(&server)
1658            .await;
1659        let client = setup_client(&server).await;
1660        let params = FindWorksParams::builder()
1661            .query("long query text")
1662            .build();
1663        let resp = client.find_works_post(&params).await.unwrap();
1664        assert!(resp.results.is_empty());
1665    }
1666
1667    // ── API key and error tests ────────────────────────────────────────
1668
1669    #[tokio::test]
1670    async fn test_api_key_sent() {
1671        let server = MockServer::start().await;
1672        Mock::given(method("GET"))
1673            .and(path("/works"))
1674            .and(query_param("api_key", "test-key-123"))
1675            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1676            .mount(&server)
1677            .await;
1678        let client = OpenAlexClient::with_api_key("test-key-123").with_base_url(server.uri());
1679        let resp = client.list_works(&ListParams::default()).await.unwrap();
1680        assert_eq!(resp.meta.count, 1);
1681    }
1682
1683    #[tokio::test]
1684    async fn test_error_404() {
1685        let server = MockServer::start().await;
1686        Mock::given(method("GET"))
1687            .and(path("/works/invalid"))
1688            .respond_with(ResponseTemplate::new(404).set_body_string("Not found"))
1689            .mount(&server)
1690            .await;
1691        let client = setup_client(&server).await;
1692        let err = client
1693            .get_work("invalid", &GetParams::default())
1694            .await
1695            .unwrap_err();
1696        match err {
1697            OpenAlexError::Api { status, message } => {
1698                assert_eq!(status, 404);
1699                assert_eq!(message, "Not found");
1700            }
1701            _ => panic!("Expected Api error, got {:?}", err),
1702        }
1703    }
1704
1705    #[tokio::test]
1706    async fn test_error_403() {
1707        let server = MockServer::start().await;
1708        Mock::given(method("GET"))
1709            .and(path("/works"))
1710            .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
1711            .mount(&server)
1712            .await;
1713        let client = setup_client(&server).await;
1714        let err = client
1715            .list_works(&ListParams::default())
1716            .await
1717            .unwrap_err();
1718        match err {
1719            OpenAlexError::Api { status, .. } => assert_eq!(status, 403),
1720            _ => panic!("Expected Api error"),
1721        }
1722    }
1723
1724    // ── Cache integration tests ──────────────────────────────────────
1725
1726    fn temp_cache() -> DiskCache {
1727        use std::collections::hash_map::DefaultHasher;
1728        use std::hash::{Hash, Hasher};
1729        use std::time::{SystemTime, UNIX_EPOCH};
1730        let mut h = DefaultHasher::new();
1731        SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos().hash(&mut h);
1732        std::thread::current().id().hash(&mut h);
1733        let dir = std::env::temp_dir()
1734            .join("papers-test-cache")
1735            .join(format!("{:x}", h.finish()));
1736        DiskCache::new(dir, Duration::from_secs(600)).unwrap()
1737    }
1738
1739    #[tokio::test]
1740    async fn test_cache_hit_avoids_second_request() {
1741        let server = MockServer::start().await;
1742        let mock = Mock::given(method("GET"))
1743            .and(path("/works"))
1744            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1745            .expect(1)
1746            .named("list_works")
1747            .mount_as_scoped(&server)
1748            .await;
1749        let client = OpenAlexClient::new()
1750            .with_base_url(server.uri())
1751            .with_cache(temp_cache());
1752        // First call hits the server
1753        let resp1 = client.list_works(&ListParams::default()).await.unwrap();
1754        assert_eq!(resp1.meta.count, 1);
1755        // Second call should come from cache (mock expects exactly 1 hit)
1756        let resp2 = client.list_works(&ListParams::default()).await.unwrap();
1757        assert_eq!(resp2.meta.count, 1);
1758        drop(mock); // verify expectations
1759    }
1760
1761    #[tokio::test]
1762    async fn test_cache_different_params_separate_entries() {
1763        let server = MockServer::start().await;
1764        Mock::given(method("GET"))
1765            .and(path("/works"))
1766            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1767            .expect(2)
1768            .mount(&server)
1769            .await;
1770        let client = OpenAlexClient::new()
1771            .with_base_url(server.uri())
1772            .with_cache(temp_cache());
1773        let p1 = ListParams { search: Some("a".into()), ..Default::default() };
1774        let p2 = ListParams { search: Some("b".into()), ..Default::default() };
1775        client.list_works(&p1).await.unwrap();
1776        client.list_works(&p2).await.unwrap();
1777    }
1778
1779    #[tokio::test]
1780    async fn test_cache_error_not_cached() {
1781        let server = MockServer::start().await;
1782        Mock::given(method("GET"))
1783            .and(path("/works/bad"))
1784            .respond_with(ResponseTemplate::new(500).set_body_string("error"))
1785            .expect(2)
1786            .mount(&server)
1787            .await;
1788        let client = OpenAlexClient::new()
1789            .with_base_url(server.uri())
1790            .with_cache(temp_cache());
1791        let _ = client.get_work("bad", &GetParams::default()).await;
1792        let _ = client.get_work("bad", &GetParams::default()).await;
1793    }
1794
1795    #[tokio::test]
1796    async fn test_no_cache_always_hits_server() {
1797        let server = MockServer::start().await;
1798        Mock::given(method("GET"))
1799            .and(path("/works"))
1800            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_list_json()))
1801            .expect(2)
1802            .mount(&server)
1803            .await;
1804        let client = setup_client(&server).await; // no cache
1805        client.list_works(&ListParams::default()).await.unwrap();
1806        client.list_works(&ListParams::default()).await.unwrap();
1807    }
1808
1809    #[tokio::test]
1810    async fn test_cache_post_json() {
1811        let server = MockServer::start().await;
1812        Mock::given(method("POST"))
1813            .and(path("/find/works"))
1814            .respond_with(ResponseTemplate::new(200).set_body_string(minimal_find_json()))
1815            .expect(1)
1816            .mount(&server)
1817            .await;
1818        let client = OpenAlexClient::with_api_key("k")
1819            .with_base_url(server.uri())
1820            .with_cache(temp_cache());
1821        let params = FindWorksParams::builder().query("test").build();
1822        client.find_works_post(&params).await.unwrap();
1823        // Second call served from cache
1824        client.find_works_post(&params).await.unwrap();
1825    }
1826}