Skip to main content

acorn/io/api/
orcid.rs

1//! Module for interacting with [ORCiD REST API](https://info.orcid.org/what-is-orcid/services/public-api/)
2//!
3//! Provides types and functions for constructing ORCiD API queries with field validation and output column selection
4//!
5//! ## Example Uses
6//!
7//! ### Get API status
8//! ```ignore
9//! use acorn_lib::io::api;
10//!
11//! println!("ORCiD REST API is healthy: {}", api::orcid::is_healthy().await);
12//! ```
13//!
14//! ### Search for last names of people affiliated with Lyrasis and ORNL
15//! ```ignore
16//! use acorn_lib::param;
17//! use acorn_lib::io::api;
18//!
19//! let params = vec![
20//!     param!(
21//!         QueryPair,
22//!         "q",
23//!         (("affiliation-org-name", "Lyrasis"), ("ror-org-id", "\"https://ror.org/01qz5mb56\""),)
24//!     ),
25//!     param!(FieldList, "fl", "family-name"),
26//! ];
27//! println!("ORCiD Search Response: {:#?}", api::orcid::search(params).await);
28//! ```
29use crate::io::api::{self, Configuration, Param, Params, RemoteResource, ValueValidator, INCLUDED_ENDPOINTS};
30use crate::io::ApiResult;
31use crate::prelude::var;
32use crate::schema::validate::{is_orcid, is_ror};
33use crate::util::{Label, Searchable};
34use bon::Builder;
35use color_eyre::eyre::{self, eyre};
36use core::fmt;
37use serde::{Deserialize, Serialize};
38use serde_with::skip_serializing_none;
39use tracing::debug;
40
41/// ORCiD API options
42#[derive(Builder, Clone, Debug)]
43#[builder(start_fn = with_token, on(String, into))]
44pub struct Options {
45    /// Bearer token for authentication
46    #[builder(start_fn)]
47    pub token: String,
48    /// Request body payload
49    pub body: Option<String>,
50    /// ORCiD API domain (defaults to pub.orcid.org)
51    #[builder(default = String::from("pub.orcid.org"))]
52    pub domain: String,
53    /// ORCiD identifier
54    pub identifier: Option<String>,
55    /// Custom API parameters to include in every request
56    #[builder(default = vec![])]
57    pub custom_params: Vec<Param>,
58}
59impl Configuration for Options {
60    /// Build options from ORCiD environment variables
61    /// - `ORCID_API_TOKEN` -> `token` (optional — public API)
62    /// - `ORCID_SERVER_HOST` -> `domain` (defaults to pub.orcid.org)
63    fn from_env() -> Self {
64        if let Err(why) = dotenvy::from_filename(".env") {
65            debug!("=> {} Load .env — {why}", Label::skip());
66        }
67        Self {
68            token: var("ORCID_API_TOKEN").unwrap_or_default(),
69            body: None,
70            domain: var("ORCID_SERVER_HOST").unwrap_or_else(|_| String::from("pub.orcid.org")),
71            identifier: None,
72            custom_params: vec![],
73        }
74    }
75    fn with_body(self, value: impl Into<String>) -> Self {
76        Self {
77            body: Some(value.into()),
78            ..self
79        }
80    }
81    fn with_domain(self, value: impl Into<String>) -> Self {
82        Self {
83            domain: value.into(),
84            ..self
85        }
86    }
87    fn with_identifier(self, value: impl Into<String>) -> Self {
88        Self {
89            identifier: Some(value.into()),
90            ..self
91        }
92    }
93    fn token(&self) -> &str {
94        &self.token
95    }
96    fn domain(&self) -> &str {
97        &self.domain
98    }
99    fn identifier(&self) -> Option<&str> {
100        self.identifier.as_deref()
101    }
102    fn with_params(self, params: Vec<Param>) -> Self {
103        Self {
104            custom_params: params,
105            ..self
106        }
107    }
108    fn params(&self) -> &[Param] {
109        &self.custom_params
110    }
111}
112
113/// ORCiD allowed search fields
114#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
115#[serde(rename_all = "kebab-case")]
116pub enum SearchField {
117    /// Affiliation organization name
118    /// ### Example
119    /// > "Oak Ridge National Laboratory"
120    AffiliationOrgName,
121    /// Preferred form or display name, which can differ from legal or given/family names
122    CreditName,
123    /// Email address
124    Email,
125    /// External ID reference
126    ExternalIdReference,
127    /// Family name (i.e., last name or surname)
128    FamilyName,
129    /// Given names (i.e., first name(s))
130    GivenNames,
131    /// Keyword
132    Keyword,
133    /// [`ORCID`](crate::schema::pid::ORCID) identifier
134    /// ### Examples
135    /// - `0000-0002-2057-9115` (Jason Wohlgemuth)
136    /// - `0009-0005-5568-6526` (Audrey Carson)
137    Orcid,
138    /// Other names
139    OtherNames,
140    /// [ROR](https://ror.org) organization ID
141    /// ### Notes
142    /// - Must include ror.org domain
143    /// - Must be enclosed in double quotes
144    /// ### Examples
145    /// - "<https://ror.org/01qz5mb56>" (Oak Ridge National Laboratory)
146    /// - "<https://ror.org/05p915b28>" (Oak Ridge Leadership Computing Facility)
147    RorOrgId,
148    /// Text field that contains all of the other fields
149    Text,
150}
151/// ORCiD allowed output columns
152#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
153#[serde(rename_all = "kebab-case")]
154pub enum OutputColumn {
155    /// Email address
156    Email,
157    /// Credit name
158    CreditName,
159    /// Current institution affiliation name
160    CurrentInstitutionAffiliationName,
161    /// Given names
162    GivenNames,
163    /// Family names
164    FamilyName,
165    /// [`ORCID`](crate::schema::pid::ORCID) identifier
166    Orcid,
167    /// Other name
168    OtherName,
169    /// Past institution affiliation name
170    PastInstitutionAffiliationName,
171}
172/// ORCiD search response
173/// ### Example response
174/// ```xml
175/// <expanded-search:expanded-search xmlns:expanded-search="http://www.orcid.org/ns/expanded-search" num-found="68">
176///     ...results
177/// </expanded-search:expanded-search>
178/// ```
179#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
180#[serde(rename_all = "kebab-case")]
181pub struct SearchResponse {
182    /// Number of results found
183    #[serde(rename = "@num-found")]
184    pub num_found: usize,
185    /// XML namespace for expanded search
186    #[builder(default = "http://www.orcid.org/ns/expanded-search".to_string())]
187    #[serde(rename = "@xmlns:expanded-search")]
188    pub namespace: String,
189    /// List of expanded search results
190    #[builder(default)]
191    #[serde(rename = "expanded-result", default)]
192    pub results: Vec<SearchResult>,
193}
194/// ORCiD search result
195/// ### Example response
196/// ```xml
197/// <expanded-search:expanded-result>
198///     <expanded-search:orcid-id>0000-0002-2057-9115</expanded-search:orcid-id>
199///     <expanded-search:given-names>Jason</expanded-search:given-names>
200///     <expanded-search:family-names>Wohlgemuth</expanded-search:family-names>
201///     <expanded-search:credit-name>Jason Wohlgemuth</expanded-search:credit-name>
202///     <expanded-search:institution-name>Lyrasis</expanded-search:institution-name>
203///     <expanded-search:institution-name>Oak Ridge National Laboratory</expanded-search:institution-name>
204///     <expanded-search:institution-name>USSTRATCOM</expanded-search:institution-name>
205///     <expanded-search:institution-name>University of Nebraska Omaha</expanded-search:institution-name>
206/// </expanded-search:expanded-result>
207/// ```
208#[skip_serializing_none]
209#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
210#[serde(rename_all = "kebab-case")]
211pub struct SearchResult {
212    /// [`ORCID`](crate::schema::pid::ORCID) identifier
213    #[serde(rename = "orcid-id")]
214    pub orcid_id: Option<String>,
215    /// Given names (first name(s))
216    #[serde(rename = "given-names")]
217    pub given_names: Option<String>,
218    /// Family name (last name or surname)
219    #[serde(rename = "family-names")]
220    pub family_names: Option<String>,
221    /// Credit name (preferred display name)
222    #[serde(rename = "credit-name")]
223    pub credit_name: Option<String>,
224    /// Email addresses
225    #[serde(rename = "email")]
226    pub emails: Option<Vec<String>>,
227    /// Institution names
228    #[serde(rename = "institution-name")]
229    pub institution_names: Option<Vec<String>>,
230    /// Other names
231    #[serde(rename = "other-name")]
232    pub other_name: Option<Vec<String>>,
233}
234/// Describes status of the ORCiD API
235/// ### Caution
236/// > Limit status checks to once every 5 mins ([docs](https://info.orcid.org/ufaqs/how-do-i-check-the-server-status/))
237/// ### Example response
238/// ```json
239/// {
240///   "tomcatUp": true,
241///   "dbConnectionOk": true,
242///   "readOnlyDbConnectionOk": true,
243///   "overallOk": true
244/// }
245/// ```
246#[derive(Clone, Debug, Deserialize, Serialize)]
247pub struct StatusResponse {
248    /// Application server status
249    #[serde(rename = "tomcatUp")]
250    pub application: bool,
251    /// Database server status
252    #[serde(rename = "dbConnectionOk")]
253    pub database: bool,
254    /// Read-only database server status
255    #[serde(rename = "readOnlyDbConnectionOk")]
256    pub database_readonly: bool,
257    /// Overall API status
258    #[serde(rename = "overallOk")]
259    pub overall: bool,
260}
261impl ValueValidator for SearchField {
262    /// Validate certain types of ORCiD search field values
263    ///
264    /// Special validation is performed for `RorOrgId` and `Orcid` fields.
265    fn is_valid(&self, value: &str) -> bool {
266        match self {
267            | SearchField::RorOrgId => is_ror(value.replace("\"", "").as_str()).is_ok(),
268            | SearchField::Orcid => is_orcid(value).is_ok(),
269            | _ => true,
270        }
271    }
272}
273impl fmt::Display for SearchField {
274    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275        let s = match self {
276            | SearchField::AffiliationOrgName => "affiliation-org-name",
277            | SearchField::CreditName => "credit-name",
278            | SearchField::Email => "email",
279            | SearchField::ExternalIdReference => "external-id-reference",
280            | SearchField::FamilyName => "family-name",
281            | SearchField::GivenNames => "given-names",
282            | SearchField::Keyword => "keyword",
283            | SearchField::Orcid => "orcid",
284            | SearchField::OtherNames => "other-names",
285            | SearchField::RorOrgId => "ror-org-id",
286            | SearchField::Text => "text",
287        };
288        write!(f, "{}", s)
289    }
290}
291impl fmt::Display for OutputColumn {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        let s = match self {
294            | OutputColumn::CreditName => "credit-name",
295            | OutputColumn::CurrentInstitutionAffiliationName => "current-institution-affiliation-name",
296            | OutputColumn::Email => "email",
297            | OutputColumn::FamilyName => "family-name",
298            | OutputColumn::GivenNames => "given-names",
299            | OutputColumn::Orcid => "orcid",
300            | OutputColumn::OtherName => "other-name",
301            | OutputColumn::PastInstitutionAffiliationName => "past-institution-affiliation-name",
302        };
303        write!(f, "{}", s)
304    }
305}
306impl TryFrom<&str> for SearchField {
307    type Error = String;
308
309    fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
310        match value {
311            | "affiliation-org-name" => Ok(SearchField::AffiliationOrgName),
312            | "credit-name" => Ok(SearchField::CreditName),
313            | "email" => Ok(SearchField::Email),
314            | "external-id-reference" => Ok(SearchField::ExternalIdReference),
315            | "family-name" => Ok(SearchField::FamilyName),
316            | "given-names" => Ok(SearchField::GivenNames),
317            | "keyword" => Ok(SearchField::Keyword),
318            | "orcid" => Ok(SearchField::Orcid),
319            | "other-names" => Ok(SearchField::OtherNames),
320            | "ror-org-id" => Ok(SearchField::RorOrgId),
321            | "text" => Ok(SearchField::Text),
322            | _ => Err(format!("Invalid ORCiD search field: {value}")),
323        }
324    }
325}
326impl TryFrom<&str> for OutputColumn {
327    type Error = String;
328
329    fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
330        match value {
331            | "credit-name" => Ok(OutputColumn::CreditName),
332            | "current-institution-affiliation-name" => Ok(OutputColumn::CurrentInstitutionAffiliationName),
333            | "email" => Ok(OutputColumn::Email),
334            | "family-name" => Ok(OutputColumn::FamilyName),
335            | "given-names" => Ok(OutputColumn::GivenNames),
336            | "orcid" => Ok(OutputColumn::Orcid),
337            | "other-name" => Ok(OutputColumn::OtherName),
338            | "past-institution-affiliation-name" => Ok(OutputColumn::PastInstitutionAffiliationName),
339            | _ => Err(format!("Invalid ORCiD output column: {value}")),
340        }
341    }
342}
343/// Check if API is healthy
344/// ### Example
345/// ```ignore
346/// use acorn_lib::io::api;
347///
348/// println!("ORCiD API is healthy: {}", api::orcid::is_healthy().await);
349/// ```
350pub async fn is_healthy() -> bool {
351    match status().await {
352        | Ok(StatusResponse { overall, .. }) => overall,
353        | Err(_) => false,
354    }
355}
356/// Construct query string for ORCiD API search endpoint
357pub fn query_string(query_pairs: Vec<(&str, &str)>, field_list: Vec<&str>, query_fields: Vec<&str>) -> String {
358    api::query_string::<SearchField, OutputColumn>(query_pairs, field_list, query_fields)
359}
360/// Search the ORCiD API with given options containing query parameters and output fields
361///
362/// ### Example
363/// ```ignore
364/// use acorn::param;
365/// use acorn::io::api::orcid::{self, Options, SearchResponse};
366///
367/// let options = Options::from_env()
368///     .with_params(vec![
369///         param!(
370///             QueryPair,
371///             "q",
372///             (("affiliation-org-name", "Lyrasis"), ("ror-org-id", "\"https://ror.org/01qz5mb56\""),)
373///         ),
374///         param!(FieldList, "fl", "family-name"),
375///     ]);
376/// let result: ApiResult<SearchResponse> = orcid::search(&options).await;
377/// ```
378pub async fn search(options: &Options) -> ApiResult<SearchResponse> {
379    let name = "ORCiD";
380    let action = "search";
381    let params = Params::new().with_custom(options.params()).build();
382    let data = Some(params);
383    match INCLUDED_ENDPOINTS.find_by_name(name) {
384        | Some(endpoint) => {
385            let response = endpoint.invoke_with::<SearchField, OutputColumn>(action, data).await;
386            endpoint.handle::<SearchResponse>(response)
387        }
388        | None => Err(eyre!("{name} API endpoint not found")),
389    }
390}
391/// Get status of ORCiD API
392pub async fn status() -> ApiResult<StatusResponse> {
393    let name = "ORCiD";
394    let action = "status";
395    let data = None;
396    match INCLUDED_ENDPOINTS.find_by_name(name) {
397        | Some(endpoint) => {
398            let response = endpoint.invoke(action, data).await;
399            endpoint.handle::<StatusResponse>(response)
400        }
401        | None => Err(eyre!("{name} API endpoint not found")),
402    }
403}