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