Skip to main content

agentzero_tools/
http_request.rs

1use agentzero_core::common::url_policy::UrlAccessPolicy;
2use agentzero_core::common::util::parse_http_url_with_policy;
3use agentzero_core::{Tool, ToolContext, ToolResult};
4use anyhow::{anyhow, Context};
5use async_trait::async_trait;
6use reqwest::Method;
7
8pub struct HttpRequestTool {
9    client: reqwest::Client,
10    url_policy: UrlAccessPolicy,
11}
12
13impl Default for HttpRequestTool {
14    fn default() -> Self {
15        Self {
16            client: reqwest::Client::new(),
17            url_policy: UrlAccessPolicy::default(),
18        }
19    }
20}
21
22impl HttpRequestTool {
23    pub fn with_url_policy(mut self, policy: UrlAccessPolicy) -> Self {
24        self.url_policy = policy;
25        self
26    }
27}
28
29#[async_trait]
30impl Tool for HttpRequestTool {
31    fn name(&self) -> &'static str {
32        "http_request"
33    }
34
35    fn description(&self) -> &'static str {
36        "Send an HTTP request (GET, POST, PUT, DELETE) and return the response. Input format: \"METHOD URL [BODY]\"."
37    }
38
39    fn input_schema(&self) -> Option<serde_json::Value> {
40        Some(serde_json::json!({
41            "type": "object",
42            "properties": {
43                "method": { "type": "string", "description": "HTTP method (GET, POST, PUT, DELETE)" },
44                "url": { "type": "string", "description": "The URL to request" },
45                "body": { "type": "string", "description": "Optional request body" }
46            },
47            "required": ["method", "url"]
48        }))
49    }
50
51    async fn execute(&self, input: &str, _ctx: &ToolContext) -> anyhow::Result<ToolResult> {
52        let mut parts = input.trim().splitn(3, ' ');
53        let method = parts.next().unwrap_or_default().to_ascii_uppercase();
54        let url = parts.next().unwrap_or_default();
55        let body = parts.next();
56
57        if method.is_empty() || url.is_empty() {
58            return Err(anyhow!(
59                "usage: <METHOD> <URL> [JSON_BODY], e.g. `GET https://example.com`"
60            ));
61        }
62        let method = Method::from_bytes(method.as_bytes())
63            .with_context(|| format!("invalid method `{method}`"))?;
64        let parsed = parse_http_url_with_policy(url, &self.url_policy)?;
65
66        let mut request = self.client.request(method, parsed);
67        if let Some(body) = body {
68            let json_value: serde_json::Value =
69                serde_json::from_str(body).context("body must be valid JSON when provided")?;
70            request = request.json(&json_value);
71        }
72
73        let response = request.send().await.context("request failed")?;
74        let status = response.status().as_u16();
75        let text = response
76            .text()
77            .await
78            .context("failed to read response body")?;
79        Ok(ToolResult {
80            output: format!("status={status}\n{text}"),
81        })
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::HttpRequestTool;
88    use agentzero_core::common::url_policy::UrlAccessPolicy;
89    use agentzero_core::{Tool, ToolContext};
90
91    #[tokio::test]
92    async fn http_request_rejects_invalid_usage_negative_path() {
93        let tool = HttpRequestTool::default();
94        let err = tool
95            .execute("", &ToolContext::new(".".to_string()))
96            .await
97            .expect_err("empty input should fail");
98        assert!(err.to_string().contains("usage:"));
99    }
100
101    #[tokio::test]
102    async fn http_request_rejects_non_http_scheme_negative_path() {
103        let tool = HttpRequestTool::default();
104        let err = tool
105            .execute("GET ftp://example.com", &ToolContext::new(".".to_string()))
106            .await
107            .expect_err("non-http scheme should fail");
108        assert!(err.to_string().contains("unsupported url scheme"));
109    }
110
111    #[tokio::test]
112    async fn http_request_blocks_private_ip_negative_path() {
113        let tool = HttpRequestTool::default();
114        let err = tool
115            .execute(
116                "GET http://192.168.1.1/api",
117                &ToolContext::new(".".to_string()),
118            )
119            .await
120            .expect_err("private IP should be blocked");
121        assert!(err.to_string().contains("URL access denied"));
122    }
123
124    #[tokio::test]
125    async fn http_request_blocks_loopback_negative_path() {
126        let tool = HttpRequestTool::default();
127        let err = tool
128            .execute(
129                "GET http://127.0.0.1:8080/api",
130                &ToolContext::new(".".to_string()),
131            )
132            .await
133            .expect_err("loopback should be blocked");
134        assert!(err.to_string().contains("URL access denied"));
135    }
136
137    #[tokio::test]
138    async fn http_request_allows_loopback_when_configured() {
139        let tool = HttpRequestTool::default().with_url_policy(UrlAccessPolicy {
140            allow_loopback: true,
141            ..Default::default()
142        });
143        // Won't succeed (no server), but should NOT fail with policy error
144        let err = tool
145            .execute(
146                "GET http://127.0.0.1:19999/api",
147                &ToolContext::new(".".to_string()),
148            )
149            .await
150            .expect_err("connection should fail (no server)");
151        // Should fail with connection error, NOT policy error
152        assert!(!err.to_string().contains("URL access denied"));
153    }
154}