Commit 1d6d9b99 authored by Wohlgemuth, Jason's avatar Wohlgemuth, Jason
Browse files

feat: Refine remote resource interface for built-in endpoints

parent 5880a794
Loading
Loading
Loading
Loading
Loading
+36 −71
Original line number Diff line number Diff line
use acorn::io::api::{self, Endpoint, EndpointSearch, RemoteResource, ENDPOINTS};
use acorn::io::api::{self, Endpoint, RemoteResource, Searchable};
use acorn::param;
use acorn::prelude::PathBuf;
use clap_verbosity_flag::Verbosity;
use color_eyre::eyre::{Report, Result};

pub async fn run(_path: &Option<PathBuf>, _ignore: &Option<String>, _offline: &bool, _verbose: &Verbosity) -> Result<(), Report> {
    let text = match ENDPOINTS.find_by_name("geonames") {
        | Some(endpoint) => {
            let response = endpoint.invoke("countries", None).await;
            endpoint
                .handle::<api::TextResponse>(response)
                .map(|text| api::geonames::parse_tsv(&text.to_string()))
        }
        | None => Err("No GeoNames endpoint found".into()),
    };
    println!("Countries: {text:#?}");
    spdx_example(&ENDPOINTS).await;
    orcid_example(&ENDPOINTS).await;
    ror_example(&ENDPOINTS).await;
    let countries = api::geonames::get_countries().await;
    println!("Countries: {:#?}", countries);
    println!("CiteAs API is healthy: {}", api::citeas::is_healthy().await);
    println!("ORCiD API is healthy: {}", api::orcid::is_healthy().await);
    println!("ROR API is healthy: {}", api::ror::is_healthy().await);
    // spdx_example(&INCLUDED_ENDPOINTS).await;
    orcid_example().await;
    ror_example().await;
    Ok(())
}
#[allow(dead_code)]
async fn orcid_example(endpoints: &Vec<Endpoint>) {
    // ORCiD API examples
    let orcid = endpoints.find_by_name("orcid");
    let text = match &orcid {
        | Some(endpoint) => {
            let data = vec![
async fn orcid_example() {
    let params = vec![
        param!(
            QueryPair,
            "q",
@@ -34,55 +25,29 @@ async fn orcid_example(endpoints: &Vec<Endpoint>) {
        ),
        param!(FieldList, "fl", "family-name"),
    ];
            let response = endpoint
                .invoke_with::<api::orcid::SearchField, api::orcid::OutputColumn>("search", Some(data))
                .await;
            endpoint.handle::<api::orcid::SearchResponse>(response)
        }
        | None => Err("No ORCiD endpoint found".into()),
    };
    // ORCiD API examples
    let text = api::orcid::search(params).await;
    println!("ORCiD Search Response: {text:#?}");
    let text = match &orcid {
        | Some(endpoint) => {
            let response = endpoint.invoke("status", None).await;
            endpoint.handle::<api::orcid::StatusResponse>(response)
        }
        | None => Err("No ORCiD endpoint found".into()),
    };
    println!("ORCiD Status: {text:#?}");
}
#[allow(dead_code)]
async fn ror_example(endpoints: &Vec<Endpoint>) {
async fn ror_example() {
    // TODO: Unify record and search
    // ROR API examples
    let ror = endpoints.find_by_name("ror");
    let text = match &ror {
        | Some(endpoint) => {
            let response = endpoint.invoke("status", None).await;
            endpoint.handle::<api::TextResponse>(response)
        }
        | None => Err("No ROR endpoint found".into()),
    };
    println!("ROR Status: {text:#?}");
    let text = match &ror {
        | Some(endpoint) => {
            let data = vec![param!(TemplateValue, "identifier", "01qz5mb56")];
            let response = endpoint.invoke("record", Some(data)).await;
            endpoint.handle::<api::ror::SingleRecord>(response)
        }
        | None => Err("No ROR endpoint found".into()),
    };
    println!("ROR Record: {text:#?}");
    let text = match &ror {
        | Some(endpoint) => {
            let data = vec![
    // let ror = endpoints.find_by_name("ror");
    // let text = match &ror {
    //     | Some(endpoint) => {
    //         let data = vec![param!(TemplateValue, "identifier", "01qz5mb56")];
    //         let response = endpoint.invoke("record", Some(data)).await;
    //         endpoint.handle::<api::ror::SingleRecord>(response)
    //     }
    //     | None => Err("No ROR endpoint found".into()),
    // };
    // println!("ROR Record: {text:#?}");
    let params = vec![
        param!(FieldList, "query", "Oak Ridge"),
        param!(QueryPair, "filter", ("status", "inactive")),
    ];
            let response = endpoint.invoke("search", Some(data)).await;
            endpoint.handle::<api::ror::SearchResponse>(response)
        }
        | None => Err("No ROR endpoint found".into()),
    };
    let text = api::ror::search(params).await;
    println!("ROR Search Results: {text:#?}");
}
#[allow(dead_code)]
+53 −15
Original line number Diff line number Diff line
@@ -2,7 +2,7 @@
//! > CiteAs is a way to get the correct citation for diverse research products including, software, datasets, preprints, and traditional articles. By making it easier to cite software and other "alternative" scholarly products, we aim to help the creators of such products get full credit for their work.
//!
//! See <https://citeas.org/api> for more information
use crate::io::api::{EndpointSearch, RemoteResource, ENDPOINTS};
use crate::io::api::{Param, RemoteResource, Searchable, INCLUDED_ENDPOINTS};
use crate::param;
use crate::schema::pid::DOI;
use async_trait::async_trait;
@@ -147,27 +147,65 @@ pub struct StatusResponse {
    /// > "0.1"
    pub version: String,
}
/// Check if API is healthy
/// ### Example
/// ```ignore
/// use acorn_lib::io::api;
///
/// println!("CiteAs API is healthy: {}", api::citeas::is_healthy().await);
/// ```
pub async fn is_healthy() -> bool {
    match status().await {
        | Ok(StatusResponse { msg, .. }) => msg.eq_ignore_ascii_case("Don't panic"),
        | Err(_) => false,
    }
}
/// Perform search on CiteAs API
///
/// The CiteAs API is simple and only has two endpoints. This accesses the endpoint for retrieving citation data for a [`DOI`]
///
/// ### Example
/// ```ignore
/// use acorn::param;
/// use acorn::io::api::citeas;
///
/// let doi = "10.11578/dc.20250604.1";
/// let params = vec![param!(TemplateValue, "doi", doi)];
/// let citations = citeas::search(params).await;
/// ```
pub async fn search(params: Vec<Param>) -> Result<Citations, String> {
    let name = "CiteAs";
    let action = "record";
    let data = Some(params);
    match INCLUDED_ENDPOINTS.find_by_name(name) {
        | Some(endpoint) => {
            let response = endpoint.invoke(action, data).await;
            endpoint.handle::<Citations>(response)
        }
        | None => Err(format!("{name} API endpoint not found")),
    }
}
/// Get status of CiteAs API
///
/// See `https://citeas.org/api#api-status-object` for more information
pub async fn status() -> Result<StatusResponse, String> {
    match ENDPOINTS.find_by_name("citeas") {
    let name = "CiteAs";
    let action = "status";
    let data = None;
    match INCLUDED_ENDPOINTS.find_by_name(name) {
        | Some(endpoint) => {
            let response = endpoint.invoke("status", None).await;
            let response = endpoint.invoke(action, data).await;
            endpoint.handle::<StatusResponse>(response)
        }
        | None => Err("CiteAs API endpoint not found".to_string()),
        | None => Err(format!("{name} API endpoint not found")),
    }
}
impl Citations {
    /// Use CiteAs API to get citation data from DOI value
    pub async fn from_doi(value: &str) -> Result<Citations, String> {
        match ENDPOINTS.find_by_name("citeas") {
            | Some(endpoint) => {
                let data = vec![param!(TemplateValue, "doi", value)];
                let response = endpoint.invoke("record", Some(data)).await;
                endpoint.handle::<Citations>(response)
            }
            | None => Err("CiteAs API endpoint not found".to_string()),
        }
    /// Use CiteAs API to get citation data from a [`DOI`]
    pub async fn from(value: DOI) -> Result<Citations, String> {
        let doi = value.to_string();
        let params = vec![param!(TemplateValue, "doi", &doi)];
        search(params).await
    }
    /// Get citation data with given citation style (ex. "APA")
    ///
@@ -196,6 +234,6 @@ impl Citations {
impl ToCitations for DOI {
    /// Convert a [`DOI`] to a [`Citations`]
    async fn to_citations(&self) -> Result<Citations, String> {
        Citations::from_doi(&self.to_string()).await
        Citations::from(self.clone()).await
    }
}
+102 −86
Original line number Diff line number Diff line
//! Module for downloading, parsing, and using GeoNames data
//!
//! See [Geonames web services documentation](https://www.geonames.org/export/web-services.html) for more information
use crate::io::api::{RemoteResource, Searchable, TextResponse, INCLUDED_ENDPOINTS};
use bon::Builder;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;

/// Trait for finding countries in a list of GeoNames country data
pub trait CountrySearch {
    /// Check if a two- or three-letter ISO code exists in the data
    fn contains(&self, code: &str) -> bool;
    /// Search for a country by its ISO 3166-1 alpha-2 code (e.g., "US")
    fn find_by_iso(&self, code: &str) -> Option<&Country>;
    /// Search for a country by its name (e.g., "United States")
    fn find_by_name(&self, name: &str) -> Option<&Country>;
/// Type alias for GeoNames API reponse for country data, which is returned as TSV text that can be parsed into structured data
pub type SearchResponse = TextResponse;
/// Trait for parsing GeoNames country data
pub trait CountryParser {
    /// Parse a GeoNames TSV export into a list of GeoNames countries
    fn parse(text: &str) -> Countries;
}
/// Type alias for a list of GeoNames countries
pub type Countries = Vec<Country>;
@@ -62,37 +61,42 @@ pub struct Country {
    /// Equivalent FIPS code, if any (deprecated in favor of ISO codes)
    pub equivalent_fips_code: Option<String>,
}
impl CountrySearch for Countries {
    fn find_by_iso(&self, code: &str) -> Option<&Country> {
        let trimmed = code.trim();
        self.iter().find(|country| {
impl Searchable<Country> for Vec<Country> {
    fn contains(&self, value: &str) -> bool {
        let trimmed = value.trim();
        !trimmed.is_empty()
            && self.iter().any(|country| {
                let Country { iso, iso3, .. } = country;
                iso.eq_ignore_ascii_case(trimmed) || iso3.eq_ignore_ascii_case(trimmed)
            })
    }
    fn find_by_name(&self, name: &str) -> Option<&Country> {
        let trimmed = name.trim();
        self.iter().find(|country| {
            let Country { name, .. } = country;
            name.eq_ignore_ascii_case(trimmed)
    fn find_by_iso(&self, value: impl Into<String>) -> Option<Country> {
        let trimmed = value.into().trim().to_string();
        self.iter()
            .find(|country| {
                let Country { iso, iso3, .. } = country;
                iso.eq_ignore_ascii_case(&trimmed) || iso3.eq_ignore_ascii_case(&trimmed)
            })
            .cloned()
    }
    fn contains(&self, code: &str) -> bool {
        let trimmed = code.trim();
        !trimmed.is_empty()
            && self.iter().any(|country| {
                let Country { iso, iso3, .. } = country;
                iso.eq_ignore_ascii_case(trimmed) || iso3.eq_ignore_ascii_case(trimmed)
    fn find_by_name(&self, value: impl Into<String>) -> Option<Country> {
        let trimmed = value.into().trim().to_string();
        self.iter()
            .find(|country| {
                let Country { name, .. } = country;
                name.eq_ignore_ascii_case(&trimmed)
            })
            .cloned()
    }
}
/// Parse GeoNames country TSV content, removing comments and returning structured rows.
impl SearchResponse {
    /// Parse GeoNames country TSV text response, removing comments and returning structured rows.
    ///
    /// Lines beginning with `#` are treated as comments and skipped. The first non-comment
    /// line that starts with the `ISO` header row is treated as the header and skipped.
    /// Remaining lines are split on tab characters and converted into [`Country`] values.
    /// Lines that do not contain at least 19 tab-separated columns are ignored.
pub fn parse_tsv(content: &str) -> Countries {
    pub fn parse(&self) -> Countries {
        fn parse_optional_string_field(value: &str) -> Option<String> {
            let trimmed = value.trim();
            if trimmed.is_empty() {
@@ -120,7 +124,7 @@ pub fn parse_tsv(content: &str) -> Countries {
        fn string_field(columns: &[&str], index: usize) -> String {
            columns.get(index).map(|s| s.trim().to_string()).unwrap_or_default()
        }
    content
        self.content
            .lines()
            .filter(|line| {
                let line = line.trim();
@@ -153,3 +157,15 @@ pub fn parse_tsv(content: &str) -> Countries {
            })
            .collect()
    }
}
/// Pull GeoNames country data from the API and parse it into structured data
pub async fn get_countries() -> Result<Countries, String> {
    let name = "GeoNames";
    match INCLUDED_ENDPOINTS.find_by_name(name) {
        | Some(endpoint) => {
            let response = endpoint.invoke("countries", None).await;
            endpoint.handle::<TextResponse>(response).map(|text| text.parse())
        }
        | None => Err(format!("{name} endpoint not found")),
    }
}
+37 −20
Original line number Diff line number Diff line
@@ -32,14 +32,9 @@ pub mod spdx;

lazy_static! {
    /// Vector of API endpoints used during the endeavor of scientific research, communication, and collaboration
    pub static ref ENDPOINTS: Vec<Endpoint> = ApplicationConfiguration::parse(ENDPOINTS_TEXT).unwrap().endpoints.unwrap_or_default();
    pub static ref INCLUDED_ENDPOINTS: Vec<Endpoint> = ApplicationConfiguration::parse(ENDPOINTS_TEXT).unwrap().endpoints.unwrap_or_default();
}

/// Helper trait for working with list of endpoints
pub trait EndpointSearch {
    /// Filter list of endpoints by name and return the first match
    fn find_by_name(&self, value: impl Into<String>) -> Option<Endpoint>;
}
/// Helper trait for converting parameter collections into HTTP request body
pub trait IntoBody {
    /// Convert this value into a `serde_json::Value` for request body, using only body-style parameters.
@@ -90,6 +85,23 @@ pub trait RemoteResource {
    where
        R: for<'de> Deserialize<'de>;
}
/// Helper trait for searching lists of named API elements
pub trait Searchable<T> {
    /// Check if a certain value is present in the list
    /// ### Note
    /// This method will differ greatly on the implementation and type of T
    fn contains(&self, _value: &str) -> bool {
        false
    }
    /// Filter list by ISO or ISO3 and return the first match
    ///### Note
    /// This method is specific to the `Country` type in the GeoNames API, but is included in the trait for convenience and consistency with `find_by_name`
    fn find_by_iso(&self, _value: impl Into<String>) -> Option<T> {
        None
    }
    /// Filter list by name and return the first match
    fn find_by_name(&self, value: impl Into<String>) -> Option<T>;
}
/// Trait to enable validation of field values
pub trait ValueValidator {
    /// Verify associated field value is valid
@@ -267,10 +279,16 @@ impl Endpoint {
        format!("{scheme}://{domain}{port}{root}")
    }
}
impl EndpointSearch for Vec<Endpoint> {
impl Searchable<Endpoint> for Vec<Endpoint> {
    fn find_by_name(&self, value: impl Into<String>) -> Option<Endpoint> {
        let name = value.into();
        self.iter().find(|endpoint| endpoint.name == name).cloned()
        self.iter().find(|endpoint| endpoint.name.eq_ignore_ascii_case(&name)).cloned()
    }
}
impl Searchable<Resource> for Vec<Resource> {
    fn find_by_name(&self, value: impl Into<String>) -> Option<Resource> {
        let name = value.into();
        self.iter().find(|resource| resource.name.eq_ignore_ascii_case(&name)).cloned()
    }
}
/// Blanket implementation for all types that satisfy the bounds
@@ -546,19 +564,18 @@ impl RemoteResource for Endpoint {
    /// Invoke an endpoint resource asynchronously with data and receive a response using explicit query and field types.
    /// ### Example
    /// ```ignore
    /// let orcid = endpoints.find_by_name("orcid");
    /// use acorn::io::api::{self, Searchable, INCLUDED_ENDPOINTS};
    ///
    /// let orcid = INCLUDED_ENDPOINTS.find_by_name("orcid");
    /// let text = match &orcid {
    ///     | Some(endpoint) => {
    ///         let data = vec![
    ///             api::Param::of_type(api::ParamStyle::QueryPair)
    ///                 .values(vec![
    ///                     vec![Some("affiliation-org-name"), Some("Lyrasis")],
    ///                     vec![Some("ror-org-id"), Some("\"https://ror.org/01qz5mb56\"")],
    ///                 ])
    ///                 .with_key("q"),
    ///             api::Param::of_type(api::ParamStyle::FieldList)
    ///                 .values(vec![vec![Some("family-name")]])
    ///                 .with_key("fl"),
    ///             param!(
    ///                 QueryPair,
    ///                 "q",
    ///                 (("affiliation-org-name", "Lyrasis"), ("ror-org-id", "\"https://ror.org/01qz5mb56\""),)
    ///             ),
    ///             param!(FieldList, "fl", "family-name"),
    ///         ];
    ///         let response = endpoint.invoke_with::<api::orcid::SearchField, api::orcid::OutputColumn>("search", Some(data)).await;
    ///         endpoint.handle::<api::orcid::SearchResponse>(response)
@@ -575,10 +592,10 @@ impl RemoteResource for Endpoint {
        let Self { resources, .. } = self;
        let mut tera = Tera::default();
        let context = self.context_with::<Q, F>(data.clone());
        let resource = resources.iter().find(|resource| resource.name == name.clone().into());
        let resource = resources.find_by_name(name);
        match resource {
            | Some(Resource { method, template, .. }) => {
                let path = tera.render_str(template, &context).unwrap_or_default();
                let path = tera.render_str(&template, &context).unwrap_or_default();
                let params = data.unwrap_or_default();
                let headers = params.clone().into_headers();
                let body = params.into_body();
+66 −13
Original line number Diff line number Diff line
@@ -6,7 +6,7 @@
//!
//! ### Get API status
//! ```ignore
//! use acorn_lib::io::api::{orcid, EndpointSearch};
//! use acorn_lib::io::api::{orcid, Searchable};
//!
//! let orcid = endpoints.find_by_name("orcid");
//! let text = match &orcid {
@@ -21,21 +21,19 @@
//!
//! ### Search the ORCiD API
//! ```ignore
//! use acorn_lib::io::api::{orcid, EndpointSearch, Param, ParamStyle};
//! use acorn_lib::io::api::{orcid, Searchable};
//! use acorn_lib::param;
//!
//! let orcid = endpoints.find_by_name("orcid");
//! let text = match &orcid {
//!     | Some(endpoint) => {
//!         let data = vec![
//!             Param::of_type(ParamStyle::QueryPair)
//!                 .values(vec![
//!                     (Some("affiliation-org-name"), Some("Lyrasis")),
//!                     (Some("ror-org-id"), Some("\"https://ror.org/01qz5mb56\"")),
//!                 ])
//!                 .with_key("q"),
//!             Param::of_type(ParamStyle::FieldList)
//!                 .values(vec![(Some("family-name"), None)])
//!                 .with_key("fl"),
//!             param!(
//!                 QueryPair,
//!                 "q",
//!                 (("affiliation-org-name", "Lyrasis"), ("ror-org-id", "\"https://ror.org/01qz5mb56\""),)
//!             ),
//!             param!(FieldList, "fl", "family-name"),
//!         ];
//!         let response = endpoint.invoke_with::<orcid::SearchField, orcid::OutputColumn>("search", Some(data)).await;
//!         endpoint.handle::<orcid::SearchResponse>(response)
@@ -44,7 +42,7 @@
//! };
//! println!("ORCiD Search Response: {text:#?}");
//! ```
use crate::io::api::{self, ValueValidator};
use crate::io::api::{self, RemoteResource, Searchable, ValueValidator, INCLUDED_ENDPOINTS};
use crate::schema::validate::{is_orcid, is_ror};
use bon::Builder;
use core::fmt;
@@ -81,7 +79,7 @@ pub enum SearchField {
    /// [ROR](https://ror.org) organization ID
    /// ### Notes
    /// - Must include ror.org domain
    /// - Must be enclosed in double qoutes
    /// - Must be enclosed in double quotes
    /// ### Examples
    /// - "<https://ror.org/01qz5mb56>" (Oak Ridge National Laboratory)
    /// - "<https://ror.org/05p915b28>" (Oak Ridge Leadership Computing Facility)
@@ -281,7 +279,62 @@ impl TryFrom<&str> for OutputColumn {
        }
    }
}
/// 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)
}
/// Search the ORCiD API with given query parameters and output fields
///
/// ### Example
/// ```ignore
/// use acorn::param;
/// use acorn::io::api::orcid::{self, SearchResponse};
///
/// let params = vec![
///     param!(
///         QueryPair,
///         "q",
///         (("affiliation-org-name", "Lyrasis"), ("ror-org-id", "\"https://ror.org/01qz5mb56\""),)
///     ),
///     param!(FieldList, "fl", "family-name"),
/// ];
/// let result: Result<SearchResponse, String> = orcid::search(params).await;
/// ```
pub async fn search(params: Vec<api::Param>) -> Result<SearchResponse, String> {
    let name = "ORCiD";
    let action = "search";
    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(format!("{name} API endpoint not found")),
    }
}
/// Get status of ORCiD API
pub async fn status() -> Result<StatusResponse, String> {
    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(format!("{name} API endpoint not found")),
    }
}
Loading