Commit 60c88b55 authored by Wohlgemuth, Jason's avatar Wohlgemuth, Jason
Browse files

feat: Make GitLab API query strings type safe

parent 495658b4
Loading
Loading
Loading
Loading
Loading
+8 −8
Original line number Diff line number Diff line
@@ -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)]
+177 −7
Original line number Diff line number Diff line
@@ -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;

@@ -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)]
@@ -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
@@ -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 {
@@ -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";
@@ -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")),
@@ -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")),
+30 −2
Original line number Diff line number Diff line
@@ -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)
@@ -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)
@@ -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>>()
@@ -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()
@@ -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| {
@@ -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
+370 −331
Original line number Diff line number Diff line
@@ -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"),
@@ -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
@@ -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![
@@ -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)
@@ -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
@@ -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)
@@ -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![
@@ -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)
@@ -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)
@@ -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)
@@ -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);
    }
@@ -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
@@ -550,3 +588,4 @@ fn test_params_into_body() {
        assert!(body.get("q").is_none());
        assert!(body.get("Authorization").is_none());
    }
}
+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