Skip to main content

rmcp_openapi/
http_client.rs

1use base64::prelude::*;
2use reqwest::header::{self, HeaderMap, HeaderValue};
3use reqwest::{Client, Method, RequestBuilder, StatusCode};
4use serde_json::Value;
5use std::collections::HashMap;
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/// Content extracted from a data URI
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct DataUriContent {
19    /// The MIME type of the content (e.g., "image/png")
20    pub mime_type: String,
21    /// The decoded bytes of the content
22    pub bytes: Vec<u8>,
23}
24
25/// Parse a data URI and extract its content
26///
27/// Parses data URIs in the format `data:<mime>;base64,<content>`.
28/// Only base64 encoding is supported.
29///
30/// # Arguments
31///
32/// * `value` - The data URI string to parse
33/// * `field_name` - The name of the field (used in error messages)
34///
35/// # Returns
36///
37/// Returns `DataUriContent` with the extracted MIME type and decoded bytes.
38///
39/// # Errors
40///
41/// Returns an error if:
42/// - The data URI format is invalid
43/// - The encoding is not base64
44/// - The base64 content cannot be decoded
45///
46/// # Example
47///
48/// ```
49/// use rmcp_openapi::http_client::parse_data_uri;
50///
51/// let uri = "data:image/png;base64,iVBORw0KGgo=";
52/// let content = parse_data_uri(uri, "image_field").unwrap();
53/// assert_eq!(content.mime_type, "image/png");
54/// ```
55pub fn parse_data_uri(value: &str, field_name: &str) -> Result<DataUriContent, Error> {
56    let format_error = || {
57        Error::Validation(format!(
58            "Invalid data URI format for field '{}': expected 'data:<mime>;base64,<content>'",
59            field_name
60        ))
61    };
62
63    // Check for data: prefix
64    let remainder = value.strip_prefix("data:").ok_or_else(format_error)?;
65
66    // Find ";base64," to split MIME type (possibly with parameters) from content
67    // This handles cases like "text/plain;charset=utf-8;base64,SGVsbG8="
68    let base64_marker = ";base64,";
69    let marker_pos = remainder.find(base64_marker).ok_or_else(|| {
70        // Check if there's a different encoding specified
71        if let Some(semicolon_pos) = remainder.find(';')
72            && let Some(comma_pos) = remainder[semicolon_pos..].find(',')
73        {
74            let encoding = &remainder[semicolon_pos + 1..semicolon_pos + comma_pos];
75            if !encoding.is_empty() && encoding != "base64" {
76                return Error::Validation(format!(
77                    "Unsupported encoding '{}' for field '{}': only base64 is supported",
78                    encoding, field_name
79                ));
80            }
81        }
82        format_error()
83    })?;
84
85    let mime_type = &remainder[..marker_pos];
86    let content = &remainder[marker_pos + base64_marker.len()..];
87
88    // Validate MIME type is not empty
89    if mime_type.is_empty() {
90        return Err(Error::Validation(format!(
91            "Invalid data URI format for field '{}': MIME type cannot be empty",
92            field_name
93        )));
94    }
95
96    // Decode base64 content
97    let bytes = BASE64_STANDARD.decode(content).map_err(|e| {
98        Error::Validation(format!(
99            "Invalid base64 content for field '{}': {}",
100            field_name, e
101        ))
102    })?;
103
104    Ok(DataUriContent {
105        mime_type: mime_type.to_string(),
106        bytes,
107    })
108}
109
110/// HTTP client for executing `OpenAPI` requests
111#[derive(Clone)]
112pub struct HttpClient {
113    client: Client,
114    base_url: Option<Url>,
115    default_headers: HeaderMap,
116}
117
118impl HttpClient {
119    /// Create the user agent string for HTTP requests
120    fn create_user_agent() -> String {
121        format!("rmcp-openapi-server/{}", env!("CARGO_PKG_VERSION"))
122    }
123    /// Create a new HTTP client
124    ///
125    /// # Panics
126    ///
127    /// Panics if the HTTP client cannot be created
128    #[must_use]
129    pub fn new() -> Self {
130        let user_agent = Self::create_user_agent();
131        let client = Client::builder()
132            .user_agent(&user_agent)
133            .timeout(Duration::from_secs(30))
134            .build()
135            .expect("Failed to create HTTP client");
136
137        Self {
138            client,
139            base_url: None,
140            default_headers: HeaderMap::new(),
141        }
142    }
143
144    /// Create a new HTTP client with custom timeout
145    ///
146    /// # Panics
147    ///
148    /// Panics if the HTTP client cannot be created
149    #[must_use]
150    pub fn with_timeout(timeout_seconds: u64) -> Self {
151        let user_agent = Self::create_user_agent();
152        let client = Client::builder()
153            .user_agent(&user_agent)
154            .timeout(Duration::from_secs(timeout_seconds))
155            .build()
156            .expect("Failed to create HTTP client");
157
158        Self {
159            client,
160            base_url: None,
161            default_headers: HeaderMap::new(),
162        }
163    }
164
165    /// Set the base URL for all requests
166    ///
167    /// # Errors
168    ///
169    /// Returns an error if the base URL is invalid
170    pub fn with_base_url(mut self, base_url: Url) -> Result<Self, Error> {
171        // Always terminate the path of the base_url with '/'
172        let mut base_url = base_url;
173        if !base_url.path().ends_with('/') {
174            base_url.set_path(&format!("{}/", base_url.path()));
175        }
176        self.base_url = Some(base_url);
177        Ok(self)
178    }
179
180    /// Set default headers for all requests
181    #[must_use]
182    pub fn with_default_headers(mut self, default_headers: HeaderMap) -> Self {
183        self.default_headers = default_headers;
184        self
185    }
186
187    /// Create a new HTTP client with authorization header
188    ///
189    /// Clones the current client and adds the Authorization header to default headers.
190    /// This allows passing authorization through to backend APIs.
191    #[must_use]
192    pub fn with_authorization(&self, auth_value: &str) -> Self {
193        let mut headers = self.default_headers.clone();
194        if let Ok(header_value) = HeaderValue::from_str(auth_value) {
195            headers.insert(header::AUTHORIZATION, header_value);
196        }
197
198        Self {
199            client: self.client.clone(),
200            base_url: self.base_url.clone(),
201            default_headers: headers,
202        }
203    }
204
205    /// Execute an `OpenAPI` tool call
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if the HTTP request fails or parameters are invalid
210    pub async fn execute_tool_call(
211        &self,
212        tool_metadata: &ToolMetadata,
213        arguments: &Value,
214    ) -> Result<HttpResponse, ToolCallError> {
215        let span = info_span!(
216            "http_request",
217            operation_id = %tool_metadata.name,
218            method = %tool_metadata.method,
219            path = %tool_metadata.path
220        );
221        let _enter = span.enter();
222
223        debug!(
224            "Executing tool call: {} {} with arguments: {}",
225            tool_metadata.method,
226            tool_metadata.path,
227            serde_json::to_string_pretty(arguments).unwrap_or_else(|_| "invalid json".to_string())
228        );
229
230        // Extract parameters from arguments
231        let extracted_params = ToolGenerator::extract_parameters(tool_metadata, arguments)?;
232
233        debug!(
234            "Extracted parameters: path={:?}, query={:?}, headers={:?}, cookies={:?}",
235            extracted_params.path,
236            extracted_params.query,
237            extracted_params.headers,
238            extracted_params.cookies
239        );
240
241        // Build the URL with path parameters
242        let mut url = self
243            .build_url(tool_metadata, &extracted_params)
244            .map_err(|e| {
245                ToolCallError::Validation(ToolCallValidationError::RequestConstructionError {
246                    reason: e.to_string(),
247                })
248            })?;
249
250        // Add query parameters with proper URL encoding
251        if !extracted_params.query.is_empty() {
252            Self::add_query_parameters(&mut url, &extracted_params.query);
253        }
254
255        info!("Final URL: {}", url);
256
257        // Create the HTTP request
258        let mut request = self
259            .create_request(&tool_metadata.method, &url)
260            .map_err(|e| {
261                ToolCallError::Validation(ToolCallValidationError::RequestConstructionError {
262                    reason: e.to_string(),
263                })
264            })?;
265
266        // Add headers: first default headers, then request-specific headers (which take precedence)
267        if !self.default_headers.is_empty() {
268            // Use the HeaderMap directly with reqwest
269            request = Self::add_headers_from_map(request, &self.default_headers);
270        }
271
272        // Add request-specific headers (these override default headers)
273        if !extracted_params.headers.is_empty() {
274            request = Self::add_headers(request, &extracted_params.headers);
275        }
276
277        // Add cookies
278        if !extracted_params.cookies.is_empty() {
279            request = Self::add_cookies(request, &extracted_params.cookies);
280        }
281
282        // Add request body if present
283        if !extracted_params.body.is_empty() {
284            request =
285                Self::add_request_body(request, &extracted_params.body, &extracted_params.config)
286                    .map_err(|e| {
287                    ToolCallError::Execution(ToolCallExecutionError::ResponseParsingError {
288                        reason: format!("Failed to serialize request body: {e}"),
289                        raw_response: None,
290                    })
291                })?;
292        }
293
294        // Apply custom timeout if specified
295        if extracted_params.config.timeout_seconds != 30 {
296            request = request.timeout(Duration::from_secs(u64::from(
297                extracted_params.config.timeout_seconds,
298            )));
299        }
300
301        // Capture request details for response formatting
302        let request_body_string = if extracted_params.body.is_empty() {
303            String::new()
304        } else if extracted_params.body.len() == 1
305            && extracted_params.body.contains_key("request_body")
306        {
307            serde_json::to_string(&extracted_params.body["request_body"]).unwrap_or_default()
308        } else {
309            let body_object = Value::Object(
310                extracted_params
311                    .body
312                    .iter()
313                    .map(|(k, v)| (k.clone(), v.clone()))
314                    .collect(),
315            );
316            serde_json::to_string(&body_object).unwrap_or_default()
317        };
318
319        // Get the final URL for logging
320        let final_url = url.to_string();
321
322        // Execute the request
323        debug!("Sending HTTP request...");
324        let start_time = std::time::Instant::now();
325        let response = request.send().await.map_err(|e| {
326            error!(
327                operation_id = %tool_metadata.name,
328                method = %tool_metadata.method,
329                url = %final_url,
330                error = %e,
331                "HTTP request failed"
332            );
333
334            // Categorize error based on reqwest's reliable error detection methods
335            let (error_msg, category) = if e.is_timeout() {
336                (
337                    format!(
338                        "Request timeout after {} seconds while calling {} {}",
339                        extracted_params.config.timeout_seconds,
340                        tool_metadata.method.to_uppercase(),
341                        final_url
342                    ),
343                    NetworkErrorCategory::Timeout,
344                )
345            } else if e.is_connect() {
346                (
347                    format!(
348                        "Connection failed to {final_url} - Error: {e}. Check if the server is running and the URL is correct."
349                    ),
350                    NetworkErrorCategory::Connect,
351                )
352            } else if e.is_request() {
353                (
354                    format!(
355                        "Request error while calling {} {} - Error: {}",
356                        tool_metadata.method.to_uppercase(),
357                        final_url,
358                        e
359                    ),
360                    NetworkErrorCategory::Request,
361                )
362            } else if e.is_body() {
363                (
364                    format!(
365                        "Body error while calling {} {} - Error: {}",
366                        tool_metadata.method.to_uppercase(),
367                        final_url,
368                        e
369                    ),
370                    NetworkErrorCategory::Body,
371                )
372            } else if e.is_decode() {
373                (
374                    format!(
375                        "Response decode error from {} {} - Error: {}",
376                        tool_metadata.method.to_uppercase(),
377                        final_url,
378                        e
379                    ),
380                    NetworkErrorCategory::Decode,
381                )
382            } else {
383                (
384                    format!(
385                        "HTTP request failed: {} (URL: {}, Method: {})",
386                        e,
387                        final_url,
388                        tool_metadata.method.to_uppercase()
389                    ),
390                    NetworkErrorCategory::Other,
391                )
392            };
393
394            ToolCallError::Execution(ToolCallExecutionError::NetworkError {
395                message: error_msg,
396                category,
397            })
398        })?;
399
400        let elapsed = start_time.elapsed();
401        info!(
402            operation_id = %tool_metadata.name,
403            method = %tool_metadata.method,
404            url = %final_url,
405            status = response.status().as_u16(),
406            elapsed_ms = elapsed.as_millis(),
407            "HTTP request completed"
408        );
409        debug!("Response received with status: {}", response.status());
410
411        // Convert response to our format with request details
412        self.process_response_with_request(
413            response,
414            &tool_metadata.method,
415            &final_url,
416            &request_body_string,
417        )
418        .await
419        .map_err(|e| {
420            ToolCallError::Execution(ToolCallExecutionError::HttpError {
421                status: 0,
422                message: e.to_string(),
423                details: None,
424            })
425        })
426    }
427
428    /// Build the complete URL with path parameters substituted
429    fn build_url(
430        &self,
431        tool_metadata: &ToolMetadata,
432        extracted_params: &ExtractedParameters,
433    ) -> Result<Url, Error> {
434        let mut path = tool_metadata.path.clone();
435
436        // Substitute path parameters
437        for (param_name, param_value) in &extracted_params.path {
438            let placeholder = format!("{{{param_name}}}");
439            let value_str = match param_value {
440                Value::String(s) => s.clone(),
441                Value::Number(n) => n.to_string(),
442                Value::Bool(b) => b.to_string(),
443                _ => param_value.to_string(),
444            };
445            path = path.replace(&placeholder, &value_str);
446        }
447
448        let mut path: &str = path.as_ref();
449
450        // Combine with base URL if available
451        if let Some(base_url) = &self.base_url {
452            // Strip the starting '/' in path to make sure the call to Url::join will not
453            // set the path starting at the root
454            if path.starts_with('/') {
455                path = &path[1..];
456            }
457            base_url.join(path).map_err(|e| {
458                Error::Http(format!(
459                    "Failed to join URL '{base_url}' with path '{path}': {e}"
460                ))
461            })
462        } else {
463            // Assume the path is already a complete URL
464            if path.starts_with("http") {
465                Url::parse(path).map_err(|e| Error::Http(format!("Invalid URL '{path}': {e}")))
466            } else {
467                Err(Error::Http(
468                    "No base URL configured and path is not a complete URL".to_string(),
469                ))
470            }
471        }
472    }
473
474    /// Create a new HTTP request with the specified method and URL
475    fn create_request(&self, method: &str, url: &Url) -> Result<RequestBuilder, Error> {
476        let http_method = method.to_uppercase();
477        let method = match http_method.as_str() {
478            "GET" => Method::GET,
479            "POST" => Method::POST,
480            "PUT" => Method::PUT,
481            "DELETE" => Method::DELETE,
482            "PATCH" => Method::PATCH,
483            "HEAD" => Method::HEAD,
484            "OPTIONS" => Method::OPTIONS,
485            _ => {
486                return Err(Error::Http(format!(
487                    "Unsupported HTTP method: {http_method}"
488                )));
489            }
490        };
491
492        Ok(self.client.request(method, url.clone()))
493    }
494
495    /// Add query parameters to the request using proper URL encoding
496    fn add_query_parameters(url: &mut Url, query_params: &HashMap<String, QueryParameter>) {
497        {
498            let mut query_pairs = url.query_pairs_mut();
499            for (key, query_param) in query_params {
500                if let Value::Array(arr) = &query_param.value {
501                    if query_param.explode {
502                        // explode=true: Handle array parameters - add each value as a separate query parameter
503                        for item in arr {
504                            let item_str = match item {
505                                Value::String(s) => s.clone(),
506                                Value::Number(n) => n.to_string(),
507                                Value::Bool(b) => b.to_string(),
508                                _ => item.to_string(),
509                            };
510                            query_pairs.append_pair(key, &item_str);
511                        }
512                    } else {
513                        // explode=false: Join array values with commas
514                        let array_values: Vec<String> = arr
515                            .iter()
516                            .map(|item| match item {
517                                Value::String(s) => s.clone(),
518                                Value::Number(n) => n.to_string(),
519                                Value::Bool(b) => b.to_string(),
520                                _ => item.to_string(),
521                            })
522                            .collect();
523                        let comma_separated = array_values.join(",");
524                        query_pairs.append_pair(key, &comma_separated);
525                    }
526                } else {
527                    let value_str = match &query_param.value {
528                        Value::String(s) => s.clone(),
529                        Value::Number(n) => n.to_string(),
530                        Value::Bool(b) => b.to_string(),
531                        _ => query_param.value.to_string(),
532                    };
533                    query_pairs.append_pair(key, &value_str);
534                }
535            }
536        }
537    }
538
539    /// Add headers to the request from HeaderMap
540    fn add_headers_from_map(mut request: RequestBuilder, headers: &HeaderMap) -> RequestBuilder {
541        for (key, value) in headers {
542            // HeaderName and HeaderValue are already validated, pass them directly to reqwest
543            request = request.header(key, value);
544        }
545        request
546    }
547
548    /// Add headers to the request
549    fn add_headers(
550        mut request: RequestBuilder,
551        headers: &HashMap<String, Value>,
552    ) -> RequestBuilder {
553        for (key, value) in headers {
554            let value_str = match value {
555                Value::String(s) => s.clone(),
556                Value::Number(n) => n.to_string(),
557                Value::Bool(b) => b.to_string(),
558                _ => value.to_string(),
559            };
560            request = request.header(key, value_str);
561        }
562        request
563    }
564
565    /// Add cookies to the request
566    fn add_cookies(
567        mut request: RequestBuilder,
568        cookies: &HashMap<String, Value>,
569    ) -> RequestBuilder {
570        if !cookies.is_empty() {
571            let cookie_header = cookies
572                .iter()
573                .map(|(key, value)| {
574                    let value_str = match value {
575                        Value::String(s) => s.clone(),
576                        Value::Number(n) => n.to_string(),
577                        Value::Bool(b) => b.to_string(),
578                        _ => value.to_string(),
579                    };
580                    format!("{key}={value_str}")
581                })
582                .collect::<Vec<_>>()
583                .join("; ");
584
585            request = request.header(header::COOKIE, cookie_header);
586        }
587        request
588    }
589
590    /// Add request body to the request
591    fn add_request_body(
592        mut request: RequestBuilder,
593        body: &HashMap<String, Value>,
594        config: &crate::tool_generator::RequestConfig,
595    ) -> Result<RequestBuilder, Error> {
596        if body.is_empty() {
597            return Ok(request);
598        }
599
600        // Handle different content types
601        match config.content_type.as_str() {
602            s if s == mime::APPLICATION_JSON.as_ref() => {
603                // Set content type header for JSON
604                request = request.header(header::CONTENT_TYPE, &config.content_type);
605
606                // For JSON content type, serialize the body
607                if body.len() == 1 && body.contains_key("request_body") {
608                    // Use the request_body directly if it's the only parameter
609                    let body_value = &body["request_body"];
610                    let json_string = serde_json::to_string(body_value).map_err(|e| {
611                        Error::Http(format!("Failed to serialize request body: {e}"))
612                    })?;
613                    request = request.body(json_string);
614                } else {
615                    // Create JSON object from all body parameters
616                    let body_object =
617                        Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
618                    let json_string = serde_json::to_string(&body_object).map_err(|e| {
619                        Error::Http(format!("Failed to serialize request body: {e}"))
620                    })?;
621                    request = request.body(json_string);
622                }
623            }
624            s if s == mime::APPLICATION_WWW_FORM_URLENCODED.as_ref() => {
625                // Set content type header for form-urlencoded
626                request = request.header(header::CONTENT_TYPE, &config.content_type);
627
628                // Handle form data
629                let form_data: Vec<(String, String)> = body
630                    .iter()
631                    .map(|(key, value)| {
632                        let value_str = match value {
633                            Value::String(s) => s.clone(),
634                            Value::Number(n) => n.to_string(),
635                            Value::Bool(b) => b.to_string(),
636                            _ => value.to_string(),
637                        };
638                        (key.clone(), value_str)
639                    })
640                    .collect();
641                request = request.form(&form_data);
642            }
643            s if s == mime::MULTIPART_FORM_DATA.as_ref() => {
644                // Build multipart form - reqwest automatically sets Content-Type with boundary
645                let mut form = reqwest::multipart::Form::new();
646
647                for (key, value) in body {
648                    // Check if this is a file field (object with "content" key containing data URI)
649                    if let Some(obj) = value.as_object()
650                        && let Some(content_value) = obj.get("content")
651                        && let Some(content_str) = content_value.as_str()
652                        && content_str.starts_with("data:")
653                    {
654                        // Parse the data URI
655                        let data_uri = parse_data_uri(content_str, key)?;
656
657                        // Get optional filename
658                        let filename = obj
659                            .get("filename")
660                            .and_then(|v| v.as_str())
661                            .unwrap_or("file")
662                            .to_string();
663
664                        // Build the file part
665                        let part = reqwest::multipart::Part::bytes(data_uri.bytes)
666                            .file_name(filename)
667                            .mime_str(&data_uri.mime_type)
668                            .map_err(|e| Error::Http(format!("Invalid MIME type: {e}")))?;
669
670                        form = form.part(key.clone(), part);
671                        continue;
672                    }
673
674                    // Not a file field - add as text part
675                    let text_value = match value {
676                        Value::String(s) => s.clone(),
677                        Value::Number(n) => n.to_string(),
678                        Value::Bool(b) => b.to_string(),
679                        _ => value.to_string(),
680                    };
681                    form = form.text(key.clone(), text_value);
682                }
683
684                request = request.multipart(form);
685            }
686            _ => {
687                // Set content type header for other content types
688                request = request.header(header::CONTENT_TYPE, &config.content_type);
689
690                // For other content types, try to serialize as JSON
691                let body_object =
692                    Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
693                let json_string = serde_json::to_string(&body_object)
694                    .map_err(|e| Error::Http(format!("Failed to serialize request body: {e}")))?;
695                request = request.body(json_string);
696            }
697        }
698
699        Ok(request)
700    }
701
702    /// Process the HTTP response with request details for better formatting
703    async fn process_response_with_request(
704        &self,
705        response: reqwest::Response,
706        method: &str,
707        url: &str,
708        request_body: &str,
709    ) -> Result<HttpResponse, Error> {
710        let status = response.status();
711
712        // Extract Content-Type header before consuming headers
713        let content_type = response
714            .headers()
715            .get(header::CONTENT_TYPE)
716            .and_then(|v| v.to_str().ok())
717            .map(|s| s.to_string());
718
719        // Check if response is binary based on content type
720        let is_binary_content = content_type
721            .as_ref()
722            .and_then(|ct| ct.parse::<mime::Mime>().ok())
723            .map(|mime_type| matches!(mime_type.type_(), mime::IMAGE | mime::AUDIO | mime::VIDEO))
724            .unwrap_or(false);
725
726        let headers = response
727            .headers()
728            .iter()
729            .map(|(name, value)| {
730                (
731                    name.to_string(),
732                    value.to_str().unwrap_or("<invalid>").to_string(),
733                )
734            })
735            .collect();
736
737        // Read response body based on content type
738        let (body, body_bytes) = if is_binary_content {
739            // For binary content, read as bytes
740            let bytes = response
741                .bytes()
742                .await
743                .map_err(|e| Error::Http(format!("Failed to read response body: {e}")))?;
744
745            // Store bytes and provide a descriptive text body
746            let body_text = format!(
747                "[Binary content: {} bytes, Content-Type: {}]",
748                bytes.len(),
749                content_type.as_ref().unwrap_or(&"unknown".to_string())
750            );
751
752            (body_text, Some(bytes.to_vec()))
753        } else {
754            // For text content, read as text
755            let text = response
756                .text()
757                .await
758                .map_err(|e| Error::Http(format!("Failed to read response body: {e}")))?;
759
760            (text, None)
761        };
762
763        let is_success = status.is_success();
764        let status_code = status.as_u16();
765        let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
766
767        // Add additional context for common error status codes
768        let enhanced_status_text = match status {
769            StatusCode::BAD_REQUEST => {
770                format!("{status_text} - Bad Request: Check request parameters")
771            }
772            StatusCode::UNAUTHORIZED => {
773                format!("{status_text} - Unauthorized: Authentication required")
774            }
775            StatusCode::FORBIDDEN => format!("{status_text} - Forbidden: Access denied"),
776            StatusCode::NOT_FOUND => {
777                format!("{status_text} - Not Found: Endpoint or resource does not exist")
778            }
779            StatusCode::METHOD_NOT_ALLOWED => format!(
780                "{} - Method Not Allowed: {} method not supported",
781                status_text,
782                method.to_uppercase()
783            ),
784            StatusCode::UNPROCESSABLE_ENTITY => {
785                format!("{status_text} - Unprocessable Entity: Request validation failed")
786            }
787            StatusCode::TOO_MANY_REQUESTS => {
788                format!("{status_text} - Too Many Requests: Rate limit exceeded")
789            }
790            StatusCode::INTERNAL_SERVER_ERROR => {
791                format!("{status_text} - Internal Server Error: Server encountered an error")
792            }
793            StatusCode::BAD_GATEWAY => {
794                format!("{status_text} - Bad Gateway: Upstream server error")
795            }
796            StatusCode::SERVICE_UNAVAILABLE => {
797                format!("{status_text} - Service Unavailable: Server temporarily unavailable")
798            }
799            StatusCode::GATEWAY_TIMEOUT => {
800                format!("{status_text} - Gateway Timeout: Upstream server timeout")
801            }
802            _ => status_text,
803        };
804
805        Ok(HttpResponse {
806            status_code,
807            status_text: enhanced_status_text,
808            headers,
809            content_type,
810            body,
811            body_bytes,
812            is_success,
813            request_method: method.to_string(),
814            request_url: url.to_string(),
815            request_body: request_body.to_string(),
816        })
817    }
818}
819
820impl Default for HttpClient {
821    fn default() -> Self {
822        Self::new()
823    }
824}
825
826/// HTTP response from an API call
827#[derive(Debug, Clone)]
828pub struct HttpResponse {
829    pub status_code: u16,
830    pub status_text: String,
831    pub headers: HashMap<String, String>,
832    pub content_type: Option<String>,
833    pub body: String,
834    pub body_bytes: Option<Vec<u8>>,
835    pub is_success: bool,
836    pub request_method: String,
837    pub request_url: String,
838    pub request_body: String,
839}
840
841impl HttpResponse {
842    /// Try to parse the response body as JSON
843    ///
844    /// # Errors
845    ///
846    /// Returns an error if the body is not valid JSON
847    pub fn json(&self) -> Result<Value, Error> {
848        serde_json::from_str(&self.body)
849            .map_err(|e| Error::Http(format!("Failed to parse response as JSON: {e}")))
850    }
851
852    /// Check if the response contains image content
853    ///
854    /// Uses the mime crate to properly parse and validate image content types.
855    #[must_use]
856    pub fn is_image(&self) -> bool {
857        self.content_type
858            .as_ref()
859            .and_then(|ct| ct.parse::<mime::Mime>().ok())
860            .map(|mime_type| mime_type.type_() == mime::IMAGE)
861            .unwrap_or(false)
862    }
863
864    /// Check if the response contains binary content (image, audio, or video)
865    ///
866    /// Uses the mime crate to properly parse and validate binary content types.
867    #[must_use]
868    pub fn is_binary(&self) -> bool {
869        self.content_type
870            .as_ref()
871            .and_then(|ct| ct.parse::<mime::Mime>().ok())
872            .map(|mime_type| matches!(mime_type.type_(), mime::IMAGE | mime::AUDIO | mime::VIDEO))
873            .unwrap_or(false)
874    }
875
876    /// Get a formatted response summary for MCP
877    #[must_use]
878    pub fn to_mcp_content(&self) -> String {
879        let method = if self.request_method.is_empty() {
880            None
881        } else {
882            Some(self.request_method.as_str())
883        };
884        let url = if self.request_url.is_empty() {
885            None
886        } else {
887            Some(self.request_url.as_str())
888        };
889        let body = if self.request_body.is_empty() {
890            None
891        } else {
892            Some(self.request_body.as_str())
893        };
894        self.to_mcp_content_with_request(method, url, body)
895    }
896
897    /// Get a formatted response summary for MCP with request details
898    pub fn to_mcp_content_with_request(
899        &self,
900        method: Option<&str>,
901        url: Option<&str>,
902        request_body: Option<&str>,
903    ) -> String {
904        let mut result = format!(
905            "HTTP {} {}\n\nStatus: {} {}\n",
906            if self.is_success { "✅" } else { "❌" },
907            if self.is_success { "Success" } else { "Error" },
908            self.status_code,
909            self.status_text
910        );
911
912        // Add request details if provided
913        if let (Some(method), Some(url)) = (method, url) {
914            result.push_str("\nRequest: ");
915            result.push_str(&method.to_uppercase());
916            result.push(' ');
917            result.push_str(url);
918            result.push('\n');
919
920            if let Some(body) = request_body
921                && !body.is_empty()
922                && body != "{}"
923            {
924                result.push_str("\nRequest Body:\n");
925                if let Ok(parsed) = serde_json::from_str::<Value>(body) {
926                    if let Ok(pretty) = serde_json::to_string_pretty(&parsed) {
927                        result.push_str(&pretty);
928                    } else {
929                        result.push_str(body);
930                    }
931                } else {
932                    result.push_str(body);
933                }
934                result.push('\n');
935            }
936        }
937
938        // Add important headers
939        if !self.headers.is_empty() {
940            result.push_str("\nHeaders:\n");
941            for (key, value) in &self.headers {
942                // Only show commonly useful headers
943                if [
944                    header::CONTENT_TYPE.as_str(),
945                    header::CONTENT_LENGTH.as_str(),
946                    header::LOCATION.as_str(),
947                    header::SET_COOKIE.as_str(),
948                ]
949                .iter()
950                .any(|&h| key.to_lowercase().contains(h))
951                {
952                    result.push_str("  ");
953                    result.push_str(key);
954                    result.push_str(": ");
955                    result.push_str(value);
956                    result.push('\n');
957                }
958            }
959        }
960
961        // Add body content
962        result.push_str("\nResponse Body:\n");
963        if self.body.is_empty() {
964            result.push_str("(empty)");
965        } else if let Ok(json_value) = self.json() {
966            // Pretty print JSON if possible
967            match serde_json::to_string_pretty(&json_value) {
968                Ok(pretty) => result.push_str(&pretty),
969                Err(_) => result.push_str(&self.body),
970            }
971        } else {
972            // Truncate very long responses
973            if self.body.len() > 2000 {
974                result.push_str(&self.body[..2000]);
975                result.push_str("\n... (");
976                result.push_str(&(self.body.len() - 2000).to_string());
977                result.push_str(" more characters)");
978            } else {
979                result.push_str(&self.body);
980            }
981        }
982
983        result
984    }
985}
986
987#[cfg(test)]
988mod tests {
989    use super::*;
990    use crate::tool_generator::ExtractedParameters;
991    use serde_json::json;
992    use std::collections::HashMap;
993
994    #[test]
995    fn test_with_base_url_validation() {
996        // Test valid URLs
997        let url = Url::parse("https://api.example.com").unwrap();
998        let client = HttpClient::new().with_base_url(url);
999        assert!(client.is_ok());
1000
1001        let url = Url::parse("http://localhost:8080").unwrap();
1002        let client = HttpClient::new().with_base_url(url);
1003        assert!(client.is_ok());
1004
1005        // Test invalid URLs - these will fail at parse time now
1006        assert!(Url::parse("not-a-url").is_err());
1007        assert!(Url::parse("").is_err());
1008
1009        // Test schemes that parse successfully
1010        let url = Url::parse("ftp://invalid-scheme.com").unwrap();
1011        let client = HttpClient::new().with_base_url(url);
1012        assert!(client.is_ok()); // url crate accepts ftp, our HttpClient should too
1013    }
1014
1015    #[test]
1016    fn test_build_url_with_base_url() {
1017        let base_url = Url::parse("https://api.example.com").unwrap();
1018        let client = HttpClient::new().with_base_url(base_url).unwrap();
1019
1020        let tool_metadata = crate::ToolMetadata {
1021            name: "test".to_string(),
1022            title: None,
1023            description: Some("test".to_string()),
1024            parameters: json!({}),
1025            output_schema: None,
1026            method: "GET".to_string(),
1027            path: "/pets/{id}".to_string(),
1028            security: None,
1029            parameter_mappings: std::collections::HashMap::new(),
1030        };
1031
1032        let mut path_params = HashMap::new();
1033        path_params.insert("id".to_string(), json!(123));
1034
1035        let extracted_params = ExtractedParameters {
1036            path: path_params,
1037            query: HashMap::new(),
1038            headers: HashMap::new(),
1039            cookies: HashMap::new(),
1040            body: HashMap::new(),
1041            config: crate::tool_generator::RequestConfig::default(),
1042        };
1043
1044        let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1045        assert_eq!(url.to_string(), "https://api.example.com/pets/123");
1046    }
1047
1048    #[test]
1049    fn test_build_url_with_base_url_containing_path() {
1050        let test_cases = vec![
1051            "https://api.example.com/api/v4",
1052            "https://api.example.com/api/v4/",
1053        ];
1054
1055        for base_url in test_cases {
1056            let base_url = Url::parse(base_url).unwrap();
1057            let client = HttpClient::new().with_base_url(base_url).unwrap();
1058
1059            let tool_metadata = crate::ToolMetadata {
1060                name: "test".to_string(),
1061                title: None,
1062                description: Some("test".to_string()),
1063                parameters: json!({}),
1064                output_schema: None,
1065                method: "GET".to_string(),
1066                path: "/pets/{id}".to_string(),
1067                security: None,
1068                parameter_mappings: std::collections::HashMap::new(),
1069            };
1070
1071            let mut path_params = HashMap::new();
1072            path_params.insert("id".to_string(), json!(123));
1073
1074            let extracted_params = ExtractedParameters {
1075                path: path_params,
1076                query: HashMap::new(),
1077                headers: HashMap::new(),
1078                cookies: HashMap::new(),
1079                body: HashMap::new(),
1080                config: crate::tool_generator::RequestConfig::default(),
1081            };
1082
1083            let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1084            assert_eq!(url.to_string(), "https://api.example.com/api/v4/pets/123");
1085        }
1086    }
1087
1088    #[test]
1089    fn test_build_url_without_base_url() {
1090        let client = HttpClient::new();
1091
1092        let tool_metadata = crate::ToolMetadata {
1093            name: "test".to_string(),
1094            title: None,
1095            description: Some("test".to_string()),
1096            parameters: json!({}),
1097            output_schema: None,
1098            method: "GET".to_string(),
1099            path: "https://api.example.com/pets/123".to_string(),
1100            security: None,
1101            parameter_mappings: std::collections::HashMap::new(),
1102        };
1103
1104        let extracted_params = ExtractedParameters {
1105            path: HashMap::new(),
1106            query: HashMap::new(),
1107            headers: HashMap::new(),
1108            cookies: HashMap::new(),
1109            body: HashMap::new(),
1110            config: crate::tool_generator::RequestConfig::default(),
1111        };
1112
1113        let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1114        assert_eq!(url.to_string(), "https://api.example.com/pets/123");
1115
1116        // Test error case: relative path without base URL
1117        let tool_metadata_relative = crate::ToolMetadata {
1118            name: "test".to_string(),
1119            title: None,
1120            description: Some("test".to_string()),
1121            parameters: json!({}),
1122            output_schema: None,
1123            method: "GET".to_string(),
1124            path: "/pets/123".to_string(),
1125            security: None,
1126            parameter_mappings: std::collections::HashMap::new(),
1127        };
1128
1129        let result = client.build_url(&tool_metadata_relative, &extracted_params);
1130        assert!(result.is_err());
1131        assert!(
1132            result
1133                .unwrap_err()
1134                .to_string()
1135                .contains("No base URL configured")
1136        );
1137    }
1138
1139    #[test]
1140    fn test_query_parameter_encoding_integration() {
1141        let base_url = Url::parse("https://api.example.com").unwrap();
1142        let client = HttpClient::new().with_base_url(base_url).unwrap();
1143
1144        let tool_metadata = crate::ToolMetadata {
1145            name: "test".to_string(),
1146            title: None,
1147            description: Some("test".to_string()),
1148            parameters: json!({}),
1149            output_schema: None,
1150            method: "GET".to_string(),
1151            path: "/search".to_string(),
1152            security: None,
1153            parameter_mappings: std::collections::HashMap::new(),
1154        };
1155
1156        // Test various query parameter values that need encoding
1157        let mut query_params = HashMap::new();
1158        query_params.insert(
1159            "q".to_string(),
1160            QueryParameter::new(json!("hello world"), true),
1161        ); // space
1162        query_params.insert(
1163            "category".to_string(),
1164            QueryParameter::new(json!("pets&dogs"), true),
1165        ); // ampersand
1166        query_params.insert(
1167            "special".to_string(),
1168            QueryParameter::new(json!("foo=bar"), true),
1169        ); // equals
1170        query_params.insert(
1171            "unicode".to_string(),
1172            QueryParameter::new(json!("café"), true),
1173        ); // unicode
1174        query_params.insert(
1175            "percent".to_string(),
1176            QueryParameter::new(json!("100%"), true),
1177        ); // percent
1178
1179        let extracted_params = ExtractedParameters {
1180            path: HashMap::new(),
1181            query: query_params,
1182            headers: HashMap::new(),
1183            cookies: HashMap::new(),
1184            body: HashMap::new(),
1185            config: crate::tool_generator::RequestConfig::default(),
1186        };
1187
1188        let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1189        HttpClient::add_query_parameters(&mut url, &extracted_params.query);
1190
1191        let url_string = url.to_string();
1192
1193        // Verify the URL contains properly encoded parameters
1194        // Note: url crate encodes spaces as + in query parameters (which is valid)
1195        assert!(url_string.contains("q=hello+world")); // space encoded as +
1196        assert!(url_string.contains("category=pets%26dogs")); // & encoded as %26
1197        assert!(url_string.contains("special=foo%3Dbar")); // = encoded as %3D
1198        assert!(url_string.contains("unicode=caf%C3%A9")); // é encoded as %C3%A9
1199        assert!(url_string.contains("percent=100%25")); // % encoded as %25
1200    }
1201
1202    #[test]
1203    fn test_array_query_parameters() {
1204        let base_url = Url::parse("https://api.example.com").unwrap();
1205        let client = HttpClient::new().with_base_url(base_url).unwrap();
1206
1207        let tool_metadata = crate::ToolMetadata {
1208            name: "test".to_string(),
1209            title: None,
1210            description: Some("test".to_string()),
1211            parameters: json!({}),
1212            output_schema: None,
1213            method: "GET".to_string(),
1214            path: "/search".to_string(),
1215            security: None,
1216            parameter_mappings: std::collections::HashMap::new(),
1217        };
1218
1219        let mut query_params = HashMap::new();
1220        query_params.insert(
1221            "status".to_string(),
1222            QueryParameter::new(json!(["available", "pending"]), true),
1223        );
1224        query_params.insert(
1225            "tags".to_string(),
1226            QueryParameter::new(json!(["red & blue", "fast=car"]), true),
1227        );
1228
1229        let extracted_params = ExtractedParameters {
1230            path: HashMap::new(),
1231            query: query_params,
1232            headers: HashMap::new(),
1233            cookies: HashMap::new(),
1234            body: HashMap::new(),
1235            config: crate::tool_generator::RequestConfig::default(),
1236        };
1237
1238        let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1239        HttpClient::add_query_parameters(&mut url, &extracted_params.query);
1240
1241        let url_string = url.to_string();
1242
1243        // Verify array parameters are added multiple times with proper encoding
1244        assert!(url_string.contains("status=available"));
1245        assert!(url_string.contains("status=pending"));
1246        assert!(url_string.contains("tags=red+%26+blue")); // "red & blue" encoded (spaces as +)
1247        assert!(url_string.contains("tags=fast%3Dcar")); // "fast=car" encoded
1248    }
1249
1250    #[test]
1251    fn test_path_parameter_substitution() {
1252        let base_url = Url::parse("https://api.example.com").unwrap();
1253        let client = HttpClient::new().with_base_url(base_url).unwrap();
1254
1255        let tool_metadata = crate::ToolMetadata {
1256            name: "test".to_string(),
1257            title: None,
1258            description: Some("test".to_string()),
1259            parameters: json!({}),
1260            output_schema: None,
1261            method: "GET".to_string(),
1262            path: "/users/{userId}/pets/{petId}".to_string(),
1263            security: None,
1264            parameter_mappings: std::collections::HashMap::new(),
1265        };
1266
1267        let mut path_params = HashMap::new();
1268        path_params.insert("userId".to_string(), json!(42));
1269        path_params.insert("petId".to_string(), json!("special-pet-123"));
1270
1271        let extracted_params = ExtractedParameters {
1272            path: path_params,
1273            query: HashMap::new(),
1274            headers: HashMap::new(),
1275            cookies: HashMap::new(),
1276            body: HashMap::new(),
1277            config: crate::tool_generator::RequestConfig::default(),
1278        };
1279
1280        let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1281        assert_eq!(
1282            url.to_string(),
1283            "https://api.example.com/users/42/pets/special-pet-123"
1284        );
1285    }
1286
1287    #[test]
1288    fn test_url_join_edge_cases() {
1289        // Test trailing slash handling
1290        let base_url1 = Url::parse("https://api.example.com/").unwrap();
1291        let client1 = HttpClient::new().with_base_url(base_url1).unwrap();
1292
1293        let base_url2 = Url::parse("https://api.example.com").unwrap();
1294        let client2 = HttpClient::new().with_base_url(base_url2).unwrap();
1295
1296        let tool_metadata = crate::ToolMetadata {
1297            name: "test".to_string(),
1298            title: None,
1299            description: Some("test".to_string()),
1300            parameters: json!({}),
1301            output_schema: None,
1302            method: "GET".to_string(),
1303            path: "/pets".to_string(),
1304            security: None,
1305            parameter_mappings: std::collections::HashMap::new(),
1306        };
1307
1308        let extracted_params = ExtractedParameters {
1309            path: HashMap::new(),
1310            query: HashMap::new(),
1311            headers: HashMap::new(),
1312            cookies: HashMap::new(),
1313            body: HashMap::new(),
1314            config: crate::tool_generator::RequestConfig::default(),
1315        };
1316
1317        let url1 = client1
1318            .build_url(&tool_metadata, &extracted_params)
1319            .unwrap();
1320        let url2 = client2
1321            .build_url(&tool_metadata, &extracted_params)
1322            .unwrap();
1323
1324        // Both should produce the same normalized URL
1325        assert_eq!(url1.to_string(), "https://api.example.com/pets");
1326        assert_eq!(url2.to_string(), "https://api.example.com/pets");
1327    }
1328
1329    #[test]
1330    fn test_explode_array_parameters() {
1331        let base_url = Url::parse("https://api.example.com").unwrap();
1332        let client = HttpClient::new().with_base_url(base_url).unwrap();
1333
1334        let tool_metadata = crate::ToolMetadata {
1335            name: "test".to_string(),
1336            title: None,
1337            description: Some("test".to_string()),
1338            parameters: json!({}),
1339            output_schema: None,
1340            method: "GET".to_string(),
1341            path: "/search".to_string(),
1342            security: None,
1343            parameter_mappings: std::collections::HashMap::new(),
1344        };
1345
1346        // Test explode=true (should generate separate parameters)
1347        let mut query_params_exploded = HashMap::new();
1348        query_params_exploded.insert(
1349            "include".to_string(),
1350            QueryParameter::new(json!(["asset", "scenes"]), true),
1351        );
1352
1353        let extracted_params_exploded = ExtractedParameters {
1354            path: HashMap::new(),
1355            query: query_params_exploded,
1356            headers: HashMap::new(),
1357            cookies: HashMap::new(),
1358            body: HashMap::new(),
1359            config: crate::tool_generator::RequestConfig::default(),
1360        };
1361
1362        let mut url_exploded = client
1363            .build_url(&tool_metadata, &extracted_params_exploded)
1364            .unwrap();
1365        HttpClient::add_query_parameters(&mut url_exploded, &extracted_params_exploded.query);
1366        let url_exploded_string = url_exploded.to_string();
1367
1368        // Test explode=false (should generate comma-separated values)
1369        let mut query_params_not_exploded = HashMap::new();
1370        query_params_not_exploded.insert(
1371            "include".to_string(),
1372            QueryParameter::new(json!(["asset", "scenes"]), false),
1373        );
1374
1375        let extracted_params_not_exploded = ExtractedParameters {
1376            path: HashMap::new(),
1377            query: query_params_not_exploded,
1378            headers: HashMap::new(),
1379            cookies: HashMap::new(),
1380            body: HashMap::new(),
1381            config: crate::tool_generator::RequestConfig::default(),
1382        };
1383
1384        let mut url_not_exploded = client
1385            .build_url(&tool_metadata, &extracted_params_not_exploded)
1386            .unwrap();
1387        HttpClient::add_query_parameters(
1388            &mut url_not_exploded,
1389            &extracted_params_not_exploded.query,
1390        );
1391        let url_not_exploded_string = url_not_exploded.to_string();
1392
1393        // Verify explode=true generates separate parameters
1394        assert!(url_exploded_string.contains("include=asset"));
1395        assert!(url_exploded_string.contains("include=scenes"));
1396
1397        // Verify explode=false generates comma-separated values
1398        assert!(url_not_exploded_string.contains("include=asset%2Cscenes")); // comma is URL-encoded as %2C
1399
1400        // Make sure they're different
1401        assert_ne!(url_exploded_string, url_not_exploded_string);
1402
1403        println!("Exploded URL: {url_exploded_string}");
1404        println!("Non-exploded URL: {url_not_exploded_string}");
1405    }
1406
1407    #[test]
1408    fn test_is_image_helper() {
1409        // Test various image content types
1410        let response_png = HttpResponse {
1411            status_code: 200,
1412            status_text: "OK".to_string(),
1413            headers: HashMap::new(),
1414            content_type: Some("image/png".to_string()),
1415            body: String::new(),
1416            body_bytes: None,
1417            is_success: true,
1418            request_method: "GET".to_string(),
1419            request_url: "http://example.com".to_string(),
1420            request_body: String::new(),
1421        };
1422        assert!(response_png.is_image());
1423
1424        let response_jpeg = HttpResponse {
1425            content_type: Some("image/jpeg".to_string()),
1426            ..response_png.clone()
1427        };
1428        assert!(response_jpeg.is_image());
1429
1430        // Test with charset parameter
1431        let response_with_charset = HttpResponse {
1432            content_type: Some("image/png; charset=utf-8".to_string()),
1433            ..response_png.clone()
1434        };
1435        assert!(response_with_charset.is_image());
1436
1437        // Test non-image content types
1438        let response_json = HttpResponse {
1439            content_type: Some("application/json".to_string()),
1440            ..response_png.clone()
1441        };
1442        assert!(!response_json.is_image());
1443
1444        let response_text = HttpResponse {
1445            content_type: Some("text/plain".to_string()),
1446            ..response_png.clone()
1447        };
1448        assert!(!response_text.is_image());
1449
1450        // Test with no content type
1451        let response_no_ct = HttpResponse {
1452            content_type: None,
1453            ..response_png
1454        };
1455        assert!(!response_no_ct.is_image());
1456    }
1457
1458    #[test]
1459    fn test_is_binary_helper() {
1460        let base_response = HttpResponse {
1461            status_code: 200,
1462            status_text: "OK".to_string(),
1463            headers: HashMap::new(),
1464            content_type: None,
1465            body: String::new(),
1466            body_bytes: None,
1467            is_success: true,
1468            request_method: "GET".to_string(),
1469            request_url: "http://example.com".to_string(),
1470            request_body: String::new(),
1471        };
1472
1473        // Test image types
1474        let response_image = HttpResponse {
1475            content_type: Some("image/png".to_string()),
1476            ..base_response.clone()
1477        };
1478        assert!(response_image.is_binary());
1479
1480        // Test audio types
1481        let response_audio = HttpResponse {
1482            content_type: Some("audio/mpeg".to_string()),
1483            ..base_response.clone()
1484        };
1485        assert!(response_audio.is_binary());
1486
1487        // Test video types
1488        let response_video = HttpResponse {
1489            content_type: Some("video/mp4".to_string()),
1490            ..base_response.clone()
1491        };
1492        assert!(response_video.is_binary());
1493
1494        // Test non-binary types
1495        let response_json = HttpResponse {
1496            content_type: Some("application/json".to_string()),
1497            ..base_response.clone()
1498        };
1499        assert!(!response_json.is_binary());
1500
1501        // Test with no content type
1502        assert!(!base_response.is_binary());
1503    }
1504
1505    #[test]
1506    fn test_parse_data_uri_valid_png() {
1507        // "hello" encoded as base64
1508        let uri = "data:image/png;base64,aGVsbG8=";
1509        let result = super::parse_data_uri(uri, "test_field").unwrap();
1510
1511        assert_eq!(result.mime_type, "image/png");
1512        assert_eq!(result.bytes, b"hello");
1513    }
1514
1515    #[test]
1516    fn test_parse_data_uri_valid_jpeg() {
1517        // "world" encoded as base64
1518        let uri = "data:image/jpeg;base64,d29ybGQ=";
1519        let result = super::parse_data_uri(uri, "image").unwrap();
1520
1521        assert_eq!(result.mime_type, "image/jpeg");
1522        assert_eq!(result.bytes, b"world");
1523    }
1524
1525    #[test]
1526    fn test_parse_data_uri_valid_application_json() {
1527        // "{}" encoded as base64
1528        let uri = "data:application/json;base64,e30=";
1529        let result = super::parse_data_uri(uri, "data").unwrap();
1530
1531        assert_eq!(result.mime_type, "application/json");
1532        assert_eq!(result.bytes, b"{}");
1533    }
1534
1535    #[test]
1536    fn test_parse_data_uri_missing_data_prefix() {
1537        let uri = "image/png;base64,aGVsbG8=";
1538        let result = super::parse_data_uri(uri, "test_field");
1539
1540        assert!(result.is_err());
1541        let err = result.unwrap_err().to_string();
1542        assert!(err.contains("Invalid data URI format"));
1543        assert!(err.contains("test_field"));
1544        assert!(err.contains("expected 'data:<mime>;base64,<content>'"));
1545    }
1546
1547    #[test]
1548    fn test_parse_data_uri_missing_semicolon() {
1549        let uri = "data:image/png,aGVsbG8=";
1550        let result = super::parse_data_uri(uri, "my_image");
1551
1552        assert!(result.is_err());
1553        let err = result.unwrap_err().to_string();
1554        assert!(err.contains("Invalid data URI format"));
1555        assert!(err.contains("my_image"));
1556    }
1557
1558    #[test]
1559    fn test_parse_data_uri_missing_comma() {
1560        let uri = "data:image/png;base64aGVsbG8=";
1561        let result = super::parse_data_uri(uri, "field");
1562
1563        assert!(result.is_err());
1564        let err = result.unwrap_err().to_string();
1565        assert!(err.contains("Invalid data URI format"));
1566    }
1567
1568    #[test]
1569    fn test_parse_data_uri_unsupported_encoding() {
1570        let uri = "data:image/png;ascii,hello";
1571        let result = super::parse_data_uri(uri, "test_field");
1572
1573        assert!(result.is_err());
1574        let err = result.unwrap_err().to_string();
1575        assert!(err.contains("Unsupported encoding 'ascii'"));
1576        assert!(err.contains("test_field"));
1577        assert!(err.contains("only base64 is supported"));
1578    }
1579
1580    #[test]
1581    fn test_parse_data_uri_unsupported_encoding_utf8() {
1582        let uri = "data:text/plain;utf-8,hello world";
1583        let result = super::parse_data_uri(uri, "content");
1584
1585        assert!(result.is_err());
1586        let err = result.unwrap_err().to_string();
1587        assert!(err.contains("Unsupported encoding 'utf-8'"));
1588        assert!(err.contains("content"));
1589    }
1590
1591    #[test]
1592    fn test_parse_data_uri_invalid_base64() {
1593        // Invalid base64: contains characters that aren't valid base64
1594        let uri = "data:image/png;base64,not-valid-base64!!!";
1595        let result = super::parse_data_uri(uri, "bad_image");
1596
1597        assert!(result.is_err());
1598        let err = result.unwrap_err().to_string();
1599        assert!(err.contains("Invalid base64 content"));
1600        assert!(err.contains("bad_image"));
1601    }
1602
1603    #[test]
1604    fn test_parse_data_uri_empty_content() {
1605        // Empty base64 content is valid and decodes to empty bytes
1606        let uri = "data:application/octet-stream;base64,";
1607        let result = super::parse_data_uri(uri, "empty").unwrap();
1608
1609        assert_eq!(result.mime_type, "application/octet-stream");
1610        assert!(result.bytes.is_empty());
1611    }
1612
1613    #[test]
1614    fn test_parse_data_uri_complex_mime_type() {
1615        // MIME type with subtype
1616        let uri = "data:application/vnd.api+json;base64,e30=";
1617        let result = super::parse_data_uri(uri, "api_data").unwrap();
1618
1619        assert_eq!(result.mime_type, "application/vnd.api+json");
1620        assert_eq!(result.bytes, b"{}");
1621    }
1622
1623    #[test]
1624    fn test_parse_data_uri_mime_type_with_parameters() {
1625        // MIME type with charset parameter
1626        let uri = "data:text/plain;charset=utf-8;base64,SGVsbG8gV29ybGQ=";
1627        let result = super::parse_data_uri(uri, "text_field").unwrap();
1628
1629        assert_eq!(result.mime_type, "text/plain;charset=utf-8");
1630        assert_eq!(result.bytes, b"Hello World");
1631    }
1632
1633    #[test]
1634    fn test_parse_data_uri_mime_type_with_multiple_parameters() {
1635        // MIME type with multiple parameters
1636        let uri = "data:text/html;charset=utf-8;boundary=something;base64,PGh0bWw+";
1637        let result = super::parse_data_uri(uri, "html_field").unwrap();
1638
1639        assert_eq!(
1640            result.mime_type,
1641            "text/html;charset=utf-8;boundary=something"
1642        );
1643        assert_eq!(result.bytes, b"<html>");
1644    }
1645
1646    #[test]
1647    fn test_parse_data_uri_empty_mime_type() {
1648        // Empty MIME type should be rejected
1649        let uri = "data:;base64,SGVsbG8=";
1650        let result = super::parse_data_uri(uri, "field");
1651
1652        assert!(result.is_err());
1653        let err = result.unwrap_err().to_string();
1654        assert!(err.contains("MIME type cannot be empty"));
1655    }
1656
1657    #[test]
1658    fn test_parse_data_uri_empty_string() {
1659        let result = super::parse_data_uri("", "field");
1660
1661        assert!(result.is_err());
1662        let err = result.unwrap_err().to_string();
1663        assert!(err.contains("Invalid data URI format"));
1664    }
1665
1666    #[test]
1667    fn test_parse_data_uri_just_data_prefix() {
1668        let result = super::parse_data_uri("data:", "field");
1669
1670        assert!(result.is_err());
1671        let err = result.unwrap_err().to_string();
1672        assert!(err.contains("Invalid data URI format"));
1673    }
1674
1675    // ==================== Multipart Form Building Tests ====================
1676    //
1677    // Note: The `add_request_body` function modifies a `reqwest::RequestBuilder`
1678    // which is an opaque type. We cannot inspect the actual multipart form content
1679    // without sending the request. These tests verify:
1680    // 1. Error handling for invalid inputs (e.g., invalid data URIs)
1681    // 2. Successful building for valid inputs (returns Ok)
1682    //
1683    // Full integration testing of multipart uploads would require a mock HTTP
1684    // server, which is beyond the scope of unit tests.
1685
1686    #[test]
1687    fn test_add_request_body_multipart_with_valid_file() {
1688        let client = HttpClient::new();
1689        let request = client.client.post("http://example.com/upload");
1690
1691        let mut body = HashMap::new();
1692        // Valid file with data URI
1693        body.insert(
1694            "file".to_string(),
1695            json!({
1696                "content": "data:image/png;base64,iVBORw0KGgo=",
1697                "filename": "test.png"
1698            }),
1699        );
1700        // Text field
1701        body.insert("description".to_string(), json!("Test file upload"));
1702
1703        let config = crate::tool_generator::RequestConfig {
1704            timeout_seconds: 30,
1705            content_type: mime::MULTIPART_FORM_DATA.to_string(),
1706        };
1707
1708        let result = HttpClient::add_request_body(request, &body, &config);
1709        assert!(
1710            result.is_ok(),
1711            "Should successfully build multipart form with valid file"
1712        );
1713    }
1714
1715    #[test]
1716    fn test_add_request_body_multipart_with_invalid_data_uri() {
1717        let client = HttpClient::new();
1718        let request = client.client.post("http://example.com/upload");
1719
1720        let mut body = HashMap::new();
1721        // Invalid data URI (missing base64 marker)
1722        body.insert(
1723            "file".to_string(),
1724            json!({
1725                "content": "data:image/png,notbase64",
1726                "filename": "test.png"
1727            }),
1728        );
1729
1730        let config = crate::tool_generator::RequestConfig {
1731            timeout_seconds: 30,
1732            content_type: mime::MULTIPART_FORM_DATA.to_string(),
1733        };
1734
1735        let result = HttpClient::add_request_body(request, &body, &config);
1736        assert!(result.is_err(), "Should fail with invalid data URI");
1737        let err = result.unwrap_err().to_string();
1738        assert!(
1739            err.contains("Invalid data URI format"),
1740            "Error should mention invalid format"
1741        );
1742    }
1743
1744    #[test]
1745    fn test_add_request_body_multipart_with_invalid_base64() {
1746        let client = HttpClient::new();
1747        let request = client.client.post("http://example.com/upload");
1748
1749        let mut body = HashMap::new();
1750        // Invalid base64 content
1751        body.insert(
1752            "file".to_string(),
1753            json!({
1754                "content": "data:image/png;base64,!!!invalid!!!",
1755                "filename": "test.png"
1756            }),
1757        );
1758
1759        let config = crate::tool_generator::RequestConfig {
1760            timeout_seconds: 30,
1761            content_type: mime::MULTIPART_FORM_DATA.to_string(),
1762        };
1763
1764        let result = HttpClient::add_request_body(request, &body, &config);
1765        assert!(result.is_err(), "Should fail with invalid base64");
1766        let err = result.unwrap_err().to_string();
1767        assert!(
1768            err.contains("Invalid base64 content"),
1769            "Error should mention invalid base64"
1770        );
1771    }
1772
1773    #[test]
1774    fn test_add_request_body_multipart_text_only() {
1775        let client = HttpClient::new();
1776        let request = client.client.post("http://example.com/upload");
1777
1778        let mut body = HashMap::new();
1779        body.insert("field1".to_string(), json!("text value"));
1780        body.insert("field2".to_string(), json!(123));
1781        body.insert("field3".to_string(), json!(true));
1782
1783        let config = crate::tool_generator::RequestConfig {
1784            timeout_seconds: 30,
1785            content_type: mime::MULTIPART_FORM_DATA.to_string(),
1786        };
1787
1788        let result = HttpClient::add_request_body(request, &body, &config);
1789        assert!(
1790            result.is_ok(),
1791            "Should successfully build multipart form with text-only fields"
1792        );
1793    }
1794
1795    #[test]
1796    fn test_add_request_body_multipart_mixed_content() {
1797        let client = HttpClient::new();
1798        let request = client.client.post("http://example.com/upload");
1799
1800        let mut body = HashMap::new();
1801        // File field
1802        body.insert(
1803            "image".to_string(),
1804            json!({
1805                "content": "data:image/jpeg;base64,/9j/4AAQ",
1806                "filename": "photo.jpg"
1807            }),
1808        );
1809        // Text fields
1810        body.insert("title".to_string(), json!("My Photo"));
1811        body.insert("tags".to_string(), json!(["nature", "sunset"]));
1812
1813        let config = crate::tool_generator::RequestConfig {
1814            timeout_seconds: 30,
1815            content_type: mime::MULTIPART_FORM_DATA.to_string(),
1816        };
1817
1818        let result = HttpClient::add_request_body(request, &body, &config);
1819        assert!(result.is_ok(), "Should handle mixed file and text content");
1820    }
1821
1822    #[test]
1823    fn test_add_request_body_multipart_without_filename() {
1824        let client = HttpClient::new();
1825        let request = client.client.post("http://example.com/upload");
1826
1827        let mut body = HashMap::new();
1828        // File without explicit filename (should default to "file")
1829        body.insert(
1830            "upload".to_string(),
1831            json!({
1832                "content": "data:application/pdf;base64,JVBERi0="
1833            }),
1834        );
1835
1836        let config = crate::tool_generator::RequestConfig {
1837            timeout_seconds: 30,
1838            content_type: mime::MULTIPART_FORM_DATA.to_string(),
1839        };
1840
1841        let result = HttpClient::add_request_body(request, &body, &config);
1842        assert!(
1843            result.is_ok(),
1844            "Should handle file upload without explicit filename"
1845        );
1846    }
1847
1848    #[test]
1849    fn test_add_request_body_json() {
1850        let client = HttpClient::new();
1851        let request = client.client.post("http://example.com/api");
1852
1853        let mut body = HashMap::new();
1854        body.insert("name".to_string(), json!("test"));
1855        body.insert("value".to_string(), json!(42));
1856
1857        let config = crate::tool_generator::RequestConfig {
1858            timeout_seconds: 30,
1859            content_type: mime::APPLICATION_JSON.to_string(),
1860        };
1861
1862        let result = HttpClient::add_request_body(request, &body, &config);
1863        assert!(result.is_ok(), "Should build JSON body");
1864    }
1865
1866    #[test]
1867    fn test_add_request_body_form_urlencoded() {
1868        let client = HttpClient::new();
1869        let request = client.client.post("http://example.com/form");
1870
1871        let mut body = HashMap::new();
1872        body.insert("username".to_string(), json!("user"));
1873        body.insert("password".to_string(), json!("secret"));
1874
1875        let config = crate::tool_generator::RequestConfig {
1876            timeout_seconds: 30,
1877            content_type: mime::APPLICATION_WWW_FORM_URLENCODED.to_string(),
1878        };
1879
1880        let result = HttpClient::add_request_body(request, &body, &config);
1881        assert!(result.is_ok(), "Should build form-urlencoded body");
1882    }
1883
1884    #[test]
1885    fn test_add_request_body_empty() {
1886        let client = HttpClient::new();
1887        let request = client.client.post("http://example.com/api");
1888
1889        let body = HashMap::new();
1890
1891        let config = crate::tool_generator::RequestConfig::default();
1892
1893        let result = HttpClient::add_request_body(request, &body, &config);
1894        assert!(result.is_ok(), "Should handle empty body");
1895    }
1896}