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