rmcp_openapi/
http_client.rs

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/// HTTP client for executing `OpenAPI` requests
18#[derive(Clone)]
19pub struct HttpClient {
20    client: Arc<Client>,
21    base_url: Option<Url>,
22    default_headers: HeaderMap,
23}
24
25impl HttpClient {
26    /// Create the user agent string for HTTP requests
27    fn create_user_agent() -> String {
28        format!("rmcp-openapi-server/{}", env!("CARGO_PKG_VERSION"))
29    }
30    /// Create a new HTTP client
31    ///
32    /// # Panics
33    ///
34    /// Panics if the HTTP client cannot be created
35    #[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    /// Create a new HTTP client with custom timeout
52    ///
53    /// # Panics
54    ///
55    /// Panics if the HTTP client cannot be created
56    #[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    /// Set the base URL for all requests
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if the base URL is invalid
77    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    /// Set default headers for all requests
83    #[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    /// Execute an `OpenAPI` tool call
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if the HTTP request fails or parameters are invalid
94    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        // Extract parameters from arguments
107        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        // Build the URL with path parameters
119        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        // Add query parameters with proper URL encoding
128        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        // Create the HTTP request
135        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        // Add headers: first default headers, then request-specific headers (which take precedence)
144        if !self.default_headers.is_empty() {
145            // Use the HeaderMap directly with reqwest
146            request = Self::add_headers_from_map(request, &self.default_headers);
147        }
148
149        // Add request-specific headers (these override default headers)
150        if !extracted_params.headers.is_empty() {
151            request = Self::add_headers(request, &extracted_params.headers);
152        }
153
154        // Add cookies
155        if !extracted_params.cookies.is_empty() {
156            request = Self::add_cookies(request, &extracted_params.cookies);
157        }
158
159        // Add request body if present
160        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        // Apply custom timeout if specified
172        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        // Capture request details for response formatting
179        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        // Get the final URL for logging
197        let final_url = url.to_string();
198
199        // Execute the request
200        debug!("Sending HTTP request...");
201        let response = request.send().await.map_err(|e| {
202            error!("HTTP request failed: {}", e);
203
204            // Categorize error based on reqwest's reliable error detection methods
205            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        // Convert response to our format with request details
274        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    /// Build the complete URL with path parameters substituted
291    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        // Substitute path parameters
299        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        // Combine with base URL if available
311        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            // Assume the path is already a complete URL
319            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    /// Create a new HTTP request with the specified method and URL
331    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    /// Add query parameters to the request using proper URL encoding
352    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                        // explode=true: Handle array parameters - add each value as a separate query parameter
359                        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                        // explode=false: Join array values with commas
370                        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    /// Add headers to the request from HeaderMap
396    fn add_headers_from_map(mut request: RequestBuilder, headers: &HeaderMap) -> RequestBuilder {
397        for (key, value) in headers {
398            // HeaderName and HeaderValue are already validated, pass them directly to reqwest
399            request = request.header(key, value);
400        }
401        request
402    }
403
404    /// Add headers to the request
405    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    /// Add cookies to the request
422    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    /// Add request body to the request
447    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        // Set content type header
457        request = request.header(header::CONTENT_TYPE, &config.content_type);
458
459        // Handle different content types
460        match config.content_type.as_str() {
461            s if s == mime::APPLICATION_JSON.as_ref() => {
462                // For JSON content type, serialize the body
463                if body.len() == 1 && body.contains_key("request_body") {
464                    // Use the request_body directly if it's the only parameter
465                    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                    // Create JSON object from all body parameters
472                    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                // Handle form data
482                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                // For other content types, try to serialize as JSON
498                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    /// Process the HTTP response with request details for better formatting
511    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        // Add additional context for common error status codes
540        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/// HTTP response from an API call
597#[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    /// Try to parse the response body as JSON
611    ///
612    /// # Errors
613    ///
614    /// Returns an error if the body is not valid JSON
615    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    /// Get a formatted response summary for MCP
621    #[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    /// Get a formatted response summary for MCP with request details
642    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        // Add request details if provided
657        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        // Add important headers
682        if !self.headers.is_empty() {
683            result.push_str("\nHeaders:\n");
684            for (key, value) in &self.headers {
685                // Only show commonly useful headers
686                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        // Add body content
705        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            // Pretty print JSON if possible
710            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            // Truncate very long responses
716            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        // Test valid URLs
740        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        // Test invalid URLs - these will fail at parse time now
749        assert!(Url::parse("not-a-url").is_err());
750        assert!(Url::parse("").is_err());
751
752        // Test schemes that parse successfully
753        let url = Url::parse("ftp://invalid-scheme.com").unwrap();
754        let client = HttpClient::new().with_base_url(url);
755        assert!(client.is_ok()); // url crate accepts ftp, our HttpClient should too
756    }
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        // Test error case: relative path without base URL
816        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        // Test various query parameter values that need encoding
852        let mut query_params = HashMap::new();
853        query_params.insert(
854            "q".to_string(),
855            QueryParameter::new(json!("hello world"), true),
856        ); // space
857        query_params.insert(
858            "category".to_string(),
859            QueryParameter::new(json!("pets&dogs"), true),
860        ); // ampersand
861        query_params.insert(
862            "special".to_string(),
863            QueryParameter::new(json!("foo=bar"), true),
864        ); // equals
865        query_params.insert(
866            "unicode".to_string(),
867            QueryParameter::new(json!("café"), true),
868        ); // unicode
869        query_params.insert(
870            "percent".to_string(),
871            QueryParameter::new(json!("100%"), true),
872        ); // percent
873
874        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        // Verify the URL contains properly encoded parameters
889        // Note: url crate encodes spaces as + in query parameters (which is valid)
890        assert!(url_string.contains("q=hello+world")); // space encoded as +
891        assert!(url_string.contains("category=pets%26dogs")); // & encoded as %26
892        assert!(url_string.contains("special=foo%3Dbar")); // = encoded as %3D
893        assert!(url_string.contains("unicode=caf%C3%A9")); // é encoded as %C3%A9
894        assert!(url_string.contains("percent=100%25")); // % encoded as %25
895    }
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        // Verify array parameters are added multiple times with proper encoding
937        assert!(url_string.contains("status=available"));
938        assert!(url_string.contains("status=pending"));
939        assert!(url_string.contains("tags=red+%26+blue")); // "red & blue" encoded (spaces as +)
940        assert!(url_string.contains("tags=fast%3Dcar")); // "fast=car" encoded
941    }
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        // Test trailing slash handling
981        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        // Both should produce the same normalized URL
1014        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        // Test explode=true (should generate separate parameters)
1034        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        // Test explode=false (should generate comma-separated values)
1056        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        // Verify explode=true generates separate parameters
1081        assert!(url_exploded_string.contains("include=asset"));
1082        assert!(url_exploded_string.contains("include=scenes"));
1083
1084        // Verify explode=false generates comma-separated values
1085        assert!(url_not_exploded_string.contains("include=asset%2Cscenes")); // comma is URL-encoded as %2C
1086
1087        // Make sure they're different
1088        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}