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