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::config::ApplicationConfiguration;
6use crate::io::database::{schema::Table, Database};
7use crate::io::http::HttpMethod;
8use crate::io::http::{delete, get, patch, post, put};
9use crate::io::ApiResult;
10use crate::param;
11use crate::util::constants::{URL_ENCODED_CARAT, URL_ENCODED_SPACE};
12use crate::util::{detect_json, detect_xml, Constant, Label, Searchable};
13use crate::{Location, Repository, Scheme};
14use async_trait::async_trait;
15use axum::http::header::{HeaderName, HeaderValue};
16use axum::http::HeaderMap;
17use bon::Builder;
18use color_eyre::eyre::{self, eyre};
19use core::iter::once;
20use core::{fmt, marker::PhantomData};
21use derive_more::Display;
22use fluent_uri::{Uri, UriRef};
23use itertools::Itertools;
24use lazy_static::lazy_static;
25use owo_colors::OwoColorize;
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28use serde_with::skip_serializing_none;
29use strum::EnumIs;
30use tera::{Context, Tera};
31use tracing::{debug, trace, warn};
32use validator::Validate;
33
34pub mod citeas;
35pub mod geonames;
36pub mod github;
37pub mod gitlab;
38pub mod huggingface;
39pub mod models_dev;
40pub mod openai;
41pub mod openapi;
42pub mod orcid;
43pub mod osti;
44pub mod raid;
45pub mod ror;
46pub mod spdx;
47
48lazy_static! {
49    /// Vector of API endpoints used during the endeavor of scientific research, communication, and collaboration
50    pub static ref INCLUDED_ENDPOINTS: Vec<Endpoint> = Constant::json::<ApplicationConfiguration>("application").endpoints.unwrap_or_default();
51}
52/// Returns the value of the first header in `names` that is present and valid UTF-8.
53pub fn first_header<'a>(headers: &'a HeaderMap, names: &[&str]) -> Option<&'a str> {
54    names.iter().filter_map(|name| headers.get(*name)).find_map(|value| value.to_str().ok())
55}
56/// Normalize an external username into a stable path component
57pub fn sluggify(username: &str, user_id: u64) -> String {
58    let (slug, _) = username
59        .trim()
60        .to_ascii_lowercase()
61        .chars()
62        .fold((String::new(), false), |(mut value, separator), character| {
63            if character.is_ascii_alphanumeric() {
64                value.push(character);
65                (value, false)
66            } else if !value.is_empty() && !separator {
67                value.push('-');
68                (value, true)
69            } else {
70                (value, separator)
71            }
72        });
73    let slug = slug.trim_matches('-');
74    if slug.is_empty() {
75        format!("user-{user_id}")
76    } else {
77        slug.to_string()
78    }
79}
80/// Trait to standardize configuration loading from environment variables and modification with new values
81pub trait Configuration {
82    /// Populate values from environment (e.g., `.env` file or environment variables)
83    fn from_env() -> Self;
84    /// Return a copy of this configuration with the specified request body payload set
85    fn with_body(self, value: impl Into<String>) -> Self;
86    /// Return a copy of this configuration with the specified domain set
87    fn with_domain(self, value: impl Into<String>) -> Self;
88    /// Return a copy of this configuration with the specified resource identifier set
89    fn with_identifier(self, value: impl Into<String>) -> Self;
90    /// Return the authentication token
91    fn token(&self) -> &str;
92    /// Return the API domain
93    fn domain(&self) -> &str;
94    /// Return the optional resource identifier
95    fn identifier(&self) -> Option<&str>;
96    /// Return a copy of this configuration with custom API parameters set.
97    /// These are appended to internally-constructed parameters before each request.
98    fn with_params(self, params: Vec<Param>) -> Self;
99    /// Return any custom API parameters
100    fn params(&self) -> &[Param];
101}
102/// Trait for objects that can be persisted in a database
103#[async_trait]
104pub trait DatabasePersistence {
105    /// Persist data to database
106    async fn persist(self, database: Database<Table>) -> ApiResult<usize>;
107}
108/// Helper trait for converting parameter collections into HTTP request body
109pub trait IntoBody {
110    /// Convert this value into a `serde_json::Value` for request body, using only body-style parameters.
111    fn into_body(self) -> serde_json::Value;
112}
113/// Helper trait for converting parameter collections into HTTP headers
114pub trait IntoHeaders {
115    /// Convert this value into a `HeaderMap`, using only header-style parameters
116    fn into_headers(self) -> HeaderMap;
117}
118/// Helper trait combining common bounds for API query field types
119pub trait QueryField: fmt::Display + for<'a> TryFrom<&'a str> {}
120/// Common repository file metadata exposed by provider APIs
121pub trait RepositoryFileMetadata {
122    /// Return the repository-relative path
123    fn path(&self) -> &str;
124    /// Return the reported file size when available
125    fn size(&self) -> Option<u64>;
126}
127/// Trait for types that can serve as a fallback error response parser
128///
129/// When `handle_or` fails to parse the primary response type, it calls
130/// `into_error` on the fallback type to attempt an alternative parse
131/// and surface a more descriptive error. Use [`NoFallback`] (the default)
132/// when no fallback is needed, or [`FallbackFor<T>`] to wrap a concrete
133/// error-response type.
134pub trait FallbackResponse {
135    /// Attempt to parse `content` as a fallback error type and return an
136    /// error report, or `None` if parsing also fails.
137    fn into_error(content: &str) -> Option<eyre::Report>;
138    /// Attempt to parse and pretty-print JSON content for readable fallback output.
139    fn to_string(content: &str) -> Option<String> {
140        serde_json::from_str::<serde_json::Value>(content).ok().and_then(|value| match value {
141            | serde_json::Value::String(inner) => serde_json::from_str::<serde_json::Value>(&inner)
142                .ok()
143                .and_then(|nested| serde_json::to_string_pretty(&nested).ok())
144                .or_else(|| serde_json::to_string_pretty(&serde_json::Value::String(inner)).ok()),
145            | other => serde_json::to_string_pretty(&other).ok(),
146        })
147    }
148}
149/// Trait for working with request-response cycle using HTTP methods like GET, POST, PUT, PATCH, and DELETE
150/// against resource URLs that return structured data
151#[async_trait]
152pub trait RemoteResource {
153    /// Query field type used for request building
154    type Query: QueryField + ValueValidator;
155    /// Field list type used for response selection
156    type Field: QueryField;
157
158    /// Build context for endpoint paths using the associated query and field types
159    fn context(&self, params: Option<Vec<Param>>) -> Context {
160        self.context_with::<Self::Query, Self::Field>(params)
161    }
162    /// Build context for endpoint paths using explicit query and field types
163    fn context_with<Q, F>(&self, data: Option<Vec<Param>>) -> Context
164    where
165        Q: QueryField + ValueValidator,
166        F: QueryField;
167    /// Handle a response from an endpoint request
168    fn handle<R>(&self, response: ApiResult<ResponseContent>) -> ApiResult<R>
169    where
170        R: for<'de> Deserialize<'de>,
171    {
172        match response {
173            | Ok(content) => match content {
174                | ResponseContent::Json(content) => parse_json(&content),
175                | ResponseContent::Xml(content) => parse_xml(&content),
176                | ResponseContent::Yaml(content) => parse_yaml(&content),
177                | ResponseContent::Raw(content) => {
178                    let raw = TextResponse { content };
179                    serde_json::to_string(&raw).map_err(|e| eyre!(e)).and_then(|json| parse_json(&json))
180                }
181            },
182            | Err(e) => Err(eyre!(e)),
183        }
184    }
185    /// Handle a response from an endpoint request, trying to parse as `R` first,
186    /// then falling back to `E` on parse failure to surface a richer error message
187    fn handle_or<R, E>(&self, response: ApiResult<ResponseContent>) -> ApiResult<R>
188    where
189        R: for<'de> Deserialize<'de>,
190        E: FallbackResponse;
191    /// Send data to the endpoint and receive a response asynchronously
192    async fn invoke(&self, action: impl Into<String> + Clone + Send, data: Option<Vec<Param>>) -> ApiResult<ResponseContent>;
193    /// Send data to the endpoint and receive a response asynchronously using explicit query/field types
194    async fn invoke_with<Q, F>(&self, action: impl Into<String> + Clone + Send, data: Option<Vec<Param>>) -> ApiResult<ResponseContent>
195    where
196        Q: QueryField + ValueValidator,
197        F: QueryField;
198}
199/// Trait to enable validation of field values
200pub trait ValueValidator {
201    /// Verify associated field value is valid
202    fn is_valid(&self, _value: &str) -> bool {
203        true
204    }
205}
206/// Authentication schemes supported for API requests
207#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)]
208pub enum AuthenticationScheme {
209    /// Bearer token authentication (e.g., JWT)
210    #[default]
211    Bearer,
212    /// Basic authentication (username:password)
213    Basic,
214    /// API key authentication
215    ApiKey,
216    /// OAuth 2.0 authentication
217    OAuth2,
218    /// AWS Signature V4 request signing
219    AwsSignatureV4,
220    /// Google Cloud Application Default Credentials
221    GoogleCloud,
222    /// Custom authentication scheme
223    Custom(String),
224}
225/// Describes the location/type of a parameter for an API resource
226#[derive(Clone, Debug, Default, Deserialize, EnumIs, Serialize)]
227pub enum ParamStyle {
228    /// Query parameter key-value pair (e.g., "given-names:Jason")
229    #[default]
230    QueryPair,
231    /// Query parameter with list of field values — used for specifying fields to boost
232    QueryField,
233    /// Specifies response fields (e.g., "given-names,family-name")
234    FieldList,
235    /// Key-value pair parameter (e.g., "key=value")
236    KeyValuePair,
237    /// Header parameter
238    Header,
239    /// Body parameter (data sent via POST or PUT request)
240    Body,
241    /// Value to be substituted directly into the URL path template
242    TemplateValue,
243}
244/// Wrapper enum for including response content MIME type with response body text
245#[derive(Clone, Debug, Deserialize, Serialize)]
246pub enum ResponseContent {
247    /// JSON response content
248    Json(String),
249    /// Raw text response content
250    Raw(String),
251    /// YAML response content
252    Yaml(String),
253    /// XML response content
254    Xml(String),
255}
256/// Type for repository tree entry
257#[derive(Clone, Debug, Display, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Ord)]
258#[serde(rename_all = "lowercase")]
259pub enum TreeEntryType {
260    /// File-like repository entry
261    #[serde(alias = "blob")]
262    #[display("file")]
263    File,
264    /// Directory-like repository entry
265    #[serde(alias = "tree")]
266    #[display("directory")]
267    Directory,
268}
269/// Wrapper that enables any `Deserialize + fmt::Debug` type as a fallback
270/// error response parser
271///
272/// ### Example
273/// ```ignore
274/// endpoint.handle_or::<Metadata, Fallback<ErrorResponse>>(response)
275/// ```
276pub struct Fallback<T>(PhantomData<T>);
277/// Default pass-through fallback — no secondary parse is attempted
278pub struct NoFallback;
279/// Represents authentication credentials for accessing an API
280#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
281#[builder(start_fn = init)]
282pub struct Authentication {
283    // TODO: Make token secret
284    /// The token used for authenticating API requests
285    pub token: Option<String>,
286    /// The scheme used for authenticating API requests
287    #[builder(default)]
288    pub scheme: AuthenticationScheme,
289}
290/// Empty struct used for cases where no query fields are needed
291#[derive(Clone, Debug, Deserialize, Serialize)]
292#[serde(rename_all = "kebab-case")]
293pub struct EmptyField(String);
294/// Represents an API endpoint with a lookup table for calling various paths
295/// ### Note
296/// Paths use handlebars templating syntax for dynamic URL construction, powered by [Tera](https://keats.github.io/tera/)
297#[skip_serializing_none]
298#[derive(Builder, Clone, Debug, Deserialize, Serialize, Validate)]
299#[builder(start_fn = at, on(String, into))]
300pub struct Endpoint {
301    /// The domain of the API endpoint
302    #[builder(start_fn)]
303    pub domain: String,
304    /// The name of the API endpoint (used mainly for logging and identification)
305    #[builder(default = String::new())]
306    pub name: String,
307    /// The scheme of the API endpoint
308    #[serde(default)]
309    pub scheme: Option<Scheme>,
310    /// The port of the API endpoint
311    pub port: Option<u16>,
312    /// Authentication credentials for accessing the API endpoint
313    pub authentication: Option<Authentication>,
314    /// Root path for the API endpoint
315    /// ### Example
316    /// "v3.0" for ORCiD API
317    pub root: Option<String>,
318    /// Resource data for generating full paths for the API endpoint using templates
319    #[builder(default = vec![])]
320    pub resources: Vec<Resource>,
321}
322/// Generic API response containing a single identifier
323#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
324pub struct Identifier<T> {
325    /// Provider-specific identifier
326    #[serde(rename = "id", alias = "iid")]
327    pub identifier: T,
328}
329/// Describes a parameter (path, query, header, etc.) for an API resource
330#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
331#[builder(start_fn = of_type, finish_fn = with_key, on(String, into))]
332pub struct Param {
333    /// The type/location of the parameter
334    #[builder(start_fn)]
335    pub style: ParamStyle,
336    /// The name of the parameter (e.g., "q", "fl", etc.)
337    #[builder(finish_fn)]
338    pub name: String,
339    /// Value(s) of the parameter
340    #[builder(
341        default = vec![],
342        with = |vecs: Vec<Vec<Option<&str>>>| {
343            vecs
344                .into_iter()
345                .map(|vec| vec.into_iter().map(|opt| opt.map(str::to_string)).collect())
346                .collect()
347        }
348    )]
349    pub values: Vec<Vec<Option<String>>>,
350    /// Whether or not the parameter is required
351    #[builder(default = false)]
352    pub required: bool,
353}
354/// Builder for constructing `Vec<Param>` with a fluent, immutable API.
355///
356/// Methods consume `self` and return `Self` — no mutation, always chainable.
357///
358/// # Examples
359///
360/// ```ignore
361/// use acorn::io::api::Params;
362///
363/// let params = Params::new()
364///     .with_auth("sk-xxx", None)
365///     .with_template("identifier", Some("chat-123"))
366///     .with_keyvalue("limit", Some("10"))
367///     .build();
368/// ```
369pub struct Params(Vec<Param>);
370/// Represents a resource for an API endpoint, which can be used to generate full paths for requests
371#[derive(Builder, Clone, Debug, Deserialize, Serialize, Validate)]
372#[builder(start_fn = init, on(String, into))]
373pub struct Resource {
374    /// Resource name (e.g., "search", "status")
375    pub name: String,
376    /// HTTP method to use when invoking this resource (e.g., GET, POST)
377    #[builder(with = |method: &str| HttpMethod::from(method))]
378    #[serde(default)]
379    pub method: HttpMethod,
380    /// Template for the resource path (e.g., "/expanded-search/{{ query }}")
381    pub template: String,
382}
383/// Wrapper struct for raw text responses that cannot be parsed as JSON or XML
384#[derive(Clone, Debug, Deserialize, Serialize)]
385pub struct TextResponse {
386    /// The raw text content from the response
387    pub content: String,
388}
389/// Generic repository tree entry shared across provider APIs
390#[skip_serializing_none]
391#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
392pub struct TreeEntry {
393    /// Repository-relative path
394    pub path: String,
395    /// Entry type normalized across provider naming
396    #[serde(rename = "type")]
397    pub entry_type: TreeEntryType,
398    /// File size in bytes, when provided
399    pub size: Option<u64>,
400    /// Provider-specific identifier, when provided
401    pub id: Option<String>,
402    /// Entry basename, when provided
403    pub name: Option<String>,
404    /// File mode, when provided
405    pub mode: Option<String>,
406    /// Git object SHA, when provided
407    pub sha: Option<String>,
408    /// Provider URL, when provided
409    pub url: Option<String>,
410}
411impl Endpoint {
412    /// Create an endpoint from explicit domain, scheme, and port parts.
413    ///
414    /// Unspecified parts fall back to the same defaults as [`Endpoint::default`].
415    pub fn from_parts(domain: String, scheme: Option<Scheme>, port: Option<u16>) -> Self {
416        Self {
417            domain,
418            scheme,
419            port,
420            ..Self::default()
421        }
422    }
423    /// Get the base URL for the API endpoint, constructed from scheme, domain, and port if not provided
424    pub fn base(&self) -> String {
425        let Self { domain, root, .. } = self;
426        let scheme = self.scheme.as_ref().map_or("https".to_string(), |s| s.to_string());
427        let port = self.port.map_or(String::new(), |port| format!(":{port}"));
428        let root = root.as_ref().map_or(String::new(), |root| format!("/{root}"));
429        format!("{scheme}://{domain}{port}{root}")
430    }
431    /// Create a new endpoint with a custom domain while preserving all other properties
432    pub fn with_domain(&self, domain: impl Into<String>) -> Self {
433        let (domain, scheme, port) = Self::split_domain(domain.into().as_str());
434        Self {
435            domain,
436            scheme: scheme.or_else(|| self.scheme.clone()),
437            port: port.or(self.port),
438            ..self.clone()
439        }
440    }
441    fn split_domain(value: &str) -> (String, Option<Scheme>, Option<u16>) {
442        Uri::parse(value)
443            .ok()
444            .and_then(|uri| {
445                uri.authority().map(|authority| {
446                    (
447                        authority.host().to_string(),
448                        Some(Scheme::from(uri.scheme().as_str())).filter(|scheme| *scheme != Scheme::Unsupported),
449                        authority.port_to_u16().ok().flatten(),
450                    )
451                })
452            })
453            .or_else(|| {
454                let authority = format!("//{}", value.trim());
455                UriRef::parse(authority.as_str()).ok().and_then(|uri| {
456                    uri.authority()
457                        .map(|parsed| (parsed.host().to_string(), None, parsed.port_to_u16().ok().flatten()))
458                })
459            })
460            .unwrap_or_else(|| (value.trim().to_string(), None, None))
461    }
462    /// Find an endpoint template by name from [`INCLUDED_ENDPOINTS`]
463    ///
464    /// # Example
465    /// ```ignore
466    /// let endpoint = Endpoint::from_template("gitlab")?.with_domain("my-gitlab.example.com");
467    /// ```
468    pub fn from_template(name: impl Into<String>) -> ApiResult<Self> {
469        let endpoint_name = name.into();
470        INCLUDED_ENDPOINTS
471            .find_by_name(&endpoint_name)
472            .ok_or_else(|| eyre!("Endpoint template '{endpoint_name}' not found in application configuration"))
473    }
474}
475impl Searchable<Endpoint> for Vec<Endpoint> {
476    fn find_by_name(&self, value: impl Into<String>) -> Option<Endpoint> {
477        let name = value.into();
478        self.iter().find(|endpoint| endpoint.name.eq_ignore_ascii_case(&name)).cloned()
479    }
480}
481impl FallbackResponse for NoFallback {
482    fn into_error(_: &str) -> Option<eyre::Report> {
483        None
484    }
485    fn to_string(_: &str) -> Option<String> {
486        None
487    }
488}
489impl<T> FallbackResponse for Fallback<T>
490where
491    T: for<'de> Deserialize<'de> + fmt::Debug,
492{
493    fn into_error(content: &str) -> Option<eyre::Report> {
494        serde_json::from_str::<T>(content).ok().map(|why| {
495            let message = Self::to_string(content).unwrap_or_else(|| format!("{why:#?}"));
496            eyre!("{message}")
497        })
498    }
499}
500impl Searchable<Resource> for Vec<Resource> {
501    fn find_by_name(&self, value: impl Into<String>) -> Option<Resource> {
502        let name = value.into();
503        self.iter().find(|resource| resource.name.eq_ignore_ascii_case(&name)).cloned()
504    }
505}
506/// Blanket implementation for all types that satisfy the bounds
507impl<T> QueryField for T where T: fmt::Display + for<'a> TryFrom<&'a str> {}
508impl fmt::Display for AuthenticationScheme {
509    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
510        write!(
511            f,
512            "{}",
513            match self {
514                | AuthenticationScheme::Bearer => "Bearer",
515                | AuthenticationScheme::Basic => "Basic",
516                | AuthenticationScheme::ApiKey => "ApiKey",
517                | AuthenticationScheme::OAuth2 => "OAuth2",
518                | AuthenticationScheme::AwsSignatureV4 => "AWS Signature V4",
519                | AuthenticationScheme::GoogleCloud => "Google Cloud",
520                | AuthenticationScheme::Custom(scheme) => scheme,
521            }
522        )
523    }
524}
525impl fmt::Display for EmptyField {
526    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
527        write!(f, "{}", self.0)
528    }
529}
530impl fmt::Display for TextResponse {
531    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
532        write!(f, "{}", self.content)
533    }
534}
535impl Default for Endpoint {
536    fn default() -> Self {
537        Endpoint::at("example.com").scheme(Scheme::default()).build()
538    }
539}
540impl<'a> From<Uri<&'a str>> for Endpoint {
541    fn from(value: Uri<&'a str>) -> Self {
542        let domain = value.authority().map(|auth| auth.host().to_string()).unwrap_or_default();
543        let port = value.authority().and_then(|auth| auth.port_to_u16().ok()).flatten();
544        Self::from_parts(
545            domain,
546            Some(Scheme::from(value.scheme().as_str())).filter(|scheme| *scheme != Scheme::Unsupported),
547            port,
548        )
549    }
550}
551impl From<Location> for Endpoint {
552    fn from(value: Location) -> Self {
553        let domain = value.host().unwrap_or_default();
554        let port = value.port();
555        Self::from_parts(domain, Some(value.scheme()), port)
556    }
557}
558impl From<Repository> for Endpoint {
559    fn from(value: Repository) -> Self {
560        value.location().into()
561    }
562}
563impl fmt::Display for HttpMethod {
564    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
565        write!(
566            f,
567            "{}",
568            match self {
569                | HttpMethod::Get => "GET",
570                | HttpMethod::Post => "POST",
571                | HttpMethod::Put => "PUT",
572                | HttpMethod::Patch => "PATCH",
573                | HttpMethod::Delete => "DELETE",
574            }
575        )
576    }
577}
578impl Param {
579    /// Check if this parameter is a query parameter (either a query pair, boosted query field, or field list)
580    pub fn is_query(&self) -> bool {
581        self.style.is_query_pair() | self.style.is_query_field() | self.style.is_field_list()
582    }
583    /// Convert a list of params to a query string
584    pub fn to_query_string<Q: QueryField + ValueValidator, F: QueryField>(params: Vec<Param>) -> String {
585        let query = params
586            .iter()
587            .filter(|param| param.is_query() || param.style.is_key_value_pair())
588            .map(|param| param.to_string::<Q, F>())
589            .filter(|s| !s.is_empty())
590            .collect::<Vec<String>>()
591            .join("&");
592        if !query.is_empty() {
593            format!("?{query}")
594        } else {
595            String::new()
596        }
597    }
598    /// Create a query pair parameter with key-value pairs
599    /// ### Example
600    /// ```ignore
601    /// let param = Param::from_query_pair("q", vec![("given-names", "Jason"), ("family-name", "Wohlgemuth")]);
602    /// let rendered = param.to_string::<orcid::SearchField, orcid::OutputColumn>();
603    /// let expected = "q=given-names:Jason+AND+family-name:Wohlgemuth";
604    /// assert_eq!(rendered, expected);
605    /// ```
606    pub fn from_query_pair(key: &str, pairs: Vec<(&str, &str)>) -> Self {
607        Param::of_type(ParamStyle::QueryPair)
608            .values(pairs.into_iter().map(|(k, v)| vec![Some(k), Some(v)]).collect())
609            .with_key(key)
610    }
611    /// Create a field list parameter with a list of field names
612    pub fn from_field_list(key: &str, fields: Vec<&str>) -> Self {
613        Param::of_type(ParamStyle::FieldList)
614            .values(fields.into_iter().map(|f| vec![Some(f)]).collect())
615            .with_key(key)
616    }
617    /// Create a boosted query field parameter with a list of field names
618    pub fn from_query_field(key: &str, fields: Vec<&str>) -> Self {
619        Param::of_type(ParamStyle::QueryField)
620            .values(fields.into_iter().map(|f| vec![Some(f)]).collect())
621            .with_key(key)
622    }
623
624    /// Render this parameter to a query string using the provided field types.
625    /// - `Q` is used for query pairs and boosted query fields and must support validation (`Validate` trait).
626    /// - `F` is used for field lists (e.g., output columns).
627    pub fn to_string<Q: QueryField + ValueValidator, F: QueryField>(&self) -> String {
628        let key = self.name.as_str();
629        let rendered: Option<String> = match self.style {
630            | ParamStyle::QueryPair => {
631                let separator = "+AND+";
632                let pairs: Vec<(&str, &str)> = self
633                    .values
634                    .iter()
635                    .filter_map(
636                        |vec| match (vec.first().and_then(|o| o.as_deref()), vec.get(1).and_then(|o| o.as_deref())) {
637                            | (Some(k), Some(v)) => Some((k, v)),
638                            | _ => None,
639                        },
640                    )
641                    .collect();
642                param_from_query_pairs::<Q>(key, separator, pairs)
643            }
644            | ParamStyle::QueryField => {
645                let separator = URL_ENCODED_SPACE;
646                let fields: Vec<&str> = self.values.iter().filter_map(|vec| vec.first().and_then(|o| o.as_deref())).collect();
647                param_from_query_fields::<Q>(key, separator, fields)
648            }
649            | ParamStyle::FieldList => {
650                let separator = ",";
651                let fields: Vec<&str> = self.values.iter().filter_map(|vec| vec.first().and_then(|o| o.as_deref())).collect();
652                param_from_field_list::<F>(key, separator, fields)
653            }
654            | ParamStyle::KeyValuePair => {
655                let value = self
656                    .values
657                    .iter()
658                    .filter_map(|vec| vec.first().and_then(|o| o.as_deref()))
659                    .collect::<String>();
660                param_from_key_value_pair::<Q>(key, &value)
661            }
662            | _ => None,
663        };
664        rendered.unwrap_or_default()
665    }
666}
667impl Default for Params {
668    fn default() -> Self {
669        Self::new()
670    }
671}
672impl Params {
673    /// Start building an empty param list
674    pub fn new() -> Self {
675        Self(Vec::new())
676    }
677    /// Build and return the underlying `Vec<Param>`
678    pub fn build(self) -> Vec<Param> {
679        self.0
680    }
681    /// Populate with Bearer auth header and identifier template value from a
682    /// [`Configuration`] provider.
683    pub fn from_config(config: &impl Configuration) -> Self {
684        Self::new()
685            .with_auth(config.token(), None)
686            .with_template("identifier", config.identifier())
687    }
688    /// Add any pre-built `Param` value
689    pub fn with(self, param: Param) -> Self {
690        Self(self.0.into_iter().chain(once(param)).collect())
691    }
692    /// Add an authentication header if `token` is non-empty.
693    ///
694    /// When `name` is `None`, adds `Authorization: Bearer {token}` (Bearer auth).
695    /// When `name` is `Some(header_name)`, adds `{header_name}: {token}` (custom header).
696    ///
697    /// Used by Bearer providers (OpenAI, RAiD) and custom-header providers (GitLab's `PRIVATE-TOKEN`).
698    pub fn with_auth(self, token: &str, name: Option<&str>) -> Self {
699        let value = token.trim();
700        if !value.is_empty() {
701            let (header_name, header_value): (&str, String) = match name {
702                | None => ("Authorization", format!("Bearer {value}")),
703                | Some(name) => (name, value.to_string()),
704            };
705            self.with(param!(Header, header_name, header_value.as_str()))
706        } else {
707            self
708        }
709    }
710    /// Add an authentication header when an optional token is present and non-empty
711    /// ### Note
712    /// Uses the same header semantics as [`Params::with_auth`]
713    pub fn with_auth_maybe(self, token: Option<&str>, name: Option<&str>) -> Self {
714        match token.map(str::trim).filter(|value| !value.is_empty()) {
715            | Some(value) => self.with_auth(value, name),
716            | None => self,
717        }
718    }
719    /// Add a template-value parameter, skipping if the value is `None` or empty
720    pub fn with_template(self, key: &str, value: Option<&str>) -> Self {
721        match value {
722            | Some(v) if !v.is_empty() => self.with(param!(ParamStyle::TemplateValue, key, v)),
723            | _ => self,
724        }
725    }
726    /// Add a query key-value pair, skipping if the value is `None` or empty
727    pub fn with_keyvalue(self, key: &str, value: Option<&str>) -> Self {
728        match value {
729            | Some(v) if !v.is_empty() => self.with(param!(ParamStyle::KeyValuePair, key, v)),
730            | _ => self,
731        }
732    }
733    /// Add a named body parameter (always added)
734    pub fn with_body(self, key: &str, value: &str) -> Self {
735        self.with(param!(ParamStyle::Body, key, value))
736    }
737    /// Add a named body parameter, skipping if the value is `None` or empty
738    pub fn with_body_maybe(self, key: &str, value: Option<&str>) -> Self {
739        match value {
740            | Some(v) if !v.is_empty() => self.with(param!(ParamStyle::Body, key, v)),
741            | _ => self,
742        }
743    }
744    /// Add a field-list parameter (always added)
745    pub fn with_field(self, key: &str, value: &str) -> Self {
746        self.with(param!(ParamStyle::FieldList, key, value))
747    }
748    /// Merge custom parameters into this param list.
749    /// Skipped when `custom` is empty to avoid unnecessary allocation.
750    pub fn with_custom(self, custom: &[Param]) -> Self {
751        if custom.is_empty() {
752            self
753        } else {
754            Self(self.0.iter().chain(custom.iter()).cloned().collect())
755        }
756    }
757}
758impl IntoBody for Vec<Param> {
759    fn into_body(self) -> serde_json::Value {
760        let params: Vec<Param> = self.into_iter().filter(|Param { style, .. }| style.is_body()).collect();
761        match params.as_slice() {
762            | [Param { name, values, .. }] if name.is_empty() => {
763                let flattened: Vec<String> = values.iter().cloned().flat_map(|vec| vec.into_iter().flatten()).collect();
764                if flattened.len() == 1 {
765                    let raw = flattened.into_iter().next().unwrap_or_default();
766                    serde_json::from_str::<serde_json::Value>(&raw).unwrap_or(serde_json::Value::String(raw))
767                } else if flattened.is_empty() {
768                    serde_json::Value::Null
769                } else {
770                    serde_json::Value::Array(flattened.into_iter().map(serde_json::Value::String).collect())
771                }
772            }
773            | _ => {
774                let body = params
775                    .into_iter()
776                    .map(|param| {
777                        let Param { name, values, .. } = param;
778                        let flattened: Vec<String> = values.into_iter().flat_map(|vec| vec.into_iter().flatten()).collect();
779                        let value = if flattened.len() == 1 {
780                            #[allow(clippy::unwrap_used)]
781                            serde_json::Value::String(flattened.into_iter().next().unwrap())
782                        } else if flattened.is_empty() {
783                            serde_json::Value::Null
784                        } else {
785                            serde_json::Value::Array(flattened.into_iter().map(serde_json::Value::String).collect())
786                        };
787                        (name, value)
788                    })
789                    .collect();
790                serde_json::Value::Object(body)
791            }
792        }
793    }
794}
795impl IntoHeaders for Vec<Param> {
796    fn into_headers(self) -> HeaderMap {
797        let mut headers = HeaderMap::new();
798        self.into_iter().filter(|Param { style, .. }| style.is_header()).for_each(|param| {
799            let Param { name, values, .. } = param;
800            if let Ok(header_name) = name.parse::<HeaderName>() {
801                values.into_iter().for_each(|vec| {
802                    vec.into_iter().for_each(|opt_value| {
803                        if let Some(raw) = opt_value {
804                            if let Ok(mut header_value) = HeaderValue::from_str(&raw) {
805                                header_value.set_sensitive(true);
806                                headers.append(header_name.clone(), header_value);
807                            }
808                        }
809                    });
810                });
811            }
812        });
813        headers
814    }
815}
816#[async_trait]
817impl RemoteResource for Endpoint {
818    type Query = EmptyField;
819    type Field = EmptyField;
820
821    fn context_with<Q, F>(&self, data: Option<Vec<Param>>) -> Context
822    where
823        Q: QueryField + ValueValidator,
824        F: QueryField,
825    {
826        let mut context = Context::new();
827        match data {
828            | Some(params) => {
829                let (query_params, other_params): (Vec<Param>, Vec<Param>) =
830                    params.into_iter().partition(|param| param.is_query() || param.style.is_key_value_pair());
831                let query = Param::to_query_string::<Q, F>(query_params);
832                context.insert("query", &query);
833                other_params.into_iter().for_each(|Param { name, style, values, .. }| {
834                    if style.is_template_value() {
835                        values.into_iter().for_each(|vec| {
836                            vec.into_iter().flatten().for_each(|value| {
837                                let key = name.clone();
838                                context.insert(&key, &value.clone());
839                            });
840                        });
841                    }
842                });
843            }
844            | None => (),
845        }
846        context.insert("base", &self.base());
847        context
848    }
849    fn handle_or<R, E>(&self, response: ApiResult<ResponseContent>) -> ApiResult<R>
850    where
851        R: for<'de> Deserialize<'de>,
852        E: FallbackResponse,
853    {
854        match response {
855            | Ok(content) => {
856                let raw_text = match &content {
857                    | ResponseContent::Json(s) | ResponseContent::Xml(s) | ResponseContent::Yaml(s) | ResponseContent::Raw(s) => s.clone(),
858                };
859                let result: ApiResult<R> = match content {
860                    | ResponseContent::Json(s) => parse_json(&s),
861                    | ResponseContent::Xml(s) => parse_xml(&s),
862                    | ResponseContent::Yaml(s) => parse_yaml(&s),
863                    | ResponseContent::Raw(s) => {
864                        let raw = TextResponse { content: s };
865                        serde_json::to_string(&raw).map_err(|e| eyre!(e)).and_then(|json| parse_json(&json))
866                    }
867                };
868                result.map_err(|err| E::into_error(&raw_text).unwrap_or(err))
869            }
870            | Err(why) => Err(eyre!(why)),
871        }
872    }
873    /// Invoke an endpoint resource asynchronously with data and receive a response using [`EmptyField`] as the default query and field types.
874    /// ### Example
875    /// ```ignore
876    /// let ror = endpoints.find_by_name("ror");
877    /// let text = match &ror {
878    ///     | Some(endpoint) => {
879    ///         let response = endpoint.invoke("status", None).await;
880    ///         endpoint.handle::<api::TextResponse>(response)
881    ///     }
882    ///     | None => Err(eyre!("No ROR endpoint found")),
883    /// };
884    /// println!("ROR Status: {text:#?}");
885    /// ```
886    async fn invoke(&self, name: impl Into<String> + Clone + Send, data: Option<Vec<Param>>) -> ApiResult<ResponseContent> {
887        self.invoke_with::<Self::Query, Self::Field>(name, data).await
888    }
889    /// Invoke an endpoint resource asynchronously with data and receive a response using explicit query and field types.
890    /// ### Example
891    /// ```ignore
892    /// use acorn::io::api::{self, INCLUDED_ENDPOINTS};
893    /// use acorn::util::Searchable;
894    ///
895    /// let orcid = INCLUDED_ENDPOINTS.find_by_name("orcid");
896    /// let text = match &orcid {
897    ///     | Some(endpoint) => {
898    ///         let data = vec![
899    ///             param!(
900    ///                 QueryPair,
901    ///                 "q",
902    ///                 (("affiliation-org-name", "Lyrasis"), ("ror-org-id", "\"https://ror.org/01qz5mb56\""),)
903    ///             ),
904    ///             param!(FieldList, "fl", "family-name"),
905    ///         ];
906    ///         let response = endpoint.invoke_with::<api::orcid::SearchField, api::orcid::OutputColumn>("search", Some(data)).await;
907    ///         endpoint.handle::<api::orcid::SearchResponse>(response)
908    ///     }
909    ///     | None => Err(eyre!("No ORCiD endpoint found")),
910    /// };
911    /// println!("ORCiD Search Response: {text:#?}");
912    /// ```
913    async fn invoke_with<Q, F>(&self, name: impl Into<String> + Clone + Send, data: Option<Vec<Param>>) -> ApiResult<ResponseContent>
914    where
915        Q: QueryField + ValueValidator,
916        F: QueryField,
917    {
918        let Self { resources, .. } = self;
919        let context = self.context_with::<Q, F>(data.clone());
920        let resource = resources.find_by_name(name);
921        match resource {
922            | Some(Resource { method, template, .. }) => {
923                let path = render(&template, &context);
924                let params = data.unwrap_or_default();
925                let headers = params.clone().into_headers();
926                let body = params.into_body();
927                let request = match method {
928                    | HttpMethod::Delete => delete(path),
929                    | HttpMethod::Get => get(path),
930                    | HttpMethod::Patch => patch(path).json(&body),
931                    | HttpMethod::Post => post(path).json(&body),
932                    | HttpMethod::Put => put(path).json(&body),
933                };
934                debug!("=> {} {}", Label::run(), request.cyan());
935                match request.headers(headers).send().await {
936                    | Ok(response) => match response.text().await {
937                        | Ok(text) => {
938                            trace!("=> {} Response {text}", Label::using());
939                            let content = if detect_json(&text) {
940                                ResponseContent::Json(text)
941                            } else if detect_xml(&text) {
942                                ResponseContent::Xml(text)
943                            } else {
944                                ResponseContent::Raw(text)
945                            };
946                            Ok(content)
947                        }
948                        | Err(why) => Err(eyre!(why)),
949                    },
950                    | Err(why) => Err(eyre!(why)),
951                }
952            }
953            | None => Err(eyre!("Resource not found")),
954        }
955    }
956}
957impl TryFrom<&str> for EmptyField {
958    type Error = String;
959
960    fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
961        Ok(EmptyField(value.to_string()))
962    }
963}
964impl ValueValidator for EmptyField {
965    fn is_valid(&self, _value: &str) -> bool {
966        true
967    }
968}
969impl TreeEntry {
970    /// Whether this tree entry is a file
971    pub fn is_file(&self) -> bool {
972        self.entry_type == TreeEntryType::File
973    }
974    /// Whether this tree entry is a directory
975    pub fn is_directory(&self) -> bool {
976        self.entry_type == TreeEntryType::Directory
977    }
978    /// Consume the entry and return its repository-relative path
979    pub fn path(self) -> String {
980        self.path
981    }
982}
983pub(crate) fn extract_template_keys(template: &str) -> Vec<String> {
984    fn extract_key(expression: &str) -> Option<String> {
985        let trimmed = expression.trim().trim_matches('-');
986        trimmed
987            .split('|')
988            .next()
989            .map(str::trim)
990            .and_then(|base| base.split_whitespace().next().map(str::trim))
991            .and_then(|key| (!key.is_empty()).then(|| key.to_string()))
992    }
993    template
994        .split("{{")
995        .skip(1)
996        .filter_map(|segment| segment.split_once("}}").map(|(before, _)| before))
997        .filter_map(extract_key)
998        .unique()
999        .collect()
1000}
1001/// Create a query string component from a key-value pair with key and value validation
1002pub(crate) fn param_from_key_value_pair<T: QueryField + ValueValidator>(key: &str, value: &str) -> Option<String> {
1003    match T::try_from(key) {
1004        | Ok(field) => {
1005            if field.is_valid(value) {
1006                Some(format!("{}={}", field, urlencoding::encode(value)))
1007            } else {
1008                warn!("=> {} Invalid key value ({}{})", Label::using(), format!("{key}=").dimmed(), value.red());
1009                None
1010            }
1011        }
1012        | Err(_) => {
1013            warn!("=> {} Invalid key ({}{})", Label::using(), key.red(), format!("={value}").dimmed());
1014            None
1015        }
1016    }
1017}
1018/// Create a query string from a lookup table of key-value pairs with field validation
1019pub(crate) fn param_from_query_pairs<T: QueryField + ValueValidator>(key: &str, separator: &str, pairs: Vec<(&str, &str)>) -> Option<String> {
1020    let values: Vec<String> = pairs
1021        .into_iter()
1022        .filter_map(|(k, v)| {
1023            let key: &str = k;
1024            let value: &str = v.trim();
1025            match T::try_from(key) {
1026                | Ok(field) => {
1027                    if field.is_valid(value) {
1028                        Some(format!("{}:{}", field, urlencoding::encode(value)))
1029                    } else {
1030                        warn!(
1031                            "=> {} Invalid query value ({}{})",
1032                            Label::using(),
1033                            format!("{key}=").dimmed(),
1034                            value.red()
1035                        );
1036                        None
1037                    }
1038                }
1039                | Err(_) => {
1040                    warn!("=> {} Invalid query key ({}{})", Label::using(), key.red(), format!("={value}").dimmed());
1041                    None
1042                }
1043            }
1044        })
1045        .collect();
1046    if values.is_empty() {
1047        None
1048    } else {
1049        Some(format!("{}={}", key, values.join(separator)))
1050    }
1051}
1052/// Create a query string from a list of field values
1053pub(crate) fn param_from_field_list<T: QueryField>(key: &str, separator: &str, fields: Vec<&str>) -> Option<String> {
1054    let values: Vec<String> = fields
1055        .into_iter()
1056        .filter_map(|value: &str| {
1057            let val = value;
1058            match T::try_from(val) {
1059                | Ok(column) => Some(column.to_string()),
1060                | Err(_) => None,
1061            }
1062        })
1063        .collect();
1064    if values.is_empty() {
1065        None
1066    } else {
1067        Some(format!("{key}={}", values.join(separator)))
1068    }
1069}
1070/// Create a boosted query string from a list of fields with weighted relevance
1071pub(crate) fn param_from_query_fields<T: QueryField>(key: &str, separator: &str, fields: Vec<&str>) -> Option<String> {
1072    let valid_fields: Vec<T> = fields.into_iter().filter_map(|value| T::try_from(value).ok()).collect();
1073    if valid_fields.is_empty() {
1074        None
1075    } else {
1076        let count = valid_fields.len();
1077        Some(format!(
1078            "{}={}",
1079            key,
1080            valid_fields
1081                .into_iter()
1082                .enumerate()
1083                .map(|(i, field)| format!("{}{URL_ENCODED_CARAT}{}.0", field, count.saturating_add(1).saturating_sub(i)))
1084                .collect::<Vec<String>>()
1085                .join(separator),
1086        ))
1087    }
1088}
1089pub(crate) fn parse_json<R>(content: &str) -> ApiResult<R>
1090where
1091    R: for<'de> Deserialize<'de>,
1092{
1093    match serde_json::from_str::<R>(content) {
1094        | Ok(response) => Ok(response),
1095        | Err(why) => Err(eyre!(why)),
1096    }
1097}
1098pub(crate) fn parse_xml<R>(content: &str) -> ApiResult<R>
1099where
1100    R: for<'de> Deserialize<'de>,
1101{
1102    match quick_xml::de::from_str::<R>(content) {
1103        | Ok(response) => Ok(response),
1104        | Err(why) => Err(eyre!(why)),
1105    }
1106}
1107pub(crate) fn parse_yaml<R>(content: &str) -> ApiResult<R>
1108where
1109    R: for<'de> Deserialize<'de>,
1110{
1111    match serde_norway::from_str::<R>(content) {
1112        | Ok(response) => Ok(response),
1113        | Err(why) => Err(eyre!(why)),
1114    }
1115}
1116/// 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.
1117///
1118/// The query string is constructed by joining the following parts with "&":
1119///
1120/// - The field-value pairs, joined with "+AND+", prefixed with "?q=".
1121/// - The list of fields, joined with ",", prefixed with "&fl=".
1122/// - The list of fields with boosted relevance, joined with URL encoded space, prefixed with "&qf=".
1123///
1124/// If the list of field-value pairs is empty, an empty string is returned.
1125pub(crate) fn query_string<Q: QueryField + ValueValidator, F: QueryField>(
1126    query_pairs: Vec<(&str, &str)>,
1127    field_list: Vec<&str>,
1128    query_fields: Vec<&str>,
1129) -> String {
1130    let params = vec![
1131        Param::from_query_pair("q", query_pairs),
1132        Param::from_field_list("fl", field_list),
1133        Param::from_query_field("qf", query_fields),
1134    ];
1135    Param::to_query_string::<Q, F>(params)
1136}
1137pub(crate) fn render(template: &str, context: &Context) -> String {
1138    let missing_values = extract_template_keys(template)
1139        .into_iter()
1140        .filter(|key| !context.contains_key(key))
1141        .map(|key| (key, serde_json::Value::String(String::new())));
1142    let merged = match context.clone().into_json() {
1143        | serde_json::Value::Object(existing) => serde_json::Value::Object(existing.into_iter().chain(missing_values).collect()),
1144        | _ => serde_json::Value::Object(missing_values.collect()),
1145    };
1146    Context::from_serialize(merged)
1147        .ok()
1148        .and_then(|context| Tera::one_off(template, &context, false).ok())
1149        .unwrap_or_default()
1150}
1151/// Validate that a required secret is present and non-empty
1152/// ### Note
1153/// The returned error intentionally excludes secret values
1154pub(crate) fn require_non_empty_secret(secret: &str, path: &str, names: &[&str]) -> ApiResult<String> {
1155    let value = secret.trim();
1156    if value.is_empty() {
1157        let env_list = names.join(", ");
1158        Err(eyre!("Missing required token for {path} request. Set one of: {env_list}"))
1159    } else {
1160        Ok(value.to_string())
1161    }
1162}
1163
1164#[cfg(test)]
1165mod tests;