use crate::io::api::{self, Configuration, Param, Params, RemoteResource, ValueValidator, INCLUDED_ENDPOINTS};
use crate::io::ApiResult;
use crate::prelude::var;
use crate::schema::validate::{is_orcid, is_ror};
use crate::util::{Label, Searchable};
use bon::Builder;
use color_eyre::eyre::{self, eyre};
use core::fmt;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use tracing::debug;
#[derive(Builder, Clone, Debug)]
#[builder(start_fn = with_token, on(String, into))]
pub struct Options {
#[builder(start_fn)]
pub token: String,
pub body: Option<String>,
#[builder(default = String::from("pub.orcid.org"))]
pub domain: String,
pub identifier: Option<String>,
#[builder(default = vec![])]
pub custom_params: Vec<Param>,
}
impl Configuration for Options {
fn from_env() -> Self {
if let Err(why) = dotenvy::from_filename(".env") {
debug!("=> {} Load .env — {why}", Label::skip());
}
Self {
token: var("ORCID_API_TOKEN").unwrap_or_default(),
body: None,
domain: var("ORCID_SERVER_HOST").unwrap_or_else(|_| String::from("pub.orcid.org")),
identifier: None,
custom_params: vec![],
}
}
fn with_body(self, value: impl Into<String>) -> Self {
Self {
body: Some(value.into()),
..self
}
}
fn with_domain(self, value: impl Into<String>) -> Self {
Self {
domain: value.into(),
..self
}
}
fn with_identifier(self, value: impl Into<String>) -> Self {
Self {
identifier: Some(value.into()),
..self
}
}
fn token(&self) -> &str {
&self.token
}
fn domain(&self) -> &str {
&self.domain
}
fn identifier(&self) -> Option<&str> {
self.identifier.as_deref()
}
fn with_params(self, params: Vec<Param>) -> Self {
Self {
custom_params: params,
..self
}
}
fn params(&self) -> &[Param] {
&self.custom_params
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SearchField {
AffiliationOrgName,
CreditName,
Email,
ExternalIdReference,
FamilyName,
GivenNames,
Keyword,
Orcid,
OtherNames,
RorOrgId,
Text,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum OutputColumn {
Email,
CreditName,
CurrentInstitutionAffiliationName,
GivenNames,
FamilyName,
Orcid,
OtherName,
PastInstitutionAffiliationName,
}
#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct SearchResponse {
#[serde(rename = "@num-found")]
pub num_found: usize,
#[builder(default = "http://www.orcid.org/ns/expanded-search".to_string())]
#[serde(rename = "@xmlns:expanded-search")]
pub namespace: String,
#[builder(default)]
#[serde(rename = "expanded-result", default)]
pub results: Vec<SearchResult>,
}
#[skip_serializing_none]
#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct SearchResult {
#[serde(rename = "orcid-id")]
pub orcid_id: Option<String>,
#[serde(rename = "given-names")]
pub given_names: Option<String>,
#[serde(rename = "family-names")]
pub family_names: Option<String>,
#[serde(rename = "credit-name")]
pub credit_name: Option<String>,
#[serde(rename = "email")]
pub emails: Option<Vec<String>>,
#[serde(rename = "institution-name")]
pub institution_names: Option<Vec<String>>,
#[serde(rename = "other-name")]
pub other_name: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct StatusResponse {
#[serde(rename = "tomcatUp")]
pub application: bool,
#[serde(rename = "dbConnectionOk")]
pub database: bool,
#[serde(rename = "readOnlyDbConnectionOk")]
pub database_readonly: bool,
#[serde(rename = "overallOk")]
pub overall: bool,
}
impl ValueValidator for SearchField {
fn is_valid(&self, value: &str) -> bool {
match self {
| SearchField::RorOrgId => is_ror(value.replace("\"", "").as_str()).is_ok(),
| SearchField::Orcid => is_orcid(value).is_ok(),
| _ => true,
}
}
}
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 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 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 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}")),
}
}
}
pub async fn is_healthy() -> bool {
match status().await {
| Ok(StatusResponse { overall, .. }) => overall,
| Err(_) => false,
}
}
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)
}
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")),
}
}
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")),
}
}