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