soul_core/executor/
http.rs1use tokio::sync::mpsc;
4
5use crate::error::{SoulError, SoulResult};
6use crate::tool::ToolOutput;
7use crate::types::ToolDefinition;
8
9use super::ToolExecutor;
10
11pub struct HttpExecutor {
15 client: reqwest::Client,
16}
17
18impl HttpExecutor {
19 pub fn new() -> Self {
20 Self {
21 client: reqwest::Client::new(),
22 }
23 }
24
25 pub fn with_client(client: reqwest::Client) -> Self {
26 Self { client }
27 }
28}
29
30impl Default for HttpExecutor {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
37#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
38impl ToolExecutor for HttpExecutor {
39 async fn execute(
40 &self,
41 definition: &ToolDefinition,
42 _call_id: &str,
43 arguments: serde_json::Value,
44 _partial_tx: Option<mpsc::UnboundedSender<String>>,
45 ) -> SoulResult<ToolOutput> {
46 let url = arguments
47 .get("url")
48 .and_then(|v| v.as_str())
49 .ok_or_else(|| SoulError::ToolExecution {
50 tool_name: definition.name.clone(),
51 message: "Missing 'url' argument".into(),
52 })?;
53
54 let method = arguments
55 .get("method")
56 .and_then(|v| v.as_str())
57 .unwrap_or("GET");
58
59 let builder = match method.to_uppercase().as_str() {
60 "GET" => self.client.get(url),
61 "POST" => self.client.post(url),
62 "PUT" => self.client.put(url),
63 "DELETE" => self.client.delete(url),
64 "PATCH" => self.client.patch(url),
65 other => {
66 return Err(SoulError::ToolExecution {
67 tool_name: definition.name.clone(),
68 message: format!("Unsupported HTTP method: {other}"),
69 });
70 }
71 };
72
73 let builder = if let Some(body) = arguments.get("body") {
74 builder.json(body)
75 } else {
76 builder
77 };
78
79 let response = builder.send().await.map_err(|e| SoulError::ToolExecution {
80 tool_name: definition.name.clone(),
81 message: format!("HTTP request failed: {e}"),
82 })?;
83
84 let status = response.status();
85 let body = response
86 .text()
87 .await
88 .map_err(|e| SoulError::ToolExecution {
89 tool_name: definition.name.clone(),
90 message: format!("Failed to read response body: {e}"),
91 })?;
92
93 if status.is_success() {
94 Ok(ToolOutput::success(body))
95 } else {
96 Ok(ToolOutput::error(format!(
97 "HTTP {}: {}",
98 status.as_u16(),
99 body
100 )))
101 }
102 }
103
104 fn executor_name(&self) -> &str {
105 "http"
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use serde_json::json;
113
114 fn test_def() -> ToolDefinition {
115 ToolDefinition {
116 name: "http_test".into(),
117 description: "Test".into(),
118 input_schema: json!({"type": "object"}),
119 }
120 }
121
122 #[tokio::test]
123 async fn missing_url_errors() {
124 let executor = HttpExecutor::new();
125 let result = executor
126 .execute(&test_def(), "c1", json!({"method": "GET"}), None)
127 .await;
128 assert!(result.is_err());
129 }
130
131 #[tokio::test]
132 async fn unsupported_method_errors() {
133 let executor = HttpExecutor::new();
134 let result = executor
135 .execute(
136 &test_def(),
137 "c1",
138 json!({"url": "http://localhost", "method": "FOOBAR"}),
139 None,
140 )
141 .await;
142 assert!(result.is_err());
143 }
144
145 #[test]
146 fn executor_name() {
147 let executor = HttpExecutor::new();
148 assert_eq!(executor.executor_name(), "http");
149 }
150
151 #[test]
155 fn is_send_sync() {
156 fn assert_send_sync<T: Send + Sync>() {}
157 assert_send_sync::<HttpExecutor>();
158 }
159
160 #[test]
161 fn with_custom_client() {
162 let client = reqwest::Client::builder()
163 .timeout(std::time::Duration::from_secs(5))
164 .build()
165 .unwrap();
166 let executor = HttpExecutor::with_client(client);
167 assert_eq!(executor.executor_name(), "http");
168 }
169}