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