Skip to main content

ares_tools/
http_tool.rs

1//! Runtime HTTP tool executor for A.R.E.S.
2//!
3//! This tool makes HTTP requests using templates configured via `execution_config`.
4//! Templates use `{{param_name}}` syntax for parameter substitution from tool
5//! arguments.
6
7use 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// =============================================================================
15// Configuration
16// =============================================================================
17
18/// HTTP-specific configuration parsed from `execution_config` JSONB.
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct HttpToolConfig {
21    /// HTTP method: GET, POST, PUT, PATCH, DELETE, etc.
22    pub method: String,
23    /// URL template with `{{param}}` placeholders.
24    pub url_template: String,
25    /// Optional header templates (object with `{{param}}` values).
26    #[serde(default)]
27    pub headers_template: Option<Value>,
28    /// Optional body template (JSON value with `{{param}}` placeholders).
29    #[serde(default)]
30    pub body_template: Option<Value>,
31    /// Request timeout in seconds (default: 30).
32    #[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
48// =============================================================================
49// Tool implementation
50// =============================================================================
51
52/// Runtime HTTP tool that executes templated HTTP requests.
53pub struct HttpTool {
54    name: String,
55    description: String,
56    parameters_schema: Value,
57    config: HttpToolConfig,
58    client: reqwest::Client,
59}
60
61impl HttpTool {
62    /// Create an HTTP tool from its runtime configuration.
63    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    /// Parse execution_config JSONB into [`HttpToolConfig`].
85    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        // --- Build URL ---
112        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        // --- Parse method ---
120        let method = parse_http_method(&self.config.method)?;
121
122        // --- Build request ---
123        let mut request = self.client.request(method, &url);
124
125        // --- Substitute and attach headers ---
126        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        // --- Substitute and attach body ---
141        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        // --- Execute ---
147        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        // Attempt to parse body as JSON, fall back to text
163        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
184// =============================================================================
185// Helpers
186// =============================================================================
187
188/// Replace `{{key}}` placeholders in `template` with values from `args`.
189fn 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
202/// Recursively replace `{{key}}` placeholders inside a JSON value.
203fn 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
227/// Convert a JSON value to its string representation for URL/header/body substitution.
228fn 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
241/// Parse a method string into a `reqwest::Method`.
242fn 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// =============================================================================
259// Tests
260// =============================================================================
261
262#[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}