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