acorn-lib 0.3.2

ACORN library
//! Module for interacting with [ORCiD REST API](https://info.orcid.org/what-is-orcid/services/public-api/)
//!
//! Provides types and functions for constructing ORCiD API queries with field validation and output column selection
//!
//! ## Example Uses
//!
//! ### Get API status
//! ```ignore
//! use acorn_lib::io::api;
//!
//! println!("ORCiD REST API is healthy: {}", api::orcid::is_healthy().await);
//! ```
//!
//! ### Search for last names of people affiliated with Lyrasis and ORNL
//! ```ignore
//! use acorn_lib::param;
//! use acorn_lib::io::api;
//!
//! let params = vec![
//!     param!(
//!         QueryPair,
//!         "q",
//!         (("affiliation-org-name", "Lyrasis"), ("ror-org-id", "\"https://ror.org/01qz5mb56\""),)
//!     ),
//!     param!(FieldList, "fl", "family-name"),
//! ];
//! println!("ORCiD Search Response: {:#?}", api::orcid::search(params).await);
//! ```
use crate::io::api::{self, Configuration, Param, Params, RemoteResource, ValueValidator, INCLUDED_ENDPOINTS};
use crate::io::ApiResult;
use crate::param;
use crate::util::constants::app::DEFAULT_ORCID_DOMAIN;
use crate::util::constants::env::{ORCID_API_TOKEN, ORCID_SERVER_HOST};
use acorn_core::options::{ApiExtension, ApiOptions};
use acorn_core::util::Searchable;
use acorn_schema::namespaces::ORCID_EXPANDED_SEARCH_SCHEMA_URI;
use acorn_schema::pid::{PersistentIdentifier, PersistentIdentifierConvert, PersistentIdentifierParse, ORCID};
use bon::Builder;
use color_eyre::eyre::{self, eyre};
use core::fmt;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;

