1use crate::registry::Tool;
8use ares_types::Result;
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11use serde_json::{json, Value};
12use std::collections::HashMap;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct HttpToolConfig {
21 pub method: String,
23 pub url_template: String,
25 #[serde(default)]
27 pub headers_template: Option<Value>,
28 #[serde(default)]
30 pub body_template: Option<Value>,
31 #[serde(default)]
33 pub timeout_secs: Option<u64>,
34}
35
36impl Default for HttpToolConfig {
37 fn default() -> Self {
38 Self {
39 method: "GET".to_string(),
40 url_template: String::new(),
41 headers_template: None,
42 body_template: None,
43 timeout_secs: Some(30),
44 }
45 }
46}
47
48pub struct HttpTool {
54 name: String,
55 description: String,
56 parameters_schema: Value,
57 config: HttpToolConfig,
58 client: reqwest::Client,
59}
60
61impl HttpTool {
62 pub fn new(
64 name: impl Into<String>,
65 description: impl Into<String>,
66 parameters_schema: Value,
67 config: HttpToolConfig,
68 ) -> Self {
69 let timeout = std::time::Duration::from_secs(config.timeout_secs.unwrap_or(30));
70 let client = reqwest::Client::builder()
71 .timeout(timeout)
72 .build()
73 .unwrap_or_default();
74
75 Self {
76 name: name.into(),
77 description: description.into(),
78 parameters_schema,
79 config,
80 client,
81 }
82 }
83
84 pub fn parse_config(execution_config: &Value) -> Result<HttpToolConfig> {
86 serde_json::from_value(execution_config.clone()).map_err(|e| {
87 ares_types::AppError::Configuration(format!("Invalid HTTP tool config: {e}"))
88 })
89 }
90}
91
92#[async_trait]
93impl Tool for HttpTool {
94 fn name(&self) -> &str {
95 &self.name
96 }
97
98 fn description(&self) -> &str {
99 &self.description
100 }
101
102 fn parameters_schema(&self) -> Value {
103 self.parameters_schema.clone()
104 }
105
106 async fn execute(&self, args: Value) -> Result<Value> {
107 let args_map = args.as_object().ok_or_else(|| {
108 ares_types::AppError::InvalidInput("args must be a JSON object".to_string())
109 })?;
110
111 let url = substitute_template_string(&self.config.url_template, args_map)?;
113 if url.is_empty() {
114 return Err(ares_types::AppError::InvalidInput(
115 "url_template resolved to an empty string".to_string(),
116 ));
117 }
118
119 let method = parse_http_method(&self.config.method)?;
121
122 let mut request = self.client.request(method, &url);
124
125 if let Some(headers) = &self.config.headers_template {
127 let substituted = substitute_template_value(headers, args_map)?;
128 if let Some(obj) = substituted.as_object() {
129 for (key, value) in obj {
130 let header_value = value.as_str().ok_or_else(|| {
131 ares_types::AppError::InvalidInput(format!(
132 "header '{key}' must resolve to a string"
133 ))
134 })?;
135 request = request.header(key, header_value);
136 }
137 }
138 }
139
140 if let Some(body) = &self.config.body_template {
142 let substituted = substitute_template_value(body, args_map)?;
143 request = request.json(&substituted);
144 }
145
146 let response = request
148 .send()
149 .await
150 .map_err(|e| ares_types::AppError::External(format!("HTTP request failed: {e}")))?;
151
152 let status = response.status().as_u16();
153 let headers = response
154 .headers()
155 .iter()
156 .map(|(k, v)| {
157 let val = v.to_str().unwrap_or("").to_string();
158 (k.to_string(), val)
159 })
160 .collect::<HashMap<String, String>>();
161
162 let content_type = response
164 .headers()
165 .get(reqwest::header::CONTENT_TYPE)
166 .and_then(|v| v.to_str().ok())
167 .unwrap_or("");
168
169 let body_value = if content_type.contains("application/json") {
170 response.json::<Value>().await.unwrap_or(Value::Null)
171 } else {
172 let text = response.text().await.unwrap_or_default();
173 json!(text)
174 };
175
176 Ok(json!({
177 "status": status,
178 "headers": headers,
179 "body": body_value,
180 }))
181 }
182}
183
184fn substitute_template_string(
190 template: &str,
191 args: &serde_json::Map<String, Value>,
192) -> Result<String> {
193 let mut result = template.to_string();
194 for (key, value) in args {
195 let placeholder = format!("{{{{{}}}}}", key);
196 let replacement = value_to_string(value)?;
197 result = result.replace(&placeholder, &replacement);
198 }
199 Ok(result)
200}
201
202fn substitute_template_value(
204 value: &Value,
205 args: &serde_json::Map<String, Value>,
206) -> Result<Value> {
207 match value {
208 Value::String(s) => Ok(Value::String(substitute_template_string(s, args)?)),
209 Value::Object(map) => {
210 let mut new_map = serde_json::Map::new();
211 for (k, v) in map {
212 new_map.insert(k.clone(), substitute_template_value(v, args)?);
213 }
214 Ok(Value::Object(new_map))
215 }
216 Value::Array(arr) => {
217 let new_arr = arr
218 .iter()
219 .map(|v| substitute_template_value(v, args))
220 .collect::<Result<Vec<_>>>()?;
221 Ok(Value::Array(new_arr))
222 }
223 other => Ok(other.clone()),
224 }
225}
226
227fn value_to_string(value: &Value) -> Result<String> {
229 match value {
230 Value::String(s) => Ok(s.clone()),
231 Value::Number(n) => Ok(n.to_string()),
232 Value::Bool(b) => Ok(b.to_string()),
233 Value::Null => Ok(String::new()),
234 _ => Err(ares_types::AppError::InvalidInput(
235 "Template substitution does not support objects or arrays as scalar replacements"
236 .to_string(),
237 )),
238 }
239}
240
241fn parse_http_method(method: &str) -> Result<reqwest::Method> {
243 match method.to_ascii_uppercase().as_str() {
244 "GET" => Ok(reqwest::Method::GET),
245 "POST" => Ok(reqwest::Method::POST),
246 "PUT" => Ok(reqwest::Method::PUT),
247 "PATCH" => Ok(reqwest::Method::PATCH),
248 "DELETE" => Ok(reqwest::Method::DELETE),
249 "HEAD" => Ok(reqwest::Method::HEAD),
250 "OPTIONS" => Ok(reqwest::Method::OPTIONS),
251 "TRACE" => Ok(reqwest::Method::TRACE),
252 _ => Err(ares_types::AppError::InvalidInput(format!(
253 "Unsupported HTTP method: {method}"
254 ))),
255 }
256}
257
258#[cfg(test)]
263mod tests {
264 use super::*;
265 use serde_json::json;
266
267 fn make_test_tool(config: HttpToolConfig) -> HttpTool {
268 HttpTool::new(
269 "test_http",
270 "A test HTTP tool",
271 json!({
272 "type": "object",
273 "properties": {
274 "query": { "type": "string" }
275 }
276 }),
277 config,
278 )
279 }
280
281 #[test]
282 fn test_name_and_description() {
283 let tool = make_test_tool(HttpToolConfig::default());
284 assert_eq!(tool.name(), "test_http");
285 assert_eq!(tool.description(), "A test HTTP tool");
286 }
287
288 #[test]
289 fn test_parameters_schema() {
290 let tool = make_test_tool(HttpToolConfig::default());
291 let schema = tool.parameters_schema();
292 assert_eq!(schema["type"], "object");
293 }
294
295 #[test]
296 fn test_parse_config() {
297 let raw = json!({
298 "method": "POST",
299 "url_template": "https://api.example.com/{{endpoint}}",
300 "headers_template": { "Authorization": "Bearer {{token}}" },
301 "body_template": { "query": "{{query}}" },
302 "timeout_secs": 15
303 });
304 let cfg = HttpTool::parse_config(&raw).unwrap();
305 assert_eq!(cfg.method, "POST");
306 assert_eq!(cfg.url_template, "https://api.example.com/{{endpoint}}");
307 assert_eq!(cfg.timeout_secs, Some(15));
308 }
309
310 #[test]
311 fn test_parse_config_missing_optional_fields() {
312 let raw = json!({
313 "method": "GET",
314 "url_template": "https://api.example.com/"
315 });
316 let cfg = HttpTool::parse_config(&raw).unwrap();
317 assert_eq!(cfg.method, "GET");
318 assert!(cfg.headers_template.is_none());
319 assert!(cfg.body_template.is_none());
320 assert_eq!(cfg.timeout_secs, None);
321 }
322
323 #[tokio::test]
324 async fn test_invalid_method() {
325 let tool = make_test_tool(HttpToolConfig {
326 method: "FAKE".to_string(),
327 url_template: "https://example.com".to_string(),
328 ..Default::default()
329 });
330 let err = tool.execute(json!({})).await.unwrap_err();
331 assert!(matches!(
332 err,
333 ares_types::AppError::InvalidInput(msg) if msg.contains("Unsupported HTTP method")
334 ));
335 }
336
337 #[tokio::test]
338 async fn test_empty_url_template() {
339 let tool = make_test_tool(HttpToolConfig {
340 method: "GET".to_string(),
341 url_template: "".to_string(),
342 ..Default::default()
343 });
344 let err = tool.execute(json!({})).await.unwrap_err();
345 assert!(matches!(
346 err,
347 ares_types::AppError::InvalidInput(msg) if msg.contains("empty")
348 ));
349 }
350
351 #[tokio::test]
352 async fn test_non_object_args_rejected() {
353 let tool = make_test_tool(HttpToolConfig::default());
354 let err = tool.execute(json!("not-an-object")).await.unwrap_err();
355 assert!(matches!(
356 err,
357 ares_types::AppError::InvalidInput(msg) if msg.contains("must be a JSON object")
358 ));
359 }
360
361 #[test]
362 fn test_substitute_string_simple() {
363 let mut map = serde_json::Map::new();
364 map.insert("name".to_string(), json!("world"));
365 let result = substitute_template_string("Hello {{name}}!", &map).unwrap();
366 assert_eq!(result, "Hello world!");
367 }
368
369 #[test]
370 fn test_substitute_string_multiple() {
371 let mut map = serde_json::Map::new();
372 map.insert("a".to_string(), json!("1"));
373 map.insert("b".to_string(), json!("2"));
374 let result = substitute_template_string("{{a}}-{{b}}", &map).unwrap();
375 assert_eq!(result, "1-2");
376 }
377
378 #[test]
379 fn test_substitute_value_nested() {
380 let mut map = serde_json::Map::new();
381 map.insert("q".to_string(), json!("rust"));
382 let input = json!({ "search": "{{q}}", "nested": { "term": "{{q}}" } });
383 let result = substitute_template_value(&input, &map).unwrap();
384 assert_eq!(result["search"], "rust");
385 assert_eq!(result["nested"]["term"], "rust");
386 }
387
388 #[test]
389 fn test_substitute_value_array() {
390 let mut map = serde_json::Map::new();
391 map.insert("x".to_string(), json!("val"));
392 let input = json!(["{{x}}", "static"]);
393 let result = substitute_template_value(&input, &map).unwrap();
394 assert_eq!(result.as_array().unwrap()[0], "val");
395 assert_eq!(result.as_array().unwrap()[1], "static");
396 }
397
398 #[test]
399 fn test_substitute_with_number() {
400 let mut map = serde_json::Map::new();
401 map.insert("id".to_string(), json!(42));
402 let result = substitute_template_string("/items/{{id}}", &map).unwrap();
403 assert_eq!(result, "/items/42");
404 }
405
406 #[test]
407 fn test_substitute_unsupported_array() {
408 let mut map = serde_json::Map::new();
409 map.insert("bad".to_string(), json!([1, 2, 3]));
410 let result = substitute_template_string("{{bad}}", &map);
411 assert!(result.is_err());
412 }
413
414 #[tokio::test]
415 async fn test_get_request() {
416 use wiremock::matchers::{method, path, query_param};
417 use wiremock::{Mock, MockServer, ResponseTemplate};
418
419 let server = MockServer::start().await;
420 Mock::given(method("GET"))
421 .and(path("/get"))
422 .and(query_param("foo", "baz"))
423 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ok": true })))
424 .mount(&server)
425 .await;
426
427 let tool = make_test_tool(HttpToolConfig {
428 method: "GET".to_string(),
429 url_template: format!("{}/get?foo={{{{bar}}}}", server.uri()),
430 ..Default::default()
431 });
432 let result = tool.execute(json!({ "bar": "baz" })).await.unwrap();
433 assert_eq!(result["status"], 200);
434 assert_eq!(result["body"]["ok"], true);
435 }
436
437 #[tokio::test]
438 async fn test_post_request_with_headers_and_body() {
439 use wiremock::matchers::{body_json, header, method, path};
440 use wiremock::{Mock, MockServer, ResponseTemplate};
441
442 let server = MockServer::start().await;
443 Mock::given(method("POST"))
444 .and(path("/post"))
445 .and(header("X-Custom-Token", "secret123"))
446 .and(body_json(json!({ "message": "hello" })))
447 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "received": true })))
448 .mount(&server)
449 .await;
450
451 let tool = make_test_tool(HttpToolConfig {
452 method: "POST".to_string(),
453 url_template: format!("{}/post", server.uri()),
454 headers_template: Some(json!({ "X-Custom-Token": "{{token}}" })),
455 body_template: Some(json!({ "message": "{{msg}}" })),
456 ..Default::default()
457 });
458 let result = tool
459 .execute(json!({ "token": "secret123", "msg": "hello" }))
460 .await
461 .unwrap();
462 assert_eq!(result["status"], 200);
463 assert_eq!(result["body"]["received"], true);
464 }
465
466 #[tokio::test]
467 async fn test_non_200_response_preserved() {
468 use wiremock::matchers::{method, path};
469 use wiremock::{Mock, MockServer, ResponseTemplate};
470
471 let server = MockServer::start().await;
472 Mock::given(method("GET"))
473 .and(path("/teapot"))
474 .respond_with(ResponseTemplate::new(418).set_body_string("I'm a teapot"))
475 .mount(&server)
476 .await;
477
478 let tool = make_test_tool(HttpToolConfig {
479 method: "GET".to_string(),
480 url_template: format!("{}/teapot", server.uri()),
481 ..Default::default()
482 });
483 let result = tool.execute(json!({})).await.unwrap();
484 assert_eq!(result["status"], 418);
485 assert_eq!(result["body"], "I'm a teapot");
486 }
487}