1use 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#[derive(Builder, Clone, Debug)]
43#[builder(start_fn = with_token, on(String, into))]
44pub struct Options {
45 #[builder(start_fn)]
47 pub token: String,
48 pub body: Option<String>,
50 #[builder(default = String::from("pub.orcid.org"))]
52 pub domain: String,
53 pub identifier: Option<String>,
55 #[builder(default = vec![])]
57 pub custom_params: Vec<Param>,
58}
59impl Configuration for Options {
60 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#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
115#[serde(rename_all = "kebab-case")]
116pub enum SearchField {
117 AffiliationOrgName,
121 CreditName,
123 Email,
125 ExternalIdReference,
127 FamilyName,
129 GivenNames,
131 Keyword,
133 Orcid,
138 OtherNames,
140 RorOrgId,
148 Text,
150}
151#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
153#[serde(rename_all = "kebab-case")]
154pub enum OutputColumn {
155 Email,
157 CreditName,
159 CurrentInstitutionAffiliationName,
161 GivenNames,
163 FamilyName,
165 Orcid,
167 OtherName,
169 PastInstitutionAffiliationName,
171}
172#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
180#[serde(rename_all = "kebab-case")]
181pub struct SearchResponse {
182 #[serde(rename = "@num-found")]
184 pub num_found: usize,
185 #[builder(default = "http://www.orcid.org/ns/expanded-search".to_string())]
187 #[serde(rename = "@xmlns:expanded-search")]
188 pub namespace: String,
189 #[builder(default)]
191 #[serde(rename = "expanded-result", default)]
192 pub results: Vec<SearchResult>,
193}
194#[skip_serializing_none]
209#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
210#[serde(rename_all = "kebab-case")]
211pub struct SearchResult {
212 #[serde(rename = "orcid-id")]
214 pub orcid_id: Option<String>,
215 #[serde(rename = "given-names")]
217 pub given_names: Option<String>,
218 #[serde(rename = "family-names")]
220 pub family_names: Option<String>,
221 #[serde(rename = "credit-name")]
223 pub credit_name: Option<String>,
224 #[serde(rename = "email")]
226 pub emails: Option<Vec<String>>,
227 #[serde(rename = "institution-name")]
229 pub institution_names: Option<Vec<String>>,
230 #[serde(rename = "other-name")]
232 pub other_name: Option<Vec<String>>,
233}
234#[derive(Clone, Debug, Deserialize, Serialize)]
247pub struct StatusResponse {
248 #[serde(rename = "tomcatUp")]
250 pub application: bool,
251 #[serde(rename = "dbConnectionOk")]
253 pub database: bool,
254 #[serde(rename = "readOnlyDbConnectionOk")]
256 pub database_readonly: bool,
257 #[serde(rename = "overallOk")]
259 pub overall: bool,
260}
261impl ValueValidator for SearchField {
262 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}
343pub async fn is_healthy() -> bool {
351 match status().await {
352 | Ok(StatusResponse { overall, .. }) => overall,
353 | Err(_) => false,
354 }
355}
356pub 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}
360pub 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}
391pub 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}