rmcp_openapi/
http_client.rs

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