agentzero_tools/
http_request.rs1use 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 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 assert!(!err.to_string().contains("URL access denied"));
153 }
154}