Loading acorn-cli/src/commands/gather/mod.rs +8 −8 Original line number Diff line number Diff line Loading @@ -5,15 +5,15 @@ 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 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); // println!("Licenses: {:#?}", api::spdx::Licenses::download().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); println!("Licenses: {:#?}", api::spdx::Licenses::download().await); gitlab_example().await; // orcid_example().await; // ror_example().await; orcid_example().await; ror_example().await; Ok(()) } #[allow(dead_code)] Loading acorn-lib/src/io/api/gitlab.rs +177 −7 Original line number Diff line number Diff line Loading @@ -2,9 +2,10 @@ //! // TODO: Add custom field list and query pair type // TODO: Finish modeling necessary API endpoints use crate::io::api::{RemoteResource, Searchable, TreeEntryType, INCLUDED_ENDPOINTS}; use crate::io::api::{EmptyField, RemoteResource, Searchable, TreeEntryType, ValueValidator, INCLUDED_ENDPOINTS}; use crate::io::config::{RunnerStatus, RunnerType}; use crate::param; use core::fmt; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; Loading @@ -24,16 +25,110 @@ pub enum AccessLevel { RefProtected, } /// Group visibility level #[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum GroupVisibility { /// Public visibility #[default] Public, /// Internal visibility Internal, /// Private visibility Private, } /// Pagination list parameters #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PaginationField { /// Column by which to order by OrderBy, /// Page number to retrieve (default: 1) Page, /// Enable keyset pagination Pagination, /// Number of items to list per page (default: 20, max: 100) PerPage, /// Sort order Sort, } /// Valid values for pagination order_by field /// /// Projects can be ordered by /// - `created_at` (default) /// - `id` /// - `last_activity_at` /// - `name` /// - `path` /// - `similarity` /// - `star_count` /// - `updated_at` /// /// Groups can be ordered by /// - `name` (default) /// - `id` /// - `path` /// - `similarity` /// /// Issues can be ordered by /// - `created_at` (default) /// - `due_date` /// - `label_priority` /// - `milestone_due` /// - `popularity` /// - `priority` /// - `relative_position` /// - `title` /// - `updated_at` /// - `weight` #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OrderByValue { /// Sort by creation timestamp CreatedAt, /// Sort by full hierarchical name FullName, /// Sort by unique identifier #[serde(rename = "id")] Identifier, /// Sort by label priority LabelPriority, /// Sort by last activity timestamp LastActivityAt, /// Sort by milestone due date MilestoneDue, /// Sort by human-readable name Name, /// Sort by URL-encoded path Path, /// Sort by popularity Popularity, /// Sort by due date DueDate, /// Sort by priority Priority, /// Sort by manual relative position RelativePosition, /// Sort by search similarity score Similarity, /// Sort by title Title, /// Sort by last update timestamp UpdatedAt, /// Sort by weight/priority Weight, } /// Valid values for pagination sort field #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SortValue { /// Descending order #[default] #[serde(rename = "desc")] Descending, /// Ascending order #[serde(rename = "asc")] Ascending, } /// Runner group details #[skip_serializing_none] #[derive(Clone, Debug, Serialize, Deserialize)] Loading Loading @@ -127,7 +222,8 @@ pub struct GroupDetails { /// Group deletion schedule date pub marked_for_deletion_on: Option<String>, /// LDAP common name pub ldap_cn: Option<String>, #[serde(rename = "ldap_cn")] pub ldap_common_name: Option<String>, /// LDAP access value pub ldap_access: Option<String>, /// File template project identifier Loading Loading @@ -329,6 +425,68 @@ pub struct UserDetails { #[serde(rename = "web_url")] pub url: String, } impl fmt::Display for PaginationField { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let s = match self { | PaginationField::OrderBy => "order_by", | PaginationField::Page => "page", | PaginationField::Pagination => "pagination", | PaginationField::PerPage => "per_page", | PaginationField::Sort => "sort", }; write!(f, "{}", s) } } impl TryFrom<&str> for PaginationField { type Error = String; fn try_from(value: &str) -> Result<Self, Self::Error> { match value { | "order_by" => Ok(PaginationField::OrderBy), | "page" => Ok(PaginationField::Page), | "pagination" => Ok(PaginationField::Pagination), | "per_page" => Ok(PaginationField::PerPage), | "sort" => Ok(PaginationField::Sort), | _ => Err(format!("Invalid GitLab pagination field: {value}")), } } } impl TryFrom<&str> for OrderByValue { type Error = String; fn try_from(value: &str) -> Result<Self, Self::Error> { match value { | "created_at" => Ok(OrderByValue::CreatedAt), | "due_date" => Ok(OrderByValue::DueDate), | "full_name" => Ok(OrderByValue::FullName), | "id" => Ok(OrderByValue::Identifier), | "label_priority" => Ok(OrderByValue::LabelPriority), | "last_activity_at" => Ok(OrderByValue::LastActivityAt), | "milestone_due" => Ok(OrderByValue::MilestoneDue), | "name" => Ok(OrderByValue::Name), | "path" => Ok(OrderByValue::Path), | "popularity" => Ok(OrderByValue::Popularity), | "priority" => Ok(OrderByValue::Priority), | "relative_position" => Ok(OrderByValue::RelativePosition), | "similarity" => Ok(OrderByValue::Similarity), | "title" => Ok(OrderByValue::Title), | "updated_at" => Ok(OrderByValue::UpdatedAt), | "weight" => Ok(OrderByValue::Weight), | _ => Err(format!("Invalid GitLab order_by value: {value}")), } } } impl TryFrom<&str> for SortValue { type Error = String; fn try_from(value: &str) -> Result<Self, Self::Error> { match value { | "asc" => Ok(SortValue::Ascending), | "desc" => Ok(SortValue::Descending), | _ => Err(format!("Invalid GitLab sort order: {value}")), } } } impl TreeEntry { /// Get path of tree entry pub fn path(self) -> String { Loading @@ -340,6 +498,18 @@ impl TreeEntry { entry_type.eq(&TreeEntryType::Blob) } } impl ValueValidator for PaginationField { /// Validate pagination field values according to GitLab API documentation fn is_valid(&self, value: &str) -> bool { match self { | PaginationField::OrderBy => OrderByValue::try_from(value).is_ok(), | PaginationField::Page => value.parse::<u64>().is_ok(), | PaginationField::PerPage => value.parse::<u64>().is_ok(), | PaginationField::Sort => SortValue::try_from(value).is_ok(), | _ => true, } } } /// Get descendant groups of a group by identifier pub async fn groups(identifier: impl Into<String>, token: impl Into<String>) -> Result<GroupsResponse, String> { let name = "GitLab"; Loading @@ -349,10 +519,10 @@ pub async fn groups(identifier: impl Into<String>, token: impl Into<String>) -> let params = vec![ param!(Header, "PRIVATE-TOKEN", &token.into()), param!(TemplateValue, "identifier", &identifier.into()), // param!(FieldList, "page", "1"), param!(FieldList, "per_page", "100"), param!(FieldList, "page", "1"), param!(KeyValuePair, "per_page", "100"), ]; let response = endpoint.invoke(action, Some(params)).await; let response = endpoint.invoke_with::<PaginationField, EmptyField>(action, Some(params)).await; endpoint.handle::<GroupsResponse>(response) } | None => Err(format!("{name} API endpoint not found")), Loading Loading @@ -382,7 +552,7 @@ pub async fn runners(token: impl Into<String>) -> Result<RunnersResponse, String match INCLUDED_ENDPOINTS.find_by_name(name) { | Some(endpoint) => { let params = vec![param!(Header, "PRIVATE-TOKEN", &token.into())]; let response = endpoint.invoke(action, Some(params)).await; let response = endpoint.invoke_with::<PaginationField, EmptyField>(action, Some(params)).await; endpoint.handle::<RunnersResponse>(response) } | None => Err(format!("{name} API endpoint not found")), Loading acorn-lib/src/io/api/mod.rs +30 −2 Original line number Diff line number Diff line Loading @@ -143,6 +143,8 @@ pub enum ParamStyle { QueryField, /// Specifies response fields (e.g., "given-names,family-name") FieldList, /// Key-value pair parameter (e.g., "key=value") KeyValuePair, /// Header parameter Header, /// Body parameter (data sent via POST or PUT request) Loading Loading @@ -356,6 +358,10 @@ impl Param { pub fn is_body(&self) -> bool { matches!(self.style, ParamStyle::Body) } /// Check if this parameter is a key-value parameter pub fn is_key_value(&self) -> bool { matches!(self.style, ParamStyle::KeyValuePair) } /// Check if this parameter is a query parameter (either a query pair, boosted query field, or field list) pub fn is_query(&self) -> bool { matches!(self.style, ParamStyle::QueryPair | ParamStyle::QueryField | ParamStyle::FieldList) Loading @@ -372,7 +378,7 @@ impl Param { pub fn to_query_string<Q: QueryField + ValueValidator, F: QueryField>(params: Vec<Param>) -> String { let query = params .iter() .filter(|param| param.is_query()) .filter(|param| param.is_query() || param.is_key_value()) .map(|param| param.to_string::<Q, F>()) .filter(|s| !s.is_empty()) .collect::<Vec<String>>() Loading Loading @@ -439,6 +445,14 @@ impl Param { let fields: Vec<&str> = self.values.iter().filter_map(|vec| vec.first().and_then(|o| o.as_deref())).collect(); param_from_field_list::<F>(key, separator, fields) } | ParamStyle::KeyValuePair => { let value = self .values .iter() .filter_map(|vec| vec.first().and_then(|o| o.as_deref())) .collect::<String>(); param_from_key_value_pair::<Q>(key, &value) } | _ => None, }; rendered.unwrap_or_default() Loading Loading @@ -501,7 +515,8 @@ impl RemoteResource for Endpoint { let mut context = Context::new(); match data { | Some(params) => { let (query_params, other_params): (Vec<Param>, Vec<Param>) = params.into_iter().partition(|param| param.is_query()); let (query_params, other_params): (Vec<Param>, Vec<Param>) = params.into_iter().partition(|param| param.is_query() || param.is_key_value()); let query = Param::to_query_string::<Q, F>(query_params); context.insert("query", &query); other_params.into_iter().for_each(|param| { Loading Loading @@ -668,6 +683,19 @@ pub(crate) fn extract_template_keys(template: &str) -> Vec<String> { } keys } /// Create a query string component from a key-value pair with key and value validation pub(crate) fn param_from_key_value_pair<T: QueryField + ValueValidator>(key: &str, value: &str) -> Option<String> { match T::try_from(key) { | Ok(field) => { if field.is_valid(value) { Some(format!("{}={}", field, urlencoding::encode(value))) } else { None } } | Err(_) => None, } } /// Create a query string from a lookup table of key-value pairs with field validation pub(crate) fn param_from_query_pairs<T: QueryField + ValueValidator>(key: &str, separator: &str, pairs: Vec<(&str, &str)>) -> Option<String> { let values: Vec<String> = pairs Loading acorn-lib/src/io/api/tests/mod.rs +370 −331 Original line number Diff line number Diff line Loading @@ -176,7 +176,88 @@ fn test_geonames() { assert!(!countries.contains("XYZ"), "Expected not to find XYZ in GeoNames data"); } #[test] fn test_orcid_query_string() { fn test_extract_template_keys() { let template = "{{ base }}/organizations/{{ identifier }}{{ query }}"; let keys = extract_template_keys(template); let expected = vec!["base", "identifier", "query"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = "{{ query | default(value=\"\") }} and {{- name -}}"; let keys = extract_template_keys(template); let expected = vec!["query", "name"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = "{{ base }}/{{ base }}/{{ identifier }}"; let keys = extract_template_keys(template); let expected = vec!["base", "identifier"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = "{{ base }}/{{ missing"; let keys = extract_template_keys(template); let expected = vec!["base"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = ""; let keys = extract_template_keys(template); let expected: Vec<String> = vec![]; assert_eq!(keys, expected); } #[test] fn test_query_params_empty_field() { let params = vec![ Param::of_type(api::ParamStyle::FieldList) .values(vec![vec![Some("Oak Ridge")]]) .with_key("query"), Param::of_type(api::ParamStyle::QueryPair) .values(vec![vec![Some("status"), Some("inactive")]]) .with_key("filter"), ]; let query = Param::to_query_string::<api::EmptyField, api::EmptyField>(params); insta::assert_snapshot!(query); } #[test] fn test_ror_search_response() { let json = read_file(fixtures_dir().join("response_ror_ornl.json")).expect("Failed to read JSON fixture"); let response: ror::SearchResponse = serde_json::from_str(&json).expect("Failed to deserialize JSON"); insta::assert_snapshot!(format!("{:#?}", response)); } #[cfg(test)] mod gitlab_api { use super::*; use crate::io::api::gitlab::PaginationField; #[test] fn test_query_string() { let param = param!(KeyValuePair, "per_page", "100"); let query = param.to_string::<PaginationField, api::EmptyField>(); assert_eq!(query, "per_page=100"); } #[test] fn test_params_to_query_string() { let params = vec![param!(KeyValuePair, "per_page", "100"), param!(KeyValuePair, "page", "2")]; let query = Param::to_query_string::<PaginationField, api::EmptyField>(params); assert_eq!(query, "?per_page=100&page=2"); } #[test] fn test_params_to_query_string_with_invalid_fields() { let params = vec![param!(KeyValuePair, "every_page", "100"), param!(KeyValuePair, "page", "42")]; let query = Param::to_query_string::<PaginationField, api::EmptyField>(params); assert_eq!(query, "?page=42"); } #[test] fn test_params_to_query_string_with_invalid_values() { let params = vec![param!(KeyValuePair, "per_page", "100"), param!(KeyValuePair, "page", "not a number")]; let query = Param::to_query_string::<PaginationField, api::EmptyField>(params); assert_eq!(query, "?per_page=100"); let params = vec![param!(KeyValuePair, "per_page", "{}"), param!(KeyValuePair, "page", "not a number")]; let query = Param::to_query_string::<PaginationField, api::EmptyField>(params); assert!(query.is_empty()); } } #[cfg(test)] mod orcid_api { use super::*; use crate::io::api::orcid::{OutputColumn, SearchField}; #[test] fn test_query_string() { // Basic query with multiple valid fields let pairs = vec![ ("given-names", "Jason"), Loading Loading @@ -274,31 +355,7 @@ fn test_orcid_query_string() { assert_eq!(query, expected); } #[test] fn test_extract_template_keys() { let template = "{{ base }}/organizations/{{ identifier }}{{ query }}"; let keys = extract_template_keys(template); let expected = vec!["base", "identifier", "query"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = "{{ query | default(value=\"\") }} and {{- name -}}"; let keys = extract_template_keys(template); let expected = vec!["query", "name"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = "{{ base }}/{{ base }}/{{ identifier }}"; let keys = extract_template_keys(template); let expected = vec!["base", "identifier"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = "{{ base }}/{{ missing"; let keys = extract_template_keys(template); let expected = vec!["base"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = ""; let keys = extract_template_keys(template); let expected: Vec<String> = vec![]; assert_eq!(keys, expected); } #[test] fn test_orcid_response_handler() { fn test_response_handler() { // Load XML fixture file let xml = read_file(fixtures_dir().join("response_orcid_wohlgemuth.xml")).expect("Failed to read XML fixture"); // Parse XML into OrcidSearchResponse Loading Loading @@ -364,26 +421,13 @@ fn test_orcid_response_handler() { assert!(institutions.len() >= 10); } #[test] fn test_orcid_status_response() { fn test_status_response() { let json = r#"{"tomcatUp":true,"dbConnectionOk":true,"readOnlyDbConnectionOk":false,"overallOk":true}"#; let response: orcid::StatusResponse = serde_json::from_str(json).expect("Failed to deserialize JSON"); insta::assert_snapshot!(format!("{:#?}", response)); } #[test] fn test_query_params_empty_field() { let params = vec![ Param::of_type(api::ParamStyle::FieldList) .values(vec![vec![Some("Oak Ridge")]]) .with_key("query"), Param::of_type(api::ParamStyle::QueryPair) .values(vec![vec![Some("status"), Some("inactive")]]) .with_key("filter"), ]; let query = Param::to_query_string::<api::EmptyField, api::EmptyField>(params); insta::assert_snapshot!(query); } #[test] fn test_query_params_field_list() { fn test_query_param_field_list() { // Test FieldList style (output columns) let param = Param::of_type(ParamStyle::FieldList) .values(vec![ Loading @@ -392,7 +436,7 @@ fn test_query_params_field_list() { vec![Some("credit-name"), None], ]) .with_key("fl"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "fl=orcid,email,credit-name"; assert_eq!(rendered, expected); // Test FieldList style with invalid field (should be filtered) Loading @@ -403,17 +447,17 @@ fn test_query_params_field_list() { vec![Some("email"), None], ]) .with_key("fl"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "fl=orcid,email"; assert_eq!(rendered, expected); } #[test] fn test_params_query_field() { fn test_query_param_query_field() { // Test QueryField style (boosted fields) let param = Param::of_type(ParamStyle::QueryField) .values(vec![vec![Some("given-names"), None], vec![Some("family-name"), None]]) .with_key("qf"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "qf=given-names%5E3.0%20family-name%5E2.0"; assert_eq!(rendered, expected); // Test QueryField style with three fields Loading @@ -424,7 +468,7 @@ fn test_params_query_field() { vec![Some("affiliation-org-name"), None], ]) .with_key("qf"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "qf=given-names%5E4.0%20family-name%5E3.0%20affiliation-org-name%5E2.0"; assert_eq!(rendered, expected); // Test QueryField with invalid field names (should be filtered) Loading @@ -436,19 +480,19 @@ fn test_params_query_field() { vec![Some("family-name"), None], ]) .with_key("qf"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "qf=given-names%5E3.0%20family-name%5E2.0"; assert_eq!(rendered, expected); // Test QueryField with only invalid field names (should return empty) let param = Param::of_type(ParamStyle::QueryField) .values(vec![vec![Some("invalid-field"), None], vec![Some("another-invalid"), None]]) .with_key("qf"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = ""; assert_eq!(rendered, expected); } #[test] fn test_params_query_pair() { fn test_param_query_pair() { // Test QueryPair style let param = Param::of_type(ParamStyle::QueryPair) .values(vec![ Loading @@ -456,7 +500,7 @@ fn test_params_query_pair() { vec![Some("family-name"), Some("Wohlgemuth")], ]) .with_key("q"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "q=given-names:Jason+AND+family-name:Wohlgemuth"; assert_eq!(rendered, expected); // Test QueryPair with invalid field names (should be filtered) Loading @@ -467,7 +511,7 @@ fn test_params_query_pair() { vec![Some("another-invalid"), Some("data")], ]) .with_key("q"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "q=given-names:Jason"; assert_eq!(rendered, expected); // Test QueryPair with invalid field values (invalid ORCiD should be filtered) Loading @@ -477,7 +521,7 @@ fn test_params_query_pair() { vec![Some("given-names"), Some("Jason")], ]) .with_key("q"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "q=given-names:Jason"; assert_eq!(rendered, expected); // Test QueryPair with valid ORCiD value (should be included) Loading @@ -487,7 +531,7 @@ fn test_params_query_pair() { vec![Some("given-names"), Some("Jason")], ]) .with_key("q"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "q=orcid:0000-0002-2057-9115+AND+given-names:Jason"; assert_eq!(rendered, expected); } Loading @@ -499,22 +543,16 @@ fn test_params_to_query_string() { Param::from_field_list("fl", vec!["orcid", "email", "credit-name"]), Param::from_query_field("qf", vec!["given-names", "family-name"]), ]; let query = api::Param::to_query_string::<orcid::SearchField, orcid::OutputColumn>(params); let query = api::Param::to_query_string::<SearchField, OutputColumn>(params); let expected = "?q=given-names:Jason+AND+family-name:Wohlgemuth&fl=orcid,email,credit-name&qf=given-names%5E3.0%20family-name%5E2.0"; assert_eq!(query, expected); // Test rendering with empty params (should return empty string) let params = vec![]; let query = api::Param::to_query_string::<orcid::SearchField, orcid::OutputColumn>(params); let query = api::Param::to_query_string::<SearchField, OutputColumn>(params); let expected = ""; assert_eq!(query, expected); } #[test] fn test_ror_search_response() { let json = read_file(fixtures_dir().join("response_ror_ornl.json")).expect("Failed to read JSON fixture"); let response: ror::SearchResponse = serde_json::from_str(&json).expect("Failed to deserialize JSON"); insta::assert_snapshot!(format!("{:#?}", response)); } #[test] fn test_params_into_body() { use crate::io::api::IntoBody; // Test single value body param Loading Loading @@ -550,3 +588,4 @@ fn test_params_into_body() { assert!(body.get("q").is_none()); assert!(body.get("Authorization").is_none()); } } acorn-lib/src/io/api/tests/snapshots/acorn__io__api__tests__orcid_api__orcid_status_response.snap 0 → 100644 +10 −0 Original line number Diff line number Diff line --- source: acorn-lib/src/io/api/tests/mod.rs expression: "format!(\"{:#?}\", response)" --- StatusResponse { application: true, database: true, database_readonly: false, overall: true, } Loading
acorn-cli/src/commands/gather/mod.rs +8 −8 Original line number Diff line number Diff line Loading @@ -5,15 +5,15 @@ 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 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); // println!("Licenses: {:#?}", api::spdx::Licenses::download().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); println!("Licenses: {:#?}", api::spdx::Licenses::download().await); gitlab_example().await; // orcid_example().await; // ror_example().await; orcid_example().await; ror_example().await; Ok(()) } #[allow(dead_code)] Loading
acorn-lib/src/io/api/gitlab.rs +177 −7 Original line number Diff line number Diff line Loading @@ -2,9 +2,10 @@ //! // TODO: Add custom field list and query pair type // TODO: Finish modeling necessary API endpoints use crate::io::api::{RemoteResource, Searchable, TreeEntryType, INCLUDED_ENDPOINTS}; use crate::io::api::{EmptyField, RemoteResource, Searchable, TreeEntryType, ValueValidator, INCLUDED_ENDPOINTS}; use crate::io::config::{RunnerStatus, RunnerType}; use crate::param; use core::fmt; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; Loading @@ -24,16 +25,110 @@ pub enum AccessLevel { RefProtected, } /// Group visibility level #[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum GroupVisibility { /// Public visibility #[default] Public, /// Internal visibility Internal, /// Private visibility Private, } /// Pagination list parameters #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PaginationField { /// Column by which to order by OrderBy, /// Page number to retrieve (default: 1) Page, /// Enable keyset pagination Pagination, /// Number of items to list per page (default: 20, max: 100) PerPage, /// Sort order Sort, } /// Valid values for pagination order_by field /// /// Projects can be ordered by /// - `created_at` (default) /// - `id` /// - `last_activity_at` /// - `name` /// - `path` /// - `similarity` /// - `star_count` /// - `updated_at` /// /// Groups can be ordered by /// - `name` (default) /// - `id` /// - `path` /// - `similarity` /// /// Issues can be ordered by /// - `created_at` (default) /// - `due_date` /// - `label_priority` /// - `milestone_due` /// - `popularity` /// - `priority` /// - `relative_position` /// - `title` /// - `updated_at` /// - `weight` #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OrderByValue { /// Sort by creation timestamp CreatedAt, /// Sort by full hierarchical name FullName, /// Sort by unique identifier #[serde(rename = "id")] Identifier, /// Sort by label priority LabelPriority, /// Sort by last activity timestamp LastActivityAt, /// Sort by milestone due date MilestoneDue, /// Sort by human-readable name Name, /// Sort by URL-encoded path Path, /// Sort by popularity Popularity, /// Sort by due date DueDate, /// Sort by priority Priority, /// Sort by manual relative position RelativePosition, /// Sort by search similarity score Similarity, /// Sort by title Title, /// Sort by last update timestamp UpdatedAt, /// Sort by weight/priority Weight, } /// Valid values for pagination sort field #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SortValue { /// Descending order #[default] #[serde(rename = "desc")] Descending, /// Ascending order #[serde(rename = "asc")] Ascending, } /// Runner group details #[skip_serializing_none] #[derive(Clone, Debug, Serialize, Deserialize)] Loading Loading @@ -127,7 +222,8 @@ pub struct GroupDetails { /// Group deletion schedule date pub marked_for_deletion_on: Option<String>, /// LDAP common name pub ldap_cn: Option<String>, #[serde(rename = "ldap_cn")] pub ldap_common_name: Option<String>, /// LDAP access value pub ldap_access: Option<String>, /// File template project identifier Loading Loading @@ -329,6 +425,68 @@ pub struct UserDetails { #[serde(rename = "web_url")] pub url: String, } impl fmt::Display for PaginationField { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let s = match self { | PaginationField::OrderBy => "order_by", | PaginationField::Page => "page", | PaginationField::Pagination => "pagination", | PaginationField::PerPage => "per_page", | PaginationField::Sort => "sort", }; write!(f, "{}", s) } } impl TryFrom<&str> for PaginationField { type Error = String; fn try_from(value: &str) -> Result<Self, Self::Error> { match value { | "order_by" => Ok(PaginationField::OrderBy), | "page" => Ok(PaginationField::Page), | "pagination" => Ok(PaginationField::Pagination), | "per_page" => Ok(PaginationField::PerPage), | "sort" => Ok(PaginationField::Sort), | _ => Err(format!("Invalid GitLab pagination field: {value}")), } } } impl TryFrom<&str> for OrderByValue { type Error = String; fn try_from(value: &str) -> Result<Self, Self::Error> { match value { | "created_at" => Ok(OrderByValue::CreatedAt), | "due_date" => Ok(OrderByValue::DueDate), | "full_name" => Ok(OrderByValue::FullName), | "id" => Ok(OrderByValue::Identifier), | "label_priority" => Ok(OrderByValue::LabelPriority), | "last_activity_at" => Ok(OrderByValue::LastActivityAt), | "milestone_due" => Ok(OrderByValue::MilestoneDue), | "name" => Ok(OrderByValue::Name), | "path" => Ok(OrderByValue::Path), | "popularity" => Ok(OrderByValue::Popularity), | "priority" => Ok(OrderByValue::Priority), | "relative_position" => Ok(OrderByValue::RelativePosition), | "similarity" => Ok(OrderByValue::Similarity), | "title" => Ok(OrderByValue::Title), | "updated_at" => Ok(OrderByValue::UpdatedAt), | "weight" => Ok(OrderByValue::Weight), | _ => Err(format!("Invalid GitLab order_by value: {value}")), } } } impl TryFrom<&str> for SortValue { type Error = String; fn try_from(value: &str) -> Result<Self, Self::Error> { match value { | "asc" => Ok(SortValue::Ascending), | "desc" => Ok(SortValue::Descending), | _ => Err(format!("Invalid GitLab sort order: {value}")), } } } impl TreeEntry { /// Get path of tree entry pub fn path(self) -> String { Loading @@ -340,6 +498,18 @@ impl TreeEntry { entry_type.eq(&TreeEntryType::Blob) } } impl ValueValidator for PaginationField { /// Validate pagination field values according to GitLab API documentation fn is_valid(&self, value: &str) -> bool { match self { | PaginationField::OrderBy => OrderByValue::try_from(value).is_ok(), | PaginationField::Page => value.parse::<u64>().is_ok(), | PaginationField::PerPage => value.parse::<u64>().is_ok(), | PaginationField::Sort => SortValue::try_from(value).is_ok(), | _ => true, } } } /// Get descendant groups of a group by identifier pub async fn groups(identifier: impl Into<String>, token: impl Into<String>) -> Result<GroupsResponse, String> { let name = "GitLab"; Loading @@ -349,10 +519,10 @@ pub async fn groups(identifier: impl Into<String>, token: impl Into<String>) -> let params = vec![ param!(Header, "PRIVATE-TOKEN", &token.into()), param!(TemplateValue, "identifier", &identifier.into()), // param!(FieldList, "page", "1"), param!(FieldList, "per_page", "100"), param!(FieldList, "page", "1"), param!(KeyValuePair, "per_page", "100"), ]; let response = endpoint.invoke(action, Some(params)).await; let response = endpoint.invoke_with::<PaginationField, EmptyField>(action, Some(params)).await; endpoint.handle::<GroupsResponse>(response) } | None => Err(format!("{name} API endpoint not found")), Loading Loading @@ -382,7 +552,7 @@ pub async fn runners(token: impl Into<String>) -> Result<RunnersResponse, String match INCLUDED_ENDPOINTS.find_by_name(name) { | Some(endpoint) => { let params = vec![param!(Header, "PRIVATE-TOKEN", &token.into())]; let response = endpoint.invoke(action, Some(params)).await; let response = endpoint.invoke_with::<PaginationField, EmptyField>(action, Some(params)).await; endpoint.handle::<RunnersResponse>(response) } | None => Err(format!("{name} API endpoint not found")), Loading
acorn-lib/src/io/api/mod.rs +30 −2 Original line number Diff line number Diff line Loading @@ -143,6 +143,8 @@ pub enum ParamStyle { QueryField, /// Specifies response fields (e.g., "given-names,family-name") FieldList, /// Key-value pair parameter (e.g., "key=value") KeyValuePair, /// Header parameter Header, /// Body parameter (data sent via POST or PUT request) Loading Loading @@ -356,6 +358,10 @@ impl Param { pub fn is_body(&self) -> bool { matches!(self.style, ParamStyle::Body) } /// Check if this parameter is a key-value parameter pub fn is_key_value(&self) -> bool { matches!(self.style, ParamStyle::KeyValuePair) } /// Check if this parameter is a query parameter (either a query pair, boosted query field, or field list) pub fn is_query(&self) -> bool { matches!(self.style, ParamStyle::QueryPair | ParamStyle::QueryField | ParamStyle::FieldList) Loading @@ -372,7 +378,7 @@ impl Param { pub fn to_query_string<Q: QueryField + ValueValidator, F: QueryField>(params: Vec<Param>) -> String { let query = params .iter() .filter(|param| param.is_query()) .filter(|param| param.is_query() || param.is_key_value()) .map(|param| param.to_string::<Q, F>()) .filter(|s| !s.is_empty()) .collect::<Vec<String>>() Loading Loading @@ -439,6 +445,14 @@ impl Param { let fields: Vec<&str> = self.values.iter().filter_map(|vec| vec.first().and_then(|o| o.as_deref())).collect(); param_from_field_list::<F>(key, separator, fields) } | ParamStyle::KeyValuePair => { let value = self .values .iter() .filter_map(|vec| vec.first().and_then(|o| o.as_deref())) .collect::<String>(); param_from_key_value_pair::<Q>(key, &value) } | _ => None, }; rendered.unwrap_or_default() Loading Loading @@ -501,7 +515,8 @@ impl RemoteResource for Endpoint { let mut context = Context::new(); match data { | Some(params) => { let (query_params, other_params): (Vec<Param>, Vec<Param>) = params.into_iter().partition(|param| param.is_query()); let (query_params, other_params): (Vec<Param>, Vec<Param>) = params.into_iter().partition(|param| param.is_query() || param.is_key_value()); let query = Param::to_query_string::<Q, F>(query_params); context.insert("query", &query); other_params.into_iter().for_each(|param| { Loading Loading @@ -668,6 +683,19 @@ pub(crate) fn extract_template_keys(template: &str) -> Vec<String> { } keys } /// Create a query string component from a key-value pair with key and value validation pub(crate) fn param_from_key_value_pair<T: QueryField + ValueValidator>(key: &str, value: &str) -> Option<String> { match T::try_from(key) { | Ok(field) => { if field.is_valid(value) { Some(format!("{}={}", field, urlencoding::encode(value))) } else { None } } | Err(_) => None, } } /// Create a query string from a lookup table of key-value pairs with field validation pub(crate) fn param_from_query_pairs<T: QueryField + ValueValidator>(key: &str, separator: &str, pairs: Vec<(&str, &str)>) -> Option<String> { let values: Vec<String> = pairs Loading
acorn-lib/src/io/api/tests/mod.rs +370 −331 Original line number Diff line number Diff line Loading @@ -176,7 +176,88 @@ fn test_geonames() { assert!(!countries.contains("XYZ"), "Expected not to find XYZ in GeoNames data"); } #[test] fn test_orcid_query_string() { fn test_extract_template_keys() { let template = "{{ base }}/organizations/{{ identifier }}{{ query }}"; let keys = extract_template_keys(template); let expected = vec!["base", "identifier", "query"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = "{{ query | default(value=\"\") }} and {{- name -}}"; let keys = extract_template_keys(template); let expected = vec!["query", "name"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = "{{ base }}/{{ base }}/{{ identifier }}"; let keys = extract_template_keys(template); let expected = vec!["base", "identifier"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = "{{ base }}/{{ missing"; let keys = extract_template_keys(template); let expected = vec!["base"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = ""; let keys = extract_template_keys(template); let expected: Vec<String> = vec![]; assert_eq!(keys, expected); } #[test] fn test_query_params_empty_field() { let params = vec![ Param::of_type(api::ParamStyle::FieldList) .values(vec![vec![Some("Oak Ridge")]]) .with_key("query"), Param::of_type(api::ParamStyle::QueryPair) .values(vec![vec![Some("status"), Some("inactive")]]) .with_key("filter"), ]; let query = Param::to_query_string::<api::EmptyField, api::EmptyField>(params); insta::assert_snapshot!(query); } #[test] fn test_ror_search_response() { let json = read_file(fixtures_dir().join("response_ror_ornl.json")).expect("Failed to read JSON fixture"); let response: ror::SearchResponse = serde_json::from_str(&json).expect("Failed to deserialize JSON"); insta::assert_snapshot!(format!("{:#?}", response)); } #[cfg(test)] mod gitlab_api { use super::*; use crate::io::api::gitlab::PaginationField; #[test] fn test_query_string() { let param = param!(KeyValuePair, "per_page", "100"); let query = param.to_string::<PaginationField, api::EmptyField>(); assert_eq!(query, "per_page=100"); } #[test] fn test_params_to_query_string() { let params = vec![param!(KeyValuePair, "per_page", "100"), param!(KeyValuePair, "page", "2")]; let query = Param::to_query_string::<PaginationField, api::EmptyField>(params); assert_eq!(query, "?per_page=100&page=2"); } #[test] fn test_params_to_query_string_with_invalid_fields() { let params = vec![param!(KeyValuePair, "every_page", "100"), param!(KeyValuePair, "page", "42")]; let query = Param::to_query_string::<PaginationField, api::EmptyField>(params); assert_eq!(query, "?page=42"); } #[test] fn test_params_to_query_string_with_invalid_values() { let params = vec![param!(KeyValuePair, "per_page", "100"), param!(KeyValuePair, "page", "not a number")]; let query = Param::to_query_string::<PaginationField, api::EmptyField>(params); assert_eq!(query, "?per_page=100"); let params = vec![param!(KeyValuePair, "per_page", "{}"), param!(KeyValuePair, "page", "not a number")]; let query = Param::to_query_string::<PaginationField, api::EmptyField>(params); assert!(query.is_empty()); } } #[cfg(test)] mod orcid_api { use super::*; use crate::io::api::orcid::{OutputColumn, SearchField}; #[test] fn test_query_string() { // Basic query with multiple valid fields let pairs = vec![ ("given-names", "Jason"), Loading Loading @@ -274,31 +355,7 @@ fn test_orcid_query_string() { assert_eq!(query, expected); } #[test] fn test_extract_template_keys() { let template = "{{ base }}/organizations/{{ identifier }}{{ query }}"; let keys = extract_template_keys(template); let expected = vec!["base", "identifier", "query"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = "{{ query | default(value=\"\") }} and {{- name -}}"; let keys = extract_template_keys(template); let expected = vec!["query", "name"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = "{{ base }}/{{ base }}/{{ identifier }}"; let keys = extract_template_keys(template); let expected = vec!["base", "identifier"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = "{{ base }}/{{ missing"; let keys = extract_template_keys(template); let expected = vec!["base"].into_iter().map(String::from).collect::<Vec<String>>(); assert_eq!(keys, expected); let template = ""; let keys = extract_template_keys(template); let expected: Vec<String> = vec![]; assert_eq!(keys, expected); } #[test] fn test_orcid_response_handler() { fn test_response_handler() { // Load XML fixture file let xml = read_file(fixtures_dir().join("response_orcid_wohlgemuth.xml")).expect("Failed to read XML fixture"); // Parse XML into OrcidSearchResponse Loading Loading @@ -364,26 +421,13 @@ fn test_orcid_response_handler() { assert!(institutions.len() >= 10); } #[test] fn test_orcid_status_response() { fn test_status_response() { let json = r#"{"tomcatUp":true,"dbConnectionOk":true,"readOnlyDbConnectionOk":false,"overallOk":true}"#; let response: orcid::StatusResponse = serde_json::from_str(json).expect("Failed to deserialize JSON"); insta::assert_snapshot!(format!("{:#?}", response)); } #[test] fn test_query_params_empty_field() { let params = vec![ Param::of_type(api::ParamStyle::FieldList) .values(vec![vec![Some("Oak Ridge")]]) .with_key("query"), Param::of_type(api::ParamStyle::QueryPair) .values(vec![vec![Some("status"), Some("inactive")]]) .with_key("filter"), ]; let query = Param::to_query_string::<api::EmptyField, api::EmptyField>(params); insta::assert_snapshot!(query); } #[test] fn test_query_params_field_list() { fn test_query_param_field_list() { // Test FieldList style (output columns) let param = Param::of_type(ParamStyle::FieldList) .values(vec![ Loading @@ -392,7 +436,7 @@ fn test_query_params_field_list() { vec![Some("credit-name"), None], ]) .with_key("fl"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "fl=orcid,email,credit-name"; assert_eq!(rendered, expected); // Test FieldList style with invalid field (should be filtered) Loading @@ -403,17 +447,17 @@ fn test_query_params_field_list() { vec![Some("email"), None], ]) .with_key("fl"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "fl=orcid,email"; assert_eq!(rendered, expected); } #[test] fn test_params_query_field() { fn test_query_param_query_field() { // Test QueryField style (boosted fields) let param = Param::of_type(ParamStyle::QueryField) .values(vec![vec![Some("given-names"), None], vec![Some("family-name"), None]]) .with_key("qf"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "qf=given-names%5E3.0%20family-name%5E2.0"; assert_eq!(rendered, expected); // Test QueryField style with three fields Loading @@ -424,7 +468,7 @@ fn test_params_query_field() { vec![Some("affiliation-org-name"), None], ]) .with_key("qf"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "qf=given-names%5E4.0%20family-name%5E3.0%20affiliation-org-name%5E2.0"; assert_eq!(rendered, expected); // Test QueryField with invalid field names (should be filtered) Loading @@ -436,19 +480,19 @@ fn test_params_query_field() { vec![Some("family-name"), None], ]) .with_key("qf"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "qf=given-names%5E3.0%20family-name%5E2.0"; assert_eq!(rendered, expected); // Test QueryField with only invalid field names (should return empty) let param = Param::of_type(ParamStyle::QueryField) .values(vec![vec![Some("invalid-field"), None], vec![Some("another-invalid"), None]]) .with_key("qf"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = ""; assert_eq!(rendered, expected); } #[test] fn test_params_query_pair() { fn test_param_query_pair() { // Test QueryPair style let param = Param::of_type(ParamStyle::QueryPair) .values(vec![ Loading @@ -456,7 +500,7 @@ fn test_params_query_pair() { vec![Some("family-name"), Some("Wohlgemuth")], ]) .with_key("q"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "q=given-names:Jason+AND+family-name:Wohlgemuth"; assert_eq!(rendered, expected); // Test QueryPair with invalid field names (should be filtered) Loading @@ -467,7 +511,7 @@ fn test_params_query_pair() { vec![Some("another-invalid"), Some("data")], ]) .with_key("q"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "q=given-names:Jason"; assert_eq!(rendered, expected); // Test QueryPair with invalid field values (invalid ORCiD should be filtered) Loading @@ -477,7 +521,7 @@ fn test_params_query_pair() { vec![Some("given-names"), Some("Jason")], ]) .with_key("q"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "q=given-names:Jason"; assert_eq!(rendered, expected); // Test QueryPair with valid ORCiD value (should be included) Loading @@ -487,7 +531,7 @@ fn test_params_query_pair() { vec![Some("given-names"), Some("Jason")], ]) .with_key("q"); let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>(); let rendered = param.to_string::<SearchField, OutputColumn>(); let expected = "q=orcid:0000-0002-2057-9115+AND+given-names:Jason"; assert_eq!(rendered, expected); } Loading @@ -499,22 +543,16 @@ fn test_params_to_query_string() { Param::from_field_list("fl", vec!["orcid", "email", "credit-name"]), Param::from_query_field("qf", vec!["given-names", "family-name"]), ]; let query = api::Param::to_query_string::<orcid::SearchField, orcid::OutputColumn>(params); let query = api::Param::to_query_string::<SearchField, OutputColumn>(params); let expected = "?q=given-names:Jason+AND+family-name:Wohlgemuth&fl=orcid,email,credit-name&qf=given-names%5E3.0%20family-name%5E2.0"; assert_eq!(query, expected); // Test rendering with empty params (should return empty string) let params = vec![]; let query = api::Param::to_query_string::<orcid::SearchField, orcid::OutputColumn>(params); let query = api::Param::to_query_string::<SearchField, OutputColumn>(params); let expected = ""; assert_eq!(query, expected); } #[test] fn test_ror_search_response() { let json = read_file(fixtures_dir().join("response_ror_ornl.json")).expect("Failed to read JSON fixture"); let response: ror::SearchResponse = serde_json::from_str(&json).expect("Failed to deserialize JSON"); insta::assert_snapshot!(format!("{:#?}", response)); } #[test] fn test_params_into_body() { use crate::io::api::IntoBody; // Test single value body param Loading Loading @@ -550,3 +588,4 @@ fn test_params_into_body() { assert!(body.get("q").is_none()); assert!(body.get("Authorization").is_none()); } }
acorn-lib/src/io/api/tests/snapshots/acorn__io__api__tests__orcid_api__orcid_status_response.snap 0 → 100644 +10 −0 Original line number Diff line number Diff line --- source: acorn-lib/src/io/api/tests/mod.rs expression: "format!(\"{:#?}\", response)" --- StatusResponse { application: true, database: true, database_readonly: false, overall: true, }