Skip to main content

acorn/io/api/
mod.rs

1//! Module for working with remote and local application programming interfaces (APIs)
2//!
3//! Interact with RESTful APIs, handling requests and responses, authentication, and data serialization.
4//! Also supports communicating with interfaces like large language model (LLM) interfaces like Ollama.
5use crate::io::{network_get_request, network_post_request, network_put_request};
6use crate::prelude::io::ErrorKind;
7use crate::prelude::Error;
8use crate::util::constants::{URL_ENCODED_CARAT, URL_ENCODED_SPACE};
9use crate::util::{detect_json, detect_xml};
10use crate::{Location, Repository, Scheme};
11use bon::Builder;
12use core::fmt;
13use derive_more::Display;
14use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
15use serde::{Deserialize, Serialize};
16use serde_with::skip_serializing_none;
17use tera::{Context, Tera};
18use uriparse::URI;
19use validator::Validate;
20
21pub mod citeas;
22pub mod github;
23pub mod gitlab;
24pub mod orcid;
25pub mod ror;
26pub mod spdx;
27
28/// Helper trait for working with list of endpoints
29pub trait EndpointSearch {
30    /// Filter list of endpoints by name and return the first match
31    fn find_by_name(&self, value: impl Into<String>) -> Option<Endpoint>;
32}
33/// Helper trait for converting parameter collections into HTTP headers
34pub trait IntoHeaders {
35    /// Convert this value into a `reqwest::HeaderMap`, using only header-style parameters.
36    fn into_headers(self) -> HeaderMap;
37}
38/// Helper trait combining common bounds for API query field types
39pub trait QueryField: fmt::Display + for<'a> TryFrom<&'a str> {}
40/// Trait for working with request-response cycle using HTTP methods like GET, POST, PUT, PATCH, and DELETE
41/// against resource URLs that return structured data
42pub trait RestfulInterface {
43    /// Query field type used for request building
44    type Query: QueryField + ValueValidator;
45    /// Field list type used for response selection
46    type Field: QueryField;
47
48    /// Build context for endpoint paths
49    fn context(&self, _params: Option<Vec<Param>>) -> Context {
50        Context::new()
51    }
52    /// Handle a response from an endpoint request
53    fn handle<R>(&self, response: Result<ResponseContent, Error>) -> Result<R, String>
54    where
55        R: for<'de> Deserialize<'de>;
56    /// Send data to the endpoint and receive a response
57    fn invoke_sync(&self, action: impl Into<String> + Clone, data: Option<Vec<Param>>) -> Result<ResponseContent, Error>;
58    /// Send data to the endpoint and receive a response using explicit query/field types
59    fn invoke_sync_with<Q, F>(&self, action: impl Into<String> + Clone, data: Option<Vec<Param>>) -> Result<ResponseContent, Error>
60    where
61        Q: QueryField + ValueValidator,
62        F: QueryField;
63    /// Parse JSON response string content
64    fn parse_json<R>(&self, content: &str) -> Result<R, String>
65    where
66        R: for<'de> Deserialize<'de>;
67    /// Parse XML response string content
68    fn parse_xml<R>(&self, content: &str) -> Result<R, String>
69    where
70        R: for<'de> Deserialize<'de>;
71}
72/// Trait to enable validation of field values
73pub trait ValueValidator {
74    /// Verify associated field value is valid
75    fn is_valid(&self, _value: &str) -> bool {
76        true
77    }
78}
79/// Authentication schemes supported for API requests
80#[derive(Clone, Debug, Default, Deserialize, Serialize)]
81pub enum AuthenticationScheme {
82    /// Bearer token authentication (e.g., JWT)
83    #[default]
84    Bearer,
85    /// Basic authentication (username:password)
86    Basic,
87    /// API key authentication
88    ApiKey,
89    /// OAuth 2.0 authentication
90    OAuth2,
91    /// Custom authentication scheme
92    Custom(String),
93}
94/// HTTP methods supported for API requests
95#[derive(Clone, Debug, Default, Deserialize, Serialize)]
96#[serde(rename_all = "lowercase")]
97pub enum HttpMethod {
98    /// Retrieve data from a server without modifying it — safe and idempotent
99    #[default]
100    Get,
101    /// Submit data to create a new resource or process information — not idempotent
102    Post,
103    /// Replace all current representations of the target resource with the uploaded content — idempotent
104    Put,
105    /// Apply partial modifications to a resource — not necessarily idempotent
106    Patch,
107    /// Delete a specified resource — idempotent
108    Delete,
109}
110/// Describes the location/type of a parameter for an API resource
111#[derive(Clone, Debug, Default, Deserialize, Serialize)]
112pub enum ParamStyle {
113    /// Query parameter key-value pair (e.g., "given-names:Jason")
114    #[default]
115    QueryPair,
116    /// Query parameter with list of field values — used for specifying fields to boost
117    QueryField,
118    /// Specifies response fields (e.g., "given-names,family-name")
119    FieldList,
120    /// Header parameter
121    Header,
122    /// Body parameter (data sent via POST or PUT request)
123    Body,
124    /// Value to be substituted directly into the URL path template
125    TemplateValue,
126}
127/// Wrapper enum for including response content MIME type with response body text
128#[derive(Clone, Debug, Deserialize, Serialize)]
129pub enum ResponseContent {
130    /// JSON response content
131    Json(String),
132    /// Raw text response content
133    Raw(String),
134    /// XML response content
135    Xml(String),
136}
137/// Type for Git(Hub/Lab) tree entry
138#[derive(Clone, Debug, Display, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Ord)]
139#[serde(rename_all = "lowercase")]
140pub enum TreeEntryType {
141    /// List of files and directories
142    ///
143    /// See <https://docs.gitlab.com/api/repositories/#list-repository-tree>
144    #[display("tree")]
145    Tree,
146    /// Base64 encoded content
147    ///
148    /// See <https://docs.gitlab.com/api/repositories/#get-a-blob-from-repository>
149    #[display("blob")]
150    Blob,
151}
152/// Represents authentication credentials for accessing an API
153#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
154#[builder(start_fn = init)]
155pub struct Authentication {
156    // TODO: Make token secret
157    /// The token used for authenticating API requests
158    pub token: Option<String>,
159    /// The scheme used for authenticating API requests
160    #[builder(default)]
161    pub scheme: AuthenticationScheme,
162}
163/// Empty struct used for cases where no query fields are needed
164#[derive(Clone, Debug, Deserialize, Serialize)]
165#[serde(rename_all = "kebab-case")]
166pub struct EmptyField(String);
167/// Represents an API endpoint with a lookup table for calling various paths
168/// ### Note
169/// Paths use handlebars templating syntax for dynamic URL construction, powered by [Tera](https://keats.github.io/tera/)
170#[skip_serializing_none]
171#[derive(Builder, Clone, Debug, Deserialize, Serialize, Validate)]
172#[builder(start_fn = at, on(String, into))]
173pub struct Endpoint {
174    /// The domain of the API endpoint
175    #[builder(start_fn)]
176    pub domain: String,
177    /// The name of the API endpoint (used mainly for logging and identification)
178    #[builder(default = String::new())]
179    pub name: String,
180    /// The scheme of the API endpoint
181    #[serde(default)]
182    pub scheme: Option<Scheme>,
183    /// The port of the API endpoint
184    pub port: Option<u16>,
185    /// Authentication credentials for accessing the API endpoint
186    pub authentication: Option<Authentication>,
187    /// Root path for the API endpoint
188    /// ### Example
189    /// "v3.0" for ORCiD API
190    pub root: Option<String>,
191    /// Resource data for generating full paths for the API endpoint using templates
192    #[builder(default = vec![])]
193    pub resources: Vec<Resource>,
194}
195/// Describes a parameter (path, query, header, etc.) for an API resource
196#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
197#[builder(start_fn = of_type, finish_fn = with_key, on(String, into))]
198pub struct Param {
199    /// The type/location of the parameter
200    #[builder(start_fn)]
201    pub style: ParamStyle,
202    /// The name of the parameter (e.g., "q", "fl", etc.)
203    #[builder(finish_fn)]
204    pub name: String,
205    /// Value(s) of the parameter
206    #[builder(
207        default = vec![],
208        with = |pairs: Vec<(Option<&str>, Option<&str>)>| {
209            pairs
210                .into_iter()
211                .map(|(k, v)| (k.map(str::to_string), v.map(str::to_string)))
212                .collect()
213        }
214    )]
215    pub values: Vec<(Option<String>, Option<String>)>,
216    /// Whether or not the parameter is required
217    #[builder(default = false)]
218    pub required: bool,
219}
220/// Represents a resource for an API endpoint, which can be used to generate full paths for requests
221#[derive(Builder, Clone, Debug, Deserialize, Serialize, Validate)]
222#[builder(start_fn = init, on(String, into))]
223pub struct Resource {
224    /// Resource name (e.g., "search", "status")
225    pub name: String,
226    /// HTTP method to use when invoking this resource (e.g., GET, POST)
227    #[builder(with = |method: &str| HttpMethod::from(method))]
228    #[serde(default)]
229    pub method: HttpMethod,
230    /// Template for the resource path (e.g., "/expanded-search/{{ query }}")
231    pub template: String,
232}
233/// Wrapper struct for raw text responses that cannot be parsed as JSON or XML
234#[derive(Clone, Debug, Deserialize, Serialize)]
235pub struct TextResponse {
236    /// The raw text content from the response
237    pub content: String,
238}
239// TODO: Define lookup maps for ORCiD, ROR, Vale, GitLab, GitHub, and Citeas APIs
240// TODO: Import paths from OpenAPI spec
241impl Endpoint {
242    /// Get the base URL for the API endpoint, constructed from scheme, domain, and port if not provided
243    pub fn base(&self) -> String {
244        let Self { domain, root, .. } = self;
245        let scheme = self.scheme.as_ref().map_or("https".to_string(), |s| s.to_string());
246        let port = self.port.map_or(String::new(), |port| format!(":{port}"));
247        let root = root.as_ref().map_or(String::new(), |root| format!("/{root}"));
248        format!("{scheme}://{domain}{port}{root}")
249    }
250
251    /// Build context for endpoint paths using explicit query/field types.
252    pub fn context_with<Q, F>(&self, data: Option<Vec<Param>>) -> Context
253    where
254        Q: QueryField + ValueValidator,
255        F: QueryField,
256    {
257        let mut context = Context::new();
258        match data {
259            | Some(params) => {
260                let (query_params, other_params): (Vec<Param>, Vec<Param>) = params.into_iter().partition(|param| param.is_query());
261                let query = Param::to_query_string::<Q, F>(query_params);
262                context.insert("query", &query);
263                other_params.into_iter().for_each(|param| {
264                    if param.is_template() {
265                        param.values.into_iter().filter_map(|(k, v)| k.or(v)).for_each(|value| {
266                            let key = param.name.clone();
267                            context.insert(&key, &value.clone());
268                        });
269                    }
270                });
271                // TODO: Add header/body params to request builder
272                dbg!(&context);
273            }
274            | None => (),
275        }
276        context.insert("base", &self.base());
277        context
278    }
279}
280impl EndpointSearch for Vec<Endpoint> {
281    fn find_by_name(&self, value: impl Into<String>) -> Option<Endpoint> {
282        let name = value.into();
283        self.iter().find(|endpoint| endpoint.name == name).cloned()
284    }
285}
286/// Blanket implementation for all types that satisfy the bounds
287impl<T> QueryField for T where T: fmt::Display + for<'a> TryFrom<&'a str> {}
288impl fmt::Display for AuthenticationScheme {
289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290        write!(
291            f,
292            "{}",
293            match self {
294                | AuthenticationScheme::Bearer => "Bearer",
295                | AuthenticationScheme::Basic => "Basic",
296                | AuthenticationScheme::ApiKey => "ApiKey",
297                | AuthenticationScheme::OAuth2 => "OAuth2",
298                | AuthenticationScheme::Custom(scheme) => scheme,
299            }
300        )
301    }
302}
303impl fmt::Display for EmptyField {
304    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305        write!(f, "{}", self.0)
306    }
307}
308impl Default for Endpoint {
309    fn default() -> Self {
310        Endpoint::at("https://example.com").build()
311    }
312}
313impl From<&str> for HttpMethod {
314    fn from(value: &str) -> Self {
315        match value.to_uppercase().as_str() {
316            | "GET" => HttpMethod::Get,
317            | "POST" => HttpMethod::Post,
318            | "PUT" => HttpMethod::Put,
319            | "PATCH" => HttpMethod::Patch,
320            | "DELETE" => HttpMethod::Delete,
321            | _ => HttpMethod::Get,
322        }
323    }
324}
325impl<'a> From<URI<'a>> for Endpoint {
326    fn from(value: URI<'a>) -> Self {
327        let domain: String = value.host().map(|h| format!("{}://{}", value.scheme(), h)).unwrap_or_default();
328        let port: Option<u16> = value.authority().and_then(|auth| auth.port());
329        Endpoint::at(domain).maybe_port(port).build()
330    }
331}
332impl From<Location> for Endpoint {
333    fn from(value: Location) -> Self {
334        match value.uri() {
335            | Some(uri) => {
336                let endpoint: Endpoint = uri.into();
337                endpoint
338            }
339            | None => Endpoint::default(),
340        }
341    }
342}
343impl From<Repository> for Endpoint {
344    fn from(value: Repository) -> Self {
345        value.location().into()
346    }
347}
348impl Param {
349    /// Check if this parameter is a body parameter
350    pub fn is_body(&self) -> bool {
351        matches!(self.style, ParamStyle::Body)
352    }
353    /// Check if this parameter is a query parameter (either a query pair, boosted query field, or field list)
354    pub fn is_query(&self) -> bool {
355        matches!(self.style, ParamStyle::QueryPair | ParamStyle::QueryField | ParamStyle::FieldList)
356    }
357    /// Check if this parameter is a header parameter
358    pub fn is_header(&self) -> bool {
359        matches!(self.style, ParamStyle::Header)
360    }
361    /// Check if this parameter is a template value
362    pub fn is_template(&self) -> bool {
363        matches!(self.style, ParamStyle::TemplateValue)
364    }
365    /// Convert a list of params to a query string
366    pub fn to_query_string<Q: QueryField + ValueValidator, F: QueryField>(params: Vec<Param>) -> String {
367        let query = params
368            .iter()
369            .filter(|param| param.is_query())
370            .map(|param| param.to_string::<Q, F>())
371            .filter(|s| !s.is_empty())
372            .collect::<Vec<String>>()
373            .join("&");
374        if !query.is_empty() {
375            format!("?{query}")
376        } else {
377            String::new()
378        }
379    }
380    /// Create a query pair parameter with key-value pairs
381    /// ### Example
382    /// ```ignore
383    /// let param = Param::from_query_pair("q", vec![("given-names", "Jason"), ("family-name", "Wohlgemuth")]);
384    /// let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>();
385    /// let expected = "q=given-names:Jason+AND+family-name:Wohlgemuth";
386    /// assert_eq!(rendered, expected);
387    /// ```
388    pub fn from_query_pair(key: &str, pairs: Vec<(&str, &str)>) -> Self {
389        Param::of_type(ParamStyle::QueryPair)
390            .values(pairs.into_iter().map(|(k, v)| (Some(k), Some(v))).collect())
391            .with_key(key)
392    }
393    /// Create a field list parameter with a list of field names
394    pub fn from_field_list(key: &str, fields: Vec<&str>) -> Self {
395        Param::of_type(ParamStyle::FieldList)
396            .values(fields.into_iter().map(|f| (Some(f), None)).collect())
397            .with_key(key)
398    }
399    /// Create a boosted query field parameter with a list of field names
400    pub fn from_query_field(key: &str, fields: Vec<&str>) -> Self {
401        Param::of_type(ParamStyle::QueryField)
402            .values(fields.into_iter().map(|f| (Some(f), None)).collect())
403            .with_key(key)
404    }
405
406    /// Render this parameter to a query string using the provided field types.
407    /// - `Q` is used for query pairs and boosted query fields and must support validation (`Validate` trait).
408    /// - `F` is used for field lists (e.g., output columns).
409    pub fn to_string<Q: QueryField + ValueValidator, F: QueryField>(&self) -> String {
410        let key = self.name.as_str();
411        let rendered: Option<String> = match self.style {
412            | ParamStyle::QueryPair => {
413                let separator = "+AND+";
414                let pairs: Vec<(&str, &str)> = self
415                    .values
416                    .iter()
417                    .filter_map(|(key, value)| match (key.as_deref(), value.as_deref()) {
418                        | (Some(k), Some(v)) => Some((k, v)),
419                        | _ => None,
420                    })
421                    .collect();
422                param_from_query_pairs::<Q>(key, separator, pairs)
423            }
424            | ParamStyle::QueryField => {
425                let separator = URL_ENCODED_SPACE;
426                let fields: Vec<&str> = self
427                    .values
428                    .iter()
429                    .filter_map(|(key, value)| key.as_deref().or(value.as_deref()))
430                    .collect();
431                param_from_query_fields::<Q>(key, separator, fields)
432            }
433            | ParamStyle::FieldList => {
434                let separator = ",";
435                let fields: Vec<&str> = self
436                    .values
437                    .iter()
438                    .filter_map(|(key, value)| key.as_deref().or(value.as_deref()))
439                    .collect();
440                param_from_field_list::<F>(key, separator, fields)
441            }
442            | _ => None,
443        };
444        rendered.unwrap_or_default()
445    }
446}
447impl IntoHeaders for Vec<Param> {
448    fn into_headers(self) -> HeaderMap {
449        let mut headers = HeaderMap::new();
450        self.into_iter().filter(|param| param.is_header()).for_each(|param| {
451            let Param { name, values, .. } = param;
452            let name: HeaderName = match name.parse() {
453                | Ok(header_name) => header_name,
454                | Err(_) => return,
455            };
456            values.into_iter().for_each(|(_, value)| {
457                if let Some(raw) = value {
458                    if let Ok(mut header_value) = HeaderValue::from_str(&raw) {
459                        header_value.set_sensitive(true);
460                        headers.append(name.clone(), header_value);
461                    }
462                }
463            });
464        });
465        headers
466    }
467}
468impl RestfulInterface for Endpoint {
469    type Query = EmptyField;
470    type Field = EmptyField;
471
472    fn context(&self, data: Option<Vec<Param>>) -> Context {
473        self.context_with::<Self::Query, Self::Field>(data)
474    }
475    fn handle<R>(&self, response: Result<ResponseContent, Error>) -> Result<R, String>
476    where
477        R: for<'de> Deserialize<'de>,
478    {
479        match response {
480            | Ok(content) => match content {
481                | ResponseContent::Json(content) => self.parse_json(&content),
482                | ResponseContent::Xml(content) => self.parse_xml(&content),
483                | ResponseContent::Raw(content) => {
484                    let raw = TextResponse { content };
485                    serde_json::to_string(&raw)
486                        .map_err(|e| e.to_string())
487                        .and_then(|json| self.parse_json(&json))
488                }
489            },
490            | Err(e) => Err(e.to_string()),
491        }
492    }
493    /// Invoke an endpoint resource with data and receive a response using [`EmptyField`] as the default query and field types.
494    /// ### Example
495    /// ```ignore
496    /// let ror = endpoints.find_by_name("ror");
497    /// let text = match &ror {
498    ///     | Some(endpoint) => {
499    ///         let response = endpoint.invoke_sync("status", None);
500    ///         endpoint.handle::<api::TextResponse>(response)
501    ///     }
502    ///     | None => Err("No ROR endpoint found".into()),
503    /// };
504    /// println!("ROR Status: {text:#?}");
505    /// ```
506    fn invoke_sync(&self, name: impl Into<String> + Clone, data: Option<Vec<Param>>) -> Result<ResponseContent, Error> {
507        self.invoke_sync_with::<Self::Query, Self::Field>(name, data)
508    }
509    /// Invoke an endpoint resource with data and receive a response using explicit query and field types.
510    /// ### Example
511    /// ```ignore
512    /// let orcid = endpoints.find_by_name("orcid");
513    /// let text = match &orcid {
514    ///     | Some(endpoint) => {
515    ///         let data = vec![
516    ///             api::Param::of_type(api::ParamStyle::QueryPair)
517    ///                 .values(vec![
518    ///                     (Some("affiliation-org-name"), Some("Lyrasis")),
519    ///                     (Some("ror-org-id"), Some("\"https://ror.org/01qz5mb56\"")),
520    ///                 ])
521    ///                 .with_key("q"),
522    ///             api::Param::of_type(api::ParamStyle::FieldList)
523    ///                 .values(vec![(Some("family-name"), None)])
524    ///                 .with_key("fl"),
525    ///         ];
526    ///         let response = endpoint.invoke_sync_with::<api::orcid::SearchField, api::orcid::OutputColumn>("search", Some(data));
527    ///         endpoint.handle::<api::orcid::SearchResponse>(response)
528    ///     }
529    ///     | None => Err("No ORCiD endpoint found".into()),
530    /// };
531    /// println!("ORCiD Search Response: {text:#?}");
532    /// ```
533    fn invoke_sync_with<Q, F>(&self, name: impl Into<String> + Clone, data: Option<Vec<Param>>) -> Result<ResponseContent, Error>
534    where
535        Q: QueryField + ValueValidator,
536        F: QueryField,
537    {
538        let Self { resources, .. } = self;
539        let mut tera = Tera::default();
540        let context = self.context_with::<Q, F>(data.clone());
541        let resource = resources.iter().find(|resource| resource.name == name.clone().into());
542        match resource {
543            | Some(Resource { method, template, .. }) => {
544                let path = tera.render_str(template, &context).unwrap_or_default();
545                let request = match method {
546                    | HttpMethod::Get => network_get_request(path),
547                    // TODO: Pass Body params for POST and PUT requests
548                    | HttpMethod::Post => network_post_request(path),
549                    | HttpMethod::Put => network_put_request(path),
550                    | _ => unimplemented!("Only GET, POST, and PUT methods are currently implemented"),
551                };
552                let headers = data.unwrap_or_default().into_headers();
553                match request.headers(headers).send() {
554                    | Ok(response) => match response.text() {
555                        | Ok(text) => {
556                            let content = if detect_json(&text) {
557                                ResponseContent::Json(text)
558                            } else if detect_xml(&text) {
559                                ResponseContent::Xml(text)
560                            } else {
561                                ResponseContent::Raw(text)
562                            };
563                            Ok(content)
564                        }
565                        | Err(why) => Err(Error::other(why.to_string())),
566                    },
567                    | Err(why) => Err(Error::other(why.to_string())),
568                }
569            }
570            | None => Err(Error::new(ErrorKind::NotFound, "Resource not found")),
571        }
572    }
573    fn parse_json<R>(&self, content: &str) -> Result<R, String>
574    where
575        R: for<'de> Deserialize<'de>,
576    {
577        match serde_json::from_str::<R>(content) {
578            | Ok(response) => Ok(response),
579            | Err(why) => Err(why.to_string()),
580        }
581    }
582    fn parse_xml<R>(&self, content: &str) -> Result<R, String>
583    where
584        R: for<'de> Deserialize<'de>,
585    {
586        match quick_xml::de::from_str::<R>(content) {
587            | Ok(response) => Ok(response),
588            | Err(why) => Err(why.to_string()),
589        }
590    }
591}
592impl TryFrom<&str> for EmptyField {
593    type Error = String;
594
595    fn try_from(value: &str) -> Result<Self, Self::Error> {
596        Ok(EmptyField(value.to_string()))
597    }
598}
599impl ValueValidator for EmptyField {
600    fn is_valid(&self, _value: &str) -> bool {
601        true
602    }
603}
604/// Create a query string from a lookup table of key-value pairs with field validation
605pub fn param_from_query_pairs<T: QueryField + ValueValidator>(key: &str, separator: &str, pairs: Vec<(&str, &str)>) -> Option<String> {
606    let values: Vec<String> = pairs
607        .into_iter()
608        .filter_map(|(k, v)| {
609            let key: &str = k;
610            let value: &str = v.trim();
611            match T::try_from(key) {
612                | Ok(field) => {
613                    if field.is_valid(value) {
614                        Some(format!("{}:{}", field, urlencoding::encode(value)))
615                    } else {
616                        None
617                    }
618                }
619                | Err(_) => None,
620            }
621        })
622        .collect();
623    if values.is_empty() {
624        None
625    } else {
626        Some(format!("{}={}", key, values.join(separator)))
627    }
628}
629/// Create a query string from a list of field values
630pub fn param_from_field_list<T: QueryField>(key: &str, separator: &str, fields: Vec<&str>) -> Option<String> {
631    let values: Vec<String> = fields
632        .into_iter()
633        .filter_map(|value: &str| {
634            let val = value;
635            match T::try_from(val) {
636                | Ok(column) => Some(column.to_string()),
637                | Err(_) => None,
638            }
639        })
640        .collect();
641    if values.is_empty() {
642        None
643    } else {
644        Some(format!("{key}={}", values.join(separator)))
645    }
646}
647/// Create a boosted query string from a list of fields with weighted relevance
648pub fn param_from_query_fields<T: QueryField>(key: &str, separator: &str, fields: Vec<&str>) -> Option<String> {
649    let valid_fields: Vec<T> = fields.into_iter().filter_map(|value| T::try_from(value).ok()).collect();
650    if valid_fields.is_empty() {
651        None
652    } else {
653        let count = valid_fields.len();
654        Some(format!(
655            "{}={}",
656            key,
657            valid_fields
658                .into_iter()
659                .enumerate()
660                .map(|(i, field)| format!("{}{URL_ENCODED_CARAT}{}.0", field, count + 1 - i))
661                .collect::<Vec<String>>()
662                .join(separator),
663        ))
664    }
665}
666/// Parse API response content into a structured data type using `quick_xml` for XML deserialization
667pub fn parse<R>(content: &str) -> Result<R, String>
668where
669    R: for<'de> Deserialize<'de>,
670{
671    match quick_xml::de::from_str::<R>(content) {
672        | Ok(response) => Ok(response),
673        | Err(e) => Err(format!("Failed to parse ORCiD search response: {e}")),
674    }
675}
676// TODO: Support OR statements for field values (e.g., affiliation-org-name:("University of Plymouth" OR "Plymouth University"))
677/// Construct a query string for an endpoint API query from a list of field-value pairs, a list of fields, and a list of fields with boosted relevance.
678///
679/// The query string is constructed by joining the following parts with "&":
680///
681/// - The field-value pairs, joined with "+AND+", prefixed with "?q=".
682/// - The list of fields, joined with ",", prefixed with "&fl=".
683/// - The list of fields with boosted relevance, joined with URL encoded space, prefixed with "&qf=".
684///
685/// If the list of field-value pairs is empty, an empty string is returned.
686pub fn query_string<Q: QueryField + ValueValidator, F: QueryField>(
687    query_pairs: Vec<(&str, &str)>,
688    field_list: Vec<&str>,
689    query_fields: Vec<&str>,
690) -> String {
691    let params = vec![
692        Param::from_query_pair("q", query_pairs),
693        Param::from_field_list("fl", field_list),
694        Param::from_query_field("qf", query_fields),
695    ];
696    Param::to_query_string::<Q, F>(params)
697}
698
699#[cfg(test)]
700mod tests;