/// ORCiD API options
pub type Options = ApiOptions<Extension, Param>;
/// ORCiD allowed output columns
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum OutputColumn {
    /// Email address
    Email,
    /// Credit name
    CreditName,
    /// Current institution affiliation name
    CurrentInstitutionAffiliationName,
    /// Given names
    GivenNames,
    /// Family names
    FamilyName,
    /// [`ORCID`](acorn_schema::pid::ORCID) identifier
    Orcid,
    /// Other name
    OtherName,
    /// Past institution affiliation name
    PastInstitutionAffiliationName,
}
/// ORCiD allowed search fields
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SearchField {
    /// Affiliation organization name
    /// ### Example
    /// > "Oak Ridge National Laboratory"
    AffiliationOrgName,
    /// Preferred form or display name, which can differ from legal or given/family names
    CreditName,
    /// Email address
    Email,
    /// External ID reference
    ExternalIdReference,
    /// Family name (i.e., last name or surname)
    FamilyName,
    /// Given names (i.e., first name(s))
    GivenNames,
    /// Keyword
    Keyword,
    /// [`ORCID`](acorn_schema::pid::ORCID) identifier
    /// ### Examples
    /// - `0000-0002-2057-9115` (Jason Wohlgemuth)
    /// - `0009-0005-5568-6526` (Audrey Carson)
    Orcid,
    /// Other names
    OtherNames,
    /// [ROR](https://ror.org) organization ID
    /// ### Notes
    /// - Must include ror.org domain
    /// - Must be enclosed in double quotes
    /// ### Examples
    /// - "<https://ror.org/01qz5mb56>" (Oak Ridge National Laboratory)
    /// - "<https://ror.org/05p915b28>" (Oak Ridge Leadership Computing Facility)
    RorOrgId,
    /// Text field that contains all of the other fields
    Text,
}
/// ORCiD-specific API option defaults.
#[derive(Clone, Debug, Default)]
pub struct Extension;
/// ORCiD search response
/// ### Example response
/// ```xml
/// <expanded-search:expanded-search xmlns:expanded-search="http://www.orcid.org/ns/expanded-search" num-found="68">
///     ...results
/// </expanded-search:expanded-search>
/// ```
#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct SearchResponse {
    /// Number of results found
    #[serde(rename = "@num-found")]
    pub num_found: usize,
    /// XML namespace for expanded search
    #[builder(default = ORCID_EXPANDED_SEARCH_SCHEMA_URI.to_string())]
    #[serde(rename = "@xmlns:expanded-search")]
    pub namespace: String,
    /// List of expanded search results
    #[builder(default)]
    #[serde(rename = "expanded-result", default)]
    pub results: Vec<SearchResult>,
}
/// ORCiD search result
/// ### Example response
/// ```xml
/// <expanded-search:expanded-result>
///     <expanded-search:orcid-id>0000-0002-2057-9115</expanded-search:orcid-id>
///     <expanded-search:given-names>Jason</expanded-search:given-names>
///     <expanded-search:family-names>Wohlgemuth</expanded-search:family-names>
///     <expanded-search:credit-name>Jason Wohlgemuth</expanded-search:credit-name>
///     <expanded-search:institution-name>Lyrasis</expanded-search:institution-name>
///     <expanded-search:institution-name>Oak Ridge National Laboratory</expanded-search:institution-name>
///     <expanded-search:institution-name>USSTRATCOM</expanded-search:institution-name>
///     <expanded-search:institution-name>University of Nebraska Omaha</expanded-search:institution-name>
/// </expanded-search:expanded-result>
/// ```
#[skip_serializing_none]
#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct SearchResult {
    /// [`ORCID`](acorn_schema::pid::ORCID) identifier
    #[serde(rename = "orcid-id")]
    pub orcid_id: Option<String>,
    /// Given names (first name(s))
    #[serde(rename = "given-names")]
    pub given_names: Option<String>,
    /// Family name (last name or surname)
    #[serde(rename = "family-names")]
    pub family_names: Option<String>,
    /// Credit name (preferred display name)
    #[serde(rename = "credit-name")]
    pub credit_name: Option<String>,
    /// Email addresses
    #[serde(rename = "email")]
    pub emails: Option<Vec<String>>,
    /// Institution names
    #[serde(rename = "institution-name")]
    pub institution_names: Option<Vec<String>>,
    /// Other names
    #[serde(rename = "other-name")]
    pub other_name: Option<Vec<String>>,
}
/// Describes status of the ORCiD API
/// ### Caution
/// > Limit status checks to once every 5 mins ([docs](https://info.orcid.org/ufaqs/how-do-i-check-the-server-status/))
/// ### Example response
/// ```json
/// {
///   "tomcatUp": true,
///   "dbConnectionOk": true,
///   "readOnlyDbConnectionOk": true,
///   "overallOk": true
/// }
/// ```
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct StatusResponse {
    /// Application server status
    #[serde(rename = "tomcatUp")]
    pub application: bool,
    /// Database server status
    #[serde(rename = "dbConnectionOk")]
    pub database: bool,
    /// Read-only database server status
    #[serde(rename = "readOnlyDbConnectionOk")]
    pub database_readonly: bool,
    /// Overall API status
    #[serde(rename = "overallOk")]
    pub overall: bool,
}
impl ApiExtension for Extension {
    fn default_domain() -> String {
        String::from(DEFAULT_ORCID_DOMAIN)
    }
    fn env_token_var() -> &'static str {
        ORCID_API_TOKEN
    }
    fn env_domain_var() -> &'static str {
        ORCID_SERVER_HOST
    }
}
impl fmt::Display for OutputColumn {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = match self {
            | OutputColumn::CreditName => "credit-name",
            | OutputColumn::CurrentInstitutionAffiliationName => "current-institution-affiliation-name",
            | OutputColumn::Email => "email",
            | OutputColumn::FamilyName => "family-name",
            | OutputColumn::GivenNames => "given-names",
            | OutputColumn::Orcid => "orcid",
            | OutputColumn::OtherName => "other-name",
            | OutputColumn::PastInstitutionAffiliationName => "past-institution-affiliation-name",
        };
        write!(f, "{}", s)
    }
}
impl TryFrom<&str> for OutputColumn {
    type Error = String;

    fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
        match value {
            | "credit-name" => Ok(OutputColumn::CreditName),
            | "current-institution-affiliation-name" => Ok(OutputColumn::CurrentInstitutionAffiliationName),
            | "email" => Ok(OutputColumn::Email),
            | "family-name" => Ok(OutputColumn::FamilyName),
            | "given-names" => Ok(OutputColumn::GivenNames),
            | "orcid" => Ok(OutputColumn::Orcid),
            | "other-name" => Ok(OutputColumn::OtherName),
            | "past-institution-affiliation-name" => Ok(OutputColumn::PastInstitutionAffiliationName),
            | _ => Err(format!("Invalid ORCiD output column: {value}")),
        }
    }
}
impl fmt::Display for SearchField {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = match self {
            | SearchField::AffiliationOrgName => "affiliation-org-name",
            | SearchField::CreditName => "credit-name",
            | SearchField::Email => "email",
            | SearchField::ExternalIdReference => "external-id-reference",
            | SearchField::FamilyName => "family-name",
            | SearchField::GivenNames => "given-names",
            | SearchField::Keyword => "keyword",
            | SearchField::Orcid => "orcid",
            | SearchField::OtherNames => "other-names",
            | SearchField::RorOrgId => "ror-org-id",
            | SearchField::Text => "text",
        };
        write!(f, "{}", s)
    }
}
impl TryFrom<&str> for SearchField {
    type Error = String;

    fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
        match value {
            | "affiliation-org-name" => Ok(SearchField::AffiliationOrgName),
            | "credit-name" => Ok(SearchField::CreditName),
            | "email" => Ok(SearchField::Email),
            | "external-id-reference" => Ok(SearchField::ExternalIdReference),
            | "family-name" => Ok(SearchField::FamilyName),
            | "given-names" => Ok(SearchField::GivenNames),
            | "keyword" => Ok(SearchField::Keyword),
            | "orcid" => Ok(SearchField::Orcid),
            | "other-names" => Ok(SearchField::OtherNames),
            | "ror-org-id" => Ok(SearchField::RorOrgId),
            | "text" => Ok(SearchField::Text),
            | _ => Err(format!("Invalid ORCiD search field: {value}")),
        }
    }
}
impl ValueValidator for SearchField {
    /// Validate certain types of ORCiD search field values
    ///
    /// Special validation is performed for `RorOrgId` and `Orcid` fields.
    fn is_valid(&self, value: &str) -> bool {
        match self {
            | SearchField::RorOrgId => value.replace("\"", "").is_ror(),
            | SearchField::Orcid => value.is_orcid(),
            | _ => true,
        }
    }
}
impl From<SearchResult> for SearchResponse {
    fn from(profile: SearchResult) -> Self {
        Self {
            num_found: 1,
            namespace: ORCID_EXPANDED_SEARCH_SCHEMA_URI.to_string(),
            results: vec![profile],
        }
    }
}
/// Check if API is healthy
/// ### Example
/// ```ignore
/// use acorn_lib::io::api;
///
/// println!("ORCiD API is healthy: {}", api::orcid::is_healthy().await);
/// ```
pub async fn is_healthy() -> bool {
    match status().await {
        | Ok(StatusResponse { overall, .. }) => overall,
        | Err(_) => false,
    }
}
/// Construct query string for ORCiD API search endpoint
pub fn query_string(query_pairs: Vec<(&str, &str)>, field_list: Vec<&str>, query_fields: Vec<&str>) -> String {
    api::query_string::<SearchField, OutputColumn>(query_pairs, field_list, query_fields)
}
/// Get the expanded-search profile for one ORCID identifier
pub async fn record(options: &Options) -> ApiResult<SearchResult> {
    let value = options.identifier().unwrap_or_default();
    let identifier = ORCID::from_string(value).identifier();
    match identifier.is_empty() {
        | true => Err(eyre!("Invalid ORCID identifier: {value}")),
        | false => {
            let query = param!(QueryPair, "q", ("orcid", identifier.as_str()));
            let fields = param!(
                FieldList,
                "fl",
                vec![
                    "orcid",
                    "email",
                    "credit-name",
                    "given-names",
                    "family-name",
                    "other-name",
                    "current-institution-affiliation-name",
                    "past-institution-affiliation-name",
                ]
            );
            let params = [query, fields].into_iter().chain(options.params().iter().cloned()).collect();
            let search_options = options.clone().with_params(params);
            search(&search_options).await.and_then(|response| {
                response
                    .results
                    .into_iter()
                    .find(|result| result.orcid_id.as_deref() == Some(identifier.as_str()))
                    .ok_or_else(|| eyre!("ORCID profile not found: {identifier}"))
            })
        }
    }
}
/// Search the ORCiD API with given options containing query parameters and output fields
///
/// ### Example
/// ```ignore
/// use acorn::param;
/// use acorn::io::api::orcid::{self, Options, SearchResponse};
///
/// let options = Options::from_env()
///     .with_params(vec![
///         param!(
///             QueryPair,
///             "q",
///             (("affiliation-org-name", "Lyrasis"), ("ror-org-id", "\"https://ror.org/01qz5mb56\""),)
///         ),
///         param!(FieldList, "fl", "family-name"),
///     ]);
/// let result: ApiResult<SearchResponse> = orcid::search(&options).await;
/// ```
pub async fn search(options: &Options) -> ApiResult<SearchResponse> {
    let name = "ORCiD";
    let action = "search";
    let params = Params::new().with_custom(options.params()).build();
    let data = Some(params);
    match INCLUDED_ENDPOINTS.find_by_name(name) {
        | Some(endpoint) => {
            let response = endpoint.invoke_with::<SearchField, OutputColumn>(action, data).await;
            endpoint.handle::<SearchResponse>(response)
        }
        | None => Err(eyre!("{name} API endpoint not found")),
    }
}
/// Get status of ORCiD API
pub async fn status() -> ApiResult<StatusResponse> {
    let name = "ORCiD";
    let action = "status";
    let data = None;
    match INCLUDED_ENDPOINTS.find_by_name(name) {
        | Some(endpoint) => {
            let response = endpoint.invoke(action, data).await;
            endpoint.handle::<StatusResponse>(response)
        }
        | None => Err(eyre!("{name} API endpoint not found")),
    }
}

#[cfg(test)]
mod tests;