1use 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#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
46#[serde(rename_all = "kebab-case")]
47pub enum SearchField {
48 AffiliationOrgName,
52 CreditName,
54 Email,
56 ExternalIdReference,
58 FamilyName,
60 GivenNames,
62 Keyword,
64 Orcid,
69 OtherNames,
71 RorOrgId,
79 Text,
81}
82#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
84#[serde(rename_all = "kebab-case")]
85pub enum OutputColumn {
86 Email,
88 CreditName,
90 CurrentInstitutionAffiliationName,
92 GivenNames,
94 FamilyName,
96 Orcid,
98 OtherName,
100 PastInstitutionAffiliationName,
102}
103#[derive(Builder, Clone, Debug)]
105#[builder(start_fn = with_token, on(String, into))]
106pub struct Options {
107 #[builder(start_fn)]
109 pub token: String,
110 pub body: Option<String>,
112 #[builder(default = String::from("pub.orcid.org"))]
114 pub domain: String,
115 pub identifier: Option<String>,
117 #[builder(default = vec![])]
119 pub custom_params: Vec<Param>,
120}
121#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
129#[serde(rename_all = "kebab-case")]
130pub struct SearchResponse {
131 #[serde(rename = "@num-found")]
133 pub num_found: usize,
134 #[builder(default = ORCID_EXPANDED_SEARCH_SCHEMA_URI.to_string())]
136 #[serde(rename = "@xmlns:expanded-search")]
137 pub namespace: String,
138 #[builder(default)]
140 #[serde(rename = "expanded-result", default)]
141 pub results: Vec<SearchResult>,
142}
143#[skip_serializing_none]
158#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
159#[serde(rename_all = "kebab-case")]
160pub struct SearchResult {
161 #[serde(rename = "orcid-id")]
163 pub orcid_id: Option<String>,
164 #[serde(rename = "given-names")]
166 pub given_names: Option<String>,
167 #[serde(rename = "family-names")]
169 pub family_names: Option<String>,
170 #[serde(rename = "credit-name")]
172 pub credit_name: Option<String>,
173 #[serde(rename = "email")]
175 pub emails: Option<Vec<String>>,
176 #[serde(rename = "institution-name")]
178 pub institution_names: Option<Vec<String>>,
179 #[serde(rename = "other-name")]
181 pub other_name: Option<Vec<String>>,
182}
183#[derive(Clone, Debug, Deserialize, Serialize)]
196pub struct StatusResponse {
197 #[serde(rename = "tomcatUp")]
199 pub application: bool,
200 #[serde(rename = "dbConnectionOk")]
202 pub database: bool,
203 #[serde(rename = "readOnlyDbConnectionOk")]
205 pub database_readonly: bool,
206 #[serde(rename = "overallOk")]
208 pub overall: bool,
209}
210impl Configuration for Options {
211 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 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}
354pub async fn is_healthy() -> bool {
362 match status().await {
363 | Ok(StatusResponse { overall, .. }) => overall,
364 | Err(_) => false,
365 }
366}
367pub 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}
371pub 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}
402pub 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}
436pub 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}