Skip to main content

soul_core/executor/
http.rs

1//! HTTP executor — executes tools as HTTP requests.
2
3use tokio::sync::mpsc;
4
5use crate::error::{SoulError, SoulResult};
6use crate::tool::ToolOutput;
7use crate::types::ToolDefinition;
8
9use super::ToolExecutor;
10
11/// Executes tools by making HTTP requests.
12///
13/// Arguments should contain `url` and optionally `method`, `headers`, `body`.
14pub 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    // Note: actual HTTP tests would require wiremock,
152    // which is in dev-dependencies. Integration tests handle this.
153
154    #[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}