1use reqwest::header::{self, HeaderMap};
2use reqwest::{Client, Method, RequestBuilder, StatusCode};
3use serde_json::Value;
4use std::collections::HashMap;
5use std::sync::Arc;
6use std::time::Duration;
7use tracing::{debug, error, info};
8use url::Url;
9
10use crate::error::{
11 NetworkErrorCategory, OpenApiError, ToolCallError, ToolCallExecutionError,
12 ToolCallValidationError,
13};
14use crate::server::ToolMetadata;
15use crate::tool_generator::{ExtractedParameters, QueryParameter, ToolGenerator};
16
17#[derive(Clone)]
19pub struct HttpClient {
20 client: Arc<Client>,
21 base_url: Option<Url>,
22 default_headers: HeaderMap,
23}
24
25impl HttpClient {
26 fn create_user_agent() -> String {
28 format!("rmcp-openapi-server/{}", env!("CARGO_PKG_VERSION"))
29 }
30 #[must_use]
36 pub fn new() -> Self {
37 let user_agent = Self::create_user_agent();
38 let client = Client::builder()
39 .user_agent(&user_agent)
40 .timeout(Duration::from_secs(30))
41 .build()
42 .expect("Failed to create HTTP client");
43
44 Self {
45 client: Arc::new(client),
46 base_url: None,
47 default_headers: HeaderMap::new(),
48 }
49 }
50
51 #[must_use]
57 pub fn with_timeout(timeout_seconds: u64) -> Self {
58 let user_agent = Self::create_user_agent();
59 let client = Client::builder()
60 .user_agent(&user_agent)
61 .timeout(Duration::from_secs(timeout_seconds))
62 .build()
63 .expect("Failed to create HTTP client");
64
65 Self {
66 client: Arc::new(client),
67 base_url: None,
68 default_headers: HeaderMap::new(),
69 }
70 }
71
72 pub fn with_base_url(mut self, base_url: Url) -> Result<Self, OpenApiError> {
78 self.base_url = Some(base_url);
79 Ok(self)
80 }
81
82 #[must_use]
84 pub fn with_default_headers(mut self, default_headers: HeaderMap) -> Self {
85 self.default_headers = default_headers;
86 self
87 }
88
89 pub async fn execute_tool_call(
95 &self,
96 tool_metadata: &ToolMetadata,
97 arguments: &Value,
98 ) -> Result<HttpResponse, ToolCallError> {
99 debug!(
100 "Executing tool call: {} {} with arguments: {}",
101 tool_metadata.method,
102 tool_metadata.path,
103 serde_json::to_string_pretty(arguments).unwrap_or_else(|_| "invalid json".to_string())
104 );
105
106 let extracted_params = ToolGenerator::extract_parameters(tool_metadata, arguments)?;
108
109 debug!(
110 "Extracted parameters: path={:?}, query={:?}, headers={:?}, cookies={:?}, body={:?}",
111 extracted_params.path,
112 extracted_params.query,
113 extracted_params.headers,
114 extracted_params.cookies,
115 extracted_params.body
116 );
117
118 let mut url = self
120 .build_url(tool_metadata, &extracted_params)
121 .map_err(|e| {
122 ToolCallError::Validation(ToolCallValidationError::RequestConstructionError {
123 reason: e.to_string(),
124 })
125 })?;
126
127 if !extracted_params.query.is_empty() {
129 Self::add_query_parameters(&mut url, &extracted_params.query);
130 }
131
132 info!("Final URL: {}", url);
133
134 let mut request = self
136 .create_request(&tool_metadata.method, &url)
137 .map_err(|e| {
138 ToolCallError::Validation(ToolCallValidationError::RequestConstructionError {
139 reason: e.to_string(),
140 })
141 })?;
142
143 if !self.default_headers.is_empty() {
145 request = Self::add_headers_from_map(request, &self.default_headers);
147 }
148
149 if !extracted_params.headers.is_empty() {
151 request = Self::add_headers(request, &extracted_params.headers);
152 }
153
154 if !extracted_params.cookies.is_empty() {
156 request = Self::add_cookies(request, &extracted_params.cookies);
157 }
158
159 if !extracted_params.body.is_empty() {
161 request =
162 Self::add_request_body(request, &extracted_params.body, &extracted_params.config)
163 .map_err(|e| {
164 ToolCallError::Execution(ToolCallExecutionError::ResponseParsingError {
165 reason: format!("Failed to serialize request body: {e}"),
166 raw_response: None,
167 })
168 })?;
169 }
170
171 if extracted_params.config.timeout_seconds != 30 {
173 request = request.timeout(Duration::from_secs(u64::from(
174 extracted_params.config.timeout_seconds,
175 )));
176 }
177
178 let request_body_string = if extracted_params.body.is_empty() {
180 String::new()
181 } else if extracted_params.body.len() == 1
182 && extracted_params.body.contains_key("request_body")
183 {
184 serde_json::to_string(&extracted_params.body["request_body"]).unwrap_or_default()
185 } else {
186 let body_object = Value::Object(
187 extracted_params
188 .body
189 .iter()
190 .map(|(k, v)| (k.clone(), v.clone()))
191 .collect(),
192 );
193 serde_json::to_string(&body_object).unwrap_or_default()
194 };
195
196 let final_url = url.to_string();
198
199 debug!("Sending HTTP request...");
201 let response = request.send().await.map_err(|e| {
202 error!("HTTP request failed: {}", e);
203
204 let (error_msg, category) = if e.is_timeout() {
206 (
207 format!(
208 "Request timeout after {} seconds while calling {} {}",
209 extracted_params.config.timeout_seconds,
210 tool_metadata.method.to_uppercase(),
211 final_url
212 ),
213 NetworkErrorCategory::Timeout,
214 )
215 } else if e.is_connect() {
216 (
217 format!(
218 "Connection failed to {final_url} - Error: {e}. Check if the server is running and the URL is correct."
219 ),
220 NetworkErrorCategory::Connect,
221 )
222 } else if e.is_request() {
223 (
224 format!(
225 "Request error while calling {} {} - Error: {}",
226 tool_metadata.method.to_uppercase(),
227 final_url,
228 e
229 ),
230 NetworkErrorCategory::Request,
231 )
232 } else if e.is_body() {
233 (
234 format!(
235 "Body error while calling {} {} - Error: {}",
236 tool_metadata.method.to_uppercase(),
237 final_url,
238 e
239 ),
240 NetworkErrorCategory::Body,
241 )
242 } else if e.is_decode() {
243 (
244 format!(
245 "Response decode error from {} {} - Error: {}",
246 tool_metadata.method.to_uppercase(),
247 final_url,
248 e
249 ),
250 NetworkErrorCategory::Decode,
251 )
252 } else {
253 (
254 format!(
255 "HTTP request failed: {} (URL: {}, Method: {})",
256 e,
257 final_url,
258 tool_metadata.method.to_uppercase()
259 ),
260 NetworkErrorCategory::Other,
261 )
262 };
263
264 error!("{}", error_msg);
265 ToolCallError::Execution(ToolCallExecutionError::NetworkError {
266 message: error_msg,
267 category,
268 })
269 })?;
270
271 debug!("Response received with status: {}", response.status());
272
273 self.process_response_with_request(
275 response,
276 &tool_metadata.method,
277 &final_url,
278 &request_body_string,
279 )
280 .await
281 .map_err(|e| {
282 ToolCallError::Execution(ToolCallExecutionError::HttpError {
283 status: 0,
284 message: e.to_string(),
285 details: None,
286 })
287 })
288 }
289
290 fn build_url(
292 &self,
293 tool_metadata: &ToolMetadata,
294 extracted_params: &ExtractedParameters,
295 ) -> Result<Url, OpenApiError> {
296 let mut path = tool_metadata.path.clone();
297
298 for (param_name, param_value) in &extracted_params.path {
300 let placeholder = format!("{{{param_name}}}");
301 let value_str = match param_value {
302 Value::String(s) => s.clone(),
303 Value::Number(n) => n.to_string(),
304 Value::Bool(b) => b.to_string(),
305 _ => param_value.to_string(),
306 };
307 path = path.replace(&placeholder, &value_str);
308 }
309
310 if let Some(base_url) = &self.base_url {
312 base_url.join(&path).map_err(|e| {
313 OpenApiError::Http(format!(
314 "Failed to join URL '{base_url}' with path '{path}': {e}"
315 ))
316 })
317 } else {
318 if path.starts_with("http") {
320 Url::parse(&path)
321 .map_err(|e| OpenApiError::Http(format!("Invalid URL '{path}': {e}")))
322 } else {
323 Err(OpenApiError::Http(
324 "No base URL configured and path is not a complete URL".to_string(),
325 ))
326 }
327 }
328 }
329
330 fn create_request(&self, method: &str, url: &Url) -> Result<RequestBuilder, OpenApiError> {
332 let http_method = method.to_uppercase();
333 let method = match http_method.as_str() {
334 "GET" => Method::GET,
335 "POST" => Method::POST,
336 "PUT" => Method::PUT,
337 "DELETE" => Method::DELETE,
338 "PATCH" => Method::PATCH,
339 "HEAD" => Method::HEAD,
340 "OPTIONS" => Method::OPTIONS,
341 _ => {
342 return Err(OpenApiError::Http(format!(
343 "Unsupported HTTP method: {http_method}"
344 )));
345 }
346 };
347
348 Ok(self.client.request(method, url.clone()))
349 }
350
351 fn add_query_parameters(url: &mut Url, query_params: &HashMap<String, QueryParameter>) {
353 {
354 let mut query_pairs = url.query_pairs_mut();
355 for (key, query_param) in query_params {
356 if let Value::Array(arr) = &query_param.value {
357 if query_param.explode {
358 for item in arr {
360 let item_str = match item {
361 Value::String(s) => s.clone(),
362 Value::Number(n) => n.to_string(),
363 Value::Bool(b) => b.to_string(),
364 _ => item.to_string(),
365 };
366 query_pairs.append_pair(key, &item_str);
367 }
368 } else {
369 let array_values: Vec<String> = arr
371 .iter()
372 .map(|item| match item {
373 Value::String(s) => s.clone(),
374 Value::Number(n) => n.to_string(),
375 Value::Bool(b) => b.to_string(),
376 _ => item.to_string(),
377 })
378 .collect();
379 let comma_separated = array_values.join(",");
380 query_pairs.append_pair(key, &comma_separated);
381 }
382 } else {
383 let value_str = match &query_param.value {
384 Value::String(s) => s.clone(),
385 Value::Number(n) => n.to_string(),
386 Value::Bool(b) => b.to_string(),
387 _ => query_param.value.to_string(),
388 };
389 query_pairs.append_pair(key, &value_str);
390 }
391 }
392 }
393 }
394
395 fn add_headers_from_map(mut request: RequestBuilder, headers: &HeaderMap) -> RequestBuilder {
397 for (key, value) in headers {
398 request = request.header(key, value);
400 }
401 request
402 }
403
404 fn add_headers(
406 mut request: RequestBuilder,
407 headers: &HashMap<String, Value>,
408 ) -> RequestBuilder {
409 for (key, value) in headers {
410 let value_str = match value {
411 Value::String(s) => s.clone(),
412 Value::Number(n) => n.to_string(),
413 Value::Bool(b) => b.to_string(),
414 _ => value.to_string(),
415 };
416 request = request.header(key, value_str);
417 }
418 request
419 }
420
421 fn add_cookies(
423 mut request: RequestBuilder,
424 cookies: &HashMap<String, Value>,
425 ) -> RequestBuilder {
426 if !cookies.is_empty() {
427 let cookie_header = cookies
428 .iter()
429 .map(|(key, value)| {
430 let value_str = match value {
431 Value::String(s) => s.clone(),
432 Value::Number(n) => n.to_string(),
433 Value::Bool(b) => b.to_string(),
434 _ => value.to_string(),
435 };
436 format!("{key}={value_str}")
437 })
438 .collect::<Vec<_>>()
439 .join("; ");
440
441 request = request.header(header::COOKIE, cookie_header);
442 }
443 request
444 }
445
446 fn add_request_body(
448 mut request: RequestBuilder,
449 body: &HashMap<String, Value>,
450 config: &crate::tool_generator::RequestConfig,
451 ) -> Result<RequestBuilder, OpenApiError> {
452 if body.is_empty() {
453 return Ok(request);
454 }
455
456 request = request.header(header::CONTENT_TYPE, &config.content_type);
458
459 match config.content_type.as_str() {
461 s if s == mime::APPLICATION_JSON.as_ref() => {
462 if body.len() == 1 && body.contains_key("request_body") {
464 let body_value = &body["request_body"];
466 let json_string = serde_json::to_string(body_value).map_err(|e| {
467 OpenApiError::Http(format!("Failed to serialize request body: {e}"))
468 })?;
469 request = request.body(json_string);
470 } else {
471 let body_object =
473 Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
474 let json_string = serde_json::to_string(&body_object).map_err(|e| {
475 OpenApiError::Http(format!("Failed to serialize request body: {e}"))
476 })?;
477 request = request.body(json_string);
478 }
479 }
480 s if s == mime::APPLICATION_WWW_FORM_URLENCODED.as_ref() => {
481 let form_data: Vec<(String, String)> = body
483 .iter()
484 .map(|(key, value)| {
485 let value_str = match value {
486 Value::String(s) => s.clone(),
487 Value::Number(n) => n.to_string(),
488 Value::Bool(b) => b.to_string(),
489 _ => value.to_string(),
490 };
491 (key.clone(), value_str)
492 })
493 .collect();
494 request = request.form(&form_data);
495 }
496 _ => {
497 let body_object =
499 Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
500 let json_string = serde_json::to_string(&body_object).map_err(|e| {
501 OpenApiError::Http(format!("Failed to serialize request body: {e}"))
502 })?;
503 request = request.body(json_string);
504 }
505 }
506
507 Ok(request)
508 }
509
510 async fn process_response_with_request(
512 &self,
513 response: reqwest::Response,
514 method: &str,
515 url: &str,
516 request_body: &str,
517 ) -> Result<HttpResponse, OpenApiError> {
518 let status = response.status();
519 let headers = response
520 .headers()
521 .iter()
522 .map(|(name, value)| {
523 (
524 name.to_string(),
525 value.to_str().unwrap_or("<invalid>").to_string(),
526 )
527 })
528 .collect();
529
530 let body = response
531 .text()
532 .await
533 .map_err(|e| OpenApiError::Http(format!("Failed to read response body: {e}")))?;
534
535 let is_success = status.is_success();
536 let status_code = status.as_u16();
537 let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
538
539 let enhanced_status_text = match status {
541 StatusCode::BAD_REQUEST => {
542 format!("{status_text} - Bad Request: Check request parameters")
543 }
544 StatusCode::UNAUTHORIZED => {
545 format!("{status_text} - Unauthorized: Authentication required")
546 }
547 StatusCode::FORBIDDEN => format!("{status_text} - Forbidden: Access denied"),
548 StatusCode::NOT_FOUND => {
549 format!("{status_text} - Not Found: Endpoint or resource does not exist")
550 }
551 StatusCode::METHOD_NOT_ALLOWED => format!(
552 "{} - Method Not Allowed: {} method not supported",
553 status_text,
554 method.to_uppercase()
555 ),
556 StatusCode::UNPROCESSABLE_ENTITY => {
557 format!("{status_text} - Unprocessable Entity: Request validation failed")
558 }
559 StatusCode::TOO_MANY_REQUESTS => {
560 format!("{status_text} - Too Many Requests: Rate limit exceeded")
561 }
562 StatusCode::INTERNAL_SERVER_ERROR => {
563 format!("{status_text} - Internal Server Error: Server encountered an error")
564 }
565 StatusCode::BAD_GATEWAY => {
566 format!("{status_text} - Bad Gateway: Upstream server error")
567 }
568 StatusCode::SERVICE_UNAVAILABLE => {
569 format!("{status_text} - Service Unavailable: Server temporarily unavailable")
570 }
571 StatusCode::GATEWAY_TIMEOUT => {
572 format!("{status_text} - Gateway Timeout: Upstream server timeout")
573 }
574 _ => status_text,
575 };
576
577 Ok(HttpResponse {
578 status_code,
579 status_text: enhanced_status_text,
580 headers,
581 body,
582 is_success,
583 request_method: method.to_string(),
584 request_url: url.to_string(),
585 request_body: request_body.to_string(),
586 })
587 }
588}
589
590impl Default for HttpClient {
591 fn default() -> Self {
592 Self::new()
593 }
594}
595
596#[derive(Debug, Clone)]
598pub struct HttpResponse {
599 pub status_code: u16,
600 pub status_text: String,
601 pub headers: HashMap<String, String>,
602 pub body: String,
603 pub is_success: bool,
604 pub request_method: String,
605 pub request_url: String,
606 pub request_body: String,
607}
608
609impl HttpResponse {
610 pub fn json(&self) -> Result<Value, OpenApiError> {
616 serde_json::from_str(&self.body)
617 .map_err(|e| OpenApiError::Http(format!("Failed to parse response as JSON: {e}")))
618 }
619
620 #[must_use]
622 pub fn to_mcp_content(&self) -> String {
623 let method = if self.request_method.is_empty() {
624 None
625 } else {
626 Some(self.request_method.as_str())
627 };
628 let url = if self.request_url.is_empty() {
629 None
630 } else {
631 Some(self.request_url.as_str())
632 };
633 let body = if self.request_body.is_empty() {
634 None
635 } else {
636 Some(self.request_body.as_str())
637 };
638 self.to_mcp_content_with_request(method, url, body)
639 }
640
641 pub fn to_mcp_content_with_request(
643 &self,
644 method: Option<&str>,
645 url: Option<&str>,
646 request_body: Option<&str>,
647 ) -> String {
648 let mut result = format!(
649 "HTTP {} {}\n\nStatus: {} {}\n",
650 if self.is_success { "✅" } else { "❌" },
651 if self.is_success { "Success" } else { "Error" },
652 self.status_code,
653 self.status_text
654 );
655
656 if let (Some(method), Some(url)) = (method, url) {
658 result.push_str("\nRequest: ");
659 result.push_str(&method.to_uppercase());
660 result.push(' ');
661 result.push_str(url);
662 result.push('\n');
663
664 if let Some(body) = request_body {
665 if !body.is_empty() && body != "{}" {
666 result.push_str("\nRequest Body:\n");
667 if let Ok(parsed) = serde_json::from_str::<Value>(body) {
668 if let Ok(pretty) = serde_json::to_string_pretty(&parsed) {
669 result.push_str(&pretty);
670 } else {
671 result.push_str(body);
672 }
673 } else {
674 result.push_str(body);
675 }
676 result.push('\n');
677 }
678 }
679 }
680
681 if !self.headers.is_empty() {
683 result.push_str("\nHeaders:\n");
684 for (key, value) in &self.headers {
685 if [
687 header::CONTENT_TYPE.as_str(),
688 header::CONTENT_LENGTH.as_str(),
689 header::LOCATION.as_str(),
690 header::SET_COOKIE.as_str(),
691 ]
692 .iter()
693 .any(|&h| key.to_lowercase().contains(h))
694 {
695 result.push_str(" ");
696 result.push_str(key);
697 result.push_str(": ");
698 result.push_str(value);
699 result.push('\n');
700 }
701 }
702 }
703
704 result.push_str("\nResponse Body:\n");
706 if self.body.is_empty() {
707 result.push_str("(empty)");
708 } else if let Ok(json_value) = self.json() {
709 match serde_json::to_string_pretty(&json_value) {
711 Ok(pretty) => result.push_str(&pretty),
712 Err(_) => result.push_str(&self.body),
713 }
714 } else {
715 if self.body.len() > 2000 {
717 result.push_str(&self.body[..2000]);
718 result.push_str("\n... (");
719 result.push_str(&(self.body.len() - 2000).to_string());
720 result.push_str(" more characters)");
721 } else {
722 result.push_str(&self.body);
723 }
724 }
725
726 result
727 }
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use crate::tool_generator::ExtractedParameters;
734 use serde_json::json;
735 use std::collections::HashMap;
736
737 #[test]
738 fn test_with_base_url_validation() {
739 let url = Url::parse("https://api.example.com").unwrap();
741 let client = HttpClient::new().with_base_url(url);
742 assert!(client.is_ok());
743
744 let url = Url::parse("http://localhost:8080").unwrap();
745 let client = HttpClient::new().with_base_url(url);
746 assert!(client.is_ok());
747
748 assert!(Url::parse("not-a-url").is_err());
750 assert!(Url::parse("").is_err());
751
752 let url = Url::parse("ftp://invalid-scheme.com").unwrap();
754 let client = HttpClient::new().with_base_url(url);
755 assert!(client.is_ok()); }
757
758 #[test]
759 fn test_build_url_with_base_url() {
760 let base_url = Url::parse("https://api.example.com").unwrap();
761 let client = HttpClient::new().with_base_url(base_url).unwrap();
762
763 let tool_metadata = crate::server::ToolMetadata {
764 name: "test".to_string(),
765 title: None,
766 description: "test".to_string(),
767 parameters: json!({}),
768 output_schema: None,
769 method: "GET".to_string(),
770 path: "/pets/{id}".to_string(),
771 };
772
773 let mut path_params = HashMap::new();
774 path_params.insert("id".to_string(), json!(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!(url.to_string(), "https://api.example.com/pets/123");
787 }
788
789 #[test]
790 fn test_build_url_without_base_url() {
791 let client = HttpClient::new();
792
793 let tool_metadata = crate::server::ToolMetadata {
794 name: "test".to_string(),
795 title: None,
796 description: "test".to_string(),
797 parameters: json!({}),
798 output_schema: None,
799 method: "GET".to_string(),
800 path: "https://api.example.com/pets/123".to_string(),
801 };
802
803 let extracted_params = ExtractedParameters {
804 path: HashMap::new(),
805 query: HashMap::new(),
806 headers: HashMap::new(),
807 cookies: HashMap::new(),
808 body: HashMap::new(),
809 config: crate::tool_generator::RequestConfig::default(),
810 };
811
812 let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
813 assert_eq!(url.to_string(), "https://api.example.com/pets/123");
814
815 let tool_metadata_relative = crate::server::ToolMetadata {
817 name: "test".to_string(),
818 title: None,
819 description: "test".to_string(),
820 parameters: json!({}),
821 output_schema: None,
822 method: "GET".to_string(),
823 path: "/pets/123".to_string(),
824 };
825
826 let result = client.build_url(&tool_metadata_relative, &extracted_params);
827 assert!(result.is_err());
828 assert!(
829 result
830 .unwrap_err()
831 .to_string()
832 .contains("No base URL configured")
833 );
834 }
835
836 #[test]
837 fn test_query_parameter_encoding_integration() {
838 let base_url = Url::parse("https://api.example.com").unwrap();
839 let client = HttpClient::new().with_base_url(base_url).unwrap();
840
841 let tool_metadata = crate::server::ToolMetadata {
842 name: "test".to_string(),
843 title: None,
844 description: "test".to_string(),
845 parameters: json!({}),
846 output_schema: None,
847 method: "GET".to_string(),
848 path: "/search".to_string(),
849 };
850
851 let mut query_params = HashMap::new();
853 query_params.insert(
854 "q".to_string(),
855 QueryParameter::new(json!("hello world"), true),
856 ); query_params.insert(
858 "category".to_string(),
859 QueryParameter::new(json!("pets&dogs"), true),
860 ); query_params.insert(
862 "special".to_string(),
863 QueryParameter::new(json!("foo=bar"), true),
864 ); query_params.insert(
866 "unicode".to_string(),
867 QueryParameter::new(json!("café"), true),
868 ); query_params.insert(
870 "percent".to_string(),
871 QueryParameter::new(json!("100%"), true),
872 ); let extracted_params = ExtractedParameters {
875 path: HashMap::new(),
876 query: query_params,
877 headers: HashMap::new(),
878 cookies: HashMap::new(),
879 body: HashMap::new(),
880 config: crate::tool_generator::RequestConfig::default(),
881 };
882
883 let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
884 HttpClient::add_query_parameters(&mut url, &extracted_params.query);
885
886 let url_string = url.to_string();
887
888 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")); }
896
897 #[test]
898 fn test_array_query_parameters() {
899 let base_url = Url::parse("https://api.example.com").unwrap();
900 let client = HttpClient::new().with_base_url(base_url).unwrap();
901
902 let tool_metadata = crate::server::ToolMetadata {
903 name: "test".to_string(),
904 title: None,
905 description: "test".to_string(),
906 parameters: json!({}),
907 output_schema: None,
908 method: "GET".to_string(),
909 path: "/search".to_string(),
910 };
911
912 let mut query_params = HashMap::new();
913 query_params.insert(
914 "status".to_string(),
915 QueryParameter::new(json!(["available", "pending"]), true),
916 );
917 query_params.insert(
918 "tags".to_string(),
919 QueryParameter::new(json!(["red & blue", "fast=car"]), true),
920 );
921
922 let extracted_params = ExtractedParameters {
923 path: HashMap::new(),
924 query: query_params,
925 headers: HashMap::new(),
926 cookies: HashMap::new(),
927 body: HashMap::new(),
928 config: crate::tool_generator::RequestConfig::default(),
929 };
930
931 let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
932 HttpClient::add_query_parameters(&mut url, &extracted_params.query);
933
934 let url_string = url.to_string();
935
936 assert!(url_string.contains("status=available"));
938 assert!(url_string.contains("status=pending"));
939 assert!(url_string.contains("tags=red+%26+blue")); assert!(url_string.contains("tags=fast%3Dcar")); }
942
943 #[test]
944 fn test_path_parameter_substitution() {
945 let base_url = Url::parse("https://api.example.com").unwrap();
946 let client = HttpClient::new().with_base_url(base_url).unwrap();
947
948 let tool_metadata = crate::server::ToolMetadata {
949 name: "test".to_string(),
950 title: None,
951 description: "test".to_string(),
952 parameters: json!({}),
953 output_schema: None,
954 method: "GET".to_string(),
955 path: "/users/{userId}/pets/{petId}".to_string(),
956 };
957
958 let mut path_params = HashMap::new();
959 path_params.insert("userId".to_string(), json!(42));
960 path_params.insert("petId".to_string(), json!("special-pet-123"));
961
962 let extracted_params = ExtractedParameters {
963 path: path_params,
964 query: HashMap::new(),
965 headers: HashMap::new(),
966 cookies: HashMap::new(),
967 body: HashMap::new(),
968 config: crate::tool_generator::RequestConfig::default(),
969 };
970
971 let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
972 assert_eq!(
973 url.to_string(),
974 "https://api.example.com/users/42/pets/special-pet-123"
975 );
976 }
977
978 #[test]
979 fn test_url_join_edge_cases() {
980 let base_url1 = Url::parse("https://api.example.com/").unwrap();
982 let client1 = HttpClient::new().with_base_url(base_url1).unwrap();
983
984 let base_url2 = Url::parse("https://api.example.com").unwrap();
985 let client2 = HttpClient::new().with_base_url(base_url2).unwrap();
986
987 let tool_metadata = crate::server::ToolMetadata {
988 name: "test".to_string(),
989 title: None,
990 description: "test".to_string(),
991 parameters: json!({}),
992 output_schema: None,
993 method: "GET".to_string(),
994 path: "/pets".to_string(),
995 };
996
997 let extracted_params = ExtractedParameters {
998 path: HashMap::new(),
999 query: HashMap::new(),
1000 headers: HashMap::new(),
1001 cookies: HashMap::new(),
1002 body: HashMap::new(),
1003 config: crate::tool_generator::RequestConfig::default(),
1004 };
1005
1006 let url1 = client1
1007 .build_url(&tool_metadata, &extracted_params)
1008 .unwrap();
1009 let url2 = client2
1010 .build_url(&tool_metadata, &extracted_params)
1011 .unwrap();
1012
1013 assert_eq!(url1.to_string(), "https://api.example.com/pets");
1015 assert_eq!(url2.to_string(), "https://api.example.com/pets");
1016 }
1017
1018 #[test]
1019 fn test_explode_array_parameters() {
1020 let base_url = Url::parse("https://api.example.com").unwrap();
1021 let client = HttpClient::new().with_base_url(base_url).unwrap();
1022
1023 let tool_metadata = crate::server::ToolMetadata {
1024 name: "test".to_string(),
1025 title: None,
1026 description: "test".to_string(),
1027 parameters: json!({}),
1028 output_schema: None,
1029 method: "GET".to_string(),
1030 path: "/search".to_string(),
1031 };
1032
1033 let mut query_params_exploded = HashMap::new();
1035 query_params_exploded.insert(
1036 "include".to_string(),
1037 QueryParameter::new(json!(["asset", "scenes"]), true),
1038 );
1039
1040 let extracted_params_exploded = ExtractedParameters {
1041 path: HashMap::new(),
1042 query: query_params_exploded,
1043 headers: HashMap::new(),
1044 cookies: HashMap::new(),
1045 body: HashMap::new(),
1046 config: crate::tool_generator::RequestConfig::default(),
1047 };
1048
1049 let mut url_exploded = client
1050 .build_url(&tool_metadata, &extracted_params_exploded)
1051 .unwrap();
1052 HttpClient::add_query_parameters(&mut url_exploded, &extracted_params_exploded.query);
1053 let url_exploded_string = url_exploded.to_string();
1054
1055 let mut query_params_not_exploded = HashMap::new();
1057 query_params_not_exploded.insert(
1058 "include".to_string(),
1059 QueryParameter::new(json!(["asset", "scenes"]), false),
1060 );
1061
1062 let extracted_params_not_exploded = ExtractedParameters {
1063 path: HashMap::new(),
1064 query: query_params_not_exploded,
1065 headers: HashMap::new(),
1066 cookies: HashMap::new(),
1067 body: HashMap::new(),
1068 config: crate::tool_generator::RequestConfig::default(),
1069 };
1070
1071 let mut url_not_exploded = client
1072 .build_url(&tool_metadata, &extracted_params_not_exploded)
1073 .unwrap();
1074 HttpClient::add_query_parameters(
1075 &mut url_not_exploded,
1076 &extracted_params_not_exploded.query,
1077 );
1078 let url_not_exploded_string = url_not_exploded.to_string();
1079
1080 assert!(url_exploded_string.contains("include=asset"));
1082 assert!(url_exploded_string.contains("include=scenes"));
1083
1084 assert!(url_not_exploded_string.contains("include=asset%2Cscenes")); assert_ne!(url_exploded_string, url_not_exploded_string);
1089
1090 println!("Exploded URL: {url_exploded_string}");
1091 println!("Non-exploded URL: {url_not_exploded_string}");
1092 }
1093}