1use reqwest::header;
2use reqwest::{Client, Method, RequestBuilder, StatusCode};
3use serde_json::Value;
4use std::collections::HashMap;
5use std::sync::Arc;
6use std::time::Duration;
7use url::Url;
8
9use crate::error::OpenApiError;
10use crate::server::ToolMetadata;
11use crate::tool_generator::{ExtractedParameters, ToolGenerator};
12
13#[derive(Clone)]
15pub struct HttpClient {
16 client: Arc<Client>,
17 base_url: Option<Url>,
18}
19
20impl HttpClient {
21 #[must_use]
27 pub fn new() -> Self {
28 let client = Client::builder()
29 .timeout(Duration::from_secs(30))
30 .build()
31 .expect("Failed to create HTTP client");
32
33 Self {
34 client: Arc::new(client),
35 base_url: None,
36 }
37 }
38
39 #[must_use]
45 pub fn with_timeout(timeout_seconds: u64) -> Self {
46 let client = Client::builder()
47 .timeout(Duration::from_secs(timeout_seconds))
48 .build()
49 .expect("Failed to create HTTP client");
50
51 Self {
52 client: Arc::new(client),
53 base_url: None,
54 }
55 }
56
57 pub fn with_base_url(mut self, base_url: Url) -> Result<Self, OpenApiError> {
63 self.base_url = Some(base_url);
64 Ok(self)
65 }
66
67 pub async fn execute_tool_call(
73 &self,
74 tool_metadata: &ToolMetadata,
75 arguments: &Value,
76 ) -> Result<HttpResponse, OpenApiError> {
77 let extracted_params = ToolGenerator::extract_parameters(tool_metadata, arguments)?;
79
80 let mut url = self.build_url(tool_metadata, &extracted_params)?;
82
83 if !extracted_params.query.is_empty() {
85 Self::add_query_parameters(&mut url, &extracted_params.query);
86 }
87
88 let mut request = self.create_request(&tool_metadata.method, &url)?;
90
91 if !extracted_params.headers.is_empty() {
93 request = Self::add_headers(request, &extracted_params.headers);
94 }
95
96 if !extracted_params.cookies.is_empty() {
98 request = Self::add_cookies(request, &extracted_params.cookies);
99 }
100
101 if !extracted_params.body.is_empty() {
103 request =
104 Self::add_request_body(request, &extracted_params.body, &extracted_params.config)?;
105 }
106
107 if extracted_params.config.timeout_seconds != 30 {
109 request = request.timeout(Duration::from_secs(u64::from(
110 extracted_params.config.timeout_seconds,
111 )));
112 }
113
114 let request_body_string = if extracted_params.body.is_empty() {
116 String::new()
117 } else if extracted_params.body.len() == 1
118 && extracted_params.body.contains_key("request_body")
119 {
120 serde_json::to_string(&extracted_params.body["request_body"]).unwrap_or_default()
121 } else {
122 let body_object = Value::Object(
123 extracted_params
124 .body
125 .iter()
126 .map(|(k, v)| (k.clone(), v.clone()))
127 .collect(),
128 );
129 serde_json::to_string(&body_object).unwrap_or_default()
130 };
131
132 let final_url = url.to_string();
134
135 let response = request.send().await.map_err(|e| {
137 if e.is_timeout() {
139 OpenApiError::Http(format!(
140 "Request timeout after {} seconds while calling {} {}",
141 extracted_params.config.timeout_seconds,
142 tool_metadata.method.to_uppercase(),
143 final_url
144 ))
145 } else if e.is_connect() {
146 OpenApiError::Http(format!(
147 "Connection failed to {final_url} - check if the server is running and the URL is correct"
148 ))
149 } else if e.is_request() {
150 OpenApiError::Http(format!(
151 "Request error: {} (URL: {}, Method: {})",
152 e,
153 final_url,
154 tool_metadata.method.to_uppercase()
155 ))
156 } else {
157 OpenApiError::Http(format!(
158 "HTTP request failed: {} (URL: {}, Method: {})",
159 e,
160 final_url,
161 tool_metadata.method.to_uppercase()
162 ))
163 }
164 })?;
165
166 self.process_response_with_request(
168 response,
169 &tool_metadata.method,
170 &final_url,
171 &request_body_string,
172 )
173 .await
174 }
175
176 fn build_url(
178 &self,
179 tool_metadata: &ToolMetadata,
180 extracted_params: &ExtractedParameters,
181 ) -> Result<Url, OpenApiError> {
182 let mut path = tool_metadata.path.clone();
183
184 for (param_name, param_value) in &extracted_params.path {
186 let placeholder = format!("{{{param_name}}}");
187 let value_str = match param_value {
188 Value::String(s) => s.clone(),
189 Value::Number(n) => n.to_string(),
190 Value::Bool(b) => b.to_string(),
191 _ => param_value.to_string(),
192 };
193 path = path.replace(&placeholder, &value_str);
194 }
195
196 if let Some(base_url) = &self.base_url {
198 base_url.join(&path).map_err(|e| {
199 OpenApiError::Http(format!(
200 "Failed to join URL '{base_url}' with path '{path}': {e}"
201 ))
202 })
203 } else {
204 if path.starts_with("http") {
206 Url::parse(&path)
207 .map_err(|e| OpenApiError::Http(format!("Invalid URL '{path}': {e}")))
208 } else {
209 Err(OpenApiError::Http(
210 "No base URL configured and path is not a complete URL".to_string(),
211 ))
212 }
213 }
214 }
215
216 fn create_request(&self, method: &str, url: &Url) -> Result<RequestBuilder, OpenApiError> {
218 let http_method = method.to_uppercase();
219 let method = match http_method.as_str() {
220 "GET" => Method::GET,
221 "POST" => Method::POST,
222 "PUT" => Method::PUT,
223 "DELETE" => Method::DELETE,
224 "PATCH" => Method::PATCH,
225 "HEAD" => Method::HEAD,
226 "OPTIONS" => Method::OPTIONS,
227 _ => {
228 return Err(OpenApiError::Http(format!(
229 "Unsupported HTTP method: {http_method}"
230 )));
231 }
232 };
233
234 Ok(self.client.request(method, url.clone()))
235 }
236
237 fn add_query_parameters(url: &mut Url, query_params: &HashMap<String, Value>) {
239 {
240 let mut query_pairs = url.query_pairs_mut();
241 for (key, value) in query_params {
242 if let Value::Array(arr) = value {
243 for item in arr {
245 let item_str = match item {
246 Value::String(s) => s.clone(),
247 Value::Number(n) => n.to_string(),
248 Value::Bool(b) => b.to_string(),
249 _ => item.to_string(),
250 };
251 query_pairs.append_pair(key, &item_str);
252 }
253 } else {
254 let value_str = match value {
255 Value::String(s) => s.clone(),
256 Value::Number(n) => n.to_string(),
257 Value::Bool(b) => b.to_string(),
258 _ => value.to_string(),
259 };
260 query_pairs.append_pair(key, &value_str);
261 }
262 }
263 }
264 }
265
266 fn add_headers(
268 mut request: RequestBuilder,
269 headers: &HashMap<String, Value>,
270 ) -> RequestBuilder {
271 for (key, value) in headers {
272 let value_str = match value {
273 Value::String(s) => s.clone(),
274 Value::Number(n) => n.to_string(),
275 Value::Bool(b) => b.to_string(),
276 _ => value.to_string(),
277 };
278 request = request.header(key, value_str);
279 }
280 request
281 }
282
283 fn add_cookies(
285 mut request: RequestBuilder,
286 cookies: &HashMap<String, Value>,
287 ) -> RequestBuilder {
288 if !cookies.is_empty() {
289 let cookie_header = cookies
290 .iter()
291 .map(|(key, value)| {
292 let value_str = match value {
293 Value::String(s) => s.clone(),
294 Value::Number(n) => n.to_string(),
295 Value::Bool(b) => b.to_string(),
296 _ => value.to_string(),
297 };
298 format!("{key}={value_str}")
299 })
300 .collect::<Vec<_>>()
301 .join("; ");
302
303 request = request.header(header::COOKIE, cookie_header);
304 }
305 request
306 }
307
308 fn add_request_body(
310 mut request: RequestBuilder,
311 body: &HashMap<String, Value>,
312 config: &crate::tool_generator::RequestConfig,
313 ) -> Result<RequestBuilder, OpenApiError> {
314 if body.is_empty() {
315 return Ok(request);
316 }
317
318 request = request.header(header::CONTENT_TYPE, &config.content_type);
320
321 match config.content_type.as_str() {
323 s if s == mime::APPLICATION_JSON.as_ref() => {
324 if body.len() == 1 && body.contains_key("request_body") {
326 let body_value = &body["request_body"];
328 let json_string = serde_json::to_string(body_value).map_err(|e| {
329 OpenApiError::Http(format!("Failed to serialize request body: {e}"))
330 })?;
331 request = request.body(json_string);
332 } else {
333 let body_object =
335 Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
336 let json_string = serde_json::to_string(&body_object).map_err(|e| {
337 OpenApiError::Http(format!("Failed to serialize request body: {e}"))
338 })?;
339 request = request.body(json_string);
340 }
341 }
342 s if s == mime::APPLICATION_WWW_FORM_URLENCODED.as_ref() => {
343 let form_data: Vec<(String, String)> = body
345 .iter()
346 .map(|(key, value)| {
347 let value_str = match value {
348 Value::String(s) => s.clone(),
349 Value::Number(n) => n.to_string(),
350 Value::Bool(b) => b.to_string(),
351 _ => value.to_string(),
352 };
353 (key.clone(), value_str)
354 })
355 .collect();
356 request = request.form(&form_data);
357 }
358 _ => {
359 let body_object =
361 Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
362 let json_string = serde_json::to_string(&body_object).map_err(|e| {
363 OpenApiError::Http(format!("Failed to serialize request body: {e}"))
364 })?;
365 request = request.body(json_string);
366 }
367 }
368
369 Ok(request)
370 }
371
372 async fn process_response_with_request(
374 &self,
375 response: reqwest::Response,
376 method: &str,
377 url: &str,
378 request_body: &str,
379 ) -> Result<HttpResponse, OpenApiError> {
380 let status = response.status();
381 let headers = response
382 .headers()
383 .iter()
384 .map(|(name, value)| {
385 (
386 name.to_string(),
387 value.to_str().unwrap_or("<invalid>").to_string(),
388 )
389 })
390 .collect();
391
392 let body = response
393 .text()
394 .await
395 .map_err(|e| OpenApiError::Http(format!("Failed to read response body: {e}")))?;
396
397 let is_success = status.is_success();
398 let status_code = status.as_u16();
399 let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
400
401 let enhanced_status_text = match status {
403 StatusCode::BAD_REQUEST => {
404 format!("{status_text} - Bad Request: Check request parameters")
405 }
406 StatusCode::UNAUTHORIZED => {
407 format!("{status_text} - Unauthorized: Authentication required")
408 }
409 StatusCode::FORBIDDEN => format!("{status_text} - Forbidden: Access denied"),
410 StatusCode::NOT_FOUND => {
411 format!("{status_text} - Not Found: Endpoint or resource does not exist")
412 }
413 StatusCode::METHOD_NOT_ALLOWED => format!(
414 "{} - Method Not Allowed: {} method not supported",
415 status_text,
416 method.to_uppercase()
417 ),
418 StatusCode::UNPROCESSABLE_ENTITY => {
419 format!("{status_text} - Unprocessable Entity: Request validation failed")
420 }
421 StatusCode::TOO_MANY_REQUESTS => {
422 format!("{status_text} - Too Many Requests: Rate limit exceeded")
423 }
424 StatusCode::INTERNAL_SERVER_ERROR => {
425 format!("{status_text} - Internal Server Error: Server encountered an error")
426 }
427 StatusCode::BAD_GATEWAY => {
428 format!("{status_text} - Bad Gateway: Upstream server error")
429 }
430 StatusCode::SERVICE_UNAVAILABLE => {
431 format!("{status_text} - Service Unavailable: Server temporarily unavailable")
432 }
433 StatusCode::GATEWAY_TIMEOUT => {
434 format!("{status_text} - Gateway Timeout: Upstream server timeout")
435 }
436 _ => status_text,
437 };
438
439 Ok(HttpResponse {
440 status_code,
441 status_text: enhanced_status_text,
442 headers,
443 body,
444 is_success,
445 request_method: method.to_string(),
446 request_url: url.to_string(),
447 request_body: request_body.to_string(),
448 })
449 }
450}
451
452impl Default for HttpClient {
453 fn default() -> Self {
454 Self::new()
455 }
456}
457
458#[derive(Debug, Clone)]
460pub struct HttpResponse {
461 pub status_code: u16,
462 pub status_text: String,
463 pub headers: HashMap<String, String>,
464 pub body: String,
465 pub is_success: bool,
466 pub request_method: String,
467 pub request_url: String,
468 pub request_body: String,
469}
470
471impl HttpResponse {
472 pub fn json(&self) -> Result<Value, OpenApiError> {
478 serde_json::from_str(&self.body)
479 .map_err(|e| OpenApiError::Http(format!("Failed to parse response as JSON: {e}")))
480 }
481
482 #[must_use]
484 pub fn to_mcp_content(&self) -> String {
485 let method = if self.request_method.is_empty() {
486 None
487 } else {
488 Some(self.request_method.as_str())
489 };
490 let url = if self.request_url.is_empty() {
491 None
492 } else {
493 Some(self.request_url.as_str())
494 };
495 let body = if self.request_body.is_empty() {
496 None
497 } else {
498 Some(self.request_body.as_str())
499 };
500 self.to_mcp_content_with_request(method, url, body)
501 }
502
503 pub fn to_mcp_content_with_request(
505 &self,
506 method: Option<&str>,
507 url: Option<&str>,
508 request_body: Option<&str>,
509 ) -> String {
510 let mut result = format!(
511 "HTTP {} {}\n\nStatus: {} {}\n",
512 if self.is_success { "✅" } else { "❌" },
513 if self.is_success { "Success" } else { "Error" },
514 self.status_code,
515 self.status_text
516 );
517
518 if let (Some(method), Some(url)) = (method, url) {
520 result.push_str("\nRequest: ");
521 result.push_str(&method.to_uppercase());
522 result.push(' ');
523 result.push_str(url);
524 result.push('\n');
525
526 if let Some(body) = request_body {
527 if !body.is_empty() && body != "{}" {
528 result.push_str("\nRequest Body:\n");
529 if let Ok(parsed) = serde_json::from_str::<Value>(body) {
530 if let Ok(pretty) = serde_json::to_string_pretty(&parsed) {
531 result.push_str(&pretty);
532 } else {
533 result.push_str(body);
534 }
535 } else {
536 result.push_str(body);
537 }
538 result.push('\n');
539 }
540 }
541 }
542
543 if !self.headers.is_empty() {
545 result.push_str("\nHeaders:\n");
546 for (key, value) in &self.headers {
547 if [
549 header::CONTENT_TYPE.as_str(),
550 header::CONTENT_LENGTH.as_str(),
551 header::LOCATION.as_str(),
552 header::SET_COOKIE.as_str(),
553 ]
554 .iter()
555 .any(|&h| key.to_lowercase().contains(h))
556 {
557 result.push_str(" ");
558 result.push_str(key);
559 result.push_str(": ");
560 result.push_str(value);
561 result.push('\n');
562 }
563 }
564 }
565
566 result.push_str("\nResponse Body:\n");
568 if self.body.is_empty() {
569 result.push_str("(empty)");
570 } else if let Ok(json_value) = self.json() {
571 match serde_json::to_string_pretty(&json_value) {
573 Ok(pretty) => result.push_str(&pretty),
574 Err(_) => result.push_str(&self.body),
575 }
576 } else {
577 if self.body.len() > 2000 {
579 result.push_str(&self.body[..2000]);
580 result.push_str("\n... (");
581 result.push_str(&(self.body.len() - 2000).to_string());
582 result.push_str(" more characters)");
583 } else {
584 result.push_str(&self.body);
585 }
586 }
587
588 result
589 }
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use crate::tool_generator::ExtractedParameters;
596 use serde_json::json;
597 use std::collections::HashMap;
598
599 #[test]
600 fn test_with_base_url_validation() {
601 let url = Url::parse("https://api.example.com").unwrap();
603 let client = HttpClient::new().with_base_url(url);
604 assert!(client.is_ok());
605
606 let url = Url::parse("http://localhost:8080").unwrap();
607 let client = HttpClient::new().with_base_url(url);
608 assert!(client.is_ok());
609
610 assert!(Url::parse("not-a-url").is_err());
612 assert!(Url::parse("").is_err());
613
614 let url = Url::parse("ftp://invalid-scheme.com").unwrap();
616 let client = HttpClient::new().with_base_url(url);
617 assert!(client.is_ok()); }
619
620 #[test]
621 fn test_build_url_with_base_url() {
622 let base_url = Url::parse("https://api.example.com").unwrap();
623 let client = HttpClient::new().with_base_url(base_url).unwrap();
624
625 let tool_metadata = crate::server::ToolMetadata {
626 name: "test".to_string(),
627 description: "test".to_string(),
628 parameters: json!({}),
629 method: "GET".to_string(),
630 path: "/pets/{id}".to_string(),
631 };
632
633 let mut path_params = HashMap::new();
634 path_params.insert("id".to_string(), json!(123));
635
636 let extracted_params = ExtractedParameters {
637 path: path_params,
638 query: HashMap::new(),
639 headers: HashMap::new(),
640 cookies: HashMap::new(),
641 body: HashMap::new(),
642 config: crate::tool_generator::RequestConfig::default(),
643 };
644
645 let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
646 assert_eq!(url.to_string(), "https://api.example.com/pets/123");
647 }
648
649 #[test]
650 fn test_build_url_without_base_url() {
651 let client = HttpClient::new();
652
653 let tool_metadata = crate::server::ToolMetadata {
654 name: "test".to_string(),
655 description: "test".to_string(),
656 parameters: json!({}),
657 method: "GET".to_string(),
658 path: "https://api.example.com/pets/123".to_string(),
659 };
660
661 let extracted_params = ExtractedParameters {
662 path: HashMap::new(),
663 query: HashMap::new(),
664 headers: HashMap::new(),
665 cookies: HashMap::new(),
666 body: HashMap::new(),
667 config: crate::tool_generator::RequestConfig::default(),
668 };
669
670 let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
671 assert_eq!(url.to_string(), "https://api.example.com/pets/123");
672
673 let tool_metadata_relative = crate::server::ToolMetadata {
675 name: "test".to_string(),
676 description: "test".to_string(),
677 parameters: json!({}),
678 method: "GET".to_string(),
679 path: "/pets/123".to_string(),
680 };
681
682 let result = client.build_url(&tool_metadata_relative, &extracted_params);
683 assert!(result.is_err());
684 assert!(
685 result
686 .unwrap_err()
687 .to_string()
688 .contains("No base URL configured")
689 );
690 }
691
692 #[test]
693 fn test_query_parameter_encoding_integration() {
694 let base_url = Url::parse("https://api.example.com").unwrap();
695 let client = HttpClient::new().with_base_url(base_url).unwrap();
696
697 let tool_metadata = crate::server::ToolMetadata {
698 name: "test".to_string(),
699 description: "test".to_string(),
700 parameters: json!({}),
701 method: "GET".to_string(),
702 path: "/search".to_string(),
703 };
704
705 let mut query_params = HashMap::new();
707 query_params.insert("q".to_string(), json!("hello world")); query_params.insert("category".to_string(), json!("pets&dogs")); query_params.insert("special".to_string(), json!("foo=bar")); query_params.insert("unicode".to_string(), json!("café")); query_params.insert("percent".to_string(), json!("100%")); let extracted_params = ExtractedParameters {
714 path: HashMap::new(),
715 query: query_params,
716 headers: HashMap::new(),
717 cookies: HashMap::new(),
718 body: HashMap::new(),
719 config: crate::tool_generator::RequestConfig::default(),
720 };
721
722 let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
723 HttpClient::add_query_parameters(&mut url, &extracted_params.query);
724
725 let url_string = url.to_string();
726
727 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")); }
735
736 #[test]
737 fn test_array_query_parameters() {
738 let base_url = Url::parse("https://api.example.com").unwrap();
739 let client = HttpClient::new().with_base_url(base_url).unwrap();
740
741 let tool_metadata = crate::server::ToolMetadata {
742 name: "test".to_string(),
743 description: "test".to_string(),
744 parameters: json!({}),
745 method: "GET".to_string(),
746 path: "/search".to_string(),
747 };
748
749 let mut query_params = HashMap::new();
750 query_params.insert("status".to_string(), json!(["available", "pending"]));
751 query_params.insert("tags".to_string(), json!(["red & blue", "fast=car"]));
752
753 let extracted_params = ExtractedParameters {
754 path: HashMap::new(),
755 query: query_params,
756 headers: HashMap::new(),
757 cookies: HashMap::new(),
758 body: HashMap::new(),
759 config: crate::tool_generator::RequestConfig::default(),
760 };
761
762 let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
763 HttpClient::add_query_parameters(&mut url, &extracted_params.query);
764
765 let url_string = url.to_string();
766
767 assert!(url_string.contains("status=available"));
769 assert!(url_string.contains("status=pending"));
770 assert!(url_string.contains("tags=red+%26+blue")); assert!(url_string.contains("tags=fast%3Dcar")); }
773
774 #[test]
775 fn test_path_parameter_substitution() {
776 let base_url = Url::parse("https://api.example.com").unwrap();
777 let client = HttpClient::new().with_base_url(base_url).unwrap();
778
779 let tool_metadata = crate::server::ToolMetadata {
780 name: "test".to_string(),
781 description: "test".to_string(),
782 parameters: json!({}),
783 method: "GET".to_string(),
784 path: "/users/{userId}/pets/{petId}".to_string(),
785 };
786
787 let mut path_params = HashMap::new();
788 path_params.insert("userId".to_string(), json!(42));
789 path_params.insert("petId".to_string(), json!("special-pet-123"));
790
791 let extracted_params = ExtractedParameters {
792 path: path_params,
793 query: HashMap::new(),
794 headers: HashMap::new(),
795 cookies: HashMap::new(),
796 body: HashMap::new(),
797 config: crate::tool_generator::RequestConfig::default(),
798 };
799
800 let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
801 assert_eq!(
802 url.to_string(),
803 "https://api.example.com/users/42/pets/special-pet-123"
804 );
805 }
806
807 #[test]
808 fn test_url_join_edge_cases() {
809 let base_url1 = Url::parse("https://api.example.com/").unwrap();
811 let client1 = HttpClient::new().with_base_url(base_url1).unwrap();
812
813 let base_url2 = Url::parse("https://api.example.com").unwrap();
814 let client2 = HttpClient::new().with_base_url(base_url2).unwrap();
815
816 let tool_metadata = crate::server::ToolMetadata {
817 name: "test".to_string(),
818 description: "test".to_string(),
819 parameters: json!({}),
820 method: "GET".to_string(),
821 path: "/pets".to_string(),
822 };
823
824 let extracted_params = ExtractedParameters {
825 path: HashMap::new(),
826 query: HashMap::new(),
827 headers: HashMap::new(),
828 cookies: HashMap::new(),
829 body: HashMap::new(),
830 config: crate::tool_generator::RequestConfig::default(),
831 };
832
833 let url1 = client1
834 .build_url(&tool_metadata, &extracted_params)
835 .unwrap();
836 let url2 = client2
837 .build_url(&tool_metadata, &extracted_params)
838 .unwrap();
839
840 assert_eq!(url1.to_string(), "https://api.example.com/pets");
842 assert_eq!(url2.to_string(), "https://api.example.com/pets");
843 }
844}