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(¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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", ¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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", ¶ms).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(¶ms).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(¶ms).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(¶ms).await.unwrap();
1823 // Second call served from cache
1824 client.find_works_post(¶ms).await.unwrap();
1825 }
1826}