rmcp_openapi/
http_client.rs

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