sage_runtime/tools/
http.rs1use 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#[derive(Debug, Clone)]
14pub struct HttpConfig {
15 pub timeout_secs: u64,
17 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 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
53pub struct HttpResponse {
54 pub status: i64,
56 pub body: String,
58 pub headers: HashMap<String, String>,
60}
61
62#[derive(Debug, Clone)]
66pub struct HttpClient {
67 client: reqwest::Client,
68}
69
70impl HttpClient {
71 pub fn new() -> Self {
73 Self::with_config(HttpConfig::default())
74 }
75
76 pub fn from_env() -> Self {
78 Self::with_config(HttpConfig::from_env())
79 }
80
81 pub fn with_config(config: HttpConfig) -> Self {
83 let builder = reqwest::Client::builder();
84
85 #[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; let client = builder.build().expect("failed to build HTTP client");
95
96 Self { client }
97 }
98
99 pub async fn get(&self, url: String) -> SageResult<HttpResponse> {
107 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 pub async fn post(&self, url: String, body: String) -> SageResult<HttpResponse> {
143 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 pub async fn put(&self, url: String, body: String) -> SageResult<HttpResponse> {
185 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 pub async fn delete(&self, url: String) -> SageResult<HttpResponse> {
226 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 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 drop(client);
285 }
286
287 #[tokio::test]
288 async fn http_get_works() {
289 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}