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