Skip to main content

rustant_tools/
http_api.rs

1//! HTTP API tool — make HTTP requests (GET, POST, PUT, DELETE).
2
3use async_trait::async_trait;
4use rustant_core::error::ToolError;
5use rustant_core::types::{RiskLevel, ToolOutput};
6use serde_json::{Value, json};
7use std::time::Duration;
8
9use crate::registry::Tool;
10
11pub struct HttpApiTool;
12
13impl Default for HttpApiTool {
14    fn default() -> Self {
15        Self
16    }
17}
18
19impl HttpApiTool {
20    pub fn new() -> Self {
21        Self
22    }
23}
24
25#[async_trait]
26impl Tool for HttpApiTool {
27    fn name(&self) -> &str {
28        "http_api"
29    }
30    fn description(&self) -> &str {
31        "Make HTTP API requests. Actions: get, post, put, delete. Returns status code and response body."
32    }
33    fn parameters_schema(&self) -> Value {
34        json!({
35            "type": "object",
36            "properties": {
37                "action": {
38                    "type": "string",
39                    "enum": ["get", "post", "put", "delete"],
40                    "description": "HTTP method"
41                },
42                "url": { "type": "string", "description": "Request URL" },
43                "body": { "type": "string", "description": "Request body (JSON string for post/put)" },
44                "headers": {
45                    "type": "object",
46                    "description": "Custom headers as key-value pairs",
47                    "additionalProperties": { "type": "string" }
48                }
49            },
50            "required": ["action", "url"]
51        })
52    }
53    fn risk_level(&self) -> RiskLevel {
54        RiskLevel::Execute
55    }
56    fn timeout(&self) -> Duration {
57        Duration::from_secs(30)
58    }
59
60    async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
61        let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("get");
62        let url = args.get("url").and_then(|v| v.as_str()).unwrap_or("");
63        if url.is_empty() {
64            return Ok(ToolOutput::text("Please provide a URL."));
65        }
66
67        let client = reqwest::Client::builder()
68            .timeout(Duration::from_secs(25))
69            .build()
70            .map_err(|e| ToolError::ExecutionFailed {
71                name: "http_api".into(),
72                message: format!("Failed to create HTTP client: {}", e),
73            })?;
74
75        let mut builder = match action {
76            "get" => client.get(url),
77            "post" => client.post(url),
78            "put" => client.put(url),
79            "delete" => client.delete(url),
80            _ => return Ok(ToolOutput::text(format!("Unknown method: {}", action))),
81        };
82
83        // Add custom headers
84        if let Some(headers) = args.get("headers").and_then(|v| v.as_object()) {
85            for (key, value) in headers {
86                if let Some(val_str) = value.as_str() {
87                    builder = builder.header(key.as_str(), val_str);
88                }
89            }
90        }
91
92        // Add body for post/put
93        if let Some(body) = args.get("body").and_then(|v| v.as_str()) {
94            builder = builder
95                .header("Content-Type", "application/json")
96                .body(body.to_string());
97        }
98
99        let response = builder
100            .send()
101            .await
102            .map_err(|e| ToolError::ExecutionFailed {
103                name: "http_api".into(),
104                message: format!("HTTP request failed: {}", e),
105            })?;
106
107        let status = response.status();
108        let headers_str: Vec<String> = response
109            .headers()
110            .iter()
111            .take(10)
112            .map(|(k, v)| format!("  {}: {}", k, v.to_str().unwrap_or("?")))
113            .collect();
114        let body = response
115            .text()
116            .await
117            .unwrap_or_else(|_| "<binary>".to_string());
118
119        // Truncate large responses
120        let body_display = if body.len() > 5000 {
121            format!(
122                "{}...\n(truncated, {} bytes total)",
123                &body[..5000],
124                body.len()
125            )
126        } else {
127            body
128        };
129
130        Ok(ToolOutput::text(format!(
131            "HTTP {} {} → {}\nHeaders:\n{}\nBody:\n{}",
132            action.to_uppercase(),
133            url,
134            status,
135            headers_str.join("\n"),
136            body_display
137        )))
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[tokio::test]
146    async fn test_http_api_missing_url() {
147        let tool = HttpApiTool::new();
148        let result = tool
149            .execute(json!({"action": "get", "url": ""}))
150            .await
151            .unwrap();
152        assert!(result.content.contains("provide a URL"));
153    }
154
155    #[tokio::test]
156    async fn test_http_api_schema() {
157        let tool = HttpApiTool::new();
158        assert_eq!(tool.name(), "http_api");
159        assert_eq!(tool.risk_level(), RiskLevel::Execute);
160        let schema = tool.parameters_schema();
161        assert!(schema["properties"]["action"]["enum"].is_array());
162    }
163
164    #[tokio::test]
165    async fn test_http_api_invalid_url() {
166        let tool = HttpApiTool::new();
167        let result = tool
168            .execute(json!({"action": "get", "url": "not-a-url"}))
169            .await;
170        // Should return error or error message
171        assert!(result.is_err() || result.unwrap().content.contains("failed"));
172    }
173}