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