Skip to main content

sage_runtime/tools/
http.rs

1//! RFC-0011: HTTP client tool for Sage agents.
2//!
3//! Provides the `Http` tool with `get`, `post`, `put`, and `delete` methods.
4
5use std::collections::HashMap;
6#[cfg(not(target_arch = "wasm32"))]
7use std::time::Duration;
8
9use crate::error::{SageError, SageResult};
10use crate::mock::{try_get_mock, MockResponse};
11
12/// Configuration for the HTTP client.
13#[derive(Debug, Clone)]
14pub struct HttpConfig {
15    /// Request timeout in seconds.
16    pub timeout_secs: u64,
17    /// User-Agent header value.
18    pub user_agent: String,
19}
20
21impl Default for HttpConfig {
22    fn default() -> Self {
23        Self {
24            timeout_secs: 30,
25            user_agent: format!("sage-agent/{}", env!("CARGO_PKG_VERSION")),
26        }
27    }
28}
29
30impl HttpConfig {
31    /// Create config from environment variables (native) or defaults (WASM).
32    pub fn from_env() -> Self {
33        #[cfg(not(target_arch = "wasm32"))]
34        let timeout_secs = std::env::var("SAGE_HTTP_TIMEOUT")
35            .ok()
36            .and_then(|s| s.parse().ok())
37            .unwrap_or(30);
38
39        #[cfg(target_arch = "wasm32")]
40        let timeout_secs = 30;
41
42        Self {
43            timeout_secs,
44            ..Default::default()
45        }
46    }
47}
48
49/// Response from an HTTP request.
50///
51/// Exposed to Sage programs as the return type of `Http.get()` etc.
52#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
53pub struct HttpResponse {
54    /// HTTP status code (e.g., 200, 404).
55    pub status: i64,
56    /// Response body as a string.
57    pub body: String,
58    /// Response headers.
59    pub headers: HashMap<String, String>,
60}
61
62/// HTTP client for Sage agents.
63///
64/// Created via `HttpClient::from_env()` and used by generated code.
65#[derive(Debug, Clone)]
66pub struct HttpClient {
67    client: reqwest::Client,
68}
69
70impl HttpClient {
71    /// Create a new HTTP client with default configuration.
72    pub fn new() -> Self {
73        Self::with_config(HttpConfig::default())
74    }
75
76    /// Create a new HTTP client from environment variables.
77    pub fn from_env() -> Self {
78        Self::with_config(HttpConfig::from_env())
79    }
80
81    /// Create a new HTTP client with the given configuration.
82    pub fn with_config(config: HttpConfig) -> Self {
83        let builder = reqwest::Client::builder();
84
85        // timeout() and user_agent() are not available on the WASM backend
86        #[cfg(not(target_arch = "wasm32"))]
87        let builder = builder
88            .timeout(Duration::from_secs(config.timeout_secs))
89            .user_agent(&config.user_agent);
90
91        #[cfg(target_arch = "wasm32")]
92        let _ = &config; // suppress unused warning
93
94        let client = builder.build().expect("failed to build HTTP client");
95
96        Self { client }
97    }
98
99    /// Perform an HTTP GET request.
100    ///
101    /// # Arguments
102    /// * `url` - The URL to request
103    ///
104    /// # Returns
105    /// An `HttpResponse` with status, body, and headers.
106    pub async fn get(&self, url: String) -> SageResult<HttpResponse> {
107        // Check for mock response first
108        if let Some(mock_response) = try_get_mock("Http", "get") {
109            return Self::apply_mock(mock_response);
110        }
111
112        let response = self.client.get(url).send().await?;
113
114        let status = response.status().as_u16() as i64;
115        let headers = response
116            .headers()
117            .iter()
118            .map(|(k, v)| {
119                (
120                    k.as_str().to_string(),
121                    v.to_str().unwrap_or_default().to_string(),
122                )
123            })
124            .collect();
125        let body = response.text().await?;
126
127        Ok(HttpResponse {
128            status,
129            body,
130            headers,
131        })
132    }
133
134    /// Perform an HTTP POST request.
135    ///
136    /// # Arguments
137    /// * `url` - The URL to request
138    /// * `body` - The request body as a string
139    ///
140    /// # Returns
141    /// An `HttpResponse` with status, body, and headers.
142    pub async fn post(&self, url: String, body: String) -> SageResult<HttpResponse> {
143        // Check for mock response first
144        if let Some(mock_response) = try_get_mock("Http", "post") {
145            return Self::apply_mock(mock_response);
146        }
147
148        let response = self
149            .client
150            .post(url)
151            .header("Content-Type", "application/json")
152            .body(body)
153            .send()
154            .await?;
155
156        let status = response.status().as_u16() as i64;
157        let headers = response
158            .headers()
159            .iter()
160            .map(|(k, v)| {
161                (
162                    k.as_str().to_string(),
163                    v.to_str().unwrap_or_default().to_string(),
164                )
165            })
166            .collect();
167        let response_body = response.text().await?;
168
169        Ok(HttpResponse {
170            status,
171            body: response_body,
172            headers,
173        })
174    }
175
176    /// Perform an HTTP PUT request.
177    ///
178    /// # Arguments
179    /// * `url` - The URL to request
180    /// * `body` - The request body as a string
181    ///
182    /// # Returns
183    /// An `HttpResponse` with status, body, and headers.
184    pub async fn put(&self, url: String, body: String) -> SageResult<HttpResponse> {
185        // Check for mock response first
186        if let Some(mock_response) = try_get_mock("Http", "put") {
187            return Self::apply_mock(mock_response);
188        }
189
190        let response = self
191            .client
192            .put(url)
193            .header("Content-Type", "application/json")
194            .body(body)
195            .send()
196            .await?;
197
198        let status = response.status().as_u16() as i64;
199        let headers = response
200            .headers()
201            .iter()
202            .map(|(k, v)| {
203                (
204                    k.as_str().to_string(),
205                    v.to_str().unwrap_or_default().to_string(),
206                )
207            })
208            .collect();
209        let response_body = response.text().await?;
210
211        Ok(HttpResponse {
212            status,
213            body: response_body,
214            headers,
215        })
216    }
217
218    /// Perform an HTTP DELETE request.
219    ///
220    /// # Arguments
221    /// * `url` - The URL to request
222    ///
223    /// # Returns
224    /// An `HttpResponse` with status, body, and headers.
225    pub async fn delete(&self, url: String) -> SageResult<HttpResponse> {
226        // Check for mock response first
227        if let Some(mock_response) = try_get_mock("Http", "delete") {
228            return Self::apply_mock(mock_response);
229        }
230
231        let response = self.client.delete(url).send().await?;
232
233        let status = response.status().as_u16() as i64;
234        let headers = response
235            .headers()
236            .iter()
237            .map(|(k, v)| {
238                (
239                    k.as_str().to_string(),
240                    v.to_str().unwrap_or_default().to_string(),
241                )
242            })
243            .collect();
244        let body = response.text().await?;
245
246        Ok(HttpResponse {
247            status,
248            body,
249            headers,
250        })
251    }
252
253    /// Apply a mock response, deserializing it to HttpResponse.
254    fn apply_mock(mock_response: MockResponse) -> SageResult<HttpResponse> {
255        match mock_response {
256            MockResponse::Value(v) => serde_json::from_value(v)
257                .map_err(|e| SageError::Tool(format!("mock deserialize: {e}"))),
258            MockResponse::Fail(msg) => Err(SageError::Tool(msg)),
259        }
260    }
261}
262
263impl Default for HttpClient {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn http_config_defaults() {
275        let config = HttpConfig::default();
276        assert_eq!(config.timeout_secs, 30);
277        assert!(config.user_agent.starts_with("sage-agent/"));
278    }
279
280    #[test]
281    fn http_client_creates() {
282        let client = HttpClient::new();
283        // Just verify it doesn't panic
284        drop(client);
285    }
286
287    #[tokio::test]
288    async fn http_get_works() {
289        // Use a mock server or skip in CI
290        if std::env::var("CI").is_ok() {
291            return;
292        }
293
294        let client = HttpClient::new();
295        let response = client.get("https://httpbin.org/get".to_string()).await;
296        assert!(response.is_ok());
297        let response = response.unwrap();
298        assert_eq!(response.status, 200);
299        assert!(!response.body.is_empty());
300    }
301}