rmcp_openapi/
http_client.rs

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/// HTTP client for executing `OpenAPI` requests
18#[derive(Clone)]
19pub struct HttpClient {
20    client: Arc<Client>,
21    base_url: Option<Url>,
22}
23
24impl HttpClient {
25    /// Create a new HTTP client
26    ///
27    /// # Panics
28    ///
29    /// Panics if the HTTP client cannot be created
30    #[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    /// Create a new HTTP client with custom timeout
44    ///
45    /// # Panics
46    ///
47    /// Panics if the HTTP client cannot be created
48    #[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    /// Set the base URL for all requests
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if the base URL is invalid
66    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    /// Execute an `OpenAPI` tool call
72    ///
73    /// # Errors
74    ///
75    /// Returns an error if the HTTP request fails or parameters are invalid
76    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        // Extract parameters from arguments
89        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        // Build the URL with path parameters
101        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        // Add query parameters with proper URL encoding
110        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        // Create the HTTP request
117        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        // Add headers
126        if !extracted_params.headers.is_empty() {
127            request = Self::add_headers(request, &extracted_params.headers);
128        }
129
130        // Add cookies
131        if !extracted_params.cookies.is_empty() {
132            request = Self::add_cookies(request, &extracted_params.cookies);
133        }
134
135        // Add request body if present
136        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        // Apply custom timeout if specified
148        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        // Capture request details for response formatting
155        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        // Get the final URL for logging
173        let final_url = url.to_string();
174
175        // Execute the request
176        debug!("Sending HTTP request...");
177        let response = request.send().await.map_err(|e| {
178            error!("HTTP request failed: {}", e);
179
180            // Categorize error based on reqwest's reliable error detection methods
181            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        // Convert response to our format with request details
250        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    /// Build the complete URL with path parameters substituted
267    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        // Substitute path parameters
275        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        // Combine with base URL if available
287        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            // Assume the path is already a complete URL
295            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    /// Create a new HTTP request with the specified method and URL
307    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    /// Add query parameters to the request using proper URL encoding
328    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                    // Handle array parameters - add each value as a separate query parameter
334                    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    /// Add headers to the request
357    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    /// Add cookies to the request
374    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    /// Add request body to the request
399    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        // Set content type header
409        request = request.header(header::CONTENT_TYPE, &config.content_type);
410
411        // Handle different content types
412        match config.content_type.as_str() {
413            s if s == mime::APPLICATION_JSON.as_ref() => {
414                // For JSON content type, serialize the body
415                if body.len() == 1 && body.contains_key("request_body") {
416                    // Use the request_body directly if it's the only parameter
417                    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                    // Create JSON object from all body parameters
424                    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                // Handle form data
434                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                // For other content types, try to serialize as JSON
450                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    /// Process the HTTP response with request details for better formatting
463    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        // Add additional context for common error status codes
492        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/// HTTP response from an API call
549#[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    /// Try to parse the response body as JSON
563    ///
564    /// # Errors
565    ///
566    /// Returns an error if the body is not valid JSON
567    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    /// Get a formatted response summary for MCP
573    #[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    /// Get a formatted response summary for MCP with request details
594    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        // Add request details if provided
609        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        // Add important headers
634        if !self.headers.is_empty() {
635            result.push_str("\nHeaders:\n");
636            for (key, value) in &self.headers {
637                // Only show commonly useful headers
638                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        // Add body content
657        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            // Pretty print JSON if possible
662            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            // Truncate very long responses
668            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        // Test valid URLs
692        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        // Test invalid URLs - these will fail at parse time now
701        assert!(Url::parse("not-a-url").is_err());
702        assert!(Url::parse("").is_err());
703
704        // Test schemes that parse successfully
705        let url = Url::parse("ftp://invalid-scheme.com").unwrap();
706        let client = HttpClient::new().with_base_url(url);
707        assert!(client.is_ok()); // url crate accepts ftp, our HttpClient should too
708    }
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        // Test error case: relative path without base URL
768        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        // Test various query parameter values that need encoding
804        let mut query_params = HashMap::new();
805        query_params.insert("q".to_string(), json!("hello world")); // space
806        query_params.insert("category".to_string(), json!("pets&dogs")); // ampersand
807        query_params.insert("special".to_string(), json!("foo=bar")); // equals
808        query_params.insert("unicode".to_string(), json!("café")); // unicode
809        query_params.insert("percent".to_string(), json!("100%")); // percent
810
811        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        // Verify the URL contains properly encoded parameters
826        // Note: url crate encodes spaces as + in query parameters (which is valid)
827        assert!(url_string.contains("q=hello+world")); // space encoded as +
828        assert!(url_string.contains("category=pets%26dogs")); // & encoded as %26
829        assert!(url_string.contains("special=foo%3Dbar")); // = encoded as %3D
830        assert!(url_string.contains("unicode=caf%C3%A9")); // é encoded as %C3%A9
831        assert!(url_string.contains("percent=100%25")); // % encoded as %25
832    }
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        // Verify array parameters are added multiple times with proper encoding
868        assert!(url_string.contains("status=available"));
869        assert!(url_string.contains("status=pending"));
870        assert!(url_string.contains("tags=red+%26+blue")); // "red & blue" encoded (spaces as +)
871        assert!(url_string.contains("tags=fast%3Dcar")); // "fast=car" encoded
872    }
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        // Test trailing slash handling
912        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        // Both should produce the same normalized URL
945        assert_eq!(url1.to_string(), "https://api.example.com/pets");
946        assert_eq!(url2.to_string(), "https://api.example.com/pets");
947    }
948}