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