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        // Render a scalar JSON value as its bare query-string form. Strings are
540        // used verbatim; everything else falls back to its JSON rendering.
541        fn scalar_to_string(value: &Value) -> String {
542            match value {
543                Value::String(s) => s.clone(),
544                Value::Number(n) => n.to_string(),
545                Value::Bool(b) => b.to_string(),
546                other => other.to_string(),
547            }
548        }
549
550        {
551            let mut query_pairs = url.query_pairs_mut();
552            for (key, query_param) in query_params {
553                match &query_param.value {
554                    Value::Array(arr) => {
555                        if query_param.explode {
556                            // explode=true: emit one query pair per array item.
557                            for item in arr {
558                                query_pairs.append_pair(key, &scalar_to_string(item));
559                            }
560                        } else {
561                            // explode=false: join the array items with commas.
562                            let comma_separated = arr
563                                .iter()
564                                .map(scalar_to_string)
565                                .collect::<Vec<_>>()
566                                .join(",");
567                            query_pairs.append_pair(key, &comma_separated);
568                        }
569                    }
570                    Value::Object(map) => {
571                        // OpenAPI `style: deepObject`: expand the object to one
572                        // `key[property]=value` pair per entry, joining
573                        // array-valued properties with commas exactly as the
574                        // array case above. Without this an object value would be
575                        // serialized as an opaque JSON blob the server cannot read.
576                        for (property, property_value) in map {
577                            let nested_key = format!("{key}[{property}]");
578                            let value_str = match property_value {
579                                Value::Array(items) => items
580                                    .iter()
581                                    .map(scalar_to_string)
582                                    .collect::<Vec<_>>()
583                                    .join(","),
584                                scalar => scalar_to_string(scalar),
585                            };
586                            query_pairs.append_pair(&nested_key, &value_str);
587                        }
588                    }
589                    scalar => {
590                        query_pairs.append_pair(key, &scalar_to_string(scalar));
591                    }
592                }
593            }
594        }
595    }
596
597    /// Add headers to the request from HeaderMap
598    fn add_headers_from_map(mut request: RequestBuilder, headers: &HeaderMap) -> RequestBuilder {
599        for (key, value) in headers {
600            // HeaderName and HeaderValue are already validated, pass them directly to reqwest
601            request = request.header(key, value);
602        }
603        request
604    }
605
606    /// Add headers to the request
607    fn add_headers(
608        mut request: RequestBuilder,
609        headers: &HashMap<String, Value>,
610    ) -> RequestBuilder {
611        for (key, value) in headers {
612            let value_str = match value {
613                Value::String(s) => s.clone(),
614                Value::Number(n) => n.to_string(),
615                Value::Bool(b) => b.to_string(),
616                _ => value.to_string(),
617            };
618            request = request.header(key, value_str);
619        }
620        request
621    }
622
623    /// Add cookies to the request
624    fn add_cookies(
625        mut request: RequestBuilder,
626        cookies: &HashMap<String, Value>,
627    ) -> RequestBuilder {
628        if !cookies.is_empty() {
629            let cookie_header = cookies
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                    format!("{key}={value_str}")
639                })
640                .collect::<Vec<_>>()
641                .join("; ");
642
643            request = request.header(header::COOKIE, cookie_header);
644        }
645        request
646    }
647
648    /// Add request body to the request
649    fn add_request_body(
650        mut request: RequestBuilder,
651        body: &HashMap<String, Value>,
652        config: &crate::tool_generator::RequestConfig,
653    ) -> Result<RequestBuilder, Error> {
654        if body.is_empty() {
655            return Ok(request);
656        }
657
658        // Handle different content types
659        match config.content_type.as_str() {
660            s if s == mime::APPLICATION_JSON.as_ref() => {
661                // Set content type header for JSON
662                request = request.header(header::CONTENT_TYPE, &config.content_type);
663
664                // For JSON content type, serialize the body
665                if body.len() == 1 && body.contains_key("request_body") {
666                    // Use the request_body directly if it's the only parameter
667                    let body_value = &body["request_body"];
668                    let json_string = serde_json::to_string(body_value).map_err(|e| {
669                        Error::Http(format!("Failed to serialize request body: {e}"))
670                    })?;
671                    request = request.body(json_string);
672                } else {
673                    // Create JSON object from all body parameters
674                    let body_object =
675                        Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
676                    let json_string = serde_json::to_string(&body_object).map_err(|e| {
677                        Error::Http(format!("Failed to serialize request body: {e}"))
678                    })?;
679                    request = request.body(json_string);
680                }
681            }
682            s if s == mime::APPLICATION_WWW_FORM_URLENCODED.as_ref() => {
683                // Set content type header for form-urlencoded
684                request = request.header(header::CONTENT_TYPE, &config.content_type);
685
686                // Handle form data
687                let form_data: Vec<(String, String)> = body
688                    .iter()
689                    .map(|(key, value)| {
690                        let value_str = match value {
691                            Value::String(s) => s.clone(),
692                            Value::Number(n) => n.to_string(),
693                            Value::Bool(b) => b.to_string(),
694                            _ => value.to_string(),
695                        };
696                        (key.clone(), value_str)
697                    })
698                    .collect();
699                request = request.form(&form_data);
700            }
701            s if s == mime::MULTIPART_FORM_DATA.as_ref() => {
702                // Build multipart form - reqwest automatically sets Content-Type with boundary
703                let mut form = reqwest::multipart::Form::new();
704
705                for (key, value) in body {
706                    // Check if this is a file field (object with "content" key containing data URI)
707                    if let Some(obj) = value.as_object()
708                        && let Some(content_value) = obj.get("content")
709                        && let Some(content_str) = content_value.as_str()
710                        && content_str.starts_with("data:")
711                    {
712                        // Parse the data URI
713                        let data_uri = parse_data_uri(content_str, key)?;
714
715                        // Get optional filename
716                        let filename = obj
717                            .get("filename")
718                            .and_then(|v| v.as_str())
719                            .unwrap_or("file")
720                            .to_string();
721
722                        // Build the file part
723                        let part = reqwest::multipart::Part::bytes(data_uri.bytes)
724                            .file_name(filename)
725                            .mime_str(&data_uri.mime_type)
726                            .map_err(|e| Error::Http(format!("Invalid MIME type: {e}")))?;
727
728                        form = form.part(key.clone(), part);
729                        continue;
730                    }
731
732                    // Not a file field - add as text part
733                    let text_value = match value {
734                        Value::String(s) => s.clone(),
735                        Value::Number(n) => n.to_string(),
736                        Value::Bool(b) => b.to_string(),
737                        _ => value.to_string(),
738                    };
739                    form = form.text(key.clone(), text_value);
740                }
741
742                request = request.multipart(form);
743            }
744            _ => {
745                // Set content type header for other content types
746                request = request.header(header::CONTENT_TYPE, &config.content_type);
747
748                // For other content types, try to serialize as JSON
749                let body_object =
750                    Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
751                let json_string = serde_json::to_string(&body_object)
752                    .map_err(|e| Error::Http(format!("Failed to serialize request body: {e}")))?;
753                request = request.body(json_string);
754            }
755        }
756
757        Ok(request)
758    }
759
760    /// Process the HTTP response with request details for better formatting
761    async fn process_response_with_request(
762        &self,
763        response: reqwest::Response,
764        method: &str,
765        url: &str,
766        request_body: &str,
767    ) -> Result<HttpResponse, Error> {
768        let status = response.status();
769
770        // Extract Content-Type header before consuming headers
771        let content_type = response
772            .headers()
773            .get(header::CONTENT_TYPE)
774            .and_then(|v| v.to_str().ok())
775            .map(|s| s.to_string());
776
777        // Check if response is binary based on content type
778        let is_binary_content = content_type
779            .as_ref()
780            .and_then(|ct| ct.parse::<mime::Mime>().ok())
781            .map(|mime_type| matches!(mime_type.type_(), mime::IMAGE | mime::AUDIO | mime::VIDEO))
782            .unwrap_or(false);
783
784        let headers = response
785            .headers()
786            .iter()
787            .map(|(name, value)| {
788                (
789                    name.to_string(),
790                    value.to_str().unwrap_or("<invalid>").to_string(),
791                )
792            })
793            .collect();
794
795        // Read response body based on content type
796        let (body, body_bytes) = if is_binary_content {
797            // For binary content, read as bytes
798            let bytes = response
799                .bytes()
800                .await
801                .map_err(|e| Error::Http(format!("Failed to read response body: {e}")))?;
802
803            // Store bytes and provide a descriptive text body
804            let body_text = format!(
805                "[Binary content: {} bytes, Content-Type: {}]",
806                bytes.len(),
807                content_type.as_ref().unwrap_or(&"unknown".to_string())
808            );
809
810            (body_text, Some(bytes.to_vec()))
811        } else {
812            // For text content, read as text
813            let text = response
814                .text()
815                .await
816                .map_err(|e| Error::Http(format!("Failed to read response body: {e}")))?;
817
818            (text, None)
819        };
820
821        let is_success = status.is_success();
822        let status_code = status.as_u16();
823        let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
824
825        // Add additional context for common error status codes
826        let enhanced_status_text = match status {
827            StatusCode::BAD_REQUEST => {
828                format!("{status_text} - Bad Request: Check request parameters")
829            }
830            StatusCode::UNAUTHORIZED => {
831                format!("{status_text} - Unauthorized: Authentication required")
832            }
833            StatusCode::FORBIDDEN => format!("{status_text} - Forbidden: Access denied"),
834            StatusCode::NOT_FOUND => {
835                format!("{status_text} - Not Found: Endpoint or resource does not exist")
836            }
837            StatusCode::METHOD_NOT_ALLOWED => format!(
838                "{} - Method Not Allowed: {} method not supported",
839                status_text,
840                method.to_uppercase()
841            ),
842            StatusCode::UNPROCESSABLE_ENTITY => {
843                format!("{status_text} - Unprocessable Entity: Request validation failed")
844            }
845            StatusCode::TOO_MANY_REQUESTS => {
846                format!("{status_text} - Too Many Requests: Rate limit exceeded")
847            }
848            StatusCode::INTERNAL_SERVER_ERROR => {
849                format!("{status_text} - Internal Server Error: Server encountered an error")
850            }
851            StatusCode::BAD_GATEWAY => {
852                format!("{status_text} - Bad Gateway: Upstream server error")
853            }
854            StatusCode::SERVICE_UNAVAILABLE => {
855                format!("{status_text} - Service Unavailable: Server temporarily unavailable")
856            }
857            StatusCode::GATEWAY_TIMEOUT => {
858                format!("{status_text} - Gateway Timeout: Upstream server timeout")
859            }
860            _ => status_text,
861        };
862
863        Ok(HttpResponse {
864            status_code,
865            status_text: enhanced_status_text,
866            headers,
867            content_type,
868            body,
869            body_bytes,
870            is_success,
871            request_method: method.to_string(),
872            request_url: url.to_string(),
873            request_body: request_body.to_string(),
874        })
875    }
876}
877
878impl Default for HttpClient {
879    fn default() -> Self {
880        Self::new()
881    }
882}
883
884/// HTTP response from an API call
885#[derive(Debug, Clone)]
886pub struct HttpResponse {
887    pub status_code: u16,
888    pub status_text: String,
889    pub headers: HashMap<String, String>,
890    pub content_type: Option<String>,
891    pub body: String,
892    pub body_bytes: Option<Vec<u8>>,
893    pub is_success: bool,
894    pub request_method: String,
895    pub request_url: String,
896    pub request_body: String,
897}
898
899impl HttpResponse {
900    /// Try to parse the response body as JSON
901    ///
902    /// # Errors
903    ///
904    /// Returns an error if the body is not valid JSON
905    pub fn json(&self) -> Result<Value, Error> {
906        serde_json::from_str(&self.body)
907            .map_err(|e| Error::Http(format!("Failed to parse response as JSON: {e}")))
908    }
909
910    /// Check if the response contains image content
911    ///
912    /// Uses the mime crate to properly parse and validate image content types.
913    #[must_use]
914    pub fn is_image(&self) -> bool {
915        self.content_type
916            .as_ref()
917            .and_then(|ct| ct.parse::<mime::Mime>().ok())
918            .map(|mime_type| mime_type.type_() == mime::IMAGE)
919            .unwrap_or(false)
920    }
921
922    /// Check if the response contains binary content (image, audio, or video)
923    ///
924    /// Uses the mime crate to properly parse and validate binary content types.
925    #[must_use]
926    pub fn is_binary(&self) -> bool {
927        self.content_type
928            .as_ref()
929            .and_then(|ct| ct.parse::<mime::Mime>().ok())
930            .map(|mime_type| matches!(mime_type.type_(), mime::IMAGE | mime::AUDIO | mime::VIDEO))
931            .unwrap_or(false)
932    }
933
934    /// Get a formatted response summary for MCP
935    #[must_use]
936    pub fn to_mcp_content(&self) -> String {
937        let method = if self.request_method.is_empty() {
938            None
939        } else {
940            Some(self.request_method.as_str())
941        };
942        let url = if self.request_url.is_empty() {
943            None
944        } else {
945            Some(self.request_url.as_str())
946        };
947        let body = if self.request_body.is_empty() {
948            None
949        } else {
950            Some(self.request_body.as_str())
951        };
952        self.to_mcp_content_with_request(method, url, body)
953    }
954
955    /// Get a formatted response summary for MCP with request details
956    pub fn to_mcp_content_with_request(
957        &self,
958        method: Option<&str>,
959        url: Option<&str>,
960        request_body: Option<&str>,
961    ) -> String {
962        let mut result = format!(
963            "HTTP {} {}\n\nStatus: {} {}\n",
964            if self.is_success { "✅" } else { "❌" },
965            if self.is_success { "Success" } else { "Error" },
966            self.status_code,
967            self.status_text
968        );
969
970        // Add request details if provided
971        if let (Some(method), Some(url)) = (method, url) {
972            result.push_str("\nRequest: ");
973            result.push_str(&method.to_uppercase());
974            result.push(' ');
975            result.push_str(url);
976            result.push('\n');
977
978            if let Some(body) = request_body
979                && !body.is_empty()
980                && body != "{}"
981            {
982                result.push_str("\nRequest Body:\n");
983                if let Ok(parsed) = serde_json::from_str::<Value>(body) {
984                    if let Ok(pretty) = serde_json::to_string_pretty(&parsed) {
985                        result.push_str(&pretty);
986                    } else {
987                        result.push_str(body);
988                    }
989                } else {
990                    result.push_str(body);
991                }
992                result.push('\n');
993            }
994        }
995
996        // Add important headers
997        if !self.headers.is_empty() {
998            result.push_str("\nHeaders:\n");
999            for (key, value) in &self.headers {
1000                // Only show commonly useful headers
1001                if [
1002                    header::CONTENT_TYPE.as_str(),
1003                    header::CONTENT_LENGTH.as_str(),
1004                    header::LOCATION.as_str(),
1005                    header::SET_COOKIE.as_str(),
1006                ]
1007                .iter()
1008                .any(|&h| key.to_lowercase().contains(h))
1009                {
1010                    result.push_str("  ");
1011                    result.push_str(key);
1012                    result.push_str(": ");
1013                    result.push_str(value);
1014                    result.push('\n');
1015                }
1016            }
1017        }
1018
1019        // Add body content
1020        result.push_str("\nResponse Body:\n");
1021        if self.body.is_empty() {
1022            result.push_str("(empty)");
1023        } else if let Ok(json_value) = self.json() {
1024            // Pretty print JSON if possible
1025            match serde_json::to_string_pretty(&json_value) {
1026                Ok(pretty) => result.push_str(&pretty),
1027                Err(_) => result.push_str(&self.body),
1028            }
1029        } else {
1030            // Truncate very long responses
1031            if self.body.len() > 2000 {
1032                result.push_str(&self.body[..2000]);
1033                result.push_str("\n... (");
1034                result.push_str(&(self.body.len() - 2000).to_string());
1035                result.push_str(" more characters)");
1036            } else {
1037                result.push_str(&self.body);
1038            }
1039        }
1040
1041        result
1042    }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047    use super::*;
1048    use crate::tool_generator::ExtractedParameters;
1049    use serde_json::json;
1050    use std::collections::HashMap;
1051
1052    #[test]
1053    fn test_with_base_url_validation() {
1054        // Test valid URLs
1055        let url = Url::parse("https://api.example.com").unwrap();
1056        let client = HttpClient::new().with_base_url(url);
1057        assert!(client.is_ok());
1058
1059        let url = Url::parse("http://localhost:8080").unwrap();
1060        let client = HttpClient::new().with_base_url(url);
1061        assert!(client.is_ok());
1062
1063        // Test invalid URLs - these will fail at parse time now
1064        assert!(Url::parse("not-a-url").is_err());
1065        assert!(Url::parse("").is_err());
1066
1067        // Test schemes that parse successfully
1068        let url = Url::parse("ftp://invalid-scheme.com").unwrap();
1069        let client = HttpClient::new().with_base_url(url);
1070        assert!(client.is_ok()); // url crate accepts ftp, our HttpClient should too
1071    }
1072
1073    #[test]
1074    fn test_build_url_with_base_url() {
1075        let base_url = Url::parse("https://api.example.com").unwrap();
1076        let client = HttpClient::new().with_base_url(base_url).unwrap();
1077
1078        let tool_metadata = crate::ToolMetadata {
1079            name: "test".to_string(),
1080            title: None,
1081            description: Some("test".to_string()),
1082            parameters: json!({}),
1083            output_schema: None,
1084            method: "GET".to_string(),
1085            path: "/pets/{id}".to_string(),
1086            security: None,
1087            parameter_mappings: std::collections::HashMap::new(),
1088        };
1089
1090        let mut path_params = HashMap::new();
1091        path_params.insert("id".to_string(), json!(123));
1092
1093        let extracted_params = ExtractedParameters {
1094            path: path_params,
1095            query: HashMap::new(),
1096            headers: HashMap::new(),
1097            cookies: HashMap::new(),
1098            body: HashMap::new(),
1099            config: crate::tool_generator::RequestConfig::default(),
1100        };
1101
1102        let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1103        assert_eq!(url.to_string(), "https://api.example.com/pets/123");
1104    }
1105
1106    #[test]
1107    fn test_build_url_with_base_url_containing_path() {
1108        let test_cases = vec![
1109            "https://api.example.com/api/v4",
1110            "https://api.example.com/api/v4/",
1111        ];
1112
1113        for base_url in test_cases {
1114            let base_url = Url::parse(base_url).unwrap();
1115            let client = HttpClient::new().with_base_url(base_url).unwrap();
1116
1117            let tool_metadata = 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/{id}".to_string(),
1125                security: None,
1126                parameter_mappings: std::collections::HashMap::new(),
1127            };
1128
1129            let mut path_params = HashMap::new();
1130            path_params.insert("id".to_string(), json!(123));
1131
1132            let extracted_params = ExtractedParameters {
1133                path: path_params,
1134                query: HashMap::new(),
1135                headers: HashMap::new(),
1136                cookies: HashMap::new(),
1137                body: HashMap::new(),
1138                config: crate::tool_generator::RequestConfig::default(),
1139            };
1140
1141            let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1142            assert_eq!(url.to_string(), "https://api.example.com/api/v4/pets/123");
1143        }
1144    }
1145
1146    #[test]
1147    fn test_build_url_without_base_url() {
1148        let client = HttpClient::new();
1149
1150        let tool_metadata = crate::ToolMetadata {
1151            name: "test".to_string(),
1152            title: None,
1153            description: Some("test".to_string()),
1154            parameters: json!({}),
1155            output_schema: None,
1156            method: "GET".to_string(),
1157            path: "https://api.example.com/pets/123".to_string(),
1158            security: None,
1159            parameter_mappings: std::collections::HashMap::new(),
1160        };
1161
1162        let extracted_params = ExtractedParameters {
1163            path: HashMap::new(),
1164            query: HashMap::new(),
1165            headers: HashMap::new(),
1166            cookies: HashMap::new(),
1167            body: HashMap::new(),
1168            config: crate::tool_generator::RequestConfig::default(),
1169        };
1170
1171        let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1172        assert_eq!(url.to_string(), "https://api.example.com/pets/123");
1173
1174        // Test error case: relative path without base URL
1175        let tool_metadata_relative = crate::ToolMetadata {
1176            name: "test".to_string(),
1177            title: None,
1178            description: Some("test".to_string()),
1179            parameters: json!({}),
1180            output_schema: None,
1181            method: "GET".to_string(),
1182            path: "/pets/123".to_string(),
1183            security: None,
1184            parameter_mappings: std::collections::HashMap::new(),
1185        };
1186
1187        let result = client.build_url(&tool_metadata_relative, &extracted_params);
1188        assert!(result.is_err());
1189        assert!(
1190            result
1191                .unwrap_err()
1192                .to_string()
1193                .contains("No base URL configured")
1194        );
1195    }
1196
1197    #[test]
1198    fn test_query_parameter_encoding_integration() {
1199        let base_url = Url::parse("https://api.example.com").unwrap();
1200        let client = HttpClient::new().with_base_url(base_url).unwrap();
1201
1202        let tool_metadata = crate::ToolMetadata {
1203            name: "test".to_string(),
1204            title: None,
1205            description: Some("test".to_string()),
1206            parameters: json!({}),
1207            output_schema: None,
1208            method: "GET".to_string(),
1209            path: "/search".to_string(),
1210            security: None,
1211            parameter_mappings: std::collections::HashMap::new(),
1212        };
1213
1214        // Test various query parameter values that need encoding
1215        let mut query_params = HashMap::new();
1216        query_params.insert(
1217            "q".to_string(),
1218            QueryParameter::new(json!("hello world"), true),
1219        ); // space
1220        query_params.insert(
1221            "category".to_string(),
1222            QueryParameter::new(json!("pets&dogs"), true),
1223        ); // ampersand
1224        query_params.insert(
1225            "special".to_string(),
1226            QueryParameter::new(json!("foo=bar"), true),
1227        ); // equals
1228        query_params.insert(
1229            "unicode".to_string(),
1230            QueryParameter::new(json!("café"), true),
1231        ); // unicode
1232        query_params.insert(
1233            "percent".to_string(),
1234            QueryParameter::new(json!("100%"), true),
1235        ); // percent
1236
1237        let extracted_params = ExtractedParameters {
1238            path: HashMap::new(),
1239            query: query_params,
1240            headers: HashMap::new(),
1241            cookies: HashMap::new(),
1242            body: HashMap::new(),
1243            config: crate::tool_generator::RequestConfig::default(),
1244        };
1245
1246        let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1247        HttpClient::add_query_parameters(&mut url, &extracted_params.query);
1248
1249        let url_string = url.to_string();
1250
1251        // Verify the URL contains properly encoded parameters
1252        // Note: url crate encodes spaces as + in query parameters (which is valid)
1253        assert!(url_string.contains("q=hello+world")); // space encoded as +
1254        assert!(url_string.contains("category=pets%26dogs")); // & encoded as %26
1255        assert!(url_string.contains("special=foo%3Dbar")); // = encoded as %3D
1256        assert!(url_string.contains("unicode=caf%C3%A9")); // é encoded as %C3%A9
1257        assert!(url_string.contains("percent=100%25")); // % encoded as %25
1258    }
1259
1260    #[test]
1261    fn test_array_query_parameters() {
1262        let base_url = Url::parse("https://api.example.com").unwrap();
1263        let client = HttpClient::new().with_base_url(base_url).unwrap();
1264
1265        let tool_metadata = crate::ToolMetadata {
1266            name: "test".to_string(),
1267            title: None,
1268            description: Some("test".to_string()),
1269            parameters: json!({}),
1270            output_schema: None,
1271            method: "GET".to_string(),
1272            path: "/search".to_string(),
1273            security: None,
1274            parameter_mappings: std::collections::HashMap::new(),
1275        };
1276
1277        let mut query_params = HashMap::new();
1278        query_params.insert(
1279            "status".to_string(),
1280            QueryParameter::new(json!(["available", "pending"]), true),
1281        );
1282        query_params.insert(
1283            "tags".to_string(),
1284            QueryParameter::new(json!(["red & blue", "fast=car"]), true),
1285        );
1286
1287        let extracted_params = ExtractedParameters {
1288            path: HashMap::new(),
1289            query: query_params,
1290            headers: HashMap::new(),
1291            cookies: HashMap::new(),
1292            body: HashMap::new(),
1293            config: crate::tool_generator::RequestConfig::default(),
1294        };
1295
1296        let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1297        HttpClient::add_query_parameters(&mut url, &extracted_params.query);
1298
1299        let url_string = url.to_string();
1300
1301        // Verify array parameters are added multiple times with proper encoding
1302        assert!(url_string.contains("status=available"));
1303        assert!(url_string.contains("status=pending"));
1304        assert!(url_string.contains("tags=red+%26+blue")); // "red & blue" encoded (spaces as +)
1305        assert!(url_string.contains("tags=fast%3Dcar")); // "fast=car" encoded
1306    }
1307
1308    #[test]
1309    fn test_path_parameter_substitution() {
1310        let base_url = Url::parse("https://api.example.com").unwrap();
1311        let client = HttpClient::new().with_base_url(base_url).unwrap();
1312
1313        let tool_metadata = crate::ToolMetadata {
1314            name: "test".to_string(),
1315            title: None,
1316            description: Some("test".to_string()),
1317            parameters: json!({}),
1318            output_schema: None,
1319            method: "GET".to_string(),
1320            path: "/users/{userId}/pets/{petId}".to_string(),
1321            security: None,
1322            parameter_mappings: std::collections::HashMap::new(),
1323        };
1324
1325        let mut path_params = HashMap::new();
1326        path_params.insert("userId".to_string(), json!(42));
1327        path_params.insert("petId".to_string(), json!("special-pet-123"));
1328
1329        let extracted_params = ExtractedParameters {
1330            path: path_params,
1331            query: HashMap::new(),
1332            headers: HashMap::new(),
1333            cookies: HashMap::new(),
1334            body: HashMap::new(),
1335            config: crate::tool_generator::RequestConfig::default(),
1336        };
1337
1338        let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1339        assert_eq!(
1340            url.to_string(),
1341            "https://api.example.com/users/42/pets/special-pet-123"
1342        );
1343    }
1344
1345    #[test]
1346    fn test_url_join_edge_cases() {
1347        // Test trailing slash handling
1348        let base_url1 = Url::parse("https://api.example.com/").unwrap();
1349        let client1 = HttpClient::new().with_base_url(base_url1).unwrap();
1350
1351        let base_url2 = Url::parse("https://api.example.com").unwrap();
1352        let client2 = HttpClient::new().with_base_url(base_url2).unwrap();
1353
1354        let tool_metadata = crate::ToolMetadata {
1355            name: "test".to_string(),
1356            title: None,
1357            description: Some("test".to_string()),
1358            parameters: json!({}),
1359            output_schema: None,
1360            method: "GET".to_string(),
1361            path: "/pets".to_string(),
1362            security: None,
1363            parameter_mappings: std::collections::HashMap::new(),
1364        };
1365
1366        let extracted_params = ExtractedParameters {
1367            path: HashMap::new(),
1368            query: HashMap::new(),
1369            headers: HashMap::new(),
1370            cookies: HashMap::new(),
1371            body: HashMap::new(),
1372            config: crate::tool_generator::RequestConfig::default(),
1373        };
1374
1375        let url1 = client1
1376            .build_url(&tool_metadata, &extracted_params)
1377            .unwrap();
1378        let url2 = client2
1379            .build_url(&tool_metadata, &extracted_params)
1380            .unwrap();
1381
1382        // Both should produce the same normalized URL
1383        assert_eq!(url1.to_string(), "https://api.example.com/pets");
1384        assert_eq!(url2.to_string(), "https://api.example.com/pets");
1385    }
1386
1387    #[test]
1388    fn test_explode_array_parameters() {
1389        let base_url = Url::parse("https://api.example.com").unwrap();
1390        let client = HttpClient::new().with_base_url(base_url).unwrap();
1391
1392        let tool_metadata = crate::ToolMetadata {
1393            name: "test".to_string(),
1394            title: None,
1395            description: Some("test".to_string()),
1396            parameters: json!({}),
1397            output_schema: None,
1398            method: "GET".to_string(),
1399            path: "/search".to_string(),
1400            security: None,
1401            parameter_mappings: std::collections::HashMap::new(),
1402        };
1403
1404        // Test explode=true (should generate separate parameters)
1405        let mut query_params_exploded = HashMap::new();
1406        query_params_exploded.insert(
1407            "include".to_string(),
1408            QueryParameter::new(json!(["asset", "scenes"]), true),
1409        );
1410
1411        let extracted_params_exploded = ExtractedParameters {
1412            path: HashMap::new(),
1413            query: query_params_exploded,
1414            headers: HashMap::new(),
1415            cookies: HashMap::new(),
1416            body: HashMap::new(),
1417            config: crate::tool_generator::RequestConfig::default(),
1418        };
1419
1420        let mut url_exploded = client
1421            .build_url(&tool_metadata, &extracted_params_exploded)
1422            .unwrap();
1423        HttpClient::add_query_parameters(&mut url_exploded, &extracted_params_exploded.query);
1424        let url_exploded_string = url_exploded.to_string();
1425
1426        // Test explode=false (should generate comma-separated values)
1427        let mut query_params_not_exploded = HashMap::new();
1428        query_params_not_exploded.insert(
1429            "include".to_string(),
1430            QueryParameter::new(json!(["asset", "scenes"]), false),
1431        );
1432
1433        let extracted_params_not_exploded = ExtractedParameters {
1434            path: HashMap::new(),
1435            query: query_params_not_exploded,
1436            headers: HashMap::new(),
1437            cookies: HashMap::new(),
1438            body: HashMap::new(),
1439            config: crate::tool_generator::RequestConfig::default(),
1440        };
1441
1442        let mut url_not_exploded = client
1443            .build_url(&tool_metadata, &extracted_params_not_exploded)
1444            .unwrap();
1445        HttpClient::add_query_parameters(
1446            &mut url_not_exploded,
1447            &extracted_params_not_exploded.query,
1448        );
1449        let url_not_exploded_string = url_not_exploded.to_string();
1450
1451        // Verify explode=true generates separate parameters
1452        assert!(url_exploded_string.contains("include=asset"));
1453        assert!(url_exploded_string.contains("include=scenes"));
1454
1455        // Verify explode=false generates comma-separated values
1456        assert!(url_not_exploded_string.contains("include=asset%2Cscenes")); // comma is URL-encoded as %2C
1457
1458        // Make sure they're different
1459        assert_ne!(url_exploded_string, url_not_exploded_string);
1460
1461        println!("Exploded URL: {url_exploded_string}");
1462        println!("Non-exploded URL: {url_not_exploded_string}");
1463    }
1464
1465    #[test]
1466    fn test_deep_object_parameters() {
1467        let base_url = Url::parse("https://api.example.com").unwrap();
1468        let client = HttpClient::new().with_base_url(base_url).unwrap();
1469
1470        let tool_metadata = crate::ToolMetadata {
1471            name: "test".to_string(),
1472            title: None,
1473            description: Some("test".to_string()),
1474            parameters: json!({}),
1475            output_schema: None,
1476            method: "GET".to_string(),
1477            path: "/search".to_string(),
1478            security: None,
1479            parameter_mappings: std::collections::HashMap::new(),
1480        };
1481
1482        // An OpenAPI `style: deepObject` parameter arrives as an object value.
1483        let mut query_params = HashMap::new();
1484        query_params.insert(
1485            "fields".to_string(),
1486            QueryParameter::new(
1487                json!({ "camera": ["name", "type"], "node": ["name"] }),
1488                true,
1489            ),
1490        );
1491
1492        let extracted_params = ExtractedParameters {
1493            path: HashMap::new(),
1494            query: query_params,
1495            headers: HashMap::new(),
1496            cookies: HashMap::new(),
1497            body: HashMap::new(),
1498            config: crate::tool_generator::RequestConfig::default(),
1499        };
1500
1501        let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
1502        HttpClient::add_query_parameters(&mut url, &extracted_params.query);
1503        let url_string = url.to_string();
1504
1505        // Each property expands to `fields[<prop>]=<csv>`; `[`/`]`/`,` are
1506        // percent-encoded as %5B/%5D/%2C.
1507        assert!(
1508            url_string.contains("fields%5Bcamera%5D=name%2Ctype"),
1509            "expected fields[camera]=name,type, got {url_string}"
1510        );
1511        assert!(
1512            url_string.contains("fields%5Bnode%5D=name"),
1513            "expected fields[node]=name, got {url_string}"
1514        );
1515        // The object must not be sent as a JSON blob.
1516        assert!(
1517            !url_string.contains("%7B"),
1518            "object must not be JSON-serialized, got {url_string}"
1519        );
1520    }
1521
1522    #[test]
1523    fn test_is_image_helper() {
1524        // Test various image content types
1525        let response_png = HttpResponse {
1526            status_code: 200,
1527            status_text: "OK".to_string(),
1528            headers: HashMap::new(),
1529            content_type: Some("image/png".to_string()),
1530            body: String::new(),
1531            body_bytes: None,
1532            is_success: true,
1533            request_method: "GET".to_string(),
1534            request_url: "http://example.com".to_string(),
1535            request_body: String::new(),
1536        };
1537        assert!(response_png.is_image());
1538
1539        let response_jpeg = HttpResponse {
1540            content_type: Some("image/jpeg".to_string()),
1541            ..response_png.clone()
1542        };
1543        assert!(response_jpeg.is_image());
1544
1545        // Test with charset parameter
1546        let response_with_charset = HttpResponse {
1547            content_type: Some("image/png; charset=utf-8".to_string()),
1548            ..response_png.clone()
1549        };
1550        assert!(response_with_charset.is_image());
1551
1552        // Test non-image content types
1553        let response_json = HttpResponse {
1554            content_type: Some("application/json".to_string()),
1555            ..response_png.clone()
1556        };
1557        assert!(!response_json.is_image());
1558
1559        let response_text = HttpResponse {
1560            content_type: Some("text/plain".to_string()),
1561            ..response_png.clone()
1562        };
1563        assert!(!response_text.is_image());
1564
1565        // Test with no content type
1566        let response_no_ct = HttpResponse {
1567            content_type: None,
1568            ..response_png
1569        };
1570        assert!(!response_no_ct.is_image());
1571    }
1572
1573    #[test]
1574    fn test_is_binary_helper() {
1575        let base_response = HttpResponse {
1576            status_code: 200,
1577            status_text: "OK".to_string(),
1578            headers: HashMap::new(),
1579            content_type: None,
1580            body: String::new(),
1581            body_bytes: None,
1582            is_success: true,
1583            request_method: "GET".to_string(),
1584            request_url: "http://example.com".to_string(),
1585            request_body: String::new(),
1586        };
1587
1588        // Test image types
1589        let response_image = HttpResponse {
1590            content_type: Some("image/png".to_string()),
1591            ..base_response.clone()
1592        };
1593        assert!(response_image.is_binary());
1594
1595        // Test audio types
1596        let response_audio = HttpResponse {
1597            content_type: Some("audio/mpeg".to_string()),
1598            ..base_response.clone()
1599        };
1600        assert!(response_audio.is_binary());
1601
1602        // Test video types
1603        let response_video = HttpResponse {
1604            content_type: Some("video/mp4".to_string()),
1605            ..base_response.clone()
1606        };
1607        assert!(response_video.is_binary());
1608
1609        // Test non-binary types
1610        let response_json = HttpResponse {
1611            content_type: Some("application/json".to_string()),
1612            ..base_response.clone()
1613        };
1614        assert!(!response_json.is_binary());
1615
1616        // Test with no content type
1617        assert!(!base_response.is_binary());
1618    }
1619
1620    #[test]
1621    fn test_parse_data_uri_valid_png() {
1622        // "hello" encoded as base64
1623        let uri = "data:image/png;base64,aGVsbG8=";
1624        let result = super::parse_data_uri(uri, "test_field").unwrap();
1625
1626        assert_eq!(result.mime_type, "image/png");
1627        assert_eq!(result.bytes, b"hello");
1628    }
1629
1630    #[test]
1631    fn test_parse_data_uri_valid_jpeg() {
1632        // "world" encoded as base64
1633        let uri = "data:image/jpeg;base64,d29ybGQ=";
1634        let result = super::parse_data_uri(uri, "image").unwrap();
1635
1636        assert_eq!(result.mime_type, "image/jpeg");
1637        assert_eq!(result.bytes, b"world");
1638    }
1639
1640    #[test]
1641    fn test_parse_data_uri_valid_application_json() {
1642        // "{}" encoded as base64
1643        let uri = "data:application/json;base64,e30=";
1644        let result = super::parse_data_uri(uri, "data").unwrap();
1645
1646        assert_eq!(result.mime_type, "application/json");
1647        assert_eq!(result.bytes, b"{}");
1648    }
1649
1650    #[test]
1651    fn test_parse_data_uri_missing_data_prefix() {
1652        let uri = "image/png;base64,aGVsbG8=";
1653        let result = super::parse_data_uri(uri, "test_field");
1654
1655        assert!(result.is_err());
1656        let err = result.unwrap_err().to_string();
1657        assert!(err.contains("Invalid data URI format"));
1658        assert!(err.contains("test_field"));
1659        assert!(err.contains("expected 'data:<mime>;base64,<content>'"));
1660    }
1661
1662    #[test]
1663    fn test_parse_data_uri_missing_semicolon() {
1664        let uri = "data:image/png,aGVsbG8=";
1665        let result = super::parse_data_uri(uri, "my_image");
1666
1667        assert!(result.is_err());
1668        let err = result.unwrap_err().to_string();
1669        assert!(err.contains("Invalid data URI format"));
1670        assert!(err.contains("my_image"));
1671    }
1672
1673    #[test]
1674    fn test_parse_data_uri_missing_comma() {
1675        let uri = "data:image/png;base64aGVsbG8=";
1676        let result = super::parse_data_uri(uri, "field");
1677
1678        assert!(result.is_err());
1679        let err = result.unwrap_err().to_string();
1680        assert!(err.contains("Invalid data URI format"));
1681    }
1682
1683    #[test]
1684    fn test_parse_data_uri_unsupported_encoding() {
1685        let uri = "data:image/png;ascii,hello";
1686        let result = super::parse_data_uri(uri, "test_field");
1687
1688        assert!(result.is_err());
1689        let err = result.unwrap_err().to_string();
1690        assert!(err.contains("Unsupported encoding 'ascii'"));
1691        assert!(err.contains("test_field"));
1692        assert!(err.contains("only base64 is supported"));
1693    }
1694
1695    #[test]
1696    fn test_parse_data_uri_unsupported_encoding_utf8() {
1697        let uri = "data:text/plain;utf-8,hello world";
1698        let result = super::parse_data_uri(uri, "content");
1699
1700        assert!(result.is_err());
1701        let err = result.unwrap_err().to_string();
1702        assert!(err.contains("Unsupported encoding 'utf-8'"));
1703        assert!(err.contains("content"));
1704    }
1705
1706    #[test]
1707    fn test_parse_data_uri_invalid_base64() {
1708        // Invalid base64: contains characters that aren't valid base64
1709        let uri = "data:image/png;base64,not-valid-base64!!!";
1710        let result = super::parse_data_uri(uri, "bad_image");
1711
1712        assert!(result.is_err());
1713        let err = result.unwrap_err().to_string();
1714        assert!(err.contains("Invalid base64 content"));
1715        assert!(err.contains("bad_image"));
1716    }
1717
1718    #[test]
1719    fn test_parse_data_uri_empty_content() {
1720        // Empty base64 content is valid and decodes to empty bytes
1721        let uri = "data:application/octet-stream;base64,";
1722        let result = super::parse_data_uri(uri, "empty").unwrap();
1723
1724        assert_eq!(result.mime_type, "application/octet-stream");
1725        assert!(result.bytes.is_empty());
1726    }
1727
1728    #[test]
1729    fn test_parse_data_uri_complex_mime_type() {
1730        // MIME type with subtype
1731        let uri = "data:application/vnd.api+json;base64,e30=";
1732        let result = super::parse_data_uri(uri, "api_data").unwrap();
1733
1734        assert_eq!(result.mime_type, "application/vnd.api+json");
1735        assert_eq!(result.bytes, b"{}");
1736    }
1737
1738    #[test]
1739    fn test_parse_data_uri_mime_type_with_parameters() {
1740        // MIME type with charset parameter
1741        let uri = "data:text/plain;charset=utf-8;base64,SGVsbG8gV29ybGQ=";
1742        let result = super::parse_data_uri(uri, "text_field").unwrap();
1743
1744        assert_eq!(result.mime_type, "text/plain;charset=utf-8");
1745        assert_eq!(result.bytes, b"Hello World");
1746    }
1747
1748    #[test]
1749    fn test_parse_data_uri_mime_type_with_multiple_parameters() {
1750        // MIME type with multiple parameters
1751        let uri = "data:text/html;charset=utf-8;boundary=something;base64,PGh0bWw+";
1752        let result = super::parse_data_uri(uri, "html_field").unwrap();
1753
1754        assert_eq!(
1755            result.mime_type,
1756            "text/html;charset=utf-8;boundary=something"
1757        );
1758        assert_eq!(result.bytes, b"<html>");
1759    }
1760
1761    #[test]
1762    fn test_parse_data_uri_empty_mime_type() {
1763        // Empty MIME type should be rejected
1764        let uri = "data:;base64,SGVsbG8=";
1765        let result = super::parse_data_uri(uri, "field");
1766
1767        assert!(result.is_err());
1768        let err = result.unwrap_err().to_string();
1769        assert!(err.contains("MIME type cannot be empty"));
1770    }
1771
1772    #[test]
1773    fn test_parse_data_uri_empty_string() {
1774        let result = super::parse_data_uri("", "field");
1775
1776        assert!(result.is_err());
1777        let err = result.unwrap_err().to_string();
1778        assert!(err.contains("Invalid data URI format"));
1779    }
1780
1781    #[test]
1782    fn test_parse_data_uri_just_data_prefix() {
1783        let result = super::parse_data_uri("data:", "field");
1784
1785        assert!(result.is_err());
1786        let err = result.unwrap_err().to_string();
1787        assert!(err.contains("Invalid data URI format"));
1788    }
1789
1790    // ==================== Multipart Form Building Tests ====================
1791    //
1792    // Note: The `add_request_body` function modifies a `reqwest::RequestBuilder`
1793    // which is an opaque type. We cannot inspect the actual multipart form content
1794    // without sending the request. These tests verify:
1795    // 1. Error handling for invalid inputs (e.g., invalid data URIs)
1796    // 2. Successful building for valid inputs (returns Ok)
1797    //
1798    // Full integration testing of multipart uploads would require a mock HTTP
1799    // server, which is beyond the scope of unit tests.
1800
1801    #[test]
1802    fn test_add_request_body_multipart_with_valid_file() {
1803        let client = HttpClient::new();
1804        let request = client.client.post("http://example.com/upload");
1805
1806        let mut body = HashMap::new();
1807        // Valid file with data URI
1808        body.insert(
1809            "file".to_string(),
1810            json!({
1811                "content": "data:image/png;base64,iVBORw0KGgo=",
1812                "filename": "test.png"
1813            }),
1814        );
1815        // Text field
1816        body.insert("description".to_string(), json!("Test file upload"));
1817
1818        let config = crate::tool_generator::RequestConfig {
1819            timeout_seconds: 30,
1820            content_type: mime::MULTIPART_FORM_DATA.to_string(),
1821        };
1822
1823        let result = HttpClient::add_request_body(request, &body, &config);
1824        assert!(
1825            result.is_ok(),
1826            "Should successfully build multipart form with valid file"
1827        );
1828    }
1829
1830    #[test]
1831    fn test_add_request_body_multipart_with_invalid_data_uri() {
1832        let client = HttpClient::new();
1833        let request = client.client.post("http://example.com/upload");
1834
1835        let mut body = HashMap::new();
1836        // Invalid data URI (missing base64 marker)
1837        body.insert(
1838            "file".to_string(),
1839            json!({
1840                "content": "data:image/png,notbase64",
1841                "filename": "test.png"
1842            }),
1843        );
1844
1845        let config = crate::tool_generator::RequestConfig {
1846            timeout_seconds: 30,
1847            content_type: mime::MULTIPART_FORM_DATA.to_string(),
1848        };
1849
1850        let result = HttpClient::add_request_body(request, &body, &config);
1851        assert!(result.is_err(), "Should fail with invalid data URI");
1852        let err = result.unwrap_err().to_string();
1853        assert!(
1854            err.contains("Invalid data URI format"),
1855            "Error should mention invalid format"
1856        );
1857    }
1858
1859    #[test]
1860    fn test_add_request_body_multipart_with_invalid_base64() {
1861        let client = HttpClient::new();
1862        let request = client.client.post("http://example.com/upload");
1863
1864        let mut body = HashMap::new();
1865        // Invalid base64 content
1866        body.insert(
1867            "file".to_string(),
1868            json!({
1869                "content": "data:image/png;base64,!!!invalid!!!",
1870                "filename": "test.png"
1871            }),
1872        );
1873
1874        let config = crate::tool_generator::RequestConfig {
1875            timeout_seconds: 30,
1876            content_type: mime::MULTIPART_FORM_DATA.to_string(),
1877        };
1878
1879        let result = HttpClient::add_request_body(request, &body, &config);
1880        assert!(result.is_err(), "Should fail with invalid base64");
1881        let err = result.unwrap_err().to_string();
1882        assert!(
1883            err.contains("Invalid base64 content"),
1884            "Error should mention invalid base64"
1885        );
1886    }
1887
1888    #[test]
1889    fn test_add_request_body_multipart_text_only() {
1890        let client = HttpClient::new();
1891        let request = client.client.post("http://example.com/upload");
1892
1893        let mut body = HashMap::new();
1894        body.insert("field1".to_string(), json!("text value"));
1895        body.insert("field2".to_string(), json!(123));
1896        body.insert("field3".to_string(), json!(true));
1897
1898        let config = crate::tool_generator::RequestConfig {
1899            timeout_seconds: 30,
1900            content_type: mime::MULTIPART_FORM_DATA.to_string(),
1901        };
1902
1903        let result = HttpClient::add_request_body(request, &body, &config);
1904        assert!(
1905            result.is_ok(),
1906            "Should successfully build multipart form with text-only fields"
1907        );
1908    }
1909
1910    #[test]
1911    fn test_add_request_body_multipart_mixed_content() {
1912        let client = HttpClient::new();
1913        let request = client.client.post("http://example.com/upload");
1914
1915        let mut body = HashMap::new();
1916        // File field
1917        body.insert(
1918            "image".to_string(),
1919            json!({
1920                "content": "data:image/jpeg;base64,/9j/4AAQ",
1921                "filename": "photo.jpg"
1922            }),
1923        );
1924        // Text fields
1925        body.insert("title".to_string(), json!("My Photo"));
1926        body.insert("tags".to_string(), json!(["nature", "sunset"]));
1927
1928        let config = crate::tool_generator::RequestConfig {
1929            timeout_seconds: 30,
1930            content_type: mime::MULTIPART_FORM_DATA.to_string(),
1931        };
1932
1933        let result = HttpClient::add_request_body(request, &body, &config);
1934        assert!(result.is_ok(), "Should handle mixed file and text content");
1935    }
1936
1937    #[test]
1938    fn test_add_request_body_multipart_without_filename() {
1939        let client = HttpClient::new();
1940        let request = client.client.post("http://example.com/upload");
1941
1942        let mut body = HashMap::new();
1943        // File without explicit filename (should default to "file")
1944        body.insert(
1945            "upload".to_string(),
1946            json!({
1947                "content": "data:application/pdf;base64,JVBERi0="
1948            }),
1949        );
1950
1951        let config = crate::tool_generator::RequestConfig {
1952            timeout_seconds: 30,
1953            content_type: mime::MULTIPART_FORM_DATA.to_string(),
1954        };
1955
1956        let result = HttpClient::add_request_body(request, &body, &config);
1957        assert!(
1958            result.is_ok(),
1959            "Should handle file upload without explicit filename"
1960        );
1961    }
1962
1963    #[test]
1964    fn test_add_request_body_json() {
1965        let client = HttpClient::new();
1966        let request = client.client.post("http://example.com/api");
1967
1968        let mut body = HashMap::new();
1969        body.insert("name".to_string(), json!("test"));
1970        body.insert("value".to_string(), json!(42));
1971
1972        let config = crate::tool_generator::RequestConfig {
1973            timeout_seconds: 30,
1974            content_type: mime::APPLICATION_JSON.to_string(),
1975        };
1976
1977        let result = HttpClient::add_request_body(request, &body, &config);
1978        assert!(result.is_ok(), "Should build JSON body");
1979    }
1980
1981    #[test]
1982    fn test_add_request_body_form_urlencoded() {
1983        let client = HttpClient::new();
1984        let request = client.client.post("http://example.com/form");
1985
1986        let mut body = HashMap::new();
1987        body.insert("username".to_string(), json!("user"));
1988        body.insert("password".to_string(), json!("secret"));
1989
1990        let config = crate::tool_generator::RequestConfig {
1991            timeout_seconds: 30,
1992            content_type: mime::APPLICATION_WWW_FORM_URLENCODED.to_string(),
1993        };
1994
1995        let result = HttpClient::add_request_body(request, &body, &config);
1996        assert!(result.is_ok(), "Should build form-urlencoded body");
1997    }
1998
1999    #[tokio::test]
2000    async fn http_client_with_insecure_still_serves_plain_http() {
2001        let mut server = mockito::Server::new_async().await;
2002        let mock = server
2003            .mock("GET", "/ping")
2004            .with_status(200)
2005            .with_body("pong")
2006            .create_async()
2007            .await;
2008
2009        let base_url: Url = server.url().parse().unwrap();
2010        let client = HttpClient::new()
2011            .with_insecure(true)
2012            .with_base_url(base_url.clone())
2013            .unwrap();
2014
2015        let url = base_url.join("ping").unwrap();
2016        let response = client.client.get(url).send().await.unwrap();
2017        assert_eq!(response.status().as_u16(), 200);
2018        assert_eq!(response.text().await.unwrap(), "pong");
2019
2020        mock.assert_async().await;
2021    }
2022
2023    #[test]
2024    fn test_add_request_body_empty() {
2025        let client = HttpClient::new();
2026        let request = client.client.post("http://example.com/api");
2027
2028        let body = HashMap::new();
2029
2030        let config = crate::tool_generator::RequestConfig::default();
2031
2032        let result = HttpClient::add_request_body(request, &body, &config);
2033        assert!(result.is_ok(), "Should handle empty body");
2034    }
2035}