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 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, ToolGenerator};
16
17#[derive(Clone)]
19pub struct HttpClient {
20 client: Arc<Client>,
21 base_url: Option<Url>,
22}
23
24impl HttpClient {
25 #[must_use]
31 pub fn new() -> Self {
32 let client = Client::builder()
33 .timeout(Duration::from_secs(30))
34 .build()
35 .expect("Failed to create HTTP client");
36
37 Self {
38 client: Arc::new(client),
39 base_url: None,
40 }
41 }
42
43 #[must_use]
49 pub fn with_timeout(timeout_seconds: u64) -> Self {
50 let client = Client::builder()
51 .timeout(Duration::from_secs(timeout_seconds))
52 .build()
53 .expect("Failed to create HTTP client");
54
55 Self {
56 client: Arc::new(client),
57 base_url: None,
58 }
59 }
60
61 pub fn with_base_url(mut self, base_url: Url) -> Result<Self, OpenApiError> {
67 self.base_url = Some(base_url);
68 Ok(self)
69 }
70
71 pub async fn execute_tool_call(
77 &self,
78 tool_metadata: &ToolMetadata,
79 arguments: &Value,
80 ) -> Result<HttpResponse, ToolCallError> {
81 debug!(
82 "Executing tool call: {} {} with arguments: {}",
83 tool_metadata.method,
84 tool_metadata.path,
85 serde_json::to_string_pretty(arguments).unwrap_or_else(|_| "invalid json".to_string())
86 );
87
88 let extracted_params = ToolGenerator::extract_parameters(tool_metadata, arguments)?;
90
91 debug!(
92 "Extracted parameters: path={:?}, query={:?}, headers={:?}, cookies={:?}, body={:?}",
93 extracted_params.path,
94 extracted_params.query,
95 extracted_params.headers,
96 extracted_params.cookies,
97 extracted_params.body
98 );
99
100 let mut url = self
102 .build_url(tool_metadata, &extracted_params)
103 .map_err(|e| {
104 ToolCallError::Validation(ToolCallValidationError::RequestConstructionError {
105 reason: e.to_string(),
106 })
107 })?;
108
109 if !extracted_params.query.is_empty() {
111 Self::add_query_parameters(&mut url, &extracted_params.query);
112 }
113
114 info!("Final URL: {}", url);
115
116 let mut request = self
118 .create_request(&tool_metadata.method, &url)
119 .map_err(|e| {
120 ToolCallError::Validation(ToolCallValidationError::RequestConstructionError {
121 reason: e.to_string(),
122 })
123 })?;
124
125 if !extracted_params.headers.is_empty() {
127 request = Self::add_headers(request, &extracted_params.headers);
128 }
129
130 if !extracted_params.cookies.is_empty() {
132 request = Self::add_cookies(request, &extracted_params.cookies);
133 }
134
135 if !extracted_params.body.is_empty() {
137 request =
138 Self::add_request_body(request, &extracted_params.body, &extracted_params.config)
139 .map_err(|e| {
140 ToolCallError::Execution(ToolCallExecutionError::ResponseParsingError {
141 reason: format!("Failed to serialize request body: {e}"),
142 raw_response: None,
143 })
144 })?;
145 }
146
147 if extracted_params.config.timeout_seconds != 30 {
149 request = request.timeout(Duration::from_secs(u64::from(
150 extracted_params.config.timeout_seconds,
151 )));
152 }
153
154 let request_body_string = if extracted_params.body.is_empty() {
156 String::new()
157 } else if extracted_params.body.len() == 1
158 && extracted_params.body.contains_key("request_body")
159 {
160 serde_json::to_string(&extracted_params.body["request_body"]).unwrap_or_default()
161 } else {
162 let body_object = Value::Object(
163 extracted_params
164 .body
165 .iter()
166 .map(|(k, v)| (k.clone(), v.clone()))
167 .collect(),
168 );
169 serde_json::to_string(&body_object).unwrap_or_default()
170 };
171
172 let final_url = url.to_string();
174
175 debug!("Sending HTTP request...");
177 let response = request.send().await.map_err(|e| {
178 error!("HTTP request failed: {}", e);
179
180 let (error_msg, category) = if e.is_timeout() {
182 (
183 format!(
184 "Request timeout after {} seconds while calling {} {}",
185 extracted_params.config.timeout_seconds,
186 tool_metadata.method.to_uppercase(),
187 final_url
188 ),
189 NetworkErrorCategory::Timeout,
190 )
191 } else if e.is_connect() {
192 (
193 format!(
194 "Connection failed to {final_url} - Error: {e}. Check if the server is running and the URL is correct."
195 ),
196 NetworkErrorCategory::Connect,
197 )
198 } else if e.is_request() {
199 (
200 format!(
201 "Request error while calling {} {} - Error: {}",
202 tool_metadata.method.to_uppercase(),
203 final_url,
204 e
205 ),
206 NetworkErrorCategory::Request,
207 )
208 } else if e.is_body() {
209 (
210 format!(
211 "Body error while calling {} {} - Error: {}",
212 tool_metadata.method.to_uppercase(),
213 final_url,
214 e
215 ),
216 NetworkErrorCategory::Body,
217 )
218 } else if e.is_decode() {
219 (
220 format!(
221 "Response decode error from {} {} - Error: {}",
222 tool_metadata.method.to_uppercase(),
223 final_url,
224 e
225 ),
226 NetworkErrorCategory::Decode,
227 )
228 } else {
229 (
230 format!(
231 "HTTP request failed: {} (URL: {}, Method: {})",
232 e,
233 final_url,
234 tool_metadata.method.to_uppercase()
235 ),
236 NetworkErrorCategory::Other,
237 )
238 };
239
240 error!("{}", error_msg);
241 ToolCallError::Execution(ToolCallExecutionError::NetworkError {
242 message: error_msg,
243 category,
244 })
245 })?;
246
247 debug!("Response received with status: {}", response.status());
248
249 self.process_response_with_request(
251 response,
252 &tool_metadata.method,
253 &final_url,
254 &request_body_string,
255 )
256 .await
257 .map_err(|e| {
258 ToolCallError::Execution(ToolCallExecutionError::HttpError {
259 status: 0,
260 message: e.to_string(),
261 details: None,
262 })
263 })
264 }
265
266 fn build_url(
268 &self,
269 tool_metadata: &ToolMetadata,
270 extracted_params: &ExtractedParameters,
271 ) -> Result<Url, OpenApiError> {
272 let mut path = tool_metadata.path.clone();
273
274 for (param_name, param_value) in &extracted_params.path {
276 let placeholder = format!("{{{param_name}}}");
277 let value_str = match param_value {
278 Value::String(s) => s.clone(),
279 Value::Number(n) => n.to_string(),
280 Value::Bool(b) => b.to_string(),
281 _ => param_value.to_string(),
282 };
283 path = path.replace(&placeholder, &value_str);
284 }
285
286 if let Some(base_url) = &self.base_url {
288 base_url.join(&path).map_err(|e| {
289 OpenApiError::Http(format!(
290 "Failed to join URL '{base_url}' with path '{path}': {e}"
291 ))
292 })
293 } else {
294 if path.starts_with("http") {
296 Url::parse(&path)
297 .map_err(|e| OpenApiError::Http(format!("Invalid URL '{path}': {e}")))
298 } else {
299 Err(OpenApiError::Http(
300 "No base URL configured and path is not a complete URL".to_string(),
301 ))
302 }
303 }
304 }
305
306 fn create_request(&self, method: &str, url: &Url) -> Result<RequestBuilder, OpenApiError> {
308 let http_method = method.to_uppercase();
309 let method = match http_method.as_str() {
310 "GET" => Method::GET,
311 "POST" => Method::POST,
312 "PUT" => Method::PUT,
313 "DELETE" => Method::DELETE,
314 "PATCH" => Method::PATCH,
315 "HEAD" => Method::HEAD,
316 "OPTIONS" => Method::OPTIONS,
317 _ => {
318 return Err(OpenApiError::Http(format!(
319 "Unsupported HTTP method: {http_method}"
320 )));
321 }
322 };
323
324 Ok(self.client.request(method, url.clone()))
325 }
326
327 fn add_query_parameters(url: &mut Url, query_params: &HashMap<String, Value>) {
329 {
330 let mut query_pairs = url.query_pairs_mut();
331 for (key, value) in query_params {
332 if let Value::Array(arr) = value {
333 for item in arr {
335 let item_str = match item {
336 Value::String(s) => s.clone(),
337 Value::Number(n) => n.to_string(),
338 Value::Bool(b) => b.to_string(),
339 _ => item.to_string(),
340 };
341 query_pairs.append_pair(key, &item_str);
342 }
343 } else {
344 let value_str = match value {
345 Value::String(s) => s.clone(),
346 Value::Number(n) => n.to_string(),
347 Value::Bool(b) => b.to_string(),
348 _ => value.to_string(),
349 };
350 query_pairs.append_pair(key, &value_str);
351 }
352 }
353 }
354 }
355
356 fn add_headers(
358 mut request: RequestBuilder,
359 headers: &HashMap<String, Value>,
360 ) -> RequestBuilder {
361 for (key, value) in headers {
362 let value_str = match value {
363 Value::String(s) => s.clone(),
364 Value::Number(n) => n.to_string(),
365 Value::Bool(b) => b.to_string(),
366 _ => value.to_string(),
367 };
368 request = request.header(key, value_str);
369 }
370 request
371 }
372
373 fn add_cookies(
375 mut request: RequestBuilder,
376 cookies: &HashMap<String, Value>,
377 ) -> RequestBuilder {
378 if !cookies.is_empty() {
379 let cookie_header = cookies
380 .iter()
381 .map(|(key, value)| {
382 let value_str = match value {
383 Value::String(s) => s.clone(),
384 Value::Number(n) => n.to_string(),
385 Value::Bool(b) => b.to_string(),
386 _ => value.to_string(),
387 };
388 format!("{key}={value_str}")
389 })
390 .collect::<Vec<_>>()
391 .join("; ");
392
393 request = request.header(header::COOKIE, cookie_header);
394 }
395 request
396 }
397
398 fn add_request_body(
400 mut request: RequestBuilder,
401 body: &HashMap<String, Value>,
402 config: &crate::tool_generator::RequestConfig,
403 ) -> Result<RequestBuilder, OpenApiError> {
404 if body.is_empty() {
405 return Ok(request);
406 }
407
408 request = request.header(header::CONTENT_TYPE, &config.content_type);
410
411 match config.content_type.as_str() {
413 s if s == mime::APPLICATION_JSON.as_ref() => {
414 if body.len() == 1 && body.contains_key("request_body") {
416 let body_value = &body["request_body"];
418 let json_string = serde_json::to_string(body_value).map_err(|e| {
419 OpenApiError::Http(format!("Failed to serialize request body: {e}"))
420 })?;
421 request = request.body(json_string);
422 } else {
423 let body_object =
425 Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
426 let json_string = serde_json::to_string(&body_object).map_err(|e| {
427 OpenApiError::Http(format!("Failed to serialize request body: {e}"))
428 })?;
429 request = request.body(json_string);
430 }
431 }
432 s if s == mime::APPLICATION_WWW_FORM_URLENCODED.as_ref() => {
433 let form_data: Vec<(String, String)> = body
435 .iter()
436 .map(|(key, value)| {
437 let value_str = match value {
438 Value::String(s) => s.clone(),
439 Value::Number(n) => n.to_string(),
440 Value::Bool(b) => b.to_string(),
441 _ => value.to_string(),
442 };
443 (key.clone(), value_str)
444 })
445 .collect();
446 request = request.form(&form_data);
447 }
448 _ => {
449 let body_object =
451 Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
452 let json_string = serde_json::to_string(&body_object).map_err(|e| {
453 OpenApiError::Http(format!("Failed to serialize request body: {e}"))
454 })?;
455 request = request.body(json_string);
456 }
457 }
458
459 Ok(request)
460 }
461
462 async fn process_response_with_request(
464 &self,
465 response: reqwest::Response,
466 method: &str,
467 url: &str,
468 request_body: &str,
469 ) -> Result<HttpResponse, OpenApiError> {
470 let status = response.status();
471 let headers = response
472 .headers()
473 .iter()
474 .map(|(name, value)| {
475 (
476 name.to_string(),
477 value.to_str().unwrap_or("<invalid>").to_string(),
478 )
479 })
480 .collect();
481
482 let body = response
483 .text()
484 .await
485 .map_err(|e| OpenApiError::Http(format!("Failed to read response body: {e}")))?;
486
487 let is_success = status.is_success();
488 let status_code = status.as_u16();
489 let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
490
491 let enhanced_status_text = match status {
493 StatusCode::BAD_REQUEST => {
494 format!("{status_text} - Bad Request: Check request parameters")
495 }
496 StatusCode::UNAUTHORIZED => {
497 format!("{status_text} - Unauthorized: Authentication required")
498 }
499 StatusCode::FORBIDDEN => format!("{status_text} - Forbidden: Access denied"),
500 StatusCode::NOT_FOUND => {
501 format!("{status_text} - Not Found: Endpoint or resource does not exist")
502 }
503 StatusCode::METHOD_NOT_ALLOWED => format!(
504 "{} - Method Not Allowed: {} method not supported",
505 status_text,
506 method.to_uppercase()
507 ),
508 StatusCode::UNPROCESSABLE_ENTITY => {
509 format!("{status_text} - Unprocessable Entity: Request validation failed")
510 }
511 StatusCode::TOO_MANY_REQUESTS => {
512 format!("{status_text} - Too Many Requests: Rate limit exceeded")
513 }
514 StatusCode::INTERNAL_SERVER_ERROR => {
515 format!("{status_text} - Internal Server Error: Server encountered an error")
516 }
517 StatusCode::BAD_GATEWAY => {
518 format!("{status_text} - Bad Gateway: Upstream server error")
519 }
520 StatusCode::SERVICE_UNAVAILABLE => {
521 format!("{status_text} - Service Unavailable: Server temporarily unavailable")
522 }
523 StatusCode::GATEWAY_TIMEOUT => {
524 format!("{status_text} - Gateway Timeout: Upstream server timeout")
525 }
526 _ => status_text,
527 };
528
529 Ok(HttpResponse {
530 status_code,
531 status_text: enhanced_status_text,
532 headers,
533 body,
534 is_success,
535 request_method: method.to_string(),
536 request_url: url.to_string(),
537 request_body: request_body.to_string(),
538 })
539 }
540}
541
542impl Default for HttpClient {
543 fn default() -> Self {
544 Self::new()
545 }
546}
547
548#[derive(Debug, Clone)]
550pub struct HttpResponse {
551 pub status_code: u16,
552 pub status_text: String,
553 pub headers: HashMap<String, String>,
554 pub body: String,
555 pub is_success: bool,
556 pub request_method: String,
557 pub request_url: String,
558 pub request_body: String,
559}
560
561impl HttpResponse {
562 pub fn json(&self) -> Result<Value, OpenApiError> {
568 serde_json::from_str(&self.body)
569 .map_err(|e| OpenApiError::Http(format!("Failed to parse response as JSON: {e}")))
570 }
571
572 #[must_use]
574 pub fn to_mcp_content(&self) -> String {
575 let method = if self.request_method.is_empty() {
576 None
577 } else {
578 Some(self.request_method.as_str())
579 };
580 let url = if self.request_url.is_empty() {
581 None
582 } else {
583 Some(self.request_url.as_str())
584 };
585 let body = if self.request_body.is_empty() {
586 None
587 } else {
588 Some(self.request_body.as_str())
589 };
590 self.to_mcp_content_with_request(method, url, body)
591 }
592
593 pub fn to_mcp_content_with_request(
595 &self,
596 method: Option<&str>,
597 url: Option<&str>,
598 request_body: Option<&str>,
599 ) -> String {
600 let mut result = format!(
601 "HTTP {} {}\n\nStatus: {} {}\n",
602 if self.is_success { "✅" } else { "❌" },
603 if self.is_success { "Success" } else { "Error" },
604 self.status_code,
605 self.status_text
606 );
607
608 if let (Some(method), Some(url)) = (method, url) {
610 result.push_str("\nRequest: ");
611 result.push_str(&method.to_uppercase());
612 result.push(' ');
613 result.push_str(url);
614 result.push('\n');
615
616 if let Some(body) = request_body {
617 if !body.is_empty() && body != "{}" {
618 result.push_str("\nRequest Body:\n");
619 if let Ok(parsed) = serde_json::from_str::<Value>(body) {
620 if let Ok(pretty) = serde_json::to_string_pretty(&parsed) {
621 result.push_str(&pretty);
622 } else {
623 result.push_str(body);
624 }
625 } else {
626 result.push_str(body);
627 }
628 result.push('\n');
629 }
630 }
631 }
632
633 if !self.headers.is_empty() {
635 result.push_str("\nHeaders:\n");
636 for (key, value) in &self.headers {
637 if [
639 header::CONTENT_TYPE.as_str(),
640 header::CONTENT_LENGTH.as_str(),
641 header::LOCATION.as_str(),
642 header::SET_COOKIE.as_str(),
643 ]
644 .iter()
645 .any(|&h| key.to_lowercase().contains(h))
646 {
647 result.push_str(" ");
648 result.push_str(key);
649 result.push_str(": ");
650 result.push_str(value);
651 result.push('\n');
652 }
653 }
654 }
655
656 result.push_str("\nResponse Body:\n");
658 if self.body.is_empty() {
659 result.push_str("(empty)");
660 } else if let Ok(json_value) = self.json() {
661 match serde_json::to_string_pretty(&json_value) {
663 Ok(pretty) => result.push_str(&pretty),
664 Err(_) => result.push_str(&self.body),
665 }
666 } else {
667 if self.body.len() > 2000 {
669 result.push_str(&self.body[..2000]);
670 result.push_str("\n... (");
671 result.push_str(&(self.body.len() - 2000).to_string());
672 result.push_str(" more characters)");
673 } else {
674 result.push_str(&self.body);
675 }
676 }
677
678 result
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685 use crate::tool_generator::ExtractedParameters;
686 use serde_json::json;
687 use std::collections::HashMap;
688
689 #[test]
690 fn test_with_base_url_validation() {
691 let url = Url::parse("https://api.example.com").unwrap();
693 let client = HttpClient::new().with_base_url(url);
694 assert!(client.is_ok());
695
696 let url = Url::parse("http://localhost:8080").unwrap();
697 let client = HttpClient::new().with_base_url(url);
698 assert!(client.is_ok());
699
700 assert!(Url::parse("not-a-url").is_err());
702 assert!(Url::parse("").is_err());
703
704 let url = Url::parse("ftp://invalid-scheme.com").unwrap();
706 let client = HttpClient::new().with_base_url(url);
707 assert!(client.is_ok()); }
709
710 #[test]
711 fn test_build_url_with_base_url() {
712 let base_url = Url::parse("https://api.example.com").unwrap();
713 let client = HttpClient::new().with_base_url(base_url).unwrap();
714
715 let tool_metadata = crate::server::ToolMetadata {
716 name: "test".to_string(),
717 title: None,
718 description: "test".to_string(),
719 parameters: json!({}),
720 output_schema: None,
721 method: "GET".to_string(),
722 path: "/pets/{id}".to_string(),
723 };
724
725 let mut path_params = HashMap::new();
726 path_params.insert("id".to_string(), json!(123));
727
728 let extracted_params = ExtractedParameters {
729 path: path_params,
730 query: HashMap::new(),
731 headers: HashMap::new(),
732 cookies: HashMap::new(),
733 body: HashMap::new(),
734 config: crate::tool_generator::RequestConfig::default(),
735 };
736
737 let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
738 assert_eq!(url.to_string(), "https://api.example.com/pets/123");
739 }
740
741 #[test]
742 fn test_build_url_without_base_url() {
743 let client = HttpClient::new();
744
745 let tool_metadata = crate::server::ToolMetadata {
746 name: "test".to_string(),
747 title: None,
748 description: "test".to_string(),
749 parameters: json!({}),
750 output_schema: None,
751 method: "GET".to_string(),
752 path: "https://api.example.com/pets/123".to_string(),
753 };
754
755 let extracted_params = ExtractedParameters {
756 path: HashMap::new(),
757 query: HashMap::new(),
758 headers: HashMap::new(),
759 cookies: HashMap::new(),
760 body: HashMap::new(),
761 config: crate::tool_generator::RequestConfig::default(),
762 };
763
764 let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
765 assert_eq!(url.to_string(), "https://api.example.com/pets/123");
766
767 let tool_metadata_relative = crate::server::ToolMetadata {
769 name: "test".to_string(),
770 title: None,
771 description: "test".to_string(),
772 parameters: json!({}),
773 output_schema: None,
774 method: "GET".to_string(),
775 path: "/pets/123".to_string(),
776 };
777
778 let result = client.build_url(&tool_metadata_relative, &extracted_params);
779 assert!(result.is_err());
780 assert!(
781 result
782 .unwrap_err()
783 .to_string()
784 .contains("No base URL configured")
785 );
786 }
787
788 #[test]
789 fn test_query_parameter_encoding_integration() {
790 let base_url = Url::parse("https://api.example.com").unwrap();
791 let client = HttpClient::new().with_base_url(base_url).unwrap();
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: "/search".to_string(),
801 };
802
803 let mut query_params = HashMap::new();
805 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 {
812 path: HashMap::new(),
813 query: query_params,
814 headers: HashMap::new(),
815 cookies: HashMap::new(),
816 body: HashMap::new(),
817 config: crate::tool_generator::RequestConfig::default(),
818 };
819
820 let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
821 HttpClient::add_query_parameters(&mut url, &extracted_params.query);
822
823 let url_string = url.to_string();
824
825 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")); }
833
834 #[test]
835 fn test_array_query_parameters() {
836 let base_url = Url::parse("https://api.example.com").unwrap();
837 let client = HttpClient::new().with_base_url(base_url).unwrap();
838
839 let tool_metadata = crate::server::ToolMetadata {
840 name: "test".to_string(),
841 title: None,
842 description: "test".to_string(),
843 parameters: json!({}),
844 output_schema: None,
845 method: "GET".to_string(),
846 path: "/search".to_string(),
847 };
848
849 let mut query_params = HashMap::new();
850 query_params.insert("status".to_string(), json!(["available", "pending"]));
851 query_params.insert("tags".to_string(), json!(["red & blue", "fast=car"]));
852
853 let extracted_params = ExtractedParameters {
854 path: HashMap::new(),
855 query: query_params,
856 headers: HashMap::new(),
857 cookies: HashMap::new(),
858 body: HashMap::new(),
859 config: crate::tool_generator::RequestConfig::default(),
860 };
861
862 let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
863 HttpClient::add_query_parameters(&mut url, &extracted_params.query);
864
865 let url_string = url.to_string();
866
867 assert!(url_string.contains("status=available"));
869 assert!(url_string.contains("status=pending"));
870 assert!(url_string.contains("tags=red+%26+blue")); assert!(url_string.contains("tags=fast%3Dcar")); }
873
874 #[test]
875 fn test_path_parameter_substitution() {
876 let base_url = Url::parse("https://api.example.com").unwrap();
877 let client = HttpClient::new().with_base_url(base_url).unwrap();
878
879 let tool_metadata = crate::server::ToolMetadata {
880 name: "test".to_string(),
881 title: None,
882 description: "test".to_string(),
883 parameters: json!({}),
884 output_schema: None,
885 method: "GET".to_string(),
886 path: "/users/{userId}/pets/{petId}".to_string(),
887 };
888
889 let mut path_params = HashMap::new();
890 path_params.insert("userId".to_string(), json!(42));
891 path_params.insert("petId".to_string(), json!("special-pet-123"));
892
893 let extracted_params = ExtractedParameters {
894 path: path_params,
895 query: HashMap::new(),
896 headers: HashMap::new(),
897 cookies: HashMap::new(),
898 body: HashMap::new(),
899 config: crate::tool_generator::RequestConfig::default(),
900 };
901
902 let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
903 assert_eq!(
904 url.to_string(),
905 "https://api.example.com/users/42/pets/special-pet-123"
906 );
907 }
908
909 #[test]
910 fn test_url_join_edge_cases() {
911 let base_url1 = Url::parse("https://api.example.com/").unwrap();
913 let client1 = HttpClient::new().with_base_url(base_url1).unwrap();
914
915 let base_url2 = Url::parse("https://api.example.com").unwrap();
916 let client2 = HttpClient::new().with_base_url(base_url2).unwrap();
917
918 let tool_metadata = crate::server::ToolMetadata {
919 name: "test".to_string(),
920 title: None,
921 description: "test".to_string(),
922 parameters: json!({}),
923 output_schema: None,
924 method: "GET".to_string(),
925 path: "/pets".to_string(),
926 };
927
928 let extracted_params = ExtractedParameters {
929 path: HashMap::new(),
930 query: HashMap::new(),
931 headers: HashMap::new(),
932 cookies: HashMap::new(),
933 body: HashMap::new(),
934 config: crate::tool_generator::RequestConfig::default(),
935 };
936
937 let url1 = client1
938 .build_url(&tool_metadata, &extracted_params)
939 .unwrap();
940 let url2 = client2
941 .build_url(&tool_metadata, &extracted_params)
942 .unwrap();
943
944 assert_eq!(url1.to_string(), "https://api.example.com/pets");
946 assert_eq!(url2.to_string(), "https://api.example.com/pets");
947 }
948}