use base64::prelude::*;
use reqwest::header::{self, HeaderMap, HeaderValue};
use reqwest::{Client, Method, RequestBuilder, StatusCode};
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
use tracing::{debug, error, info, info_span};
use url::Url;
use crate::error::{
Error, NetworkErrorCategory, ToolCallError, ToolCallExecutionError, ToolCallValidationError,
};
use crate::tool::ToolMetadata;
use crate::tool_generator::{ExtractedParameters, QueryParameter, ToolGenerator};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DataUriContent {
pub mime_type: String,
pub bytes: Vec<u8>,
}
pub fn parse_data_uri(value: &str, field_name: &str) -> Result<DataUriContent, Error> {
let format_error = || {
Error::Validation(format!(
"Invalid data URI format for field '{}': expected 'data:<mime>;base64,<content>'",
field_name
))
};
let remainder = value.strip_prefix("data:").ok_or_else(format_error)?;
let base64_marker = ";base64,";
let marker_pos = remainder.find(base64_marker).ok_or_else(|| {
if let Some(semicolon_pos) = remainder.find(';')
&& let Some(comma_pos) = remainder[semicolon_pos..].find(',')
{
let encoding = &remainder[semicolon_pos + 1..semicolon_pos + comma_pos];
if !encoding.is_empty() && encoding != "base64" {
return Error::Validation(format!(
"Unsupported encoding '{}' for field '{}': only base64 is supported",
encoding, field_name
));
}
}
format_error()
})?;
let mime_type = &remainder[..marker_pos];
let content = &remainder[marker_pos + base64_marker.len()..];
if mime_type.is_empty() {
return Err(Error::Validation(format!(
"Invalid data URI format for field '{}': MIME type cannot be empty",
field_name
)));
}
let bytes = BASE64_STANDARD.decode(content).map_err(|e| {
Error::Validation(format!(
"Invalid base64 content for field '{}': {}",
field_name, e
))
})?;
Ok(DataUriContent {
mime_type: mime_type.to_string(),
bytes,
})
}
const DEFAULT_TIMEOUT_SECONDS: u64 = 30;
#[derive(Clone)]
pub struct HttpClient {
client: Client,
base_url: Option<Url>,
default_headers: HeaderMap,
timeout_seconds: u64,
}
impl HttpClient {
fn create_user_agent() -> String {
format!("rmcp-openapi-server/{}", env!("CARGO_PKG_VERSION"))
}
fn build_reqwest_client(timeout_seconds: u64, insecure: bool) -> Client {
let user_agent = Self::create_user_agent();
let mut builder = Client::builder()
.user_agent(&user_agent)
.timeout(Duration::from_secs(timeout_seconds));
if insecure {
builder = builder
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true);
}
builder.build().expect("Failed to create HTTP client")
}
#[must_use]
pub fn new() -> Self {
Self {
client: Self::build_reqwest_client(DEFAULT_TIMEOUT_SECONDS, false),
base_url: None,
default_headers: HeaderMap::new(),
timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
}
}
#[must_use]
pub fn with_timeout(timeout_seconds: u64) -> Self {
Self {
client: Self::build_reqwest_client(timeout_seconds, false),
base_url: None,
default_headers: HeaderMap::new(),
timeout_seconds,
}
}
#[must_use]
pub fn with_insecure(mut self, insecure: bool) -> Self {
if insecure {
self.client = Self::build_reqwest_client(self.timeout_seconds, true);
}
self
}
pub fn with_base_url(mut self, base_url: Url) -> Result<Self, Error> {
let mut base_url = base_url;
if !base_url.path().ends_with('/') {
base_url.set_path(&format!("{}/", base_url.path()));
}
self.base_url = Some(base_url);
Ok(self)
}
#[must_use]
pub fn with_default_headers(mut self, default_headers: HeaderMap) -> Self {
self.default_headers = default_headers;
self
}
#[must_use]
pub fn with_authorization(&self, auth_value: &str) -> Self {
let mut headers = self.default_headers.clone();
if let Ok(header_value) = HeaderValue::from_str(auth_value) {
headers.insert(header::AUTHORIZATION, header_value);
}
Self {
client: self.client.clone(),
base_url: self.base_url.clone(),
default_headers: headers,
timeout_seconds: self.timeout_seconds,
}
}
pub async fn execute_tool_call(
&self,
tool_metadata: &ToolMetadata,
arguments: &Value,
) -> Result<HttpResponse, ToolCallError> {
let span = info_span!(
"http_request",
operation_id = %tool_metadata.name,
method = %tool_metadata.method,
path = %tool_metadata.path
);
let _enter = span.enter();
debug!(
"Executing tool call: {} {} with arguments: {}",
tool_metadata.method,
tool_metadata.path,
serde_json::to_string_pretty(arguments).unwrap_or_else(|_| "invalid json".to_string())
);
let extracted_params = ToolGenerator::extract_parameters(tool_metadata, arguments)?;
debug!(
"Extracted parameters: path={:?}, query={:?}, headers={:?}, cookies={:?}",
extracted_params.path,
extracted_params.query,
extracted_params.headers,
extracted_params.cookies
);
let mut url = self
.build_url(tool_metadata, &extracted_params)
.map_err(|e| {
ToolCallError::Validation(ToolCallValidationError::RequestConstructionError {
reason: e.to_string(),
})
})?;
if !extracted_params.query.is_empty() {
Self::add_query_parameters(&mut url, &extracted_params.query);
}
info!("Final URL: {}", url);
let mut request = self
.create_request(&tool_metadata.method, &url)
.map_err(|e| {
ToolCallError::Validation(ToolCallValidationError::RequestConstructionError {
reason: e.to_string(),
})
})?;
if !self.default_headers.is_empty() {
request = Self::add_headers_from_map(request, &self.default_headers);
}
if !extracted_params.headers.is_empty() {
request = Self::add_headers(request, &extracted_params.headers);
}
if !extracted_params.cookies.is_empty() {
request = Self::add_cookies(request, &extracted_params.cookies);
}
if !extracted_params.body.is_empty() {
request =
Self::add_request_body(request, &extracted_params.body, &extracted_params.config)
.map_err(|e| {
ToolCallError::Execution(ToolCallExecutionError::ResponseParsingError {
reason: format!("Failed to serialize request body: {e}"),
raw_response: None,
})
})?;
}
if extracted_params.config.timeout_seconds != 30 {
request = request.timeout(Duration::from_secs(u64::from(
extracted_params.config.timeout_seconds,
)));
}
let request_body_string = if extracted_params.body.is_empty() {
String::new()
} else if extracted_params.body.len() == 1
&& extracted_params.body.contains_key("request_body")
{
serde_json::to_string(&extracted_params.body["request_body"]).unwrap_or_default()
} else {
let body_object = Value::Object(
extracted_params
.body
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
);
serde_json::to_string(&body_object).unwrap_or_default()
};
let final_url = url.to_string();
debug!("Sending HTTP request...");
let start_time = std::time::Instant::now();
let response = request.send().await.map_err(|e| {
error!(
operation_id = %tool_metadata.name,
method = %tool_metadata.method,
url = %final_url,
error = %e,
"HTTP request failed"
);
let (error_msg, category) = if e.is_timeout() {
(
format!(
"Request timeout after {} seconds while calling {} {}",
extracted_params.config.timeout_seconds,
tool_metadata.method.to_uppercase(),
final_url
),
NetworkErrorCategory::Timeout,
)
} else if e.is_connect() {
(
format!(
"Connection failed to {final_url} - Error: {e}. Check if the server is running and the URL is correct."
),
NetworkErrorCategory::Connect,
)
} else if e.is_request() {
(
format!(
"Request error while calling {} {} - Error: {}",
tool_metadata.method.to_uppercase(),
final_url,
e
),
NetworkErrorCategory::Request,
)
} else if e.is_body() {
(
format!(
"Body error while calling {} {} - Error: {}",
tool_metadata.method.to_uppercase(),
final_url,
e
),
NetworkErrorCategory::Body,
)
} else if e.is_decode() {
(
format!(
"Response decode error from {} {} - Error: {}",
tool_metadata.method.to_uppercase(),
final_url,
e
),
NetworkErrorCategory::Decode,
)
} else {
(
format!(
"HTTP request failed: {} (URL: {}, Method: {})",
e,
final_url,
tool_metadata.method.to_uppercase()
),
NetworkErrorCategory::Other,
)
};
ToolCallError::Execution(ToolCallExecutionError::NetworkError {
message: error_msg,
category,
})
})?;
let elapsed = start_time.elapsed();
info!(
operation_id = %tool_metadata.name,
method = %tool_metadata.method,
url = %final_url,
status = response.status().as_u16(),
elapsed_ms = elapsed.as_millis(),
"HTTP request completed"
);
debug!("Response received with status: {}", response.status());
self.process_response_with_request(
response,
&tool_metadata.method,
&final_url,
&request_body_string,
)
.await
.map_err(|e| {
ToolCallError::Execution(ToolCallExecutionError::HttpError {
status: 0,
message: e.to_string(),
details: None,
})
})
}
fn build_url(
&self,
tool_metadata: &ToolMetadata,
extracted_params: &ExtractedParameters,
) -> Result<Url, Error> {
let mut path = tool_metadata.path.clone();
for (param_name, param_value) in &extracted_params.path {
let placeholder = format!("{{{param_name}}}");
let value_str = match param_value {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => param_value.to_string(),
};
path = path.replace(&placeholder, &value_str);
}
let mut path: &str = path.as_ref();
if let Some(base_url) = &self.base_url {
if path.starts_with('/') {
path = &path[1..];
}
base_url.join(path).map_err(|e| {
Error::Http(format!(
"Failed to join URL '{base_url}' with path '{path}': {e}"
))
})
} else {
if path.starts_with("http") {
Url::parse(path).map_err(|e| Error::Http(format!("Invalid URL '{path}': {e}")))
} else {
Err(Error::Http(
"No base URL configured and path is not a complete URL".to_string(),
))
}
}
}
fn create_request(&self, method: &str, url: &Url) -> Result<RequestBuilder, Error> {
let http_method = method.to_uppercase();
let method = match http_method.as_str() {
"GET" => Method::GET,
"POST" => Method::POST,
"PUT" => Method::PUT,
"DELETE" => Method::DELETE,
"PATCH" => Method::PATCH,
"HEAD" => Method::HEAD,
"OPTIONS" => Method::OPTIONS,
_ => {
return Err(Error::Http(format!(
"Unsupported HTTP method: {http_method}"
)));
}
};
Ok(self.client.request(method, url.clone()))
}
fn add_query_parameters(url: &mut Url, query_params: &HashMap<String, QueryParameter>) {
{
let mut query_pairs = url.query_pairs_mut();
for (key, query_param) in query_params {
if let Value::Array(arr) = &query_param.value {
if query_param.explode {
for item in arr {
let item_str = match item {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => item.to_string(),
};
query_pairs.append_pair(key, &item_str);
}
} else {
let array_values: Vec<String> = arr
.iter()
.map(|item| match item {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => item.to_string(),
})
.collect();
let comma_separated = array_values.join(",");
query_pairs.append_pair(key, &comma_separated);
}
} else {
let value_str = match &query_param.value {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => query_param.value.to_string(),
};
query_pairs.append_pair(key, &value_str);
}
}
}
}
fn add_headers_from_map(mut request: RequestBuilder, headers: &HeaderMap) -> RequestBuilder {
for (key, value) in headers {
request = request.header(key, value);
}
request
}
fn add_headers(
mut request: RequestBuilder,
headers: &HashMap<String, Value>,
) -> RequestBuilder {
for (key, value) in headers {
let value_str = match value {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => value.to_string(),
};
request = request.header(key, value_str);
}
request
}
fn add_cookies(
mut request: RequestBuilder,
cookies: &HashMap<String, Value>,
) -> RequestBuilder {
if !cookies.is_empty() {
let cookie_header = cookies
.iter()
.map(|(key, value)| {
let value_str = match value {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => value.to_string(),
};
format!("{key}={value_str}")
})
.collect::<Vec<_>>()
.join("; ");
request = request.header(header::COOKIE, cookie_header);
}
request
}
fn add_request_body(
mut request: RequestBuilder,
body: &HashMap<String, Value>,
config: &crate::tool_generator::RequestConfig,
) -> Result<RequestBuilder, Error> {
if body.is_empty() {
return Ok(request);
}
match config.content_type.as_str() {
s if s == mime::APPLICATION_JSON.as_ref() => {
request = request.header(header::CONTENT_TYPE, &config.content_type);
if body.len() == 1 && body.contains_key("request_body") {
let body_value = &body["request_body"];
let json_string = serde_json::to_string(body_value).map_err(|e| {
Error::Http(format!("Failed to serialize request body: {e}"))
})?;
request = request.body(json_string);
} else {
let body_object =
Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
let json_string = serde_json::to_string(&body_object).map_err(|e| {
Error::Http(format!("Failed to serialize request body: {e}"))
})?;
request = request.body(json_string);
}
}
s if s == mime::APPLICATION_WWW_FORM_URLENCODED.as_ref() => {
request = request.header(header::CONTENT_TYPE, &config.content_type);
let form_data: Vec<(String, String)> = body
.iter()
.map(|(key, value)| {
let value_str = match value {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => value.to_string(),
};
(key.clone(), value_str)
})
.collect();
request = request.form(&form_data);
}
s if s == mime::MULTIPART_FORM_DATA.as_ref() => {
let mut form = reqwest::multipart::Form::new();
for (key, value) in body {
if let Some(obj) = value.as_object()
&& let Some(content_value) = obj.get("content")
&& let Some(content_str) = content_value.as_str()
&& content_str.starts_with("data:")
{
let data_uri = parse_data_uri(content_str, key)?;
let filename = obj
.get("filename")
.and_then(|v| v.as_str())
.unwrap_or("file")
.to_string();
let part = reqwest::multipart::Part::bytes(data_uri.bytes)
.file_name(filename)
.mime_str(&data_uri.mime_type)
.map_err(|e| Error::Http(format!("Invalid MIME type: {e}")))?;
form = form.part(key.clone(), part);
continue;
}
let text_value = match value {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => value.to_string(),
};
form = form.text(key.clone(), text_value);
}
request = request.multipart(form);
}
_ => {
request = request.header(header::CONTENT_TYPE, &config.content_type);
let body_object =
Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
let json_string = serde_json::to_string(&body_object)
.map_err(|e| Error::Http(format!("Failed to serialize request body: {e}")))?;
request = request.body(json_string);
}
}
Ok(request)
}
async fn process_response_with_request(
&self,
response: reqwest::Response,
method: &str,
url: &str,
request_body: &str,
) -> Result<HttpResponse, Error> {
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let is_binary_content = content_type
.as_ref()
.and_then(|ct| ct.parse::<mime::Mime>().ok())
.map(|mime_type| matches!(mime_type.type_(), mime::IMAGE | mime::AUDIO | mime::VIDEO))
.unwrap_or(false);
let headers = response
.headers()
.iter()
.map(|(name, value)| {
(
name.to_string(),
value.to_str().unwrap_or("<invalid>").to_string(),
)
})
.collect();
let (body, body_bytes) = if is_binary_content {
let bytes = response
.bytes()
.await
.map_err(|e| Error::Http(format!("Failed to read response body: {e}")))?;
let body_text = format!(
"[Binary content: {} bytes, Content-Type: {}]",
bytes.len(),
content_type.as_ref().unwrap_or(&"unknown".to_string())
);
(body_text, Some(bytes.to_vec()))
} else {
let text = response
.text()
.await
.map_err(|e| Error::Http(format!("Failed to read response body: {e}")))?;
(text, None)
};
let is_success = status.is_success();
let status_code = status.as_u16();
let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
let enhanced_status_text = match status {
StatusCode::BAD_REQUEST => {
format!("{status_text} - Bad Request: Check request parameters")
}
StatusCode::UNAUTHORIZED => {
format!("{status_text} - Unauthorized: Authentication required")
}
StatusCode::FORBIDDEN => format!("{status_text} - Forbidden: Access denied"),
StatusCode::NOT_FOUND => {
format!("{status_text} - Not Found: Endpoint or resource does not exist")
}
StatusCode::METHOD_NOT_ALLOWED => format!(
"{} - Method Not Allowed: {} method not supported",
status_text,
method.to_uppercase()
),
StatusCode::UNPROCESSABLE_ENTITY => {
format!("{status_text} - Unprocessable Entity: Request validation failed")
}
StatusCode::TOO_MANY_REQUESTS => {
format!("{status_text} - Too Many Requests: Rate limit exceeded")
}
StatusCode::INTERNAL_SERVER_ERROR => {
format!("{status_text} - Internal Server Error: Server encountered an error")
}
StatusCode::BAD_GATEWAY => {
format!("{status_text} - Bad Gateway: Upstream server error")
}
StatusCode::SERVICE_UNAVAILABLE => {
format!("{status_text} - Service Unavailable: Server temporarily unavailable")
}
StatusCode::GATEWAY_TIMEOUT => {
format!("{status_text} - Gateway Timeout: Upstream server timeout")
}
_ => status_text,
};
Ok(HttpResponse {
status_code,
status_text: enhanced_status_text,
headers,
content_type,
body,
body_bytes,
is_success,
request_method: method.to_string(),
request_url: url.to_string(),
request_body: request_body.to_string(),
})
}
}
impl Default for HttpClient {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct HttpResponse {
pub status_code: u16,
pub status_text: String,
pub headers: HashMap<String, String>,
pub content_type: Option<String>,
pub body: String,
pub body_bytes: Option<Vec<u8>>,
pub is_success: bool,
pub request_method: String,
pub request_url: String,
pub request_body: String,
}
impl HttpResponse {
pub fn json(&self) -> Result<Value, Error> {
serde_json::from_str(&self.body)
.map_err(|e| Error::Http(format!("Failed to parse response as JSON: {e}")))
}
#[must_use]
pub fn is_image(&self) -> bool {
self.content_type
.as_ref()
.and_then(|ct| ct.parse::<mime::Mime>().ok())
.map(|mime_type| mime_type.type_() == mime::IMAGE)
.unwrap_or(false)
}
#[must_use]
pub fn is_binary(&self) -> bool {
self.content_type
.as_ref()
.and_then(|ct| ct.parse::<mime::Mime>().ok())
.map(|mime_type| matches!(mime_type.type_(), mime::IMAGE | mime::AUDIO | mime::VIDEO))
.unwrap_or(false)
}
#[must_use]
pub fn to_mcp_content(&self) -> String {
let method = if self.request_method.is_empty() {
None
} else {
Some(self.request_method.as_str())
};
let url = if self.request_url.is_empty() {
None
} else {
Some(self.request_url.as_str())
};
let body = if self.request_body.is_empty() {
None
} else {
Some(self.request_body.as_str())
};
self.to_mcp_content_with_request(method, url, body)
}
pub fn to_mcp_content_with_request(
&self,
method: Option<&str>,
url: Option<&str>,
request_body: Option<&str>,
) -> String {
let mut result = format!(
"HTTP {} {}\n\nStatus: {} {}\n",
if self.is_success { "✅" } else { "❌" },
if self.is_success { "Success" } else { "Error" },
self.status_code,
self.status_text
);
if let (Some(method), Some(url)) = (method, url) {
result.push_str("\nRequest: ");
result.push_str(&method.to_uppercase());
result.push(' ');
result.push_str(url);
result.push('\n');
if let Some(body) = request_body
&& !body.is_empty()
&& body != "{}"
{
result.push_str("\nRequest Body:\n");
if let Ok(parsed) = serde_json::from_str::<Value>(body) {
if let Ok(pretty) = serde_json::to_string_pretty(&parsed) {
result.push_str(&pretty);
} else {
result.push_str(body);
}
} else {
result.push_str(body);
}
result.push('\n');
}
}
if !self.headers.is_empty() {
result.push_str("\nHeaders:\n");
for (key, value) in &self.headers {
if [
header::CONTENT_TYPE.as_str(),
header::CONTENT_LENGTH.as_str(),
header::LOCATION.as_str(),
header::SET_COOKIE.as_str(),
]
.iter()
.any(|&h| key.to_lowercase().contains(h))
{
result.push_str(" ");
result.push_str(key);
result.push_str(": ");
result.push_str(value);
result.push('\n');
}
}
}
result.push_str("\nResponse Body:\n");
if self.body.is_empty() {
result.push_str("(empty)");
} else if let Ok(json_value) = self.json() {
match serde_json::to_string_pretty(&json_value) {
Ok(pretty) => result.push_str(&pretty),
Err(_) => result.push_str(&self.body),
}
} else {
if self.body.len() > 2000 {
result.push_str(&self.body[..2000]);
result.push_str("\n... (");
result.push_str(&(self.body.len() - 2000).to_string());
result.push_str(" more characters)");
} else {
result.push_str(&self.body);
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tool_generator::ExtractedParameters;
use serde_json::json;
use std::collections::HashMap;
#[test]
fn test_with_base_url_validation() {
let url = Url::parse("https://api.example.com").unwrap();
let client = HttpClient::new().with_base_url(url);
assert!(client.is_ok());
let url = Url::parse("http://localhost:8080").unwrap();
let client = HttpClient::new().with_base_url(url);
assert!(client.is_ok());
assert!(Url::parse("not-a-url").is_err());
assert!(Url::parse("").is_err());
let url = Url::parse("ftp://invalid-scheme.com").unwrap();
let client = HttpClient::new().with_base_url(url);
assert!(client.is_ok()); }
#[test]
fn test_build_url_with_base_url() {
let base_url = Url::parse("https://api.example.com").unwrap();
let client = HttpClient::new().with_base_url(base_url).unwrap();
let tool_metadata = crate::ToolMetadata {
name: "test".to_string(),
title: None,
description: Some("test".to_string()),
parameters: json!({}),
output_schema: None,
method: "GET".to_string(),
path: "/pets/{id}".to_string(),
security: None,
parameter_mappings: std::collections::HashMap::new(),
};
let mut path_params = HashMap::new();
path_params.insert("id".to_string(), json!(123));
let extracted_params = ExtractedParameters {
path: path_params,
query: HashMap::new(),
headers: HashMap::new(),
cookies: HashMap::new(),
body: HashMap::new(),
config: crate::tool_generator::RequestConfig::default(),
};
let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
assert_eq!(url.to_string(), "https://api.example.com/pets/123");
}
#[test]
fn test_build_url_with_base_url_containing_path() {
let test_cases = vec![
"https://api.example.com/api/v4",
"https://api.example.com/api/v4/",
];
for base_url in test_cases {
let base_url = Url::parse(base_url).unwrap();
let client = HttpClient::new().with_base_url(base_url).unwrap();
let tool_metadata = crate::ToolMetadata {
name: "test".to_string(),
title: None,
description: Some("test".to_string()),
parameters: json!({}),
output_schema: None,
method: "GET".to_string(),
path: "/pets/{id}".to_string(),
security: None,
parameter_mappings: std::collections::HashMap::new(),
};
let mut path_params = HashMap::new();
path_params.insert("id".to_string(), json!(123));
let extracted_params = ExtractedParameters {
path: path_params,
query: HashMap::new(),
headers: HashMap::new(),
cookies: HashMap::new(),
body: HashMap::new(),
config: crate::tool_generator::RequestConfig::default(),
};
let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
assert_eq!(url.to_string(), "https://api.example.com/api/v4/pets/123");
}
}
#[test]
fn test_build_url_without_base_url() {
let client = HttpClient::new();
let tool_metadata = crate::ToolMetadata {
name: "test".to_string(),
title: None,
description: Some("test".to_string()),
parameters: json!({}),
output_schema: None,
method: "GET".to_string(),
path: "https://api.example.com/pets/123".to_string(),
security: None,
parameter_mappings: std::collections::HashMap::new(),
};
let extracted_params = ExtractedParameters {
path: HashMap::new(),
query: HashMap::new(),
headers: HashMap::new(),
cookies: HashMap::new(),
body: HashMap::new(),
config: crate::tool_generator::RequestConfig::default(),
};
let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
assert_eq!(url.to_string(), "https://api.example.com/pets/123");
let tool_metadata_relative = crate::ToolMetadata {
name: "test".to_string(),
title: None,
description: Some("test".to_string()),
parameters: json!({}),
output_schema: None,
method: "GET".to_string(),
path: "/pets/123".to_string(),
security: None,
parameter_mappings: std::collections::HashMap::new(),
};
let result = client.build_url(&tool_metadata_relative, &extracted_params);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("No base URL configured")
);
}
#[test]
fn test_query_parameter_encoding_integration() {
let base_url = Url::parse("https://api.example.com").unwrap();
let client = HttpClient::new().with_base_url(base_url).unwrap();
let tool_metadata = crate::ToolMetadata {
name: "test".to_string(),
title: None,
description: Some("test".to_string()),
parameters: json!({}),
output_schema: None,
method: "GET".to_string(),
path: "/search".to_string(),
security: None,
parameter_mappings: std::collections::HashMap::new(),
};
let mut query_params = HashMap::new();
query_params.insert(
"q".to_string(),
QueryParameter::new(json!("hello world"), true),
); query_params.insert(
"category".to_string(),
QueryParameter::new(json!("pets&dogs"), true),
); query_params.insert(
"special".to_string(),
QueryParameter::new(json!("foo=bar"), true),
); query_params.insert(
"unicode".to_string(),
QueryParameter::new(json!("café"), true),
); query_params.insert(
"percent".to_string(),
QueryParameter::new(json!("100%"), true),
);
let extracted_params = ExtractedParameters {
path: HashMap::new(),
query: query_params,
headers: HashMap::new(),
cookies: HashMap::new(),
body: HashMap::new(),
config: crate::tool_generator::RequestConfig::default(),
};
let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
HttpClient::add_query_parameters(&mut url, &extracted_params.query);
let url_string = url.to_string();
assert!(url_string.contains("q=hello+world")); assert!(url_string.contains("category=pets%26dogs")); assert!(url_string.contains("special=foo%3Dbar")); assert!(url_string.contains("unicode=caf%C3%A9")); assert!(url_string.contains("percent=100%25")); }
#[test]
fn test_array_query_parameters() {
let base_url = Url::parse("https://api.example.com").unwrap();
let client = HttpClient::new().with_base_url(base_url).unwrap();
let tool_metadata = crate::ToolMetadata {
name: "test".to_string(),
title: None,
description: Some("test".to_string()),
parameters: json!({}),
output_schema: None,
method: "GET".to_string(),
path: "/search".to_string(),
security: None,
parameter_mappings: std::collections::HashMap::new(),
};
let mut query_params = HashMap::new();
query_params.insert(
"status".to_string(),
QueryParameter::new(json!(["available", "pending"]), true),
);
query_params.insert(
"tags".to_string(),
QueryParameter::new(json!(["red & blue", "fast=car"]), true),
);
let extracted_params = ExtractedParameters {
path: HashMap::new(),
query: query_params,
headers: HashMap::new(),
cookies: HashMap::new(),
body: HashMap::new(),
config: crate::tool_generator::RequestConfig::default(),
};
let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
HttpClient::add_query_parameters(&mut url, &extracted_params.query);
let url_string = url.to_string();
assert!(url_string.contains("status=available"));
assert!(url_string.contains("status=pending"));
assert!(url_string.contains("tags=red+%26+blue")); assert!(url_string.contains("tags=fast%3Dcar")); }
#[test]
fn test_path_parameter_substitution() {
let base_url = Url::parse("https://api.example.com").unwrap();
let client = HttpClient::new().with_base_url(base_url).unwrap();
let tool_metadata = crate::ToolMetadata {
name: "test".to_string(),
title: None,
description: Some("test".to_string()),
parameters: json!({}),
output_schema: None,
method: "GET".to_string(),
path: "/users/{userId}/pets/{petId}".to_string(),
security: None,
parameter_mappings: std::collections::HashMap::new(),
};
let mut path_params = HashMap::new();
path_params.insert("userId".to_string(), json!(42));
path_params.insert("petId".to_string(), json!("special-pet-123"));
let extracted_params = ExtractedParameters {
path: path_params,
query: HashMap::new(),
headers: HashMap::new(),
cookies: HashMap::new(),
body: HashMap::new(),
config: crate::tool_generator::RequestConfig::default(),
};
let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
assert_eq!(
url.to_string(),
"https://api.example.com/users/42/pets/special-pet-123"
);
}
#[test]
fn test_url_join_edge_cases() {
let base_url1 = Url::parse("https://api.example.com/").unwrap();
let client1 = HttpClient::new().with_base_url(base_url1).unwrap();
let base_url2 = Url::parse("https://api.example.com").unwrap();
let client2 = HttpClient::new().with_base_url(base_url2).unwrap();
let tool_metadata = crate::ToolMetadata {
name: "test".to_string(),
title: None,
description: Some("test".to_string()),
parameters: json!({}),
output_schema: None,
method: "GET".to_string(),
path: "/pets".to_string(),
security: None,
parameter_mappings: std::collections::HashMap::new(),
};
let extracted_params = ExtractedParameters {
path: HashMap::new(),
query: HashMap::new(),
headers: HashMap::new(),
cookies: HashMap::new(),
body: HashMap::new(),
config: crate::tool_generator::RequestConfig::default(),
};
let url1 = client1
.build_url(&tool_metadata, &extracted_params)
.unwrap();
let url2 = client2
.build_url(&tool_metadata, &extracted_params)
.unwrap();
assert_eq!(url1.to_string(), "https://api.example.com/pets");
assert_eq!(url2.to_string(), "https://api.example.com/pets");
}
#[test]
fn test_explode_array_parameters() {
let base_url = Url::parse("https://api.example.com").unwrap();
let client = HttpClient::new().with_base_url(base_url).unwrap();
let tool_metadata = crate::ToolMetadata {
name: "test".to_string(),
title: None,
description: Some("test".to_string()),
parameters: json!({}),
output_schema: None,
method: "GET".to_string(),
path: "/search".to_string(),
security: None,
parameter_mappings: std::collections::HashMap::new(),
};
let mut query_params_exploded = HashMap::new();
query_params_exploded.insert(
"include".to_string(),
QueryParameter::new(json!(["asset", "scenes"]), true),
);
let extracted_params_exploded = ExtractedParameters {
path: HashMap::new(),
query: query_params_exploded,
headers: HashMap::new(),
cookies: HashMap::new(),
body: HashMap::new(),
config: crate::tool_generator::RequestConfig::default(),
};
let mut url_exploded = client
.build_url(&tool_metadata, &extracted_params_exploded)
.unwrap();
HttpClient::add_query_parameters(&mut url_exploded, &extracted_params_exploded.query);
let url_exploded_string = url_exploded.to_string();
let mut query_params_not_exploded = HashMap::new();
query_params_not_exploded.insert(
"include".to_string(),
QueryParameter::new(json!(["asset", "scenes"]), false),
);
let extracted_params_not_exploded = ExtractedParameters {
path: HashMap::new(),
query: query_params_not_exploded,
headers: HashMap::new(),
cookies: HashMap::new(),
body: HashMap::new(),
config: crate::tool_generator::RequestConfig::default(),
};
let mut url_not_exploded = client
.build_url(&tool_metadata, &extracted_params_not_exploded)
.unwrap();
HttpClient::add_query_parameters(
&mut url_not_exploded,
&extracted_params_not_exploded.query,
);
let url_not_exploded_string = url_not_exploded.to_string();
assert!(url_exploded_string.contains("include=asset"));
assert!(url_exploded_string.contains("include=scenes"));
assert!(url_not_exploded_string.contains("include=asset%2Cscenes"));
assert_ne!(url_exploded_string, url_not_exploded_string);
println!("Exploded URL: {url_exploded_string}");
println!("Non-exploded URL: {url_not_exploded_string}");
}
#[test]
fn test_is_image_helper() {
let response_png = HttpResponse {
status_code: 200,
status_text: "OK".to_string(),
headers: HashMap::new(),
content_type: Some("image/png".to_string()),
body: String::new(),
body_bytes: None,
is_success: true,
request_method: "GET".to_string(),
request_url: "http://example.com".to_string(),
request_body: String::new(),
};
assert!(response_png.is_image());
let response_jpeg = HttpResponse {
content_type: Some("image/jpeg".to_string()),
..response_png.clone()
};
assert!(response_jpeg.is_image());
let response_with_charset = HttpResponse {
content_type: Some("image/png; charset=utf-8".to_string()),
..response_png.clone()
};
assert!(response_with_charset.is_image());
let response_json = HttpResponse {
content_type: Some("application/json".to_string()),
..response_png.clone()
};
assert!(!response_json.is_image());
let response_text = HttpResponse {
content_type: Some("text/plain".to_string()),
..response_png.clone()
};
assert!(!response_text.is_image());
let response_no_ct = HttpResponse {
content_type: None,
..response_png
};
assert!(!response_no_ct.is_image());
}
#[test]
fn test_is_binary_helper() {
let base_response = HttpResponse {
status_code: 200,
status_text: "OK".to_string(),
headers: HashMap::new(),
content_type: None,
body: String::new(),
body_bytes: None,
is_success: true,
request_method: "GET".to_string(),
request_url: "http://example.com".to_string(),
request_body: String::new(),
};
let response_image = HttpResponse {
content_type: Some("image/png".to_string()),
..base_response.clone()
};
assert!(response_image.is_binary());
let response_audio = HttpResponse {
content_type: Some("audio/mpeg".to_string()),
..base_response.clone()
};
assert!(response_audio.is_binary());
let response_video = HttpResponse {
content_type: Some("video/mp4".to_string()),
..base_response.clone()
};
assert!(response_video.is_binary());
let response_json = HttpResponse {
content_type: Some("application/json".to_string()),
..base_response.clone()
};
assert!(!response_json.is_binary());
assert!(!base_response.is_binary());
}
#[test]
fn test_parse_data_uri_valid_png() {
let uri = "data:image/png;base64,aGVsbG8=";
let result = super::parse_data_uri(uri, "test_field").unwrap();
assert_eq!(result.mime_type, "image/png");
assert_eq!(result.bytes, b"hello");
}
#[test]
fn test_parse_data_uri_valid_jpeg() {
let uri = "data:image/jpeg;base64,d29ybGQ=";
let result = super::parse_data_uri(uri, "image").unwrap();
assert_eq!(result.mime_type, "image/jpeg");
assert_eq!(result.bytes, b"world");
}
#[test]
fn test_parse_data_uri_valid_application_json() {
let uri = "data:application/json;base64,e30=";
let result = super::parse_data_uri(uri, "data").unwrap();
assert_eq!(result.mime_type, "application/json");
assert_eq!(result.bytes, b"{}");
}
#[test]
fn test_parse_data_uri_missing_data_prefix() {
let uri = "image/png;base64,aGVsbG8=";
let result = super::parse_data_uri(uri, "test_field");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Invalid data URI format"));
assert!(err.contains("test_field"));
assert!(err.contains("expected 'data:<mime>;base64,<content>'"));
}
#[test]
fn test_parse_data_uri_missing_semicolon() {
let uri = "data:image/png,aGVsbG8=";
let result = super::parse_data_uri(uri, "my_image");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Invalid data URI format"));
assert!(err.contains("my_image"));
}
#[test]
fn test_parse_data_uri_missing_comma() {
let uri = "data:image/png;base64aGVsbG8=";
let result = super::parse_data_uri(uri, "field");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Invalid data URI format"));
}
#[test]
fn test_parse_data_uri_unsupported_encoding() {
let uri = "data:image/png;ascii,hello";
let result = super::parse_data_uri(uri, "test_field");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Unsupported encoding 'ascii'"));
assert!(err.contains("test_field"));
assert!(err.contains("only base64 is supported"));
}
#[test]
fn test_parse_data_uri_unsupported_encoding_utf8() {
let uri = "data:text/plain;utf-8,hello world";
let result = super::parse_data_uri(uri, "content");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Unsupported encoding 'utf-8'"));
assert!(err.contains("content"));
}
#[test]
fn test_parse_data_uri_invalid_base64() {
let uri = "data:image/png;base64,not-valid-base64!!!";
let result = super::parse_data_uri(uri, "bad_image");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Invalid base64 content"));
assert!(err.contains("bad_image"));
}
#[test]
fn test_parse_data_uri_empty_content() {
let uri = "data:application/octet-stream;base64,";
let result = super::parse_data_uri(uri, "empty").unwrap();
assert_eq!(result.mime_type, "application/octet-stream");
assert!(result.bytes.is_empty());
}
#[test]
fn test_parse_data_uri_complex_mime_type() {
let uri = "data:application/vnd.api+json;base64,e30=";
let result = super::parse_data_uri(uri, "api_data").unwrap();
assert_eq!(result.mime_type, "application/vnd.api+json");
assert_eq!(result.bytes, b"{}");
}
#[test]
fn test_parse_data_uri_mime_type_with_parameters() {
let uri = "data:text/plain;charset=utf-8;base64,SGVsbG8gV29ybGQ=";
let result = super::parse_data_uri(uri, "text_field").unwrap();
assert_eq!(result.mime_type, "text/plain;charset=utf-8");
assert_eq!(result.bytes, b"Hello World");
}
#[test]
fn test_parse_data_uri_mime_type_with_multiple_parameters() {
let uri = "data:text/html;charset=utf-8;boundary=something;base64,PGh0bWw+";
let result = super::parse_data_uri(uri, "html_field").unwrap();
assert_eq!(
result.mime_type,
"text/html;charset=utf-8;boundary=something"
);
assert_eq!(result.bytes, b"<html>");
}
#[test]
fn test_parse_data_uri_empty_mime_type() {
let uri = "data:;base64,SGVsbG8=";
let result = super::parse_data_uri(uri, "field");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("MIME type cannot be empty"));
}
#[test]
fn test_parse_data_uri_empty_string() {
let result = super::parse_data_uri("", "field");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Invalid data URI format"));
}
#[test]
fn test_parse_data_uri_just_data_prefix() {
let result = super::parse_data_uri("data:", "field");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Invalid data URI format"));
}
#[test]
fn test_add_request_body_multipart_with_valid_file() {
let client = HttpClient::new();
let request = client.client.post("http://example.com/upload");
let mut body = HashMap::new();
body.insert(
"file".to_string(),
json!({
"content": "data:image/png;base64,iVBORw0KGgo=",
"filename": "test.png"
}),
);
body.insert("description".to_string(), json!("Test file upload"));
let config = crate::tool_generator::RequestConfig {
timeout_seconds: 30,
content_type: mime::MULTIPART_FORM_DATA.to_string(),
};
let result = HttpClient::add_request_body(request, &body, &config);
assert!(
result.is_ok(),
"Should successfully build multipart form with valid file"
);
}
#[test]
fn test_add_request_body_multipart_with_invalid_data_uri() {
let client = HttpClient::new();
let request = client.client.post("http://example.com/upload");
let mut body = HashMap::new();
body.insert(
"file".to_string(),
json!({
"content": "data:image/png,notbase64",
"filename": "test.png"
}),
);
let config = crate::tool_generator::RequestConfig {
timeout_seconds: 30,
content_type: mime::MULTIPART_FORM_DATA.to_string(),
};
let result = HttpClient::add_request_body(request, &body, &config);
assert!(result.is_err(), "Should fail with invalid data URI");
let err = result.unwrap_err().to_string();
assert!(
err.contains("Invalid data URI format"),
"Error should mention invalid format"
);
}
#[test]
fn test_add_request_body_multipart_with_invalid_base64() {
let client = HttpClient::new();
let request = client.client.post("http://example.com/upload");
let mut body = HashMap::new();
body.insert(
"file".to_string(),
json!({
"content": "data:image/png;base64,!!!invalid!!!",
"filename": "test.png"
}),
);
let config = crate::tool_generator::RequestConfig {
timeout_seconds: 30,
content_type: mime::MULTIPART_FORM_DATA.to_string(),
};
let result = HttpClient::add_request_body(request, &body, &config);
assert!(result.is_err(), "Should fail with invalid base64");
let err = result.unwrap_err().to_string();
assert!(
err.contains("Invalid base64 content"),
"Error should mention invalid base64"
);
}
#[test]
fn test_add_request_body_multipart_text_only() {
let client = HttpClient::new();
let request = client.client.post("http://example.com/upload");
let mut body = HashMap::new();
body.insert("field1".to_string(), json!("text value"));
body.insert("field2".to_string(), json!(123));
body.insert("field3".to_string(), json!(true));
let config = crate::tool_generator::RequestConfig {
timeout_seconds: 30,
content_type: mime::MULTIPART_FORM_DATA.to_string(),
};
let result = HttpClient::add_request_body(request, &body, &config);
assert!(
result.is_ok(),
"Should successfully build multipart form with text-only fields"
);
}
#[test]
fn test_add_request_body_multipart_mixed_content() {
let client = HttpClient::new();
let request = client.client.post("http://example.com/upload");
let mut body = HashMap::new();
body.insert(
"image".to_string(),
json!({
"content": "data:image/jpeg;base64,/9j/4AAQ",
"filename": "photo.jpg"
}),
);
body.insert("title".to_string(), json!("My Photo"));
body.insert("tags".to_string(), json!(["nature", "sunset"]));
let config = crate::tool_generator::RequestConfig {
timeout_seconds: 30,
content_type: mime::MULTIPART_FORM_DATA.to_string(),
};
let result = HttpClient::add_request_body(request, &body, &config);
assert!(result.is_ok(), "Should handle mixed file and text content");
}
#[test]
fn test_add_request_body_multipart_without_filename() {
let client = HttpClient::new();
let request = client.client.post("http://example.com/upload");
let mut body = HashMap::new();
body.insert(
"upload".to_string(),
json!({
"content": "data:application/pdf;base64,JVBERi0="
}),
);
let config = crate::tool_generator::RequestConfig {
timeout_seconds: 30,
content_type: mime::MULTIPART_FORM_DATA.to_string(),
};
let result = HttpClient::add_request_body(request, &body, &config);
assert!(
result.is_ok(),
"Should handle file upload without explicit filename"
);
}
#[test]
fn test_add_request_body_json() {
let client = HttpClient::new();
let request = client.client.post("http://example.com/api");
let mut body = HashMap::new();
body.insert("name".to_string(), json!("test"));
body.insert("value".to_string(), json!(42));
let config = crate::tool_generator::RequestConfig {
timeout_seconds: 30,
content_type: mime::APPLICATION_JSON.to_string(),
};
let result = HttpClient::add_request_body(request, &body, &config);
assert!(result.is_ok(), "Should build JSON body");
}
#[test]
fn test_add_request_body_form_urlencoded() {
let client = HttpClient::new();
let request = client.client.post("http://example.com/form");
let mut body = HashMap::new();
body.insert("username".to_string(), json!("user"));
body.insert("password".to_string(), json!("secret"));
let config = crate::tool_generator::RequestConfig {
timeout_seconds: 30,
content_type: mime::APPLICATION_WWW_FORM_URLENCODED.to_string(),
};
let result = HttpClient::add_request_body(request, &body, &config);
assert!(result.is_ok(), "Should build form-urlencoded body");
}
#[tokio::test]
async fn http_client_with_insecure_still_serves_plain_http() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/ping")
.with_status(200)
.with_body("pong")
.create_async()
.await;
let base_url: Url = server.url().parse().unwrap();
let client = HttpClient::new()
.with_insecure(true)
.with_base_url(base_url.clone())
.unwrap();
let url = base_url.join("ping").unwrap();
let response = client.client.get(url).send().await.unwrap();
assert_eq!(response.status().as_u16(), 200);
assert_eq!(response.text().await.unwrap(), "pong");
mock.assert_async().await;
}
#[test]
fn test_add_request_body_empty() {
let client = HttpClient::new();
let request = client.client.post("http://example.com/api");
let body = HashMap::new();
let config = crate::tool_generator::RequestConfig::default();
let result = HttpClient::add_request_body(request, &body, &config);
assert!(result.is_ok(), "Should handle empty body");
}
}