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, tool_metadata);
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, tool_metadata);
158        }
159
160        // Add cookies
161        if !extracted_params.cookies.is_empty() {
162            request = Self::add_cookies(request, &extracted_params.cookies, tool_metadata);
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(
373        url: &mut Url,
374        query_params: &HashMap<String, QueryParameter>,
375        tool_metadata: &ToolMetadata,
376    ) {
377        {
378            let mut query_pairs = url.query_pairs_mut();
379            for (key, query_param) in query_params {
380                // Skip empty optional array parameters
381                if tool_metadata.should_omit_empty_array_parameter(key, &query_param.value) {
382                    continue;
383                }
384                if let Value::Array(arr) = &query_param.value {
385                    if arr.is_empty() {
386                        // For empty arrays that should be included (required or with defaults),
387                        // add the parameter with an empty value
388                        query_pairs.append_pair(key, "");
389                    } else if query_param.explode {
390                        // explode=true: Handle array parameters - add each value as a separate query parameter
391                        for item in arr {
392                            let item_str = 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                            query_pairs.append_pair(key, &item_str);
399                        }
400                    } else {
401                        // explode=false: Join array values with commas
402                        let array_values: Vec<String> = arr
403                            .iter()
404                            .map(|item| match item {
405                                Value::String(s) => s.clone(),
406                                Value::Number(n) => n.to_string(),
407                                Value::Bool(b) => b.to_string(),
408                                _ => item.to_string(),
409                            })
410                            .collect();
411                        let comma_separated = array_values.join(",");
412                        query_pairs.append_pair(key, &comma_separated);
413                    }
414                } else {
415                    let value_str = match &query_param.value {
416                        Value::String(s) => s.clone(),
417                        Value::Number(n) => n.to_string(),
418                        Value::Bool(b) => b.to_string(),
419                        _ => query_param.value.to_string(),
420                    };
421                    query_pairs.append_pair(key, &value_str);
422                }
423            }
424        }
425    }
426
427    /// Add headers to the request from HeaderMap
428    fn add_headers_from_map(mut request: RequestBuilder, headers: &HeaderMap) -> RequestBuilder {
429        for (key, value) in headers {
430            // HeaderName and HeaderValue are already validated, pass them directly to reqwest
431            request = request.header(key, value);
432        }
433        request
434    }
435
436    /// Add headers to the request
437    fn add_headers(
438        mut request: RequestBuilder,
439        headers: &HashMap<String, Value>,
440        tool_metadata: &ToolMetadata,
441    ) -> RequestBuilder {
442        for (key, value) in headers {
443            // Skip empty optional array parameters
444            if tool_metadata.should_omit_empty_array_parameter(key, value) {
445                continue;
446            }
447            let value_str = match value {
448                Value::String(s) => s.clone(),
449                Value::Number(n) => n.to_string(),
450                Value::Bool(b) => b.to_string(),
451                _ => value.to_string(),
452            };
453            request = request.header(key, value_str);
454        }
455        request
456    }
457
458    /// Add cookies to the request
459    fn add_cookies(
460        mut request: RequestBuilder,
461        cookies: &HashMap<String, Value>,
462        tool_metadata: &ToolMetadata,
463    ) -> RequestBuilder {
464        if !cookies.is_empty() {
465            let cookie_header = cookies
466                .iter()
467                .filter(|(key, value)| {
468                    // Skip empty optional array parameters
469                    !tool_metadata.should_omit_empty_array_parameter(key, value)
470                })
471                .map(|(key, value)| {
472                    let value_str = match value {
473                        Value::String(s) => s.clone(),
474                        Value::Number(n) => n.to_string(),
475                        Value::Bool(b) => b.to_string(),
476                        _ => value.to_string(),
477                    };
478                    format!("{key}={value_str}")
479                })
480                .collect::<Vec<_>>()
481                .join("; ");
482
483            if !cookie_header.is_empty() {
484                request = request.header(header::COOKIE, cookie_header);
485            }
486        }
487        request
488    }
489
490    /// Add request body to the request
491    fn add_request_body(
492        mut request: RequestBuilder,
493        body: &HashMap<String, Value>,
494        config: &crate::tool_generator::RequestConfig,
495    ) -> Result<RequestBuilder, Error> {
496        if body.is_empty() {
497            return Ok(request);
498        }
499
500        // Set content type header
501        request = request.header(header::CONTENT_TYPE, &config.content_type);
502
503        // Handle different content types
504        match config.content_type.as_str() {
505            s if s == mime::APPLICATION_JSON.as_ref() => {
506                // For JSON content type, serialize the body
507                if body.len() == 1 && body.contains_key("request_body") {
508                    // Use the request_body directly if it's the only parameter
509                    let body_value = &body["request_body"];
510                    let json_string = serde_json::to_string(body_value).map_err(|e| {
511                        Error::Http(format!("Failed to serialize request body: {e}"))
512                    })?;
513                    request = request.body(json_string);
514                } else {
515                    // Create JSON object from all body parameters
516                    let body_object =
517                        Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
518                    let json_string = serde_json::to_string(&body_object).map_err(|e| {
519                        Error::Http(format!("Failed to serialize request body: {e}"))
520                    })?;
521                    request = request.body(json_string);
522                }
523            }
524            s if s == mime::APPLICATION_WWW_FORM_URLENCODED.as_ref() => {
525                // Handle form data
526                let form_data: Vec<(String, String)> = body
527                    .iter()
528                    .map(|(key, value)| {
529                        let value_str = match value {
530                            Value::String(s) => s.clone(),
531                            Value::Number(n) => n.to_string(),
532                            Value::Bool(b) => b.to_string(),
533                            _ => value.to_string(),
534                        };
535                        (key.clone(), value_str)
536                    })
537                    .collect();
538                request = request.form(&form_data);
539            }
540            _ => {
541                // For other content types, try to serialize as JSON
542                let body_object =
543                    Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
544                let json_string = serde_json::to_string(&body_object)
545                    .map_err(|e| Error::Http(format!("Failed to serialize request body: {e}")))?;
546                request = request.body(json_string);
547            }
548        }
549
550        Ok(request)
551    }
552
553    /// Process the HTTP response with request details for better formatting
554    async fn process_response_with_request(
555        &self,
556        response: reqwest::Response,
557        method: &str,
558        url: &str,
559        request_body: &str,
560    ) -> Result<HttpResponse, Error> {
561        let status = response.status();
562        let headers = response
563            .headers()
564            .iter()
565            .map(|(name, value)| {
566                (
567                    name.to_string(),
568                    value.to_str().unwrap_or("<invalid>").to_string(),
569                )
570            })
571            .collect();
572
573        let body = response
574            .text()
575            .await
576            .map_err(|e| Error::Http(format!("Failed to read response body: {e}")))?;
577
578        let is_success = status.is_success();
579        let status_code = status.as_u16();
580        let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
581
582        // Add additional context for common error status codes
583        let enhanced_status_text = match status {
584            StatusCode::BAD_REQUEST => {
585                format!("{status_text} - Bad Request: Check request parameters")
586            }
587            StatusCode::UNAUTHORIZED => {
588                format!("{status_text} - Unauthorized: Authentication required")
589            }
590            StatusCode::FORBIDDEN => format!("{status_text} - Forbidden: Access denied"),
591            StatusCode::NOT_FOUND => {
592                format!("{status_text} - Not Found: Endpoint or resource does not exist")
593            }
594            StatusCode::METHOD_NOT_ALLOWED => format!(
595                "{} - Method Not Allowed: {} method not supported",
596                status_text,
597                method.to_uppercase()
598            ),
599            StatusCode::UNPROCESSABLE_ENTITY => {
600                format!("{status_text} - Unprocessable Entity: Request validation failed")
601            }
602            StatusCode::TOO_MANY_REQUESTS => {
603                format!("{status_text} - Too Many Requests: Rate limit exceeded")
604            }
605            StatusCode::INTERNAL_SERVER_ERROR => {
606                format!("{status_text} - Internal Server Error: Server encountered an error")
607            }
608            StatusCode::BAD_GATEWAY => {
609                format!("{status_text} - Bad Gateway: Upstream server error")
610            }
611            StatusCode::SERVICE_UNAVAILABLE => {
612                format!("{status_text} - Service Unavailable: Server temporarily unavailable")
613            }
614            StatusCode::GATEWAY_TIMEOUT => {
615                format!("{status_text} - Gateway Timeout: Upstream server timeout")
616            }
617            _ => status_text,
618        };
619
620        Ok(HttpResponse {
621            status_code,
622            status_text: enhanced_status_text,
623            headers,
624            body,
625            is_success,
626            request_method: method.to_string(),
627            request_url: url.to_string(),
628            request_body: request_body.to_string(),
629        })
630    }
631}
632
633impl Default for HttpClient {
634    fn default() -> Self {
635        Self::new()
636    }
637}
638
639/// HTTP response from an API call
640#[derive(Debug, Clone)]
641pub struct HttpResponse {
642    pub status_code: u16,
643    pub status_text: String,
644    pub headers: HashMap<String, String>,
645    pub body: String,
646    pub is_success: bool,
647    pub request_method: String,
648    pub request_url: String,
649    pub request_body: String,
650}
651
652impl HttpResponse {
653    /// Try to parse the response body as JSON
654    ///
655    /// # Errors
656    ///
657    /// Returns an error if the body is not valid JSON
658    pub fn json(&self) -> Result<Value, Error> {
659        serde_json::from_str(&self.body)
660            .map_err(|e| Error::Http(format!("Failed to parse response as JSON: {e}")))
661    }
662
663    /// Get a formatted response summary for MCP
664    #[must_use]
665    pub fn to_mcp_content(&self) -> String {
666        let method = if self.request_method.is_empty() {
667            None
668        } else {
669            Some(self.request_method.as_str())
670        };
671        let url = if self.request_url.is_empty() {
672            None
673        } else {
674            Some(self.request_url.as_str())
675        };
676        let body = if self.request_body.is_empty() {
677            None
678        } else {
679            Some(self.request_body.as_str())
680        };
681        self.to_mcp_content_with_request(method, url, body)
682    }
683
684    /// Get a formatted response summary for MCP with request details
685    pub fn to_mcp_content_with_request(
686        &self,
687        method: Option<&str>,
688        url: Option<&str>,
689        request_body: Option<&str>,
690    ) -> String {
691        let mut result = format!(
692            "HTTP {} {}\n\nStatus: {} {}\n",
693            if self.is_success { "✅" } else { "❌" },
694            if self.is_success { "Success" } else { "Error" },
695            self.status_code,
696            self.status_text
697        );
698
699        // Add request details if provided
700        if let (Some(method), Some(url)) = (method, url) {
701            result.push_str("\nRequest: ");
702            result.push_str(&method.to_uppercase());
703            result.push(' ');
704            result.push_str(url);
705            result.push('\n');
706
707            if let Some(body) = request_body
708                && !body.is_empty()
709                && body != "{}"
710            {
711                result.push_str("\nRequest Body:\n");
712                if let Ok(parsed) = serde_json::from_str::<Value>(body) {
713                    if let Ok(pretty) = serde_json::to_string_pretty(&parsed) {
714                        result.push_str(&pretty);
715                    } else {
716                        result.push_str(body);
717                    }
718                } else {
719                    result.push_str(body);
720                }
721                result.push('\n');
722            }
723        }
724
725        // Add important headers
726        if !self.headers.is_empty() {
727            result.push_str("\nHeaders:\n");
728            for (key, value) in &self.headers {
729                // Only show commonly useful headers
730                if [
731                    header::CONTENT_TYPE.as_str(),
732                    header::CONTENT_LENGTH.as_str(),
733                    header::LOCATION.as_str(),
734                    header::SET_COOKIE.as_str(),
735                ]
736                .iter()
737                .any(|&h| key.to_lowercase().contains(h))
738                {
739                    result.push_str("  ");
740                    result.push_str(key);
741                    result.push_str(": ");
742                    result.push_str(value);
743                    result.push('\n');
744                }
745            }
746        }
747
748        // Add body content
749        result.push_str("\nResponse Body:\n");
750        if self.body.is_empty() {
751            result.push_str("(empty)");
752        } else if let Ok(json_value) = self.json() {
753            // Pretty print JSON if possible
754            match serde_json::to_string_pretty(&json_value) {
755                Ok(pretty) => result.push_str(&pretty),
756                Err(_) => result.push_str(&self.body),
757            }
758        } else {
759            // Truncate very long responses
760            if self.body.len() > 2000 {
761                result.push_str(&self.body[..2000]);
762                result.push_str("\n... (");
763                result.push_str(&(self.body.len() - 2000).to_string());
764                result.push_str(" more characters)");
765            } else {
766                result.push_str(&self.body);
767            }
768        }
769
770        result
771    }
772}
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777    use crate::tool_generator::ExtractedParameters;
778    use serde_json::json;
779    use std::collections::HashMap;
780
781    #[test]
782    fn test_with_base_url_validation() {
783        // Test valid URLs
784        let url = Url::parse("https://api.example.com").unwrap();
785        let client = HttpClient::new().with_base_url(url);
786        assert!(client.is_ok());
787
788        let url = Url::parse("http://localhost:8080").unwrap();
789        let client = HttpClient::new().with_base_url(url);
790        assert!(client.is_ok());
791
792        // Test invalid URLs - these will fail at parse time now
793        assert!(Url::parse("not-a-url").is_err());
794        assert!(Url::parse("").is_err());
795
796        // Test schemes that parse successfully
797        let url = Url::parse("ftp://invalid-scheme.com").unwrap();
798        let client = HttpClient::new().with_base_url(url);
799        assert!(client.is_ok()); // url crate accepts ftp, our HttpClient should too
800    }
801
802    #[test]
803    fn test_build_url_with_base_url() {
804        let base_url = Url::parse("https://api.example.com").unwrap();
805        let client = HttpClient::new().with_base_url(base_url).unwrap();
806
807        let tool_metadata = crate::ToolMetadata {
808            name: "test".to_string(),
809            title: None,
810            description: "test".to_string(),
811            parameters: json!({}),
812            output_schema: None,
813            method: "GET".to_string(),
814            path: "/pets/{id}".to_string(),
815        };
816
817        let mut path_params = HashMap::new();
818        path_params.insert("id".to_string(), json!(123));
819
820        let extracted_params = ExtractedParameters {
821            path: path_params,
822            query: HashMap::new(),
823            headers: HashMap::new(),
824            cookies: HashMap::new(),
825            body: HashMap::new(),
826            config: crate::tool_generator::RequestConfig::default(),
827        };
828
829        let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
830        assert_eq!(url.to_string(), "https://api.example.com/pets/123");
831    }
832
833    #[test]
834    fn test_build_url_without_base_url() {
835        let client = HttpClient::new();
836
837        let tool_metadata = crate::ToolMetadata {
838            name: "test".to_string(),
839            title: None,
840            description: "test".to_string(),
841            parameters: json!({}),
842            output_schema: None,
843            method: "GET".to_string(),
844            path: "https://api.example.com/pets/123".to_string(),
845        };
846
847        let extracted_params = ExtractedParameters {
848            path: HashMap::new(),
849            query: HashMap::new(),
850            headers: HashMap::new(),
851            cookies: HashMap::new(),
852            body: HashMap::new(),
853            config: crate::tool_generator::RequestConfig::default(),
854        };
855
856        let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
857        assert_eq!(url.to_string(), "https://api.example.com/pets/123");
858
859        // Test error case: relative path without base URL
860        let tool_metadata_relative = crate::ToolMetadata {
861            name: "test".to_string(),
862            title: None,
863            description: "test".to_string(),
864            parameters: json!({}),
865            output_schema: None,
866            method: "GET".to_string(),
867            path: "/pets/123".to_string(),
868        };
869
870        let result = client.build_url(&tool_metadata_relative, &extracted_params);
871        assert!(result.is_err());
872        assert!(
873            result
874                .unwrap_err()
875                .to_string()
876                .contains("No base URL configured")
877        );
878    }
879
880    #[test]
881    fn test_query_parameter_encoding_integration() {
882        let base_url = Url::parse("https://api.example.com").unwrap();
883        let client = HttpClient::new().with_base_url(base_url).unwrap();
884
885        let tool_metadata = crate::ToolMetadata {
886            name: "test".to_string(),
887            title: None,
888            description: "test".to_string(),
889            parameters: json!({}),
890            output_schema: None,
891            method: "GET".to_string(),
892            path: "/search".to_string(),
893        };
894
895        // Test various query parameter values that need encoding
896        let mut query_params = HashMap::new();
897        query_params.insert(
898            "q".to_string(),
899            QueryParameter::new(json!("hello world"), true),
900        ); // space
901        query_params.insert(
902            "category".to_string(),
903            QueryParameter::new(json!("pets&dogs"), true),
904        ); // ampersand
905        query_params.insert(
906            "special".to_string(),
907            QueryParameter::new(json!("foo=bar"), true),
908        ); // equals
909        query_params.insert(
910            "unicode".to_string(),
911            QueryParameter::new(json!("café"), true),
912        ); // unicode
913        query_params.insert(
914            "percent".to_string(),
915            QueryParameter::new(json!("100%"), true),
916        ); // percent
917
918        let extracted_params = ExtractedParameters {
919            path: HashMap::new(),
920            query: query_params,
921            headers: HashMap::new(),
922            cookies: HashMap::new(),
923            body: HashMap::new(),
924            config: crate::tool_generator::RequestConfig::default(),
925        };
926
927        let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
928        HttpClient::add_query_parameters(&mut url, &extracted_params.query, &tool_metadata);
929
930        let url_string = url.to_string();
931
932        // Verify the URL contains properly encoded parameters
933        // Note: url crate encodes spaces as + in query parameters (which is valid)
934        assert!(url_string.contains("q=hello+world")); // space encoded as +
935        assert!(url_string.contains("category=pets%26dogs")); // & encoded as %26
936        assert!(url_string.contains("special=foo%3Dbar")); // = encoded as %3D
937        assert!(url_string.contains("unicode=caf%C3%A9")); // é encoded as %C3%A9
938        assert!(url_string.contains("percent=100%25")); // % encoded as %25
939    }
940
941    #[test]
942    fn test_array_query_parameters() {
943        let base_url = Url::parse("https://api.example.com").unwrap();
944        let client = HttpClient::new().with_base_url(base_url).unwrap();
945
946        let tool_metadata = crate::ToolMetadata {
947            name: "test".to_string(),
948            title: None,
949            description: "test".to_string(),
950            parameters: json!({}),
951            output_schema: None,
952            method: "GET".to_string(),
953            path: "/search".to_string(),
954        };
955
956        let mut query_params = HashMap::new();
957        query_params.insert(
958            "status".to_string(),
959            QueryParameter::new(json!(["available", "pending"]), true),
960        );
961        query_params.insert(
962            "tags".to_string(),
963            QueryParameter::new(json!(["red & blue", "fast=car"]), true),
964        );
965
966        let extracted_params = ExtractedParameters {
967            path: HashMap::new(),
968            query: query_params,
969            headers: HashMap::new(),
970            cookies: HashMap::new(),
971            body: HashMap::new(),
972            config: crate::tool_generator::RequestConfig::default(),
973        };
974
975        let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
976        HttpClient::add_query_parameters(&mut url, &extracted_params.query, &tool_metadata);
977
978        let url_string = url.to_string();
979
980        // Verify array parameters are added multiple times with proper encoding
981        assert!(url_string.contains("status=available"));
982        assert!(url_string.contains("status=pending"));
983        assert!(url_string.contains("tags=red+%26+blue")); // "red & blue" encoded (spaces as +)
984        assert!(url_string.contains("tags=fast%3Dcar")); // "fast=car" encoded
985    }
986
987    #[test]
988    fn test_path_parameter_substitution() {
989        let base_url = Url::parse("https://api.example.com").unwrap();
990        let client = HttpClient::new().with_base_url(base_url).unwrap();
991
992        let tool_metadata = crate::ToolMetadata {
993            name: "test".to_string(),
994            title: None,
995            description: "test".to_string(),
996            parameters: json!({}),
997            output_schema: None,
998            method: "GET".to_string(),
999            path: "/users/{userId}/pets/{petId}".to_string(),
1000        };
1001
1002        let mut path_params = HashMap::new();
1003        path_params.insert("userId".to_string(), json!(42));
1004        path_params.insert("petId".to_string(), json!("special-pet-123"));
1005
1006        let extracted_params = ExtractedParameters {
1007            path: path_params,
1008            query: HashMap::new(),
1009            headers: HashMap::new(),
1010            cookies: HashMap::new(),
1011            body: HashMap::new(),
1012            config: crate::tool_generator::RequestConfig::default(),
1013        };
1014
1015        let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1016        assert_eq!(
1017            url.to_string(),
1018            "https://api.example.com/users/42/pets/special-pet-123"
1019        );
1020    }
1021
1022    #[test]
1023    fn test_url_join_edge_cases() {
1024        // Test trailing slash handling
1025        let base_url1 = Url::parse("https://api.example.com/").unwrap();
1026        let client1 = HttpClient::new().with_base_url(base_url1).unwrap();
1027
1028        let base_url2 = Url::parse("https://api.example.com").unwrap();
1029        let client2 = HttpClient::new().with_base_url(base_url2).unwrap();
1030
1031        let tool_metadata = crate::ToolMetadata {
1032            name: "test".to_string(),
1033            title: None,
1034            description: "test".to_string(),
1035            parameters: json!({}),
1036            output_schema: None,
1037            method: "GET".to_string(),
1038            path: "/pets".to_string(),
1039        };
1040
1041        let extracted_params = ExtractedParameters {
1042            path: HashMap::new(),
1043            query: HashMap::new(),
1044            headers: HashMap::new(),
1045            cookies: HashMap::new(),
1046            body: HashMap::new(),
1047            config: crate::tool_generator::RequestConfig::default(),
1048        };
1049
1050        let url1 = client1
1051            .build_url(&tool_metadata, &extracted_params)
1052            .unwrap();
1053        let url2 = client2
1054            .build_url(&tool_metadata, &extracted_params)
1055            .unwrap();
1056
1057        // Both should produce the same normalized URL
1058        assert_eq!(url1.to_string(), "https://api.example.com/pets");
1059        assert_eq!(url2.to_string(), "https://api.example.com/pets");
1060    }
1061
1062    #[test]
1063    fn test_explode_array_parameters() {
1064        let base_url = Url::parse("https://api.example.com").unwrap();
1065        let client = HttpClient::new().with_base_url(base_url).unwrap();
1066
1067        let tool_metadata = crate::ToolMetadata {
1068            name: "test".to_string(),
1069            title: None,
1070            description: "test".to_string(),
1071            parameters: json!({}),
1072            output_schema: None,
1073            method: "GET".to_string(),
1074            path: "/search".to_string(),
1075        };
1076
1077        // Test explode=true (should generate separate parameters)
1078        let mut query_params_exploded = HashMap::new();
1079        query_params_exploded.insert(
1080            "include".to_string(),
1081            QueryParameter::new(json!(["asset", "scenes"]), true),
1082        );
1083
1084        let extracted_params_exploded = ExtractedParameters {
1085            path: HashMap::new(),
1086            query: query_params_exploded,
1087            headers: HashMap::new(),
1088            cookies: HashMap::new(),
1089            body: HashMap::new(),
1090            config: crate::tool_generator::RequestConfig::default(),
1091        };
1092
1093        let mut url_exploded = client
1094            .build_url(&tool_metadata, &extracted_params_exploded)
1095            .unwrap();
1096        HttpClient::add_query_parameters(
1097            &mut url_exploded,
1098            &extracted_params_exploded.query,
1099            &tool_metadata,
1100        );
1101        let url_exploded_string = url_exploded.to_string();
1102
1103        // Test explode=false (should generate comma-separated values)
1104        let mut query_params_not_exploded = HashMap::new();
1105        query_params_not_exploded.insert(
1106            "include".to_string(),
1107            QueryParameter::new(json!(["asset", "scenes"]), false),
1108        );
1109
1110        let extracted_params_not_exploded = ExtractedParameters {
1111            path: HashMap::new(),
1112            query: query_params_not_exploded,
1113            headers: HashMap::new(),
1114            cookies: HashMap::new(),
1115            body: HashMap::new(),
1116            config: crate::tool_generator::RequestConfig::default(),
1117        };
1118
1119        let mut url_not_exploded = client
1120            .build_url(&tool_metadata, &extracted_params_not_exploded)
1121            .unwrap();
1122        HttpClient::add_query_parameters(
1123            &mut url_not_exploded,
1124            &extracted_params_not_exploded.query,
1125            &tool_metadata,
1126        );
1127        let url_not_exploded_string = url_not_exploded.to_string();
1128
1129        // Verify explode=true generates separate parameters
1130        assert!(url_exploded_string.contains("include=asset"));
1131        assert!(url_exploded_string.contains("include=scenes"));
1132
1133        // Verify explode=false generates comma-separated values
1134        assert!(url_not_exploded_string.contains("include=asset%2Cscenes")); // comma is URL-encoded as %2C
1135
1136        // Make sure they're different
1137        assert_ne!(url_exploded_string, url_not_exploded_string);
1138
1139        println!("Exploded URL: {url_exploded_string}");
1140        println!("Non-exploded URL: {url_not_exploded_string}");
1141    }
1142
1143    #[test]
1144    fn test_omit_empty_optional_array_query_parameters() {
1145        let base_url = Url::parse("https://api.example.com").unwrap();
1146        let client = HttpClient::new().with_base_url(base_url).unwrap();
1147
1148        // Create tool metadata with optional and required array parameters
1149        let tool_metadata = crate::ToolMetadata {
1150            name: "test".to_string(),
1151            title: None,
1152            description: "test".to_string(),
1153            parameters: json!({
1154                "type": "object",
1155                "properties": {
1156                    "optional_array": {"type": "array", "items": {"type": "string"}},
1157                    "required_array": {"type": "array", "items": {"type": "string"}},
1158                    "optional_with_default": {
1159                        "type": "array",
1160                        "items": {"type": "string"},
1161                        "default": ["default"]
1162                    },
1163                    "optional_string": {"type": "string"}
1164                },
1165                "required": ["required_array"]
1166            }),
1167            output_schema: None,
1168            method: "GET".to_string(),
1169            path: "/test".to_string(),
1170        };
1171
1172        let mut query_params = HashMap::new();
1173        query_params.insert(
1174            "optional_array".to_string(),
1175            QueryParameter::new(json!([]), true),
1176        ); // Empty optional array - should be omitted
1177        query_params.insert(
1178            "required_array".to_string(),
1179            QueryParameter::new(json!([]), true),
1180        ); // Empty required array - should be included
1181        query_params.insert(
1182            "optional_with_default".to_string(),
1183            QueryParameter::new(json!([]), true),
1184        ); // Empty optional with default - should be included
1185        query_params.insert(
1186            "optional_string".to_string(),
1187            QueryParameter::new(json!(""), true),
1188        ); // Empty string - should be included (not array)
1189
1190        let extracted_params = ExtractedParameters {
1191            path: HashMap::new(),
1192            query: query_params,
1193            headers: HashMap::new(),
1194            cookies: HashMap::new(),
1195            body: HashMap::new(),
1196            config: crate::tool_generator::RequestConfig::default(),
1197        };
1198
1199        let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1200        HttpClient::add_query_parameters(&mut url, &extracted_params.query, &tool_metadata);
1201
1202        let url_string = url.to_string();
1203        // Verify empty optional array is omitted
1204        assert!(!url_string.contains("optional_array"));
1205
1206        // Verify empty required array is included
1207        assert!(url_string.contains("required_array"));
1208
1209        // Verify empty optional with default is included
1210        assert!(url_string.contains("optional_with_default"));
1211
1212        // Verify empty string is included (not an array)
1213        assert!(url_string.contains("optional_string"));
1214    }
1215
1216    #[test]
1217    fn test_omit_empty_optional_array_headers() {
1218        let tool_metadata = crate::ToolMetadata {
1219            name: "test".to_string(),
1220            title: None,
1221            description: "test".to_string(),
1222            parameters: json!({
1223                "type": "object",
1224                "properties": {
1225                    "x-optional-array": {"type": "array", "items": {"type": "string"}},
1226                    "x-required-array": {"type": "array", "items": {"type": "string"}},
1227                    "x-optional-string": {"type": "string"}
1228                },
1229                "required": ["x-required-array"]
1230            }),
1231            output_schema: None,
1232            method: "GET".to_string(),
1233            path: "/test".to_string(),
1234        };
1235
1236        let mut headers = HashMap::new();
1237        headers.insert("x-optional-array".to_string(), json!([])); // Should be omitted
1238        headers.insert("x-required-array".to_string(), json!([])); // Should be included
1239        headers.insert("x-optional-string".to_string(), json!("value")); // Should be included
1240
1241        let request = HttpClient::new()
1242            .client
1243            .request(reqwest::Method::GET, "https://api.example.com");
1244        let request = HttpClient::add_headers(request, &headers, &tool_metadata);
1245
1246        // We can't easily inspect the headers without sending the request,
1247        // but we can verify the function doesn't panic and returns a valid RequestBuilder
1248        assert!(request.build().is_ok());
1249    }
1250
1251    #[test]
1252    fn test_omit_empty_optional_array_cookies() {
1253        let tool_metadata = crate::ToolMetadata {
1254            name: "test".to_string(),
1255            title: None,
1256            description: "test".to_string(),
1257            parameters: json!({
1258                "type": "object",
1259                "properties": {
1260                    "optional_array_cookie": {"type": "array", "items": {"type": "string"}},
1261                    "required_array_cookie": {"type": "array", "items": {"type": "string"}},
1262                    "optional_string_cookie": {"type": "string"}
1263                },
1264                "required": ["required_array_cookie"]
1265            }),
1266            output_schema: None,
1267            method: "GET".to_string(),
1268            path: "/test".to_string(),
1269        };
1270
1271        let mut cookies = HashMap::new();
1272        cookies.insert("optional_array_cookie".to_string(), json!([])); // Should be omitted
1273        cookies.insert("required_array_cookie".to_string(), json!([])); // Should be included
1274        cookies.insert("optional_string_cookie".to_string(), json!("value")); // Should be included
1275
1276        let request = HttpClient::new()
1277            .client
1278            .request(reqwest::Method::GET, "https://api.example.com");
1279        let request = HttpClient::add_cookies(request, &cookies, &tool_metadata);
1280
1281        // Verify the function doesn't panic and returns a valid RequestBuilder
1282        assert!(request.build().is_ok());
1283    }
1284
1285    #[test]
1286    fn test_non_empty_optional_arrays_are_included() {
1287        let base_url = Url::parse("https://api.example.com").unwrap();
1288        let client = HttpClient::new().with_base_url(base_url).unwrap();
1289
1290        let tool_metadata = crate::ToolMetadata {
1291            name: "test".to_string(),
1292            title: None,
1293            description: "test".to_string(),
1294            parameters: json!({
1295                "type": "object",
1296                "properties": {
1297                    "optional_array": {"type": "array", "items": {"type": "string"}}
1298                },
1299                "required": []
1300            }),
1301            output_schema: None,
1302            method: "GET".to_string(),
1303            path: "/test".to_string(),
1304        };
1305
1306        let mut query_params = HashMap::new();
1307        query_params.insert(
1308            "optional_array".to_string(),
1309            QueryParameter::new(json!(["value1", "value2"]), true),
1310        ); // Non-empty optional array - should be included
1311
1312        let extracted_params = ExtractedParameters {
1313            path: HashMap::new(),
1314            query: query_params,
1315            headers: HashMap::new(),
1316            cookies: HashMap::new(),
1317            body: HashMap::new(),
1318            config: crate::tool_generator::RequestConfig::default(),
1319        };
1320
1321        let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1322        HttpClient::add_query_parameters(&mut url, &extracted_params.query, &tool_metadata);
1323
1324        let url_string = url.to_string();
1325
1326        // Verify non-empty optional array is included
1327        assert!(url_string.contains("optional_array=value1"));
1328        assert!(url_string.contains("optional_array=value2"));
1329    }
1330}