Skip to main content

acorn/io/api/
citeas.rs

1//! Module for communicating with CiteAs API
2//! > CiteAs is a way to get the correct citation for diverse research products including, software, datasets, preprints, and traditional articles. By making it easier to cite software and other "alternative" scholarly products, we aim to help the creators of such products get full credit for their work.
3//!
4//! See <https://citeas.org/api> for more information
5use crate::analyzer::discovery::CitationFormat;
6use crate::io::api::{ApiResult, Configuration, Param, Params, RemoteResource, INCLUDED_ENDPOINTS};
7use crate::param;
8use crate::prelude::var;
9use crate::schema::pid::{PersistentIdentifier, PublicationIdentifierType, ARXIV, DOI};
10use crate::util::{Label, Searchable};
11use async_trait::async_trait;
12use bon::Builder;
13use color_eyre::eyre::eyre;
14use serde::{Deserialize, Serialize};
15use serde_with::skip_serializing_none;
16use tracing::debug;
17
18/// CiteAs API options
19#[derive(Builder, Clone, Debug)]
20#[builder(start_fn = with_token, on(String, into))]
21pub struct Options {
22    /// Bearer token for authentication
23    #[builder(start_fn)]
24    pub token: String,
25    /// Request body payload
26    pub body: Option<String>,
27    /// CiteAs API domain (defaults to citeas.org)
28    #[builder(default = String::from("citeas.org"))]
29    pub domain: String,
30    /// Resource identifier
31    pub identifier: Option<String>,
32    /// Custom API parameters to include in every request
33    #[builder(default = vec![])]
34    pub custom_params: Vec<Param>,
35}
36impl Configuration for Options {
37    /// Build options from CiteAs environment variables
38    /// - `CITEAS_API_TOKEN` -> `token` (optional — public API)
39    /// - `CITEAS_SERVER_HOST` -> `domain` (defaults to citeas.org)
40    fn from_env() -> Self {
41        if let Err(why) = dotenvy::from_filename(".env") {
42            debug!("=> {} Load .env — {why}", Label::skip());
43        }
44        Self {
45            token: var("CITEAS_API_TOKEN").unwrap_or_default(),
46            body: None,
47            domain: var("CITEAS_SERVER_HOST").unwrap_or_else(|_| String::from("citeas.org")),
48            identifier: None,
49            custom_params: vec![],
50        }
51    }
52    fn with_body(self, value: impl Into<String>) -> Self {
53        Self {
54            body: Some(value.into()),
55            ..self
56        }
57    }
58    fn with_domain(self, value: impl Into<String>) -> Self {
59        Self {
60            domain: value.into(),
61            ..self
62        }
63    }
64    fn with_identifier(self, value: impl Into<String>) -> Self {
65        Self {
66            identifier: Some(value.into()),
67            ..self
68        }
69    }
70    fn token(&self) -> &str {
71        &self.token
72    }
73    fn domain(&self) -> &str {
74        &self.domain
75    }
76    fn identifier(&self) -> Option<&str> {
77        self.identifier.as_deref()
78    }
79    fn with_params(self, params: Vec<Param>) -> Self {
80        Self {
81            custom_params: params,
82            ..self
83        }
84    }
85    fn params(&self) -> &[Param] {
86        &self.custom_params
87    }
88}
89impl From<DOI> for Options {
90    fn from(value: DOI) -> Self {
91        let value = value.to_string();
92        let identifier = urlencoding::encode(&value);
93        Self::from_env().with_params(vec![param!(TemplateValue, "identifier", identifier.as_ref())])
94    }
95}
96impl From<ARXIV> for Options {
97    fn from(value: ARXIV) -> Self {
98        let value = value.url();
99        let identifier = urlencoding::encode(&value);
100        Self::from_env().with_params(vec![param!(TemplateValue, "identifier", identifier.as_ref())])
101    }
102}
103impl From<PublicationIdentifierType> for Options {
104    fn from(value: PublicationIdentifierType) -> Self {
105        match value {
106            | PublicationIdentifierType::Doi(doi) => Self::from(doi),
107            | PublicationIdentifierType::Arxiv(arxiv) => Self::from(arxiv),
108            | PublicationIdentifierType::Unknown => Self::from_env(),
109        }
110    }
111}
112
113/// Trait for things that can be converted to citations.
114#[async_trait]
115pub trait ToCitations {
116    /// Convert to `Citations`
117    async fn to_citations(&self) -> ApiResult<Citations>;
118}
119/// Author object
120#[derive(Clone, Debug, Deserialize, Serialize)]
121pub struct Author {
122    /// First name
123    pub given: String,
124    /// Last name
125    pub family: String,
126}
127/// Main response object for the CiteAs API, returning citations for a given input
128#[derive(Clone, Debug, Deserialize, Serialize, Default)]
129pub struct Citations {
130    /// List of citation objects
131    pub citations: Vec<Citation>,
132    /// List of export objects
133    pub exports: Vec<Export>,
134    /// Metadata for listing all metadata found for a given resource.
135    /// <div class="warning">Varies by source</div>
136    pub metadata: Metadata,
137    /// Name of referenced resource
138    pub name: String,
139    /// List of provenance objects describing sources utilized to find and build citation data
140    pub provenance: Vec<Provenance>,
141    /// URL for the given resource
142    /// <div class="warning">If input is a keyword, the URL is the first Google search result for the given keyword</div>
143    pub url: String,
144}
145/// Citation API response object
146#[derive(Clone, Debug, Deserialize, Serialize)]
147pub struct Citation {
148    /// Citation entry
149    #[serde(alias = "citation")]
150    pub text: String,
151    /// Full name of the citation style
152    /// ### Example
153    /// > "American Psychological Association 6th edition"
154    pub style_fullname: String,
155    /// Short name of the citation style
156    /// ### Example
157    /// > "APA"
158    pub style_shortname: String,
159}
160/// Exported citation data
161#[derive(Clone, Debug, Deserialize, Serialize)]
162pub struct Export {
163    /// Citation export
164    pub export: String,
165    /// Export format
166    /// ### Note
167    /// > May include CSV, enw, [RIS], and [BibTeX].
168    ///
169    /// [RIS]: https://en.wikipedia.org/wiki/RIS_(file_format)
170    /// [BibTeX]: https://www.bibtex.org/
171    pub export_name: String,
172}
173/// Metadata for source
174/// <div class="warning">Varies by source</div>
175#[derive(Clone, Debug, Deserialize, Serialize, Default)]
176pub struct Metadata {
177    /// List of authors
178    pub author: Vec<Author>,
179    /// List of categories that resource applies to
180    pub categories: Vec<String>,
181    /// List of contributors
182    pub contributor: Vec<Author>,
183    /// Valid DOI
184    #[serde(alias = "DOI")]
185    pub doi: String,
186    /// ID for the resource
187    /// <div class="warning">Always "ITEM-1"</div>
188    pub id: String,
189    /// Publisher of resource
190    pub publisher: String,
191    /// Type of resource
192    #[serde(rename = "type")]
193    pub resource_type: String,
194    /// Title of the resource
195    /// ### Example
196    /// > "Oak Ridge National Laboratory (ORNL), Oak Ridge, TN (United States)"
197    pub title: String,
198    /// Resource URL
199    #[serde(alias = "URL")]
200    pub url: String,
201    /// Year of publication
202    pub year: u16,
203}
204/// Citation provenance object
205///
206/// Describes steps taken to try and find citation data, and whether citation data was found
207#[skip_serializing_none]
208#[derive(Clone, Debug, Deserialize, Serialize)]
209pub struct Provenance {
210    /// Additional URL utilized to discover citation data
211    pub additional_content_url: Option<String>,
212    /// URL utilized to discover citation data
213    pub content_url: Option<String>,
214    /// Original URL of the resource
215    pub original_url: Option<String>,
216    /// Returns "doi" or "arXiv ID" if found via DOI or arXiv, else "null"
217    pub found_via_proxy_type: Option<String>,
218    /// Returns true if content was found at the URL
219    pub has_content: bool,
220    /// Host of the resource, such as crossref, github or pypi
221    pub host: Option<String>,
222    /// Name of the step taken to find citation data
223    pub name: String,
224    /// Name of the parent step
225    pub parent_step_name: String,
226    /// Name of the parent subject
227    pub parent_subject: Option<String>,
228    /// Subject of the current step
229    /// ### Example
230    /// > "GitHub repository main page"
231    pub subject: String,
232    /// Resource keyword
233    pub key_word: Option<String>,
234}
235/// Describes status of the CiteAs API
236#[derive(Clone, Debug, Deserialize, Serialize)]
237pub struct StatusResponse {
238    /// Where you can find documentation for this version
239    /// ### Example
240    /// > "<https://citeas.org/api>"
241    pub documentation_url: String,
242    /// Relevant messages
243    /// ### Example
244    /// > "Don't panic"
245    pub msg: String,
246    /// API version
247    /// ### Example
248    /// > "0.1"
249    pub version: String,
250}
251/// Check if API is healthy
252/// ### Example
253/// ```ignore
254/// use acorn_lib::io::api;
255///
256/// println!("CiteAs API is healthy: {}", api::citeas::is_healthy().await);
257/// ```
258pub async fn is_healthy() -> bool {
259    match status().await {
260        | Ok(StatusResponse { msg, .. }) => msg.eq_ignore_ascii_case("Don't panic"),
261        | Err(_) => false,
262    }
263}
264/// Perform search on CiteAs API
265///
266/// The CiteAs API is simple and only has two endpoints. This accesses the endpoint for retrieving citation data for a product identifier.
267///
268/// ### Example
269/// ```ignore
270/// use acorn::param;
271/// use acorn::io::api::citeas;
272///
273/// let doi = "10.11578/dc.20250604.1";
274/// let options = citeas::Options::from_env()
275///     .with_params(vec![param!(TemplateValue, "identifier", doi)]);
276/// let citations = citeas::search(&options).await;
277/// ```
278pub async fn search(options: &Options) -> ApiResult<Citations> {
279    let name = "CiteAs";
280    let action = "record";
281    let params = Params::new().with_custom(options.params()).build();
282    let data = Some(params);
283    match INCLUDED_ENDPOINTS.find_by_name(name) {
284        | Some(endpoint) => {
285            let response = endpoint.invoke(action, data).await;
286            endpoint.handle::<Citations>(response)
287        }
288        | None => Err(eyre!("{name} API endpoint not found")),
289    }
290}
291/// Get status of CiteAs API
292///
293/// See `https://citeas.org/api#api-status-object` for more information
294pub async fn status() -> ApiResult<StatusResponse> {
295    let name = "CiteAs";
296    let action = "status";
297    let data = None;
298    match INCLUDED_ENDPOINTS.find_by_name(name) {
299        | Some(endpoint) => {
300            let response = endpoint.invoke(action, data).await;
301            endpoint.handle::<StatusResponse>(response)
302        }
303        | None => Err(eyre!("{name} API endpoint not found")),
304    }
305}
306impl Citations {
307    /// Get citation data with the requested citation style.
308    ///
309    /// If the requested style is not found, the first available citation is returned.
310    pub fn match_style(self, value: CitationFormat) -> Option<Citation> {
311        let citations = self.citations;
312        let normalized_value = value.to_string();
313        let result = citations
314            .iter()
315            .find(|citation| citation.style_shortname.eq_ignore_ascii_case(&normalized_value));
316        result.cloned().or_else(|| citations.first().cloned())
317    }
318}
319#[async_trait]
320impl ToCitations for DOI {
321    /// Convert a [`DOI`] to a [`Citations`]
322    async fn to_citations(&self) -> ApiResult<Citations> {
323        search(&Options::from(self.clone())).await
324    }
325}
326#[async_trait]
327impl ToCitations for ARXIV {
328    async fn to_citations(&self) -> ApiResult<Citations> {
329        search(&Options::from(self.clone())).await
330    }
331}
332#[async_trait]
333impl ToCitations for PublicationIdentifierType {
334    async fn to_citations(&self) -> ApiResult<Citations> {
335        match self {
336            | Self::Doi(doi) => doi.to_citations().await,
337            | Self::Arxiv(arxiv) => arxiv.to_citations().await,
338            | Self::Unknown => Err(eyre!("Unsupported publication identifier")),
339        }
340    }
341}