use std::collections::HashMap;
use std::time::Duration;
use serde_json::{Map, Value};
use crate::auth::auth_manager::AuthManager;
use crate::auth::request_credentials::RequestCredentials;
use crate::core::api_url_builder::build_api_url;
use crate::core::circuit_breaker::{CircuitBreaker, CircuitBreakerError};
use crate::core::config_schema::Config;
use crate::core::rate_limiter::RateLimiter;
use crate::data::store::EndpointRecord;
fn parameters_of(endpoint: &EndpointRecord) -> Vec<Value> {
endpoint
.input_schema
.get("parameters")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
}
fn names_with_location(endpoint: &EndpointRecord, location: &str) -> Vec<String> {
parameters_of(endpoint)
.iter()
.filter(|param| param.get("in").and_then(Value::as_str) == Some(location))
.filter_map(|param| param.get("name").and_then(Value::as_str).map(String::from))
.collect()
}
fn value_to_plain_string(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn apply_path_params(endpoint: &EndpointRecord, args: &Map<String, Value>) -> String {
let mut result = String::new();
let mut chars = endpoint.path.chars().peekable();
while let Some(c) = chars.next() {
if c != '{' {
result.push(c);
continue;
}
let mut name = String::new();
for next in chars.by_ref() {
if next == '}' {
break;
}
name.push(next);
}
let value = args
.get(&name)
.map(value_to_plain_string)
.unwrap_or_default();
result.push_str(
&percent_encoding::utf8_percent_encode(&value, percent_encoding::NON_ALPHANUMERIC)
.to_string(),
);
}
result
}
fn pick_by_location(
endpoint: &EndpointRecord,
args: &Map<String, Value>,
location: &str,
) -> Vec<(String, String)> {
names_with_location(endpoint, location)
.into_iter()
.filter_map(|name| {
args.get(&name)
.map(|value| (name.clone(), value_to_plain_string(value)))
})
.collect()
}
pub struct ApiClient {
config: Config,
client: reqwest::Client,
circuit_breaker: CircuitBreaker,
rate_limiter: RateLimiter,
}
impl ApiClient {
pub fn new(config: Config) -> Self {
let rate_limiter = RateLimiter::new(config.rate_limit as usize, Duration::from_secs(1));
Self {
client: reqwest::Client::new(),
circuit_breaker: CircuitBreaker::default(),
rate_limiter,
config,
}
}
pub async fn execute(
&self,
endpoint: &EndpointRecord,
args: &Value,
auth_manager: &mut AuthManager,
request_override: Option<&RequestCredentials>,
) -> anyhow::Result<Value> {
self.rate_limiter.acquire()?;
let empty = Map::new();
let args_map = args.as_object().unwrap_or(&empty);
let query = pick_by_location(endpoint, args_map, "query");
let path = apply_path_params(endpoint, args_map);
let url = build_api_url(&self.config.url, &path, &query)?;
let mut headers: HashMap<String, String> = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
for (name, value) in pick_by_location(endpoint, args_map, "header") {
headers.insert(name, value);
}
let headers = auth_manager
.apply_auth_headers(
headers,
&endpoint.method,
&url,
self.config.transport,
request_override,
)
.await?;
let body = args_map.get("body").cloned();
match self
.circuit_breaker
.execute(|| self.dispatch(&endpoint.method, &url, body.as_ref(), &headers))
.await
{
Ok(value) => Ok(value),
Err(CircuitBreakerError::Open) => anyhow::bail!("circuit breaker is open"),
Err(CircuitBreakerError::Inner(err)) => Err(err),
}
}
async fn dispatch(
&self,
method: &str,
url: &str,
body: Option<&Value>,
headers: &HashMap<String, String>,
) -> anyhow::Result<Value> {
let parsed_method = reqwest::Method::from_bytes(method.as_bytes())?;
let mut attempt = 0u32;
loop {
let mut request = self
.client
.request(parsed_method.clone(), url)
.timeout(Duration::from_millis(self.config.timeout_ms));
for (key, value) in headers {
request = request.header(key, value);
}
if let Some(body) = body {
request = request.json(body);
}
match request.send().await {
Ok(response) => {
let value = response
.error_for_status()?
.json::<Value>()
.await
.unwrap_or(Value::Null);
return Ok(value);
}
Err(err) => {
attempt += 1;
if attempt > self.config.retry_attempts {
return Err(err.into());
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn endpoint(path: &str, input_schema: Value) -> EndpointRecord {
EndpointRecord {
operation_id: "op".to_string(),
path: path.to_string(),
method: "GET".to_string(),
summary: None,
description: None,
input_schema,
output_schema: Value::Null,
auth_scheme_ref: None,
}
}
#[test]
fn substitutes_path_parameters() {
let endpoint = endpoint("/widgets/{id}", Value::Null);
let args = Map::from_iter([("id".to_string(), Value::String("abc 123".to_string()))]);
assert_eq!(apply_path_params(&endpoint, &args), "/widgets/abc%20123");
}
#[test]
fn leaves_a_path_without_placeholders_untouched() {
let endpoint = endpoint("/widgets", Value::Null);
assert_eq!(apply_path_params(&endpoint, &Map::new()), "/widgets");
}
#[test]
fn picks_query_parameters_by_declared_location() {
let endpoint = endpoint(
"/widgets",
serde_json::json!({
"parameters": [
{ "in": "query", "name": "limit" },
{ "in": "header", "name": "X-Trace-Id" },
]
}),
);
let args = Map::from_iter([
("limit".to_string(), serde_json::json!(10)),
("X-Trace-Id".to_string(), Value::String("abc".to_string())),
]);
let query = pick_by_location(&endpoint, &args, "query");
assert_eq!(query, vec![("limit".to_string(), "10".to_string())]);
let headers = pick_by_location(&endpoint, &args, "header");
assert_eq!(headers, vec![("X-Trace-Id".to_string(), "abc".to_string())]);
}
}