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());
headers.insert(
"Accept".to_string(),
"application/vnd.github+json".to_string(),
);
headers.insert(
"User-Agent".to_string(),
"mcpify-client/0.1.0 (generated by mcpify)".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);
} else if parsed_method != reqwest::Method::GET
&& parsed_method != reqwest::Method::HEAD
{
request = request
.header(reqwest::header::CONTENT_LENGTH, "0")
.body(Vec::new());
}
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 std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use super::*;
async fn mock_http(
status: &'static str,
body: &'static str,
) -> (String, Arc<Mutex<String>>, tokio::task::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let captured = Arc::new(Mutex::new(String::new()));
let request = captured.clone();
let handle = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut bytes = Vec::new();
let mut buffer = [0u8; 4096];
loop {
let read = stream.read(&mut buffer).await.unwrap();
if read == 0 {
break;
}
bytes.extend_from_slice(&buffer[..read]);
let headers_end = bytes
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4);
if let Some(headers_end) = headers_end {
let headers = String::from_utf8_lossy(&bytes[..headers_end]);
let content_length = headers
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length:")
.and_then(|value| value.trim().parse::<usize>().ok())
})
.unwrap_or(0);
if bytes.len() >= headers_end + content_length {
break;
}
}
}
*request.lock().unwrap() = String::from_utf8_lossy(&bytes).into_owned();
let wire = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(wire.as_bytes()).await.unwrap();
});
(format!("http://{address}"), captured, handle)
}
async fn disconnecting_server() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
for _ in 0..4 {
let Ok((stream, _)) = listener.accept().await else {
break;
};
drop(stream);
}
});
format!("http://{address}")
}
fn client(url: String, retry_attempts: u32) -> ApiClient {
let config: Config = serde_json::from_value(serde_json::json!({
"url": url,
"auth_method": "pat",
"retry_attempts": retry_attempts,
"timeout_ms": 200
}))
.unwrap();
ApiClient::new(config)
}
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())]);
}
#[tokio::test]
async fn dispatch_sends_json_and_empty_bodies_and_parses_responses() {
let (url, request, server) = mock_http("200 OK", r#"{"ok":true}"#).await;
let response = client(url.clone(), 0)
.dispatch(
"POST",
&url,
Some(&serde_json::json!({ "name": "coverage" })),
&HashMap::from([("X-Coverage".to_string(), "yes".to_string())]),
)
.await
.unwrap();
assert_eq!(response, serde_json::json!({ "ok": true }));
server.await.unwrap();
{
let request = request.lock().unwrap();
assert!(request.contains(r#"{"name":"coverage"}"#));
assert!(request.to_ascii_lowercase().contains("x-coverage: yes"));
}
let (url, request, server) = mock_http("204 No Content", "").await;
let response = client(url.clone(), 0)
.dispatch("DELETE", &url, None, &HashMap::new())
.await
.unwrap();
assert_eq!(response, Value::Null);
server.await.unwrap();
assert!(
request
.lock()
.unwrap()
.to_ascii_lowercase()
.contains("content-length: 0")
);
}
#[tokio::test]
async fn dispatch_surfaces_method_status_and_retry_exhaustion_errors() {
let local_url = disconnecting_server().await;
let invalid_method = client(local_url.clone(), 0)
.dispatch("NOT A METHOD", &local_url, None, &HashMap::new())
.await;
assert!(invalid_method.is_err());
let (url, _, server) = mock_http("500 Internal Server Error", "{}").await;
assert!(
client(url.clone(), 0)
.dispatch("GET", &url, None, &HashMap::new())
.await
.is_err()
);
server.await.unwrap();
let local_url = disconnecting_server().await;
assert!(
client(local_url.clone(), 1)
.dispatch("GET", &local_url, None, &HashMap::new())
.await
.is_err()
);
}
}