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` and `post` methods.
4
5use std::collections::HashMap;
6use std::time::Duration;
7
8use crate::error::SageResult;
9
10/// Configuration for the HTTP client.
11#[derive(Debug, Clone)]
12pub struct HttpConfig {
13    /// Request timeout in seconds.
14    pub timeout_secs: u64,
15    /// User-Agent header value.
16    pub user_agent: String,
17}
18
19impl Default for HttpConfig {
20    fn default() -> Self {
21        Self {
22            timeout_secs: 30,
23            user_agent: format!("sage-agent/{}", env!("CARGO_PKG_VERSION")),
24        }
25    }
26}
27
28impl HttpConfig {
29    /// Create config from environment variables.
30    ///
31    /// Reads:
32    /// - `SAGE_HTTP_TIMEOUT`: Request timeout in seconds (default: 30)
33    pub fn from_env() -> Self {
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        Self {
40            timeout_secs,
41            ..Default::default()
42        }
43    }
44}
45
46/// Response from an HTTP request.
47///
48/// Exposed to Sage programs as the return type of `Http.get()` etc.
49#[derive(Debug, Clone)]
50pub struct HttpResponse {
51    /// HTTP status code (e.g., 200, 404).
52    pub status: i64,
53    /// Response body as a string.
54    pub body: String,
55    /// Response headers.
56    pub headers: HashMap<String, String>,
57}
58
59/// HTTP client for Sage agents.
60///
61/// Created via `HttpClient::from_env()` and used by generated code.
62#[derive(Debug, Clone)]
63pub struct HttpClient {
64    client: reqwest::Client,
65}
66
67impl HttpClient {
68    /// Create a new HTTP client with default configuration.
69    pub fn new() -> Self {
70        Self::with_config(HttpConfig::default())
71    }
72
73    /// Create a new HTTP client from environment variables.
74    pub fn from_env() -> Self {
75        Self::with_config(HttpConfig::from_env())
76    }
77
78    /// Create a new HTTP client with the given configuration.
79    pub fn with_config(config: HttpConfig) -> Self {
80        let client = reqwest::Client::builder()
81            .timeout(Duration::from_secs(config.timeout_secs))
82            .user_agent(&config.user_agent)
83            .build()
84            .expect("failed to build HTTP client");
85
86        Self { client }
87    }
88
89    /// Perform an HTTP GET request.
90    ///
91    /// # Arguments
92    /// * `url` - The URL to request
93    ///
94    /// # Returns
95    /// An `HttpResponse` with status, body, and headers.
96    pub async fn get(&self, url: String) -> SageResult<HttpResponse> {
97        let response = self.client.get(url).send().await?;
98
99        let status = response.status().as_u16() as i64;
100        let headers = response
101            .headers()
102            .iter()
103            .map(|(k, v)| {
104                (
105                    k.as_str().to_string(),
106                    v.to_str().unwrap_or_default().to_string(),
107                )
108            })
109            .collect();
110        let body = response.text().await?;
111
112        Ok(HttpResponse {
113            status,
114            body,
115            headers,
116        })
117    }
118
119    /// Perform an HTTP POST request.
120    ///
121    /// # Arguments
122    /// * `url` - The URL to request
123    /// * `body` - The request body as a string
124    ///
125    /// # Returns
126    /// An `HttpResponse` with status, body, and headers.
127    pub async fn post(&self, url: String, body: String) -> SageResult<HttpResponse> {
128        let response = self
129            .client
130            .post(url)
131            .header("Content-Type", "application/json")
132            .body(body)
133            .send()
134            .await?;
135
136        let status = response.status().as_u16() as i64;
137        let headers = response
138            .headers()
139            .iter()
140            .map(|(k, v)| {
141                (
142                    k.as_str().to_string(),
143                    v.to_str().unwrap_or_default().to_string(),
144                )
145            })
146            .collect();
147        let response_body = response.text().await?;
148
149        Ok(HttpResponse {
150            status,
151            body: response_body,
152            headers,
153        })
154    }
155}
156
157impl Default for HttpClient {
158    fn default() -> Self {
159        Self::new()
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn http_config_defaults() {
169        let config = HttpConfig::default();
170        assert_eq!(config.timeout_secs, 30);
171        assert!(config.user_agent.starts_with("sage-agent/"));
172    }
173
174    #[test]
175    fn http_client_creates() {
176        let client = HttpClient::new();
177        // Just verify it doesn't panic
178        drop(client);
179    }
180
181    #[tokio::test]
182    async fn http_get_works() {
183        // Use a mock server or skip in CI
184        if std::env::var("CI").is_ok() {
185            return;
186        }
187
188        let client = HttpClient::new();
189        let response = client.get("https://httpbin.org/get".to_string()).await;
190        assert!(response.is_ok());
191        let response = response.unwrap();
192        assert_eq!(response.status, 200);
193        assert!(!response.body.is_empty());
194    }
195}