sage_runtime/tools/
http.rs1use std::collections::HashMap;
6use std::time::Duration;
7
8use crate::error::{SageError, SageResult};
9use crate::mock::{try_get_mock, MockResponse};
10
11#[derive(Debug, Clone)]
13pub struct HttpConfig {
14 pub timeout_secs: u64,
16 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 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
51pub struct HttpResponse {
52 pub status: i64,
54 pub body: String,
56 pub headers: HashMap<String, String>,
58}
59
60#[derive(Debug, Clone)]
64pub struct HttpClient {
65 client: reqwest::Client,
66}
67
68impl HttpClient {
69 pub fn new() -> Self {
71 Self::with_config(HttpConfig::default())
72 }
73
74 pub fn from_env() -> Self {
76 Self::with_config(HttpConfig::from_env())
77 }
78
79 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 pub async fn get(&self, url: String) -> SageResult<HttpResponse> {
98 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 pub async fn post(&self, url: String, body: String) -> SageResult<HttpResponse> {
134 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 pub async fn put(&self, url: String, body: String) -> SageResult<HttpResponse> {
176 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 pub async fn delete(&self, url: String) -> SageResult<HttpResponse> {
217 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 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 drop(client);
276 }
277
278 #[tokio::test]
279 async fn http_get_works() {
280 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